From 8fd5877114d7c8241856cfa2cf672771f19aae9d Mon Sep 17 00:00:00 2001 From: Ferrol Aderholdt Date: Fri, 24 Jul 2026 14:56:17 -0700 Subject: [PATCH] Fix Esc swallowing, codex paging, resumed-codex stats, waiting shake - Forward Alt+char to the PTY as ESC + char so an Escape merged with the next keystroke (vim: Esc then :wq) no longer loses the Escape - Route PageUp/PageDown to captured scrollback for claude/codex panes, matching mouse-wheel behavior - Let the codex rollout watcher claim a pre-existing file whose mtime moves past spawn time, so resumed sessions get token/context stats - Shrink the status panel with 3s hysteresis so a flapping WAITING preview row can't oscillate pane layout and PTY sizes Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 ++++ src/app.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ src/codex_log.rs | 16 ++++++++++++++-- src/main.rs | 43 ++++++++++++++++++++++++++++++++++++++++++- src/ui.rs | 7 ++++++- 5 files changed, 109 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a159aad..e02b926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Fixed Escape keypresses being swallowed when followed quickly by another key (crossterm merges them into Alt+char): the ESC prefix is now forwarded to the PTY. Most visible in vim, where Esc then `:wq` typed fast left the session in insert mode with `:wq` inserted into the buffer. +- `PageUp`/`PageDown` now scroll linkshell's captured scrollback in claude/codex panes (matching the mouse wheel) instead of being sent to the TUI, which ignored them. +- Fixed the whole UI shaking when a codex session flapped in and out of WAITING: the status panel's waiting-preview row now shrinks with a few seconds of hysteresis, breaking the resize→repaint→state-flap feedback loop. +- Fixed missing token/context stats for resumed codex sessions (`codex resume` or the in-TUI picker): the rollout watcher now also picks up a pre-existing rollout file that starts being written after the session spawns. - Chat pane input: `Home`/`End`/`Delete` now work; `Up`/`Down` recall previously sent messages (draft preserved); typing `/` as the first character opens a filtering command popup (`Up`/`Down` select, `Tab` completes). - Added recursive split panes: any pane can be split side by side (`alt-\`) or top/bottom (`alt--`), repeatedly and in any direction, for arbitrary tiled layouts. `alt-w` closes the focused pane (its sibling reclaims the space), `alt-r` rotates a split, `alt-o` cycles focus. Replaces the previous two-pane toggle; keybinding actions are now `split_pane_right`, `split_pane_down`, `close_pane`, `rotate_split`, `focus_next_pane`. - Added screen-style multi-session support: each `linkshell` starts its own detached server, `linkshell ls` lists live sessions (id, name, pid, status), and `linkshell -r ` reattaches to a specific one. `linkshell new [name]` names a session; `linkshell -r` with no id attaches the sole running session. diff --git a/src/app.rs b/src/app.rs index 5d013ca..6712e96 100644 --- a/src/app.rs +++ b/src/app.rs @@ -433,6 +433,10 @@ pub struct App { pub pipe_list_selected: usize, pub should_quit: bool, pub needs_redraw: bool, + // Shrink hysteresis for the status panel (see stabilized_status_rows). + // Cells because the render path only has &App. + status_rows_hold: std::cell::Cell, + status_rows_hold_at: std::cell::Cell>, pub event_tx: mpsc::Sender, pub config: Arc, pub pipes: Vec, @@ -556,6 +560,8 @@ impl App { pipe_list_selected: 0, should_quit: false, needs_redraw: true, + status_rows_hold: std::cell::Cell::new(0), + status_rows_hold_at: std::cell::Cell::new(None), event_tx, config, pipes: Vec::new(), @@ -1252,6 +1258,30 @@ impl App { .unwrap_or(0) } + /// Status-panel height with shrink hysteresis. Growing applies + /// immediately; shrinking only after the smaller height has been desired + /// for a few seconds. Without this, a session whose inferred state flaps + /// (codex repaints re-triggering WAITING↔RUNNING) adds and removes its + /// waiting-preview row every few hundred ms; each change resizes the + /// output panes, the resized TUI repaints, the repaint re-flaps the + /// state, and the whole UI oscillates. + pub fn stabilized_status_rows(&self, desired: u16) -> u16 { + const HOLD: std::time::Duration = std::time::Duration::from_secs(3); + let held = self.status_rows_hold.get(); + let fresh = self + .status_rows_hold_at + .get() + .is_some_and(|t| t.elapsed() < HOLD); + if desired >= held || !fresh { + self.status_rows_hold.set(desired); + self.status_rows_hold_at + .set(Some(std::time::Instant::now())); + desired + } else { + held + } + } + // ── Event handlers ───────────────────────────────────────────────────── pub fn handle_session_writer(&mut self, session_id: usize, writer_tx: mpsc::Sender>) { @@ -5740,6 +5770,19 @@ mod tests { app.new_session_state.cursor_pos() } + #[test] + fn status_rows_grow_immediately_but_shrink_with_hysteresis() { + let app = make_app(); + assert_eq!(app.stabilized_status_rows(6), 6); + // Growth applies at once. + assert_eq!(app.stabilized_status_rows(7), 7); + // A flap back down is held at the larger height… + assert_eq!(app.stabilized_status_rows(6), 7); + // …and growing again re-arms the hold. + assert_eq!(app.stabilized_status_rows(7), 7); + assert_eq!(app.stabilized_status_rows(6), 7); + } + #[test] fn chat_paste_inserts_at_cursor_and_normalizes_newlines() { let mut app = make_app(); diff --git a/src/codex_log.rs b/src/codex_log.rs index 97522c8..46e937a 100644 --- a/src/codex_log.rs +++ b/src/codex_log.rs @@ -60,16 +60,27 @@ fn rollout_cwd(path: &Path) -> Option { /// Codex only creates the rollout file when the user submits their first /// prompt, which can be arbitrarily long after the session spawns — so there /// is no deadline here; poll until the file appears or the app shuts down. +/// +/// A resumed session (`codex resume`, or the in-TUI resume picker) appends to +/// a rollout that already existed at spawn, so a pre-existing file counts as +/// a candidate too once its mtime moves past our spawn time — otherwise +/// resumed sessions never get token stats. async fn wait_for_new_rollout( dir: &Path, existing: &HashSet, + spawn_time: std::time::SystemTime, cwd: &str, tx: &tokio::sync::mpsc::Sender, ) -> Option { loop { let mut candidates: Vec = jsonl_files(dir) .into_iter() - .filter(|p| !existing.contains(p)) + .filter(|p| { + !existing.contains(p) + || std::fs::metadata(p) + .and_then(|m| m.modified()) + .is_ok_and(|mtime| mtime > spawn_time) + }) .collect(); candidates.sort(); @@ -277,9 +288,10 @@ pub fn spawn_watcher( // spawned, so a fast-starting Codex can't create its file first and have // it land in `existing` (same race claude_log guards against). let existing = jsonl_files(&dir); + let spawn_time = std::time::SystemTime::now(); tokio::spawn(async move { - let jsonl = match wait_for_new_rollout(&dir, &existing, &cwd, &tx).await { + let jsonl = match wait_for_new_rollout(&dir, &existing, spawn_time, &cwd, &tx).await { Some(p) => p, None => return, }; diff --git a/src/main.rs b/src/main.rs index 848d01e..b032199 100644 --- a/src/main.rs +++ b/src/main.rs @@ -858,6 +858,23 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) { app.chat_key(key); return; } + // Full-screen agent TUIs (claude, codex) ignore the terminal's + // PageUp/PageDown sequences, so route those keys to linkshell's + // captured scrollback — the same history the mouse wheel scrolls. + // Shells and other alt-screen apps (vim, less) still get the keys. + if key.modifiers.is_empty() && matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) { + let agent_alt_screen = app.active_session().is_some_and(|s| { + s.kind.captures_alt_scrollback() && s.screen.screen().alternate_screen() + }); + if agent_alt_screen { + if key.code == KeyCode::PageUp { + app.scroll_up(20); + } else { + app.scroll_down(20); + } + return; + } + } // Pass through to PTY let bytes = key_to_bytes(&key); if !bytes.is_empty() { @@ -1251,7 +1268,19 @@ fn key_to_bytes(key: &crossterm::event::KeyEvent) -> Vec { return vec![b]; } let mut buf = [0u8; 4]; - c.encode_utf8(&mut buf).as_bytes().to_vec() + let encoded = c.encode_utf8(&mut buf).as_bytes(); + // Alt+char must keep its ESC prefix. Without this, an Escape + // keypress followed quickly by a character (vim users leaving + // insert mode: Esc then `:wq`) gets merged by crossterm into + // Alt+char and the Escape silently vanishes — vim never leaves + // insert mode and the command text lands in the buffer. + if key.modifiers.contains(KeyModifiers::ALT) { + let mut v = Vec::with_capacity(1 + encoded.len()); + v.push(27); + v.extend_from_slice(encoded); + return v; + } + encoded.to_vec() } // Shift+Enter: ESC [ 13 ; 2 u (kitty/xterm extended) KeyCode::Enter => { @@ -1565,6 +1594,18 @@ fn parse_tcp_flag() -> Option { mod tests { use super::*; + #[test] + fn alt_char_keeps_its_escape_prefix() { + use crossterm::event::{KeyEvent, KeyModifiers}; + // Esc followed quickly by a char is delivered by crossterm as + // Alt+char; the PTY must see ESC then the char, not a bare char. + let key = KeyEvent::new(KeyCode::Char(':'), KeyModifiers::ALT); + assert_eq!(key_to_bytes(&key), vec![27, b':']); + + let plain = KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE); + assert_eq!(key_to_bytes(&plain), vec![b':']); + } + #[test] fn transient_terminal_errors_are_retried() { let would_block = std::io::Error::new(std::io::ErrorKind::WouldBlock, "busy"); diff --git a/src/ui.rs b/src/ui.rs index a7dddfe..fff291e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -173,7 +173,12 @@ pub fn draw(f: &mut Frame<'_>, app: &App) -> LayoutInfo { 0 }; let desired_status_rows = app.visible_indices().len().max(1) as u16 + 4 + previews + orch_row; - let status_rows = desired_status_rows.min((body.height / 3).max(4)); + let capped = desired_status_rows.min((body.height / 3).max(4)); + // Hysteresis so a flapping WAITING preview row can't oscillate the + // pane layout (and with it the sessions' PTY sizes). + let status_rows = app + .stabilized_status_rows(capped) + .min((body.height / 3).max(4)); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([