From 2c9a694f778205b5913fb40eaff54dc52383038c Mon Sep 17 00:00:00 2001 From: Blacknon Date: Thu, 14 May 2026 01:20:44 +0900 Subject: [PATCH 1/9] update. README --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 019d6e0..e37f10d 100644 --- a/README.md +++ b/README.md @@ -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 From 1706a2eea0657db584ec4de4296de114879fbd6a Mon Sep 17 00:00:00 2001 From: Blacknon Date: Thu, 14 May 2026 01:24:15 +0900 Subject: [PATCH 2/9] update. add header --- src/aftercommand/mod.rs | 4 ++++ src/aftercommand/rules.rs | 4 ++++ src/aftercommand/worker.rs | 4 ++++ src/app/config.rs | 4 ++++ src/app/filter.rs | 4 ++++ src/app/history_ops.rs | 4 ++++ src/app/input/history_nav.rs | 4 ++++ src/app/input/key.rs | 4 ++++ src/app/input/mod.rs | 4 ++++ src/app/input/mouse.rs | 4 ++++ src/app/input/trace.rs | 4 ++++ src/app/mod.rs | 4 ++++ src/app/runtime.rs | 4 ++++ src/app/snapshot.rs | 4 ++++ src/app/state.rs | 4 ++++ src/app/tests.rs | 4 ++++ src/app/trigger.rs | 4 ++++ src/app/view.rs | 4 ++++ src/batch/mod.rs | 4 ++++ src/batch/render.rs | 4 ++++ src/batch/tests.rs | 4 ++++ src/diff.rs | 4 ++++ src/history.rs | 4 ++++ src/lib.rs | 4 ++++ src/logging.rs | 4 ++++ src/main.rs | 4 ++++ src/process_control.rs | 4 ++++ src/runner/capture_hook.rs | 4 ++++ src/screen.rs | 4 ++++ src/screenshot.rs | 4 ++++ src/ui/dialogs.rs | 4 ++++ src/ui/header.rs | 4 ++++ src/ui/history.rs | 4 ++++ src/ui/mod.rs | 4 ++++ src/ui/watch.rs | 4 ++++ 35 files changed, 140 insertions(+) diff --git a/src/aftercommand/mod.rs b/src/aftercommand/mod.rs index 5f8a66d..598a2f3 100644 --- a/src/aftercommand/mod.rs +++ b/src/aftercommand/mod.rs @@ -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; diff --git a/src/aftercommand/rules.rs b/src/aftercommand/rules.rs index 2a97cf4..efa8bdf 100644 --- a/src/aftercommand/rules.rs +++ b/src/aftercommand/rules.rs @@ -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( diff --git a/src/aftercommand/worker.rs b/src/aftercommand/worker.rs index fdb941d..bfac07e 100644 --- a/src/aftercommand/worker.rs +++ b/src/aftercommand/worker.rs @@ -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}; diff --git a/src/app/config.rs b/src/app/config.rs index 98655db..c48bb29 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -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::path::PathBuf; use anyhow::{Context, Result}; diff --git a/src/app/filter.rs b/src/app/filter.rs index 34209fa..92959dc 100644 --- a/src/app/filter.rs +++ b/src/app/filter.rs @@ -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; diff --git a/src/app/history_ops.rs b/src/app/history_ops.rs index 10bd072..65d8361 100644 --- a/src/app/history_ops.rs +++ b/src/app/history_ops.rs @@ -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}; diff --git a/src/app/input/history_nav.rs b/src/app/input/history_nav.rs index d310b41..06b2378 100644 --- a/src/app/input/history_nav.rs +++ b/src/app/input/history_nav.rs @@ -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 { diff --git a/src/app/input/key.rs b/src/app/input/key.rs index fe7578f..73c4cd0 100644 --- a/src/app/input/key.rs +++ b/src/app/input/key.rs @@ -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 crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index e5e0a26..ac64da2 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -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::time::Duration; use anyhow::Result; diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index be156c9..b0d4170 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -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::time::Instant; use anyhow::Result; diff --git a/src/app/input/trace.rs b/src/app/input/trace.rs index 212e6aa..94e32fa 100644 --- a/src/app/input/trace.rs +++ b/src/app/input/trace.rs @@ -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::time::{Duration, SystemTime, UNIX_EPOCH}; use crossterm::event::{KeyEvent, MouseEvent}; diff --git a/src/app/mod.rs b/src/app/mod.rs index e61cee4..608df28 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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::cell::RefCell; use std::collections::VecDeque; use std::path::PathBuf; diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 2e1530c..7339899 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -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::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, RecvTimeoutError, Sender}; diff --git a/src/app/snapshot.rs b/src/app/snapshot.rs index 67878ee..d606cba 100644 --- a/src/app/snapshot.rs +++ b/src/app/snapshot.rs @@ -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::path::PathBuf; use std::time::Instant; diff --git a/src/app/state.rs b/src/app/state.rs index 56d8b1b..a1479af 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -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::time::{Duration, Instant}; use anyhow::Result; diff --git a/src/app/tests.rs b/src/app/tests.rs index 409c44d..ae03edc 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -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 super::{App, FilterMode, FocusPane}; use crate::cli::{Cli, DiffModeArg, ScreenshotFormatArg}; use crate::logging::{LogRecord, append_record}; diff --git a/src/app/trigger.rs b/src/app/trigger.rs index 4ac9ba7..f0901d7 100644 --- a/src/app/trigger.rs +++ b/src/app/trigger.rs @@ -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::path::PathBuf; use anyhow::Result; diff --git a/src/app/view.rs b/src/app/view.rs index 4ac05ab..ad44907 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -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 super::{App, AppHistoryMetadata, ViewCache, ViewCacheKey}; use crate::screen::ScreenSnapshot; diff --git a/src/batch/mod.rs b/src/batch/mod.rs index db5af84..5d5d1f8 100644 --- a/src/batch/mod.rs +++ b/src/batch/mod.rs @@ -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::cli::{Cli, CropSpec}; use crate::screen::ScreenSnapshot; diff --git a/src/batch/render.rs b/src/batch/render.rs index 8fb14b1..6f895ab 100644 --- a/src/batch/render.rs +++ b/src/batch/render.rs @@ -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::cli::{Cli, DiffModeArg}; use crate::screen::ScreenSnapshot; use crate::screenshot::render_ansi_text; diff --git a/src/batch/tests.rs b/src/batch/tests.rs index 293dd4d..ce7e9e5 100644 --- a/src/batch/tests.rs +++ b/src/batch/tests.rs @@ -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 super::{prepare_snapshot, render_output}; use crate::cli::{Cli, CropSpec, DiffModeArg, ScreenshotFormatArg}; use crate::screen::ScreenSnapshot; diff --git a/src/diff.rs b/src/diff.rs index e3f361f..7aa5c9c 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -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. + #[derive(Clone, Debug, Eq, PartialEq)] pub struct LineDiff { pub line_index: usize, diff --git a/src/history.rs b/src/history.rs index b462840..d27b5ef 100644 --- a/src/history.rs +++ b/src/history.rs @@ -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::io::{Read, Write}; use anyhow::{Context, Result}; diff --git a/src/lib.rs b/src/lib.rs index 1952c75..621c6bb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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. + pub mod aftercommand; pub mod app; pub mod batch; diff --git a/src/logging.rs b/src/logging.rs index e085aa3..47292dd 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -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::fs::{File, OpenOptions}; use std::io::{BufRead, BufReader, Write}; use std::path::Path; diff --git a/src/main.rs b/src/main.rs index 98cf0d4..4c84294 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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::borrow::Cow; use std::fs; use std::io::Write; diff --git a/src/process_control.rs b/src/process_control.rs index 57b0fd1..efcda3d 100644 --- a/src/process_control.rs +++ b/src/process_control.rs @@ -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::io; #[cfg(unix)] diff --git a/src/runner/capture_hook.rs b/src/runner/capture_hook.rs index c4f8d0b..ec04fa2 100644 --- a/src/runner/capture_hook.rs +++ b/src/runner/capture_hook.rs @@ -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, UNIX_EPOCH}; diff --git a/src/screen.rs b/src/screen.rs index b2303f7..6ee5c00 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -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::fmt; use ratatui::style::{Color, Modifier, Style as TuiStyle}; diff --git a/src/screenshot.rs b/src/screenshot.rs index 9177964..77b0bcc 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -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::fs; use std::path::Path; diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs index 398ccac..f5c8971 100644 --- a/src/ui/dialogs.rs +++ b/src/ui/dialogs.rs @@ -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 ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Style}; diff --git a/src/ui/header.rs b/src/ui/header.rs index 30ed931..998bbdd 100644 --- a/src/ui/header.rs +++ b/src/ui/header.rs @@ -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 ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; diff --git a/src/ui/history.rs b/src/ui/history.rs index e958cd8..3aa2571 100644 --- a/src/ui/history.rs +++ b/src/ui/history.rs @@ -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 ratatui::Frame; use ratatui::layout::{Alignment, Rect}; use ratatui::style::{Color, Style}; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ec48b93..4a7a3e4 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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 ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::style::Color; diff --git a/src/ui/watch.rs b/src/ui/watch.rs index e4dd201..74a58c9 100644 --- a/src/ui/watch.rs +++ b/src/ui/watch.rs @@ -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 ratatui::buffer::Buffer; use ratatui::layout::Position; use ratatui::layout::{Constraint, Direction, Layout, Rect}; From b9dbc7183f8fb180cf09e974412fbbb6dbdb33d9 Mon Sep 17 00:00:00 2001 From: Blacknon Date: Thu, 14 May 2026 01:51:36 +0900 Subject: [PATCH 3/9] update. delete interval option --- README.md | 1 - src/app/config.rs | 2 -- src/app/mod.rs | 2 -- src/app/runtime.rs | 17 +++++---------- src/app/snapshot.rs | 3 --- src/app/state.rs | 17 --------------- src/app/tests.rs | 1 - src/batch/tests.rs | 1 - src/cli.rs | 3 --- src/main.rs | 52 +++++++++++++++++++++++++++++++++++++++++---- src/ui/header.rs | 7 +----- 11 files changed, 54 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index e37f10d..849830e 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,6 @@ Arguments: [COMMAND]... Options: - -n, --interval [default: 2] -b, --batch --batch-count Stop after emitting this many batch frames --batch-size Use a fixed PTY size such as 80,24 diff --git a/src/app/config.rs b/src/app/config.rs index c48bb29..e008a15 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -13,7 +13,6 @@ use crate::cli::Cli; 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, @@ -52,7 +51,6 @@ impl AppConfig { let command_display = App::command_display_from_cli(cli); Ok(Self { - interval_secs: cli.interval, child_pause_supported, diff_mode: cli.differences.into(), history_limit: cli.limit.max(1), diff --git a/src/app/mod.rs b/src/app/mod.rs index 608df28..6902966 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -284,7 +284,6 @@ impl InputTraceEvent { } pub struct App { - pub interval_secs: f64, pub debug: bool, pub paused: bool, pub child_paused: bool, @@ -315,7 +314,6 @@ pub struct App { aftercommand_runtime: Option, command_display: String, source: Box, - last_tick: Instant, last_mouse_input: Option, last_mouse_scroll_input: Option, pending_mouse_escape: Option, diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 7339899..d0e1265 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -2,9 +2,9 @@ // Use of this source code is governed by an MIT license // that can be found in the LICENSE file. -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, RecvTimeoutError, Sender}; +use std::sync::Arc; use std::time::Duration; use anyhow::Result; @@ -16,9 +16,7 @@ use crate::runner::SourceEvent; use crate::ui; impl App { - fn event_driven_poll_timeout(&self) -> Duration { - self.tick_timeout().min(Duration::from_millis(50)) - } + const EVENT_POLL_TIMEOUT: Duration = Duration::from_millis(50); pub fn run(mut self, terminal: DefaultTerminal) -> Result<()> { let (tx, rx) = mpsc::channel(); @@ -109,7 +107,7 @@ impl App { } if self.source.is_event_driven() { - match rx.recv_timeout(self.event_driven_poll_timeout()) { + match rx.recv_timeout(Self::EVENT_POLL_TIMEOUT) { Ok(AppEvent::Terminal(event)) => match self.process_terminal_event(event)? { LoopControl::Continue(redraw) => { if !self.paused && self.source.has_pending_update() { @@ -153,7 +151,7 @@ impl App { Err(RecvTimeoutError::Disconnected) => break, } } else { - match rx.recv_timeout(self.tick_timeout()) { + match rx.recv_timeout(Self::EVENT_POLL_TIMEOUT) { Ok(AppEvent::Terminal(event)) => match self.process_terminal_event(event)? { LoopControl::Continue(redraw) => { needs_redraw = redraw || needs_redraw; @@ -161,12 +159,7 @@ impl App { LoopControl::Break => break, }, Ok(AppEvent::SourceUpdated) | Ok(AppEvent::SourceClosed(_)) => {} - Err(RecvTimeoutError::Timeout) => { - if !self.paused && self.should_capture_now() { - self.capture_terminal_size(&terminal)?; - needs_redraw = true; - } - } + Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => break, } } diff --git a/src/app/snapshot.rs b/src/app/snapshot.rs index d606cba..00e9049 100644 --- a/src/app/snapshot.rs +++ b/src/app/snapshot.rs @@ -3,7 +3,6 @@ // that can be found in the LICENSE file. use std::path::PathBuf; -use std::time::Instant; use anyhow::Result; @@ -30,7 +29,6 @@ impl App { } = self.source.capture(width, height)?; if !changed && self.current_snapshot.is_some() { - self.last_tick = Instant::now(); return Ok(()); } @@ -93,7 +91,6 @@ impl App { self.append_log_record()?; self.maybe_save_triggered_snapshot()?; self.maybe_run_aftercommand(&raw_output)?; - self.last_tick = Instant::now(); Ok(()) } diff --git a/src/app/state.rs b/src/app/state.rs index a1479af..962f868 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -2,8 +2,6 @@ // Use of this source code is governed by an MIT license // that can be found in the LICENSE file. -use std::time::{Duration, Instant}; - use anyhow::Result; use super::config::AppConfig; @@ -16,7 +14,6 @@ impl App { pub fn new(cli: &Cli, source: Box) -> Result { let config = AppConfig::from_cli(cli, source.supports_child_pause())?; let mut app = Self { - interval_secs: config.interval_secs, debug: config.debug, paused: false, child_paused: false, @@ -47,7 +44,6 @@ impl App { aftercommand_runtime: config.aftercommand_runtime, command_display: config.command_display, source, - last_tick: Instant::now(), last_mouse_input: None, last_mouse_scroll_input: None, pending_mouse_escape: None, @@ -143,19 +139,6 @@ impl App { self.limit.saturating_add(self.trim_slack()) } - pub(super) fn tick_timeout(&self) -> Duration { - let interval = Duration::from_secs_f64(self.interval_secs.max(0.2)); - interval.saturating_sub(self.last_tick.elapsed()) - } - - pub(super) fn should_capture_now(&self) -> bool { - if self.source.is_event_driven() { - self.source.has_pending_update() - } else { - self.last_tick.elapsed() >= Duration::from_secs_f64(self.interval_secs.max(0.2)) - } - } - pub(super) fn is_source_closed_error(&self, err: &anyhow::Error) -> bool { err.chain().any(|cause| { cause.downcast_ref::().is_some_and(|io| { diff --git a/src/app/tests.rs b/src/app/tests.rs index ae03edc..9767d4a 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -1475,7 +1475,6 @@ fn history_snapshots_preserve_cursor_state() { fn test_cli() -> Cli { Cli { - interval: 2.0, batch: false, batch_count: None, batch_size: None, diff --git a/src/batch/tests.rs b/src/batch/tests.rs index ce7e9e5..c2fbdde 100644 --- a/src/batch/tests.rs +++ b/src/batch/tests.rs @@ -65,7 +65,6 @@ fn renders_word_diff_with_same_prefix_shape() { fn test_cli() -> Cli { Cli { - interval: 2.0, batch: true, batch_count: None, batch_size: None, diff --git a/src/cli.rs b/src/cli.rs index bad350f..351a5da 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -20,9 +20,6 @@ pub fn default_shell() -> String { #[derive(Debug, Clone, Parser)] #[command(author, version, about = "watch for TUI apps", long_about = None)] pub struct Cli { - #[arg(short = 'n', long, default_value_t = 2.0)] - pub interval: f64, - #[arg(short = 'b', long)] pub batch: bool, diff --git a/src/main.rs b/src/main.rs index 4c84294..784876b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,9 +20,11 @@ use twatch::aftercommand::{AfterCommandConfig, AfterCommandEvent, AfterCommandRu use twatch::app::App; use twatch::batch; use twatch::cli::Cli; -use twatch::runner::{DemoRunner, FrameSource, PtyRunner, ReplayRunner}; +use twatch::runner::{DemoRunner, FrameSource, PtyRunner, ReplayRunner, SourceEvent}; use twatch::screen::ScreenSnapshot; +const DEMO_BATCH_INTERVAL: Duration = Duration::from_millis(500); + fn main() -> Result<()> { let cli = Cli::parse(); if cli.batch { @@ -53,8 +55,8 @@ fn run_batch(cli: Cli) -> Result<()> { let (width, height) = batch::terminal_size(&cli); let mut source = build_source(&cli, SourceBuildMode::Batch, width, height)?; let mut aftercommand_runtime = build_batch_aftercommand_runtime(&cli)?; - - let interval = Duration::from_secs_f64(cli.interval.max(0.2)); + let is_event_driven = source.is_event_driven(); + let update_rx = source.take_update_receiver(); let mut stdout = std::io::stdout(); let mut emitted = 0usize; let mut previous = None; @@ -65,7 +67,15 @@ fn run_batch(cli: Cli) -> Result<()> { break; } - std::thread::sleep(interval); + if is_event_driven { + match wait_for_batch_source_update(update_rx.as_ref(), &*source) { + BatchLoopStep::Capture => {} + BatchLoopStep::Break => break, + } + } else { + std::thread::sleep(DEMO_BATCH_INTERVAL); + } + let frame = source.capture(width, height)?; if let Some(runtime) = &mut aftercommand_runtime { let changed_cell_count = frame @@ -95,6 +105,40 @@ fn run_batch(cli: Cli) -> Result<()> { Ok(()) } +enum BatchLoopStep { + Capture, + Break, +} + +fn wait_for_batch_source_update( + update_rx: Option<&std::sync::mpsc::Receiver>, + source: &dyn FrameSource, +) -> BatchLoopStep { + let Some(update_rx) = update_rx else { + return BatchLoopStep::Capture; + }; + + loop { + match update_rx.recv() { + Ok(SourceEvent::Updated) => return BatchLoopStep::Capture, + Ok(SourceEvent::Closed) | Err(_) => { + return if source.has_pending_update() { + BatchLoopStep::Capture + } else { + BatchLoopStep::Break + }; + } + Ok(SourceEvent::ClosedWithError(_)) => { + return if source.has_pending_update() { + BatchLoopStep::Capture + } else { + BatchLoopStep::Break + }; + } + } + } +} + #[derive(Copy, Clone, Debug, Eq, PartialEq)] enum SourceBuildMode { Interactive, diff --git a/src/ui/header.rs b/src/ui/header.rs index 998bbdd..3849edd 100644 --- a/src/ui/header.rs +++ b/src/ui/header.rs @@ -27,12 +27,7 @@ pub(super) fn draw_header_line_one(frame: &mut Frame<'_>, app: &App, area: Rect) .constraints([Constraint::Min(10), Constraint::Length(24)]) .split(area); - let cadence = if app.is_event_driven() { - "Event".to_string() - } else { - format!("Every {:>4.3}", app.interval_secs) - }; - let command_text = format!("{cadence} {}", app.command_display()); + let command_text = format!("Event {}", app.command_display()); let command_line = Line::from(vec![ Span::styled(" ", Style::default().bg(HEADER_BG)), Span::styled( From 5e4760970f73219ca0b108af01b7e07925a053b1 Mon Sep 17 00:00:00 2001 From: Blacknon Date: Thu, 14 May 2026 06:41:03 +0900 Subject: [PATCH 4/9] update. add bind_overwrite, keybind option --- README.md | 138 +++++++++++++++++++----- src/app/config.rs | 8 ++ src/app/input/history_nav.rs | 2 +- src/app/input/key.rs | 22 +++- src/app/input/mod.rs | 170 +++++++++++++++++++++++++++++- src/app/mod.rs | 4 + src/app/state.rs | 2 + src/app/tests.rs | 77 ++++++++++++++ src/batch/tests.rs | 2 + src/child_bindings.rs | 80 ++++++++++++++ src/cli.rs | 18 +++- src/input_key.rs | 198 +++++++++++++++++++++++++++++++++++ src/keymap.rs | 138 ++++++++++++++++++++++++ src/lib.rs | 3 + src/runner/mod.rs | 3 + src/runner/pty.rs | 9 ++ src/ui/dialogs.rs | 2 + 17 files changed, 844 insertions(+), 32 deletions(-) create mode 100644 src/child_bindings.rs create mode 100644 src/input_key.rs create mode 100644 src/keymap.rs diff --git a/README.md b/README.md index 849830e..c13b7bc 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ cargo install twatch ```text $ twatch --help -TUI watch for terminal apps +watch for TUI apps Usage: twatch [OPTIONS] [COMMAND]... @@ -64,36 +64,65 @@ Arguments: Options: -b, --batch - --batch-count Stop after emitting this many batch frames - --batch-size Use a fixed PTY size such as 80,24 - --batch-crop 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 + Stop after emitting this many batch frames + --batch-size + Use a fixed PTY size such as 80,24 + --batch-crop + 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-regex Only run aftercommand when output matches this regex + + -K, --keymap + Remap twatch keys with KEY=ACTION + -k, --bind + Override child TUI keys with FROM=TO + --aftercommand-regex + Only run aftercommand when output matches this regex --aftercommand-change-cells - Only run aftercommand when changed cell count reaches this threshold - --aftercommand-every Only run aftercommand on every Nth changed frame + Only run aftercommand when changed cell count reaches this threshold + --aftercommand-every + Only run aftercommand on every Nth changed frame --aftercommand-debounce-ms - Debounce aftercommand for this many milliseconds + Debounce aftercommand for this many milliseconds --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 - --replay Replay a saved JSONL trace in read-only mode - --screenshot-dir [default: /tmp] - --screenshot-format [default: text] [possible values: text, svg] - --snapshot-on Auto-save a snapshot when the screen contains this string - --snapshot-on-regex Auto-save a snapshot when the screen matches this regex + + --replay + Replay a saved JSONL trace in read-only mode + --screenshot-dir + [default: /tmp] + --screenshot-format + [default: text] [possible values: text, svg] + --snapshot-on + Auto-save a snapshot when the screen contains this string + --snapshot-on-regex + Auto-save a snapshot when the screen matches this regex --snapshot-on-change-cells - Auto-save a snapshot when changed cell count reaches this threshold - --snapshot-once Only trigger automatic snapshot once - -s, --shell [default: "sh -c"] - -d, --differences Diff mode: watch for TUI mode, list/word for batch mode [default: none] [possible values: none, watch, list, word] - -L, --limit [default: 500] - --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 + [default: "sh -c"] + -d, --differences + Diff mode: watch for TUI mode, list/word for batch mode [default: none] [possible values: none, watch, list, word] + -L, --limit + [default: 500] + --checkpoint-interval + [default: 12] + --debug + Show debug diagnostics in the interactive UI + -h, --help + Print help + -V, --version + Print version ``` ### Keybind @@ -128,6 +157,67 @@ 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-g=text:gg -k ctrl-t=screenshot nvim +``` + ### Notes - Mouse support is implemented, but behavior still depends on the child TUI and diff --git a/src/app/config.rs b/src/app/config.rs index e008a15..8f2a5e3 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -9,7 +9,9 @@ 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 { @@ -29,6 +31,8 @@ pub(super) struct AppConfig { pub aftercommand_runtime: Option, pub command_display: String, pub debug: bool, + pub keymap: Vec, + pub child_bindings: Vec, } impl AppConfig { @@ -49,6 +53,8 @@ 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 { child_pause_supported, @@ -78,6 +84,8 @@ impl AppConfig { }), command_display, debug: cli.debug, + keymap, + child_bindings, }) } } diff --git a/src/app/input/history_nav.rs b/src/app/input/history_nav.rs index 06b2378..f6daf48 100644 --- a/src/app/input/history_nav.rs +++ b/src/app/input/history_nav.rs @@ -95,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; } diff --git a/src/app/input/key.rs b/src/app/input/key.rs index 73c4cd0..8e3c104 100644 --- a/src/app/input/key.rs +++ b/src/app/input/key.rs @@ -44,16 +44,24 @@ impl App { return self.handle_search_key(key); } + if let Some(action) = self.find_key_action(key) { + return self.execute_key_action(action); + } + if self.ui.app_input_mode { if !self.follow_latest { return Ok(false); } + if let Some(action) = self.find_child_binding_action(key) { + self.apply_child_binding(key, action)?; + return Ok(false); + } if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('g') { self.ui.app_input_mode = false; - return Ok(false); + } else { + self.record_child_key_event(key); + self.source.send_key(key)?; } - self.record_child_key_event(key); - self.source.send_key(key)?; return Ok(false); } @@ -61,8 +69,12 @@ impl App { && self.ui.focus == FocusPane::Watch && self.should_passthrough_to_app(key) { - self.record_child_key_event(key); - self.source.send_key(key)?; + if let Some(action) = self.find_child_binding_action(key) { + self.apply_child_binding(key, action)?; + } else { + self.record_child_key_event(key); + self.source.send_key(key)?; + } return Ok(false); } diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index ac64da2..d6d37b9 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -7,7 +7,10 @@ use std::time::Duration; use anyhow::Result; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use super::{App, DiffMode, FocusPane, InputMode}; +use super::{App, DiffMode, FilterMode, FocusPane, InputMode}; +use crate::child_bindings::ChildBindingAction; +use crate::input_key::KeyPress; +use crate::keymap::KeyAction; mod history_nav; mod key; @@ -148,4 +151,169 @@ impl App { self.ui.inspect_x = self.ui.inspect_x.min(snapshot.width().saturating_sub(1)); self.ui.inspect_y = self.ui.inspect_y.min(snapshot.height().saturating_sub(1)); } + + fn find_key_action(&self, key: KeyEvent) -> Option { + let press = KeyPress::from(key); + self.keymap + .iter() + .rev() + .find(|binding| binding.trigger == press) + .map(|binding| binding.action) + } + + fn find_child_binding_action(&self, key: KeyEvent) -> Option { + let press = KeyPress::from(key); + self.child_bindings + .iter() + .rev() + .find(|binding| binding.trigger == press) + .map(|binding| binding.action.clone()) + } + + fn apply_child_binding(&mut self, key: KeyEvent, action: ChildBindingAction) -> Result<()> { + match action { + ChildBindingAction::Send(bytes) => { + self.record_child_key_event(key); + self.source.send_bytes(&bytes)?; + } + ChildBindingAction::SaveSnapshot => self.save_snapshot()?, + } + Ok(()) + } + + fn execute_key_action(&mut self, action: KeyAction) -> Result { + match action { + KeyAction::Up => self.move_up(), + KeyAction::WatchPaneUp => { + self.ui.focus = FocusPane::Watch; + self.scroll_selected_watch_view(-1); + } + KeyAction::HistoryPaneUp => { + self.ui.focus = FocusPane::History; + self.move_history_by(-1); + } + KeyAction::Down => self.move_down(), + KeyAction::WatchPaneDown => { + self.ui.focus = FocusPane::Watch; + self.scroll_selected_watch_view(1); + } + KeyAction::HistoryPaneDown => { + self.ui.focus = FocusPane::History; + self.move_history_by(1); + } + KeyAction::PageUp => self.page_up(), + KeyAction::WatchPanePageUp => { + self.ui.focus = FocusPane::Watch; + self.scroll_selected_watch_view(-10); + } + KeyAction::HistoryPanePageUp => { + self.ui.focus = FocusPane::History; + self.move_history_by(-10); + } + KeyAction::PageDown => self.page_down(), + KeyAction::WatchPanePageDown => { + self.ui.focus = FocusPane::Watch; + self.scroll_selected_watch_view(10); + } + KeyAction::HistoryPanePageDown => { + self.ui.focus = FocusPane::History; + self.move_history_by(10); + } + KeyAction::MoveTop => self.move_top(), + KeyAction::WatchPaneMoveTop => { + self.ui.focus = FocusPane::Watch; + self.ui.watch_scroll = 0; + } + KeyAction::HistoryPaneMoveTop => { + self.ui.focus = FocusPane::History; + self.follow_latest = true; + if let Some(first) = self.filtered.first().copied() { + self.selected_index = first; + } + self.invalidate_view_cache(); + } + KeyAction::MoveEnd => self.move_end(), + KeyAction::WatchPaneMoveEnd => { + self.ui.focus = FocusPane::Watch; + self.ui.watch_scroll = usize::MAX / 2; + } + KeyAction::HistoryPaneMoveEnd => { + self.ui.focus = FocusPane::History; + if let Some(last) = self.filtered.last().copied() { + self.selected_index = last; + self.follow_latest = false; + self.sync_follow_latest_with_selection(); + } + } + KeyAction::ToggleFocus => self.toggle_focus(), + KeyAction::FocusWatchPane => self.ui.focus = FocusPane::Watch, + KeyAction::FocusHistoryPane => self.ui.focus = FocusPane::History, + KeyAction::Quit => self.ui.show_exit_confirm = true, + KeyAction::Reset => { + if self.ui.show_help { + self.ui.show_help = false; + } else if self.ui.show_exit_confirm { + self.ui.show_exit_confirm = false; + } else if self.input_mode == InputMode::Search || !self.ui.filter_query.is_empty() { + self.clear_filter()?; + } + } + KeyAction::Delete => self.delete_selected_history()?, + KeyAction::ClearExceptSelected => self.clear_history_except_selected()?, + KeyAction::Cancel => { + if self.ui.filter_query.is_empty() { + self.ui.show_exit_confirm = true; + } else { + self.clear_filter()?; + } + } + KeyAction::ForceCancel => return Ok(true), + KeyAction::Help => self.ui.show_help = !self.ui.show_help, + KeyAction::ToggleViewHistoryPane => { + self.ui.show_history = !self.ui.show_history; + if self.ui.show_history { + self.ui.focus = FocusPane::History; + } else if self.ui.focus == FocusPane::History { + self.ui.focus = FocusPane::Watch; + self.ui.show_history_details = false; + } + } + KeyAction::ToggleHistorySummary => { + self.ui.show_history_details = !self.ui.show_history_details; + if !self.ui.show_history { + self.ui.show_history = true; + self.ui.focus = FocusPane::History; + } + } + KeyAction::ToggleDiffMode => self.cycle_diff_mode(), + KeyAction::SetDiffModeNone => self.diff_mode = DiffMode::None, + KeyAction::SetDiffModeWatch => self.diff_mode = DiffMode::Watch, + KeyAction::TogglePause => self.paused = !self.paused, + KeyAction::ToggleChildPause => self.toggle_child_pause()?, + KeyAction::ChangeFilterMode => self.start_search(FilterMode::Plain), + KeyAction::ChangeRegexFilterMode => self.start_search(FilterMode::Regex), + KeyAction::EnterAppInputMode => { + if self.ui.focus == FocusPane::Watch && self.follow_latest { + self.reset_watch_viewport(); + self.clear_live_scrollback_view(); + self.ui.app_input_mode = true; + } else if self.ui.focus == FocusPane::Watch { + self.ui.status_message = + Some("app input mode is available only on latest".to_string()); + } + } + KeyAction::LeaveAppInputMode => self.ui.app_input_mode = false, + KeyAction::ToggleInspector => { + self.ui.show_inspector = !self.ui.show_inspector; + self.clamp_inspector_to_snapshot(); + } + KeyAction::SaveSnapshot => self.save_snapshot()?, + KeyAction::CycleSnapshotFormat => self.cycle_screenshot_format(), + KeyAction::ScrollLeft => { + self.ui.horizontal_scroll = self.ui.horizontal_scroll.saturating_sub(4) + } + KeyAction::ScrollRight => self.ui.horizontal_scroll += 4, + } + Ok(false) + } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 6902966..834af31 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -8,9 +8,11 @@ use std::path::PathBuf; use std::time::{Duration, Instant}; use crate::aftercommand::AfterCommandRuntime; +use crate::child_bindings::ChildBinding; use crate::cli::{DiffModeArg, ScreenshotFormatArg}; use crate::history::HistoryMetadata; use crate::history::HistoryStore; +use crate::keymap::KeyBinding; use crate::runner::FrameSource; use crate::screen::ScreenSnapshot; use crate::screenshot::ScreenshotFormat; @@ -313,6 +315,8 @@ pub struct App { snapshot_trigger_fired: bool, aftercommand_runtime: Option, command_display: String, + keymap: Vec, + child_bindings: Vec, source: Box, last_mouse_input: Option, last_mouse_scroll_input: Option, diff --git a/src/app/state.rs b/src/app/state.rs index 962f868..d2ca8e9 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -43,6 +43,8 @@ impl App { snapshot_trigger_fired: false, aftercommand_runtime: config.aftercommand_runtime, command_display: config.command_display, + keymap: config.keymap, + child_bindings: config.child_bindings, source, last_mouse_input: None, last_mouse_scroll_input: None, diff --git a/src/app/tests.rs b/src/app/tests.rs index 9767d4a..7f48e4e 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -23,6 +23,7 @@ struct MockSource { mouse_passthrough_enabled: bool, view_snapshot_requests: Arc>>, key_events: Arc>>, + byte_events: Arc>>>, mouse_events: Arc>>, } @@ -36,6 +37,7 @@ impl MockSource { mouse_passthrough_enabled: true, view_snapshot_requests: Arc::new(Mutex::new(Vec::new())), key_events: Arc::new(Mutex::new(Vec::new())), + byte_events: Arc::new(Mutex::new(Vec::new())), mouse_events: Arc::new(Mutex::new(Vec::new())), } } @@ -49,6 +51,7 @@ impl MockSource { mouse_passthrough_enabled: false, view_snapshot_requests: Arc::new(Mutex::new(Vec::new())), key_events: Arc::new(Mutex::new(Vec::new())), + byte_events: Arc::new(Mutex::new(Vec::new())), mouse_events: Arc::new(Mutex::new(Vec::new())), } } @@ -62,6 +65,7 @@ impl MockSource { mouse_passthrough_enabled: true, view_snapshot_requests: Arc::new(Mutex::new(Vec::new())), key_events: Arc::new(Mutex::new(Vec::new())), + byte_events: Arc::new(Mutex::new(Vec::new())), mouse_events: Arc::new(Mutex::new(Vec::new())), } } @@ -108,6 +112,11 @@ impl FrameSource for MockSource { Ok(()) } + fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> { + self.byte_events.lock().unwrap().push(bytes.to_vec()); + Ok(()) + } + fn send_mouse(&mut self, event: MouseEvent, _body_row_offset: u16) -> Result { if !self.mouse_passthrough_enabled { return Ok(false); @@ -1120,6 +1129,72 @@ fn key_passthrough_is_allowed_when_following_latest() { assert_eq!(key_events.lock().unwrap().len(), 1); } +#[test] +fn custom_keymap_can_override_watch_passthrough() { + let mut cli = test_cli(); + cli.keymap = vec!["down=history_pane_down".to_string()]; + let mut app = App::new( + &cli, + Box::new(MockSource::new(vec![ + frame("a", &["one"]), + frame("b", &["two"]), + frame("c", &["three"]), + ])), + ) + .unwrap(); + + app.capture(20, 5).unwrap(); + app.capture(20, 5).unwrap(); + app.capture(20, 5).unwrap(); + app.ui.focus = FocusPane::Watch; + + app.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .unwrap(); + + assert!(!app.follow_latest); + assert_eq!(app.selected_index, 1); +} + +#[test] +fn child_bindings_remap_passthrough_keys_to_raw_bytes() { + let mut cli = test_cli(); + cli.bind = vec!["j=down".to_string()]; + let source = MockSource::new(vec![frame("a", &["one"])]); + let byte_events = source.byte_events.clone(); + let key_events = source.key_events.clone(); + let mut app = App::new(&cli, Box::new(source)).unwrap(); + + app.capture(20, 5).unwrap(); + app.ui.app_input_mode = true; + app.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)) + .unwrap(); + + assert!(key_events.lock().unwrap().is_empty()); + assert_eq!(byte_events.lock().unwrap().as_slice(), &[b"\x1b[B".to_vec()]); +} + +#[test] +fn custom_leave_app_input_mode_key_works_with_child_bindings() { + let mut cli = test_cli(); + cli.keymap = vec!["ctrl-t=leave_app_input_mode".to_string()]; + cli.bind = vec!["ctrl-g=text:gg".to_string()]; + let source = MockSource::new(vec![frame("a", &["one"])]); + let byte_events = source.byte_events.clone(); + let mut app = App::new(&cli, Box::new(source)).unwrap(); + + app.capture(20, 5).unwrap(); + app.ui.app_input_mode = true; + + app.handle_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)) + .unwrap(); + assert_eq!(byte_events.lock().unwrap().as_slice(), &[b"gg".to_vec()]); + assert!(app.ui.app_input_mode); + + app.handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) + .unwrap(); + assert!(!app.ui.app_input_mode); +} + #[test] fn app_input_mode_is_available_only_on_latest() { let mut app = App::new( @@ -1482,6 +1557,8 @@ fn test_cli() -> Cli { batch_diff_only: false, batch_no_color: false, aftercommand: None, + keymap: Vec::new(), + bind: Vec::new(), aftercommand_regex: None, aftercommand_change_cells: None, aftercommand_every: None, diff --git a/src/batch/tests.rs b/src/batch/tests.rs index c2fbdde..db20cbd 100644 --- a/src/batch/tests.rs +++ b/src/batch/tests.rs @@ -72,6 +72,8 @@ fn test_cli() -> Cli { batch_diff_only: false, batch_no_color: false, aftercommand: None, + keymap: Vec::new(), + bind: Vec::new(), aftercommand_regex: None, aftercommand_change_cells: None, aftercommand_every: None, diff --git a/src/child_bindings.rs b/src/child_bindings.rs new file mode 100644 index 0000000..402bef4 --- /dev/null +++ b/src/child_bindings.rs @@ -0,0 +1,80 @@ +// 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::{Context, Result}; + +use crate::input_key::{KeyPress, parse_key_bytes, parse_key_press}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ChildBindingAction { + Send(Vec), + SaveSnapshot, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ChildBinding { + pub(crate) trigger: KeyPress, + pub(crate) action: ChildBindingAction, +} + +pub(crate) fn compile_child_bindings(specs: &[String]) -> Result> { + let mut bindings = Vec::new(); + for spec in specs { + let (left, right) = spec + .split_once('=') + .with_context(|| format!("binding must be FROM=TO: {spec}"))?; + let trigger = parse_key_press(left.trim())?; + let action = parse_child_binding_action(right.trim())?; + bindings.push(ChildBinding { trigger, action }); + } + Ok(bindings) +} + +fn parse_child_binding_action(value: &str) -> Result { + if value.eq_ignore_ascii_case("screenshot") { + return Ok(ChildBindingAction::SaveSnapshot); + } + + if let Some(text) = value.strip_prefix("text:") { + return Ok(ChildBindingAction::Send(text.as_bytes().to_vec())); + } + + let key_list = value.strip_prefix("send:").unwrap_or(value); + let mut bytes = Vec::new(); + for item in key_list.split(',') { + bytes.extend_from_slice(&parse_key_bytes(item.trim())?); + } + Ok(ChildBindingAction::Send(bytes)) +} + +#[cfg(test)] +mod tests { + use super::{ChildBindingAction, compile_child_bindings}; + + #[test] + fn parses_twrap_style_bindings() { + let bindings = compile_child_bindings(&[ + "j=down".to_string(), + "ctrl-t=screenshot".to_string(), + "g=text:gg".to_string(), + ]) + .unwrap(); + + assert!( + bindings + .iter() + .any(|binding| binding.action == ChildBindingAction::Send(b"\x1b[B".to_vec())) + ); + assert!( + bindings + .iter() + .any(|binding| binding.action == ChildBindingAction::SaveSnapshot) + ); + assert!( + bindings + .iter() + .any(|binding| binding.action == ChildBindingAction::Send(b"gg".to_vec())) + ); + } +} diff --git a/src/cli.rs b/src/cli.rs index 351a5da..ea71e2e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -18,7 +18,7 @@ pub fn default_shell() -> String { } #[derive(Debug, Clone, Parser)] -#[command(author, version, about = "watch for TUI apps", long_about = None)] +#[command(author, version, about = "twatch - adds rewindable history to existing TUI applications.", long_about = None)] pub struct Cli { #[arg(short = 'b', long)] pub batch: bool, @@ -49,6 +49,22 @@ pub struct Cli { #[arg(short = 'A', long)] pub aftercommand: Option, + #[arg( + short = 'K', + long, + help = "Remap twatch keys with KEY=ACTION", + value_name = "KEY=ACTION" + )] + pub keymap: Vec, + + #[arg( + short = 'k', + long, + help = "Override child TUI keys with FROM=TO", + value_name = "FROM=TO" + )] + pub bind: Vec, + #[arg(long, help = "Only run aftercommand when output matches this regex")] pub aftercommand_regex: Option, diff --git a/src/input_key.rs b/src/input_key.rs new file mode 100644 index 0000000..77e2b46 --- /dev/null +++ b/src/input_key.rs @@ -0,0 +1,198 @@ +// 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::{Context, Result}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub(crate) struct KeyPress { + pub(crate) code: KeyCode, + pub(crate) modifiers: KeyModifiers, +} + +impl From for KeyPress { + fn from(value: KeyEvent) -> Self { + Self { + code: value.code, + modifiers: value.modifiers, + } + } +} + +pub(crate) fn parse_key_press(value: &str) -> Result { + let (modifiers, key_name) = parse_key_name(value)?; + let code = parse_key_code(key_name, modifiers)?; + Ok(KeyPress { code, modifiers }) +} + +pub(crate) fn parse_key_bytes(value: &str) -> Result> { + let normalized = value.trim().to_ascii_lowercase(); + if normalized.is_empty() { + anyhow::bail!("key binding token must not be empty"); + } + + Ok(match normalized.as_str() { + "up" => b"\x1b[A".to_vec(), + "down" => b"\x1b[B".to_vec(), + "right" => b"\x1b[C".to_vec(), + "left" => b"\x1b[D".to_vec(), + "home" => b"\x1b[H".to_vec(), + "end" => b"\x1b[F".to_vec(), + "pageup" => b"\x1b[5~".to_vec(), + "pagedown" => b"\x1b[6~".to_vec(), + "insert" => b"\x1b[2~".to_vec(), + "delete" => b"\x1b[3~".to_vec(), + "enter" => vec![b'\r'], + "tab" => vec![b'\t'], + "esc" => vec![0x1b], + "space" => vec![b' '], + "backspace" => vec![0x7f], + "f1" => b"\x1bOP".to_vec(), + "f2" => b"\x1bOQ".to_vec(), + "f3" => b"\x1bOR".to_vec(), + "f4" => b"\x1bOS".to_vec(), + "f5" => b"\x1b[15~".to_vec(), + "f6" => b"\x1b[17~".to_vec(), + "f7" => b"\x1b[18~".to_vec(), + "f8" => b"\x1b[19~".to_vec(), + "f9" => b"\x1b[20~".to_vec(), + "f10" => b"\x1b[21~".to_vec(), + "f11" => b"\x1b[23~".to_vec(), + "f12" => b"\x1b[24~".to_vec(), + token if token.starts_with("ctrl-") => parse_ctrl_key(token)?, + token => parse_literal_key(token)?, + }) +} + +fn parse_key_name(value: &str) -> Result<(KeyModifiers, &str)> { + let trimmed = value.trim(); + if trimmed.is_empty() { + anyhow::bail!("key binding token must not be empty"); + } + + let mut modifiers = KeyModifiers::empty(); + let mut rest = trimmed; + loop { + let lower = rest.to_ascii_lowercase(); + if let Some(stripped) = lower.strip_prefix("ctrl-") { + modifiers.insert(KeyModifiers::CONTROL); + rest = &rest[(rest.len() - stripped.len())..]; + continue; + } + if let Some(stripped) = lower.strip_prefix("alt-") { + modifiers.insert(KeyModifiers::ALT); + rest = &rest[(rest.len() - stripped.len())..]; + continue; + } + if let Some(stripped) = lower.strip_prefix("shift-") { + modifiers.insert(KeyModifiers::SHIFT); + rest = &rest[(rest.len() - stripped.len())..]; + continue; + } + break; + } + + if rest.is_empty() { + anyhow::bail!("key binding token must not be empty"); + } + + Ok((modifiers, rest)) +} + +fn parse_key_code(value: &str, modifiers: KeyModifiers) -> Result { + let normalized = value.to_ascii_lowercase(); + match normalized.as_str() { + "up" => Ok(KeyCode::Up), + "down" => Ok(KeyCode::Down), + "left" => Ok(KeyCode::Left), + "right" => Ok(KeyCode::Right), + "home" => Ok(KeyCode::Home), + "end" => Ok(KeyCode::End), + "pageup" => Ok(KeyCode::PageUp), + "pagedown" => Ok(KeyCode::PageDown), + "tab" => Ok(KeyCode::Tab), + "backtab" => Ok(KeyCode::BackTab), + "backspace" => Ok(KeyCode::Backspace), + "enter" => Ok(KeyCode::Enter), + "esc" => Ok(KeyCode::Esc), + "insert" => Ok(KeyCode::Insert), + "delete" => Ok(KeyCode::Delete), + "space" => Ok(KeyCode::Char(' ')), + "plus" => Ok(KeyCode::Char('+')), + "minus" => Ok(KeyCode::Char('-')), + name if name.starts_with('f') => { + let num = name[1..] + .parse::() + .with_context(|| format!("unsupported function key: {value}"))?; + Ok(KeyCode::F(num)) + } + _ => parse_char_code(value, modifiers), + } +} + +fn parse_char_code(value: &str, modifiers: KeyModifiers) -> Result { + let mut chars = value.chars(); + let ch = chars + .next() + .with_context(|| format!("unsupported key binding token: {value}"))?; + if chars.next().is_some() { + anyhow::bail!("unsupported key binding token: {value}"); + } + + let code = if modifiers.contains(KeyModifiers::SHIFT) && ch.is_ascii_alphabetic() { + ch.to_ascii_uppercase() + } else { + ch + }; + + Ok(KeyCode::Char(code)) +} + +fn parse_ctrl_key(value: &str) -> Result> { + let suffix = &value[5..]; + if suffix.len() != 1 { + anyhow::bail!("ctrl binding must have one character: {value}"); + } + let ch = suffix.as_bytes()[0]; + if !ch.is_ascii_alphabetic() && ch != b'\\' && ch != b'[' && ch != b']' { + anyhow::bail!("unsupported ctrl binding: {value}"); + } + Ok(match ch { + b'\\' => vec![0x1c], + b'[' => vec![0x1b], + b']' => vec![0x1d], + _ => vec![ch.to_ascii_uppercase() - b'@'], + }) +} + +fn parse_literal_key(value: &str) -> Result> { + if value.chars().count() != 1 { + anyhow::bail!("unsupported key binding token: {value}"); + } + Ok(value.as_bytes().to_vec()) +} + +#[cfg(test)] +mod tests { + use super::{KeyPress, parse_key_bytes, parse_key_press}; + use crossterm::event::{KeyCode, KeyModifiers}; + + #[test] + fn parses_shift_letter_keypress() { + let key = parse_key_press("shift-s").unwrap(); + assert_eq!( + key, + KeyPress { + code: KeyCode::Char('S'), + modifiers: KeyModifiers::SHIFT, + } + ); + } + + #[test] + fn parses_ctrl_bytes_like_twrap() { + assert_eq!(parse_key_bytes("ctrl-g").unwrap(), vec![0x07]); + assert_eq!(parse_key_bytes("down").unwrap(), b"\x1b[B".to_vec()); + } +} diff --git a/src/keymap.rs b/src/keymap.rs new file mode 100644 index 0000000..42176f2 --- /dev/null +++ b/src/keymap.rs @@ -0,0 +1,138 @@ +// 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::{Context, Result}; + +use crate::input_key::{KeyPress, parse_key_press}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum KeyAction { + Up, + WatchPaneUp, + HistoryPaneUp, + Down, + WatchPaneDown, + HistoryPaneDown, + PageUp, + WatchPanePageUp, + HistoryPanePageUp, + PageDown, + WatchPanePageDown, + HistoryPanePageDown, + MoveTop, + WatchPaneMoveTop, + HistoryPaneMoveTop, + MoveEnd, + WatchPaneMoveEnd, + HistoryPaneMoveEnd, + ToggleFocus, + FocusWatchPane, + FocusHistoryPane, + Quit, + Reset, + Delete, + ClearExceptSelected, + Cancel, + ForceCancel, + Help, + ToggleViewHistoryPane, + ToggleHistorySummary, + ToggleDiffMode, + SetDiffModeNone, + SetDiffModeWatch, + TogglePause, + ToggleChildPause, + ChangeFilterMode, + ChangeRegexFilterMode, + EnterAppInputMode, + LeaveAppInputMode, + ToggleInspector, + SaveSnapshot, + CycleSnapshotFormat, + ScrollLeft, + ScrollRight, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct KeyBinding { + pub(crate) trigger: KeyPress, + pub(crate) action: KeyAction, +} + +pub(crate) fn compile_keymap(specs: &[String]) -> Result> { + let mut bindings = Vec::new(); + for spec in specs { + let (left, right) = spec + .split_once('=') + .with_context(|| format!("keymap must be KEY=ACTION: {spec}"))?; + let trigger = parse_key_press(left.trim())?; + let action = parse_key_action(right.trim())?; + bindings.push(KeyBinding { trigger, action }); + } + Ok(bindings) +} + +fn parse_key_action(value: &str) -> Result { + Ok(match value.trim().to_ascii_lowercase().as_str() { + "up" => KeyAction::Up, + "watch_pane_up" => KeyAction::WatchPaneUp, + "history_pane_up" => KeyAction::HistoryPaneUp, + "down" => KeyAction::Down, + "watch_pane_down" => KeyAction::WatchPaneDown, + "history_pane_down" => KeyAction::HistoryPaneDown, + "page_up" => KeyAction::PageUp, + "watch_pane_page_up" => KeyAction::WatchPanePageUp, + "history_pane_page_up" => KeyAction::HistoryPanePageUp, + "page_down" => KeyAction::PageDown, + "watch_pane_page_down" => KeyAction::WatchPanePageDown, + "history_pane_page_down" => KeyAction::HistoryPanePageDown, + "move_top" => KeyAction::MoveTop, + "watch_pane_move_top" => KeyAction::WatchPaneMoveTop, + "history_pane_move_top" => KeyAction::HistoryPaneMoveTop, + "move_end" => KeyAction::MoveEnd, + "watch_pane_move_end" => KeyAction::WatchPaneMoveEnd, + "history_pane_move_end" => KeyAction::HistoryPaneMoveEnd, + "toggle_focus" => KeyAction::ToggleFocus, + "focus_watch_pane" => KeyAction::FocusWatchPane, + "focus_history_pane" => KeyAction::FocusHistoryPane, + "quit" => KeyAction::Quit, + "reset" => KeyAction::Reset, + "delete" => KeyAction::Delete, + "clear_except_selected" => KeyAction::ClearExceptSelected, + "cancel" => KeyAction::Cancel, + "force_cancel" => KeyAction::ForceCancel, + "help" => KeyAction::Help, + "toggle_view_history_pane" => KeyAction::ToggleViewHistoryPane, + "toggle_history_summary" => KeyAction::ToggleHistorySummary, + "toggle_diff_mode" => KeyAction::ToggleDiffMode, + "set_diff_mode_none" | "set_diff_mode_plane" => KeyAction::SetDiffModeNone, + "set_diff_mode_watch" => KeyAction::SetDiffModeWatch, + "toggle_pause" => KeyAction::TogglePause, + "toggle_child_pause" => KeyAction::ToggleChildPause, + "change_filter_mode" => KeyAction::ChangeFilterMode, + "change_regex_filter_mode" => KeyAction::ChangeRegexFilterMode, + "enter_app_input_mode" => KeyAction::EnterAppInputMode, + "leave_app_input_mode" => KeyAction::LeaveAppInputMode, + "toggle_inspector" => KeyAction::ToggleInspector, + "save_snapshot" => KeyAction::SaveSnapshot, + "cycle_snapshot_format" => KeyAction::CycleSnapshotFormat, + "scroll_left" => KeyAction::ScrollLeft, + "scroll_right" => KeyAction::ScrollRight, + _ => anyhow::bail!("unsupported key action: {value}"), + }) +} + +#[cfg(test)] +mod tests { + use super::{KeyAction, compile_keymap}; + + #[test] + fn parses_hwatch_style_keymap() { + let bindings = + compile_keymap(&["ctrl-p=history_pane_up".to_string(), "q=quit".to_string()]).unwrap(); + assert_eq!(bindings.len(), 2); + assert_eq!(bindings[0].action, KeyAction::HistoryPaneUp); + assert_eq!(bindings[1].action, KeyAction::Quit); + } +} diff --git a/src/lib.rs b/src/lib.rs index 621c6bb..71f5417 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,9 +5,12 @@ pub mod aftercommand; pub mod app; pub mod batch; +mod child_bindings; pub mod cli; pub mod diff; pub mod history; +mod input_key; +mod keymap; pub mod logging; mod process_control; pub mod runner; diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 5a5a02e..e2081c9 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -43,6 +43,9 @@ pub trait FrameSource { } fn resize(&mut self, width: u16, height: u16) -> Result<()>; fn send_key(&mut self, key: KeyEvent) -> Result<()>; + fn send_bytes(&mut self, _bytes: &[u8]) -> Result<()> { + Ok(()) + } fn send_mouse(&mut self, event: MouseEvent, body_row_offset: u16) -> Result; fn toggle_child_pause(&mut self) -> Result> { Ok(None) diff --git a/src/runner/pty.rs b/src/runner/pty.rs index 30dbcfa..46b88e1 100644 --- a/src/runner/pty.rs +++ b/src/runner/pty.rs @@ -274,6 +274,15 @@ impl FrameSource for PtyRunner { Ok(()) } + fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> { + let mut writer = self.writer.lock().expect("writer poisoned"); + writer + .write_all(bytes) + .context("failed to write child binding bytes to PTY")?; + writer.flush().ok(); + Ok(()) + } + fn send_mouse(&mut self, event: MouseEvent, body_row_offset: u16) -> Result { let encoded = { let state = self.state.read().expect("terminal state poisoned"); diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs index f5c8971..0c33b27 100644 --- a/src/ui/dialogs.rs +++ b/src/ui/dialogs.rs @@ -24,6 +24,8 @@ pub(super) fn draw_help(frame: &mut Frame<'_>, area: Rect) { Line::from("Left/Right focus watch/history"), Line::from("Up/Down child app in watch pane / move history"), Line::from("Mouse passthrough only on latest"), + Line::from("--keymap remap twatch actions"), + Line::from("--bind override child TUI keys"), Line::from("Backspace toggle history pane"), Line::from("/ search history"), Line::from("* regex filter history"), From d7b4c9dbbf4e85d7d22df49ea613a8a3bba412c2 Mon Sep 17 00:00:00 2001 From: Blacknon Date: Thu, 14 May 2026 16:27:50 +0900 Subject: [PATCH 5/9] update. ci fix --- src/app/runtime.rs | 2 +- src/app/tests.rs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/runtime.rs b/src/app/runtime.rs index d0e1265..f16b04d 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -2,9 +2,9 @@ // Use of this source code is governed by an MIT license // that can be found in the LICENSE file. +use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, RecvTimeoutError, Sender}; -use std::sync::Arc; use std::time::Duration; use anyhow::Result; diff --git a/src/app/tests.rs b/src/app/tests.rs index 7f48e4e..7c6579f 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -1170,7 +1170,10 @@ fn child_bindings_remap_passthrough_keys_to_raw_bytes() { .unwrap(); assert!(key_events.lock().unwrap().is_empty()); - assert_eq!(byte_events.lock().unwrap().as_slice(), &[b"\x1b[B".to_vec()]); + assert_eq!( + byte_events.lock().unwrap().as_slice(), + &[b"\x1b[B".to_vec()] + ); } #[test] From 63c667b629547a3f64b7e8b8d4c3a23c4df3d2e8 Mon Sep 17 00:00:00 2001 From: Blacknon Date: Thu, 14 May 2026 16:35:01 +0900 Subject: [PATCH 6/9] update. ci fix --- .github/workflows/ci.yml | 8 ++++++-- .github/workflows/release.yml | 5 +++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c5cfa8..8ffff1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bca40c7..d9988eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,10 +75,11 @@ 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 @@ -86,7 +87,7 @@ jobs: 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 From 4ff4378e7271d3b46166e8fde175de0d6da8b830 Mon Sep 17 00:00:00 2001 From: Blacknon Date: Mon, 18 May 2026 10:21:43 +0900 Subject: [PATCH 7/9] update. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bd445cb..5ff0ef6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1660,7 +1660,7 @@ dependencies = [ [[package]] name = "twatch" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index b67459b..4745ebe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" From 1c9f9b1e253446b76ec4c34838413b5b1467b9c7 Mon Sep 17 00:00:00 2001 From: Blacknon Date: Mon, 18 May 2026 23:55:57 +0900 Subject: [PATCH 8/9] update. add relase note. --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84bf0b9..4d8a1c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From 8fb4a7aba3e5fe0adfda97235625b1e1b1aa33b7 Mon Sep 17 00:00:00 2001 From: Blacknon Date: Tue, 19 May 2026 00:09:42 +0900 Subject: [PATCH 9/9] update. ci fix --- README.md | 4 +++- src/app/input/key.rs | 12 ++++++------ src/app/input/mod.rs | 20 ++++++++++++++++---- src/app/tests.rs | 17 +++++------------ src/child_bindings.rs | 44 +++++++++++++++++++++++++++---------------- 5 files changed, 58 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index c13b7bc..9095cb2 100644 --- a/README.md +++ b/README.md @@ -215,9 +215,11 @@ This follows the `twrap` style: `TO` accepts key names like `up`, `down`, ```bash twatch -k j=down -k k=up lazygit -twatch -k ctrl-g=text:gg -k ctrl-t=screenshot nvim +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 diff --git a/src/app/input/key.rs b/src/app/input/key.rs index 8e3c104..3b2c034 100644 --- a/src/app/input/key.rs +++ b/src/app/input/key.rs @@ -52,15 +52,15 @@ impl App { if !self.follow_latest { return Ok(false); } - if let Some(action) = self.find_child_binding_action(key) { - self.apply_child_binding(key, action)?; - return Ok(false); - } if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('g') { self.ui.app_input_mode = false; } else { - self.record_child_key_event(key); - self.source.send_key(key)?; + if let Some(action) = self.find_child_binding_action(key) { + self.apply_child_binding(key, action)?; + } else { + self.record_child_key_event(key); + self.source.send_key(key)?; + } } return Ok(false); } diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index d6d37b9..a44a2f7 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -5,10 +5,10 @@ use std::time::Duration; use anyhow::Result; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; use super::{App, DiffMode, FilterMode, FocusPane, InputMode}; -use crate::child_bindings::ChildBindingAction; +use crate::child_bindings::{ChildBindingAction, ChildBindingKey}; use crate::input_key::KeyPress; use crate::keymap::KeyAction; @@ -172,9 +172,21 @@ impl App { fn apply_child_binding(&mut self, key: KeyEvent, action: ChildBindingAction) -> Result<()> { match action { - ChildBindingAction::Send(bytes) => { + ChildBindingAction::SendKeys(keys) => { self.record_child_key_event(key); - self.source.send_bytes(&bytes)?; + for item in keys { + match item { + ChildBindingKey::Press(press) => { + self.source.send_key(KeyEvent { + code: press.code, + modifiers: press.modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + })?; + } + ChildBindingKey::Bytes(bytes) => self.source.send_bytes(&bytes)?, + } + } } ChildBindingAction::SaveSnapshot => self.save_snapshot()?, } diff --git a/src/app/tests.rs b/src/app/tests.rs index 7c6579f..ae9af7c 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -1169,17 +1169,14 @@ fn child_bindings_remap_passthrough_keys_to_raw_bytes() { app.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)) .unwrap(); - assert!(key_events.lock().unwrap().is_empty()); - assert_eq!( - byte_events.lock().unwrap().as_slice(), - &[b"\x1b[B".to_vec()] - ); + assert_eq!(key_events.lock().unwrap().len(), 1); + assert!(byte_events.lock().unwrap().is_empty()); + assert_eq!(key_events.lock().unwrap()[0].code, KeyCode::Down); } #[test] -fn custom_leave_app_input_mode_key_works_with_child_bindings() { +fn ctrl_g_remains_reserved_to_leave_app_input_mode() { let mut cli = test_cli(); - cli.keymap = vec!["ctrl-t=leave_app_input_mode".to_string()]; cli.bind = vec!["ctrl-g=text:gg".to_string()]; let source = MockSource::new(vec![frame("a", &["one"])]); let byte_events = source.byte_events.clone(); @@ -1190,11 +1187,7 @@ fn custom_leave_app_input_mode_key_works_with_child_bindings() { app.handle_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)) .unwrap(); - assert_eq!(byte_events.lock().unwrap().as_slice(), &[b"gg".to_vec()]); - assert!(app.ui.app_input_mode); - - app.handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) - .unwrap(); + assert!(byte_events.lock().unwrap().is_empty()); assert!(!app.ui.app_input_mode); } diff --git a/src/child_bindings.rs b/src/child_bindings.rs index 402bef4..92a5f8b 100644 --- a/src/child_bindings.rs +++ b/src/child_bindings.rs @@ -8,10 +8,16 @@ use crate::input_key::{KeyPress, parse_key_bytes, parse_key_press}; #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum ChildBindingAction { - Send(Vec), + SendKeys(Vec), SaveSnapshot, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ChildBindingKey { + Press(KeyPress), + Bytes(Vec), +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct ChildBinding { pub(crate) trigger: KeyPress, @@ -37,20 +43,29 @@ fn parse_child_binding_action(value: &str) -> Result { } if let Some(text) = value.strip_prefix("text:") { - return Ok(ChildBindingAction::Send(text.as_bytes().to_vec())); + return Ok(ChildBindingAction::SendKeys(vec![ChildBindingKey::Bytes( + text.as_bytes().to_vec(), + )])); } let key_list = value.strip_prefix("send:").unwrap_or(value); - let mut bytes = Vec::new(); + let mut keys = Vec::new(); for item in key_list.split(',') { - bytes.extend_from_slice(&parse_key_bytes(item.trim())?); + let item = item.trim(); + if let Ok(press) = parse_key_press(item) { + keys.push(ChildBindingKey::Press(press)); + } else { + keys.push(ChildBindingKey::Bytes(parse_key_bytes(item)?)); + } } - Ok(ChildBindingAction::Send(bytes)) + Ok(ChildBindingAction::SendKeys(keys)) } #[cfg(test)] mod tests { - use super::{ChildBindingAction, compile_child_bindings}; + use super::{ChildBindingAction, ChildBindingKey, compile_child_bindings}; + use crate::input_key::KeyPress; + use crossterm::event::{KeyCode, KeyModifiers}; #[test] fn parses_twrap_style_bindings() { @@ -61,20 +76,17 @@ mod tests { ]) .unwrap(); - assert!( - bindings - .iter() - .any(|binding| binding.action == ChildBindingAction::Send(b"\x1b[B".to_vec())) - ); + assert!(bindings.iter().any(|binding| binding.action + == ChildBindingAction::SendKeys(vec![ChildBindingKey::Press(KeyPress { + code: KeyCode::Down, + modifiers: KeyModifiers::NONE, + })]))); assert!( bindings .iter() .any(|binding| binding.action == ChildBindingAction::SaveSnapshot) ); - assert!( - bindings - .iter() - .any(|binding| binding.action == ChildBindingAction::Send(b"gg".to_vec())) - ); + assert!(bindings.iter().any(|binding| binding.action + == ChildBindingAction::SendKeys(vec![ChildBindingKey::Bytes(b"gg".to_vec())]))); } }