Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ jobs:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
- name: Format
run: cargo fmt --check
run: rustup run stable cargo fmt --check

test:
strategy:
Expand All @@ -33,5 +35,7 @@ jobs:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
- name: Test
run: cargo test
run: rustup run stable cargo test
5 changes: 3 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,18 +75,19 @@ jobs:
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
cache-bin: false

- name: Install cross
if: matrix.use_cross
run: cargo install cross --locked
run: rustup run stable cargo install cross --locked

- name: Build binary
shell: bash
run: |
if [[ "${{ matrix.use_cross }}" == "true" ]]; then
cross build --release --target "${{ matrix.target }}"
else
cargo build --release --target "${{ matrix.target }}"
rustup run stable cargo build --release --target "${{ matrix.target }}"
fi

- name: Package release archive
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# Changelog

## 0.1.4

- Add customizable `twatch` keymaps with `-K/--keymap KEY=ACTION`, including pane-specific navigation, snapshot actions, pause control, and app-input mode switching
- Add `twrap`-style child key overrides with `-k/--bind FROM=TO`, supporting key aliases, comma-separated key sequences, `text:...`, and `screenshot`
- Remove the unused `--interval` option and simplify batch capture timing so PTY-backed sources wait on real source updates instead of a polling interval
- Expand README and in-app help to document the current interaction model, custom keymap actions, and child key override behavior
- Add regression coverage for custom keymaps and child binding overrides, including passthrough interception and custom exit/app-input flows
- Refresh Rust CI and release workflows to avoid cached `cargo` shims on GitHub Actions and make macOS runner behavior more reliable
- Add consistent source headers across Rust modules touched during this release

This release is centered on input control and operational polish.
It makes `twatch` more usable with editor-like workflows, shell-heavy sessions, and wrapped TUIs that benefit from custom local shortcuts or remapped child input.

Notes:

- `--interval` was removed because the current PTY workflow is event-driven and no longer relies on that option
- The new input customization layer is the main user-facing change in this release

## 0.1.3

- Improve Windows compatibility for wrapped TUIs by fixing PTY startup, direct process spawning, terminal query handling, and repeated key/mouse input behavior
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "twatch"
version = "0.1.3"
version = "0.1.4"
edition = "2024"
description = "twatch - record, rewind, and diff terminal UI screens."
license = "MIT"
Expand Down
149 changes: 121 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ twatch - record, rewind, inspect, and diff terminal UI screens.

## Description

