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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` reattaches to a specific one. `linkshell new [name]` names a session; `linkshell -r` with no id attaches the sole running session.
Expand Down
43 changes: 43 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u16>,
status_rows_hold_at: std::cell::Cell<Option<std::time::Instant>>,
pub event_tx: mpsc::Sender<AppEvent>,
pub config: Arc<Config>,
pub pipes: Vec<Pipe>,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<Vec<u8>>) {
Expand Down Expand Up @@ -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();
Expand Down
16 changes: 14 additions & 2 deletions src/codex_log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,27 @@ fn rollout_cwd(path: &Path) -> Option<String> {
/// 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<PathBuf>,
spawn_time: std::time::SystemTime,
cwd: &str,
tx: &tokio::sync::mpsc::Sender<AppEvent>,
) -> Option<PathBuf> {
loop {
let mut candidates: Vec<PathBuf> = 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();

Expand Down Expand Up @@ -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,
};
Expand Down
43 changes: 42 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -1251,7 +1268,19 @@ fn key_to_bytes(key: &crossterm::event::KeyEvent) -> Vec<u8> {
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 => {
Expand Down Expand Up @@ -1565,6 +1594,18 @@ fn parse_tcp_flag() -> Option<u16> {
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");
Expand Down
7 changes: 6 additions & 1 deletion src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Loading