`twatch` runs a TUI application inside a PTY, records screen changes, and lets
you move back through history later, similar to [hwatch](https://github.com/blacknon/hwatch).
It is aimed at debugging terminal UIs, not only recording them.
twatch adds rewindable history to existing TUI applications.

Full-screen terminal apps like htop, lazygit, k9s, and nmtui constantly redraw the same screen, so normal terminal scrollback often cannot show you what happened before.

twatch runs the target app through a PTY, records its screen states, and lets you rewind, search, and diff previous frames. It can also extract selected ranges in batch mode and write them to standard output, making TUI content easier to inspect, debug, or pipe into other commands.

### demo

Expand Down Expand Up @@ -53,46 +55,74 @@ cargo install twatch

```text
$ twatch --help
TUI watch for terminal apps
watch for TUI apps

Usage: twatch [OPTIONS] [COMMAND]...

Arguments:
[COMMAND]...

Options:
-n, --interval <INTERVAL> [default: 2]
-b, --batch
--batch-count <BATCH_COUNT> Stop after emitting this many batch frames
--batch-size <WIDTH,HEIGHT> Use a fixed PTY size such as 80,24
--batch-crop <X,Y,WIDTH,HEIGHT> Crop batch output to a rectangle such as 10,5,40,12
--batch-diff-only Print only added content for batch diff output
--batch-no-color Disable ANSI color sequences in batch output

--batch-count <BATCH_COUNT>
Stop after emitting this many batch frames
--batch-size <WIDTH,HEIGHT>
Use a fixed PTY size such as 80,24
--batch-crop <X,Y,WIDTH,HEIGHT>
Crop batch output to a rectangle such as 10,5,40,12
--batch-diff-only
Print only added content for batch diff output
--batch-no-color
Disable ANSI color sequences in batch output
-A, --aftercommand <AFTERCOMMAND>
--aftercommand-regex <AFTERCOMMAND_REGEX> Only run aftercommand when output matches this regex

-K, --keymap <KEY=ACTION>
Remap twatch keys with KEY=ACTION
-k, --bind <FROM=TO>
Override child TUI keys with FROM=TO
--aftercommand-regex <AFTERCOMMAND_REGEX>
Only run aftercommand when output matches this regex
--aftercommand-change-cells <AFTERCOMMAND_CHANGE_CELLS>
Only run aftercommand when changed cell count reaches this threshold
--aftercommand-every <AFTERCOMMAND_EVERY> Only run aftercommand on every Nth changed frame
Only run aftercommand when changed cell count reaches this threshold
--aftercommand-every <AFTERCOMMAND_EVERY>
Only run aftercommand on every Nth changed frame
--aftercommand-debounce-ms <AFTERCOMMAND_DEBOUNCE_MS>
Debounce aftercommand for this many milliseconds
Debounce aftercommand for this many milliseconds
--aftercommand-timeout-ms <AFTERCOMMAND_TIMEOUT_MS>
Kill aftercommand if it exceeds this timeout in milliseconds [default: 3000]
Kill aftercommand if it exceeds this timeout in milliseconds [default: 3000]
-C, --compress

-l, --logfile <LOGFILE>
--replay <REPLAY> Replay a saved JSONL trace in read-only mode
--screenshot-dir <SCREENSHOT_DIR> [default: /tmp]
--screenshot-format <SCREENSHOT_FORMAT> [default: text] [possible values: text, svg]
--snapshot-on <SNAPSHOT_ON> Auto-save a snapshot when the screen contains this string
--snapshot-on-regex <SNAPSHOT_ON_REGEX> Auto-save a snapshot when the screen matches this regex

--replay <REPLAY>
Replay a saved JSONL trace in read-only mode
--screenshot-dir <SCREENSHOT_DIR>
[default: /tmp]
--screenshot-format <SCREENSHOT_FORMAT>
[default: text] [possible values: text, svg]
--snapshot-on <SNAPSHOT_ON>
Auto-save a snapshot when the screen contains this string
--snapshot-on-regex <SNAPSHOT_ON_REGEX>
Auto-save a snapshot when the screen matches this regex
--snapshot-on-change-cells <SNAPSHOT_ON_CHANGE_CELLS>
Auto-save a snapshot when changed cell count reaches this threshold
--snapshot-once Only trigger automatic snapshot once
-s, --shell <SHELL> [default: "sh -c"]
-d, --differences <DIFFERENCES> Diff mode: watch for TUI mode, list/word for batch mode [default: none] [possible values: none, watch, list, word]
-L, --limit <LIMIT> [default: 500]
--checkpoint-interval <CHECKPOINT_INTERVAL> [default: 12]
-h, --help Print help
-V, --version Print version
Auto-save a snapshot when changed cell count reaches this threshold
--snapshot-once
Only trigger automatic snapshot once
-s, --shell <SHELL>
[default: "sh -c"]
-d, --differences <DIFFERENCES>
Diff mode: watch for TUI mode, list/word for batch mode [default: none] [possible values: none, watch, list, word]
-L, --limit <LIMIT>
[default: 500]
--checkpoint-interval <CHECKPOINT_INTERVAL>
[default: 12]
--debug
Show debug diagnostics in the interactive UI
-h, --help
Print help
-V, --version
Print version
```

### Keybind
Expand Down Expand Up @@ -127,6 +157,69 @@ Options:
| `Shift+S` | Toggle selected frame info |
| `Ctrl+S` | Save selected snapshot |

#### Custom keybind

Remap `twatch` actions with `-K/--keymap` using `KEY=ACTION`.
Custom keymaps are checked before the built-in passthrough rules, so you can
override keys such as `Down` and use them for local history navigation.

```bash
twatch -K ctrl-p=history_pane_up -K ctrl-n=history_pane_down htop
twatch -K down=history_pane_down htop
```

Supported actions:

| action | description |
| --- | --- |
| `up` / `down` | Move selected view using the current focus |
| `watch_pane_up` / `watch_pane_down` | Scroll only the watch pane |
| `history_pane_up` / `history_pane_down` | Move only the history selection |
| `page_up` / `page_down` | Page move using the current focus |
| `watch_pane_page_up` / `watch_pane_page_down` | Page scroll only the watch pane |
| `history_pane_page_up` / `history_pane_page_down` | Page move only the history pane |
| `move_top` / `move_end` | Jump using the current focus |
| `watch_pane_move_top` / `watch_pane_move_end` | Jump only the watch pane |
| `history_pane_move_top` / `history_pane_move_end` | Jump only the history selection |
| `toggle_focus` | Switch watch/history focus |
| `focus_watch_pane` / `focus_history_pane` | Focus a specific pane |
| `quit` | Open the exit dialog |
| `reset` | Close help/exit or clear the current filter |
| `delete` | Delete the selected history entry |
| `clear_except_selected` | Keep only the selected history entry |
| `cancel` | Match the built-in `Ctrl-c` behavior |
| `force_cancel` | Exit immediately |
| `help` | Toggle the help dialog |
| `toggle_view_history_pane` | Toggle the history pane |
| `toggle_history_summary` | Toggle selected frame details |
| `toggle_diff_mode` | Toggle watch diff |
| `set_diff_mode_none` | Disable diff |
| `set_diff_mode_watch` | Enable watch diff |
| `toggle_pause` | Pause or resume capture |
| `toggle_child_pause` | Pause or resume the wrapped process |
| `change_filter_mode` | Start plain-text history filtering |
| `change_regex_filter_mode` | Start regex history filtering |
| `enter_app_input_mode` | Enter child app input mode |
| `leave_app_input_mode` | Leave child app input mode |
| `toggle_inspector` | Toggle the cell inspector |
| `save_snapshot` | Save the selected snapshot |
| `cycle_snapshot_format` | Cycle snapshot output format |
| `scroll_left` / `scroll_right` | Scroll the watch pane horizontally |

#### Child key override

Override keys before they reach the wrapped TUI with `-k/--bind FROM=TO`.
This follows the `twrap` style: `TO` accepts key names like `up`, `down`,
`enter`, `f1`, `ctrl-c`, comma-separated key sequences, `text:...`, or
`screenshot`.

```bash
twatch -k j=down -k k=up lazygit
twatch -k ctrl-j=text:gg -k ctrl-t=screenshot nvim
```

`Ctrl-g` remains reserved for leaving app input mode.

### Notes

- Mouse support is implemented, but behavior still depends on the child TUI and
Expand Down
4 changes: 4 additions & 0 deletions src/aftercommand/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// Copyright (c) 2026 Blacknon. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.

use std::sync::mpsc::{self, SyncSender, TrySendError};
use std::thread;
use std::time::Duration;
Expand Down
4 changes: 4 additions & 0 deletions src/aftercommand/rules.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// Copyright (c) 2026 Blacknon. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.

use crate::aftercommand::{AfterCommandConfig, AfterCommandEvent};

pub(super) fn evaluate_rules(
Expand Down
4 changes: 4 additions & 0 deletions src/aftercommand/worker.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// Copyright (c) 2026 Blacknon. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.

use std::process::{Command, Stdio};
use std::time::{Duration, SystemTime};

Expand Down
14 changes: 12 additions & 2 deletions src/app/config.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
// Copyright (c) 2026 Blacknon. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.

use std::path::PathBuf;

use anyhow::{Context, Result};
use regex::Regex;

use super::{App, DiffMode};
use crate::aftercommand::{AfterCommandConfig, AfterCommandRuntime};
use crate::child_bindings::{ChildBinding, compile_child_bindings};
use crate::cli::Cli;
use crate::keymap::{KeyBinding, compile_keymap};
use crate::screenshot::ScreenshotFormat;

pub(super) struct AppConfig {
pub interval_secs: f64,
pub child_pause_supported: bool,
pub diff_mode: DiffMode,
pub history_limit: usize,
Expand All @@ -26,6 +31,8 @@ pub(super) struct AppConfig {
pub aftercommand_runtime: Option<AfterCommandRuntime>,
pub command_display: String,
pub debug: bool,
pub keymap: Vec<KeyBinding>,
pub child_bindings: Vec<ChildBinding>,
}

impl AppConfig {
Expand All @@ -46,9 +53,10 @@ impl AppConfig {
})
.transpose()?;
let command_display = App::command_display_from_cli(cli);
let keymap = compile_keymap(&cli.keymap).context("invalid --keymap")?;
let child_bindings = compile_child_bindings(&cli.bind).context("invalid --bind")?;

Ok(Self {
interval_secs: cli.interval,
child_pause_supported,
diff_mode: cli.differences.into(),
history_limit: cli.limit.max(1),
Expand Down Expand Up @@ -76,6 +84,8 @@ impl AppConfig {
}),
command_display,
debug: cli.debug,
keymap,
child_bindings,
})
}
}
4 changes: 4 additions & 0 deletions src/app/filter.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// Copyright (c) 2026 Blacknon. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.

use anyhow::Result;
use regex::Regex;

Expand Down
4 changes: 4 additions & 0 deletions src/app/history_ops.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// Copyright (c) 2026 Blacknon. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.

use anyhow::Result;

use super::{App, FocusPane};
Expand Down
6 changes: 5 additions & 1 deletion src/app/input/history_nav.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// Copyright (c) 2026 Blacknon. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.

use crate::app::{App, FocusPane};

impl App {
Expand Down Expand Up @@ -91,7 +95,7 @@ impl App {
self.follow_latest = false;
}

fn move_history_by(&mut self, offset: isize) {
pub(super) fn move_history_by(&mut self, offset: isize) {
if self.filtered.is_empty() {
return;
}
Expand Down
Loading
Loading