diff --git a/src-tauri/src/persistence/mod.rs b/src-tauri/src/persistence/mod.rs index 24897c3..11080c9 100644 --- a/src-tauri/src/persistence/mod.rs +++ b/src-tauri/src/persistence/mod.rs @@ -37,9 +37,9 @@ //! blob). //! - **Block history persistence (Warp-parity).** Closed blocks are //! persisted in full and never evicted by count, so terminal history -//! is never cut off across restarts. `load_blocks` windows the -//! most-recent rows for fast restore; older blocks page in on -//! scroll-back. +//! is never cut off across restarts. `load_blocks` restores the full +//! transcript; the native renderer virtualizes painting so deep +//! history does not make each frame proportional to its row count. //! - **No graceful shutdown.** The writer thread relies on macOS //! tearing it down at app exit; WAL recovers any half-finished //! transaction on next launch. If we add long-running async writes @@ -115,16 +115,14 @@ pub struct SavedBlock { pub duration_ms: Option, } -/// How many of the most-recent blocks `load_blocks` returns on restore. -/// History is retained in full on disk (Warp-parity — never cut off); -/// the renderer pages in older blocks on scroll-back. Kept in sync with -/// the front-end's `MAX_BLOCKS` in `sessionMemory.ts`. -const HISTORY_LOAD_WINDOW: i64 = 500; - -/// Return the most-recent `HISTORY_LOAD_WINDOW` persisted blocks for a -/// pty_id in insertion order (oldest first), matching how the -/// frontend's `sessionMemory.blocks` array is ordered. Older blocks -/// stay on disk for scroll-back. Returns an empty vec for unknown +/// Return every persisted block for a pty_id in insertion order +/// (oldest first), matching how the frontend's `sessionMemory.blocks` +/// array is ordered. There used to be a 500-block read window here with +/// a promise that older rows would page in on scroll-back, but no paging +/// path existed; the 501st-oldest block was therefore permanently +/// unreachable after a restart. The render path already virtualizes +/// deep transcripts, so restoring the source of truth in full is both +/// correct and bounded at paint time. Returns an empty vec for unknown /// pty_ids — no error. pub fn load_blocks( db_path: &std::path::Path, @@ -134,19 +132,13 @@ pub fn load_blocks( .map_err(|e| format!("open RO at {}: {e}", db_path.display()))?; let mut stmt = conn .prepare( - // Window to the most-recent rows (newest-first inner, then - // re-sorted oldest-first). Restore stays fast even when a - // pty has accumulated huge history; older blocks remain on - // disk and page in on scroll-back. "SELECT block_id, input, transcript, block_rows, \ - exit_code, cwd, duration_ms FROM (\ - SELECT * FROM blocks WHERE pty_id = ?1 \ - ORDER BY id DESC LIMIT ?2\ - ) ORDER BY id ASC", + exit_code, cwd, duration_ms \ + FROM blocks WHERE pty_id = ?1 ORDER BY id ASC", ) .map_err(|e| format!("prepare load_blocks: {e}"))?; let rows = stmt - .query_map(rusqlite::params![pty_id, HISTORY_LOAD_WINDOW], |row| { + .query_map(rusqlite::params![pty_id], |row| { let rows_text: String = row.get(3)?; // Stored as a known-shape JSON literal we wrote ourselves // last save — parse failures here are real corruption and @@ -391,14 +383,14 @@ mod tests { } #[test] - fn history_is_retained_in_full_and_load_is_windowed() { + fn history_is_retained_and_restored_in_full() { let (_dir, path) = fresh_db(); let conn = db::open_rw(&path).unwrap(); let w = writer::start(conn, path.clone()); - // Insert past the load window. Warp-parity: nothing is evicted - // on save — the read path windows instead, so older history is - // never cut off on disk. + // Insert past the old 500-block load window. Both storage and + // restore must remain complete: keeping old rows in SQLite is + // not useful if the UI can never load or scroll to them. const WINDOW: i64 = 500; for i in 1..=(WINDOW + 5) { w.save_block(mk_block(i, &format!("cmd-{i}"))); @@ -420,10 +412,10 @@ mod tests { .unwrap(); assert_eq!(total, WINDOW + 5, "history must be retained in full"); - // ...but load_blocks returns only the most-recent WINDOW, oldest-first. + // load_blocks returns the entire transcript, oldest-first. let blocks = load_blocks(&path, "pty-A").unwrap(); - assert_eq!(blocks.len(), WINDOW as usize); - assert_eq!(blocks[0].block_id, 6); // 1..=5 fall outside the window + assert_eq!(blocks.len(), (WINDOW + 5) as usize); + assert_eq!(blocks[0].block_id, 1); assert_eq!(blocks.last().unwrap().block_id, WINDOW + 5); } diff --git a/src-tauri/src/persistence/writer.rs b/src-tauri/src/persistence/writer.rs index 7a6bdd1..8f126dd 100644 --- a/src-tauri/src/persistence/writer.rs +++ b/src-tauri/src/persistence/writer.rs @@ -72,10 +72,9 @@ pub struct SavedBlockPayload { } // Block history is retained in full on disk — Warp-parity: terminal -// history is never cut off across restarts. The read path -// (`load_blocks` in `mod.rs`) windows the most-recent rows so restore -// stays fast no matter how large the history grows; older blocks page -// in on scroll-back. +// history is never cut off across restarts. The read path restores the +// full source of truth; renderers virtualize deep transcripts so paint +// work stays bounded to the viewport. /// Public handle to the writer. Cloneable so multiple Tauri commands /// can hold a sender without coordinating. @@ -279,8 +278,8 @@ fn apply( ], )?; // Warp-parity: never evict by count. Full block history is - // retained on disk so it's never cut off; `load_blocks` - // windows the most-recent rows for fast restore. + // retained on disk and restored in full; the native painter + // virtualizes deep transcripts. tx.commit()?; } Event::ForgetPty(pty_id) => { diff --git a/src-tauri/src/term.rs b/src-tauri/src/term.rs index 512bc5c..4cfb4f9 100644 --- a/src-tauri/src/term.rs +++ b/src-tauri/src/term.rs @@ -591,16 +591,13 @@ pub struct RenderFrame { /// "█ in the middle of 'Switch between Clau█'" symptom users /// reported. pub cursor_visible: bool, - /// True iff the PTY's line discipline has left canonical mode - /// (`ICANON` cleared via `tcsetattr`). This is the universal signal - /// that the foreground program is reading keystrokes raw and doing - /// its own line editing / key handling — interactive prompts - /// (inquirer / `prompts` / clack / enquirer), `fzf`, password - /// readers, and full TUIs all clear it. Unlike `app_cursor` / - /// `bracketed_paste` (which a prompt library may never emit), raw - /// mode is set by *every* program that does its own keystroke - /// reading, so it's the reliable trigger for routing arrows + every - /// other key straight to the PTY. + /// True iff the PTY requires direct keystroke passthrough: either + /// canonical input is off (`ICANON` cleared) or local echo is off + /// (`ECHO` cleared). Interactive menus and full TUIs generally clear + /// ICANON; password/token readers can retain canonical buffering while + /// clearing only ECHO. Unlike `app_cursor` / `bracketed_paste` (which a + /// prompt library may never emit), these line-discipline flags reliably + /// tell us that PromptInput must get out of the way. /// /// The frontend gates this on `command_running` before acting on it: /// the interactive shell's OWN line editor (zsh ZLE, bash readline) @@ -684,17 +681,14 @@ pub fn clear_native_pty(id: &str) { } } -/// Hand a freshly-built frame to the native sink iff `id` is a mirrored pty. +/// Hand a freshly-built frame to the native sink. The native renderer keeps a +/// lightweight retained snapshot for hidden PTYs as well as the pane currently +/// on screen, so switching instances never has to rebuild a long transcript on +/// the interaction path. Hidden sessions already arrive at the 4 Hz cadence. #[inline] fn emit_native_frame(id: &str, frame: &RenderFrame) { - let is_target = NATIVE_PTYS - .lock() - .map(|g| g.iter().any(|p| p == id)) - .unwrap_or(false); - if is_target { - if let Some(sink) = NATIVE_SINK.get() { - sink(id, frame); - } + if let Some(sink) = NATIVE_SINK.get() { + sink(id, frame); } } @@ -710,10 +704,12 @@ pub fn set_native_block_sink(sink: NativeBlockSink) { let _ = NATIVE_BLOCK_SINK.set(sink); } -/// Hand a finished block to the native sink iff `id` is a mirrored pty. -/// True iff the PTY's line discipline has left canonical mode (`ICANON` -/// cleared). See `RenderFrame::raw_input` for the full rationale — this -/// is the per-frame probe that fills that field. +/// True iff the PTY needs direct keystroke passthrough: canonical mode is off +/// (`ICANON` cleared) OR local echo is off (`ECHO` cleared). The first covers +/// interactive menus/TUIs; the second covers password and token readers that +/// retain canonical line buffering but must not expose input in PromptInput. +/// See `RenderFrame::raw_input` for the full rationale — this is the per-frame +/// probe that fills that legacy-named field. /// /// Reads termios off the master fd: on a PTY the master and slave share /// one line-discipline state, so this reflects whatever the foreground @@ -732,29 +728,35 @@ fn pty_in_raw_mode(master: &dyn MasterPty) -> bool { return false; } let tio = unsafe { tio.assume_init() }; - (tio.c_lflag & libc::ICANON) == 0 + (tio.c_lflag & (libc::ICANON | libc::ECHO)) != (libc::ICANON | libc::ECHO) } +/// Hand a finished block to the native sink. Hidden PTYs are retained too so +/// their closed-block history is already current when they become visible. #[inline] fn emit_native_block(id: &str, block: &ClosedBlock) { - let is_target = NATIVE_PTYS - .lock() - .map(|g| g.iter().any(|p| p == id)) - .unwrap_or(false); - if !is_target { - return; - } if let Some(sink) = NATIVE_BLOCK_SINK.get() { sink(id, block); } } -/// Push a full-grid frame for `id` straight to the native sink (no IPC, no -/// scrollback). Called when the native surface (re)attaches to a pty so it -/// repaints immediately from current state instead of waiting for the pty's -/// next output. No-op if the session isn't alive yet — then the first real -/// frame from `term_start` fills the surface. +/// Push a full-grid frame for `id` straight to the native sink (no IPC). Called +/// on attach and resize so the surface repaints immediately without disturbing +/// the live block's separately accumulated scrollback. pub fn reemit_native(state: &TerminalState, id: &str) { + reemit_native_impl(state, id, false); +} + +/// Re-emit a directly-launched agent and include its full PTY scrollback. A +/// direct agent has no shell OSC 133 block id, so native retention may have +/// engaged after its first output frames. Shell-launched agents already have a +/// scoped live block and must not receive the whole session history here (that +/// would duplicate their closed blocks), hence the block-id guard below. +pub fn reemit_native_unscoped_agent(state: &TerminalState, id: &str) { + reemit_native_impl(state, id, true); +} + +fn reemit_native_impl(state: &TerminalState, id: &str, include_unscoped_scrollback: bool) { let sess = { let Ok(sessions) = state.sessions.lock() else { return; @@ -777,9 +779,16 @@ pub fn reemit_native(state: &TerminalState, id: &str) { .collect(); let seq = s.next_frame_seq; s.next_frame_seq = s.next_frame_seq.saturating_add(1); + let block_id = s.segmenter.current_block_id(); + let include_scrollback = include_unscoped_scrollback && block_id == 0; + let scrollback_appended = if include_scrollback { + sample_scrollback_rows(&s.term, s.term.grid().history_size()) + } else { + Vec::new() + }; let frame = RenderFrame { seq, - block_id: s.segmenter.current_block_id(), + block_id, cols: s.cols, rows: s.rows, cursor_row: cursor.line.0, @@ -791,8 +800,8 @@ pub fn reemit_native(state: &TerminalState, id: &str) { cursor_visible: s.term.mode().contains(TermMode::SHOW_CURSOR), raw_input: pty_in_raw_mode(s.pty_master.as_ref()), dirty, - scrollback_appended: Vec::new(), - scrollback_reset: false, + scrollback_appended, + scrollback_reset: include_scrollback, }; emit_native_frame(id, &frame); } @@ -2192,6 +2201,25 @@ pub struct StartArgs { pub session_id: Option, } +/// Direct-launched Codex must use its normal-screen mode so output enters the +/// PTY's scrollback buffer. Shell-launched Codex is rewritten by +/// `makeCodexScrollable` in the frontend; this covers callers that start a pane +/// with `command = codex` and therefore never submit a shell command line. +fn scrollback_safe_args(command: &str, args: &[String]) -> Vec { + let basename = std::path::Path::new(command) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(command) + .to_ascii_lowercase(); + let mut out = args.to_vec(); + if matches!(basename.as_str(), "codex" | "codex-cli") + && !out.iter().any(|arg| arg == "--no-alt-screen") + { + out.insert(0, "--no-alt-screen".to_string()); + } + out +} + #[tauri::command] pub fn term_start( app: AppHandle, @@ -2274,8 +2302,13 @@ pub fn term_start( }) .map_err(|e| format!("openpty: {e}"))?; + let command_basename = std::path::Path::new(&args.command) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(&args.command); + let command_args = scrollback_safe_args(&args.command, &args.args); let mut cmd = CommandBuilder::new(&args.command); - cmd.args(&args.args); + cmd.args(&command_args); if let Some(cwd) = args.cwd.as_deref() { cmd.cwd(cwd); } @@ -2320,10 +2353,6 @@ pub fn term_start( // Shells we don't recognise (fish, pwsh, plain `sh`, custom // shells) launch unmodified — the user just doesn't get block // segmentation in that PTY. Future work: fish + pwsh bootstrap. - let command_basename = std::path::Path::new(&args.command) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or(&args.command); if command_basename == "zsh" { if let Ok(dir) = ensure_zsh_integration_dir(&app) { // Stash the user's existing ZDOTDIR (if any) so the @@ -2338,8 +2367,7 @@ pub fn term_start( // Only inject the flags when the caller didn't already // supply their own — respecting any explicit override // they passed in args.args. - let already_set = args - .args + let already_set = command_args .iter() .any(|a| a == "--rcfile" || a == "--noprofile" || a == "-i"); if !already_set { @@ -3949,6 +3977,21 @@ mod tests { assert!(msg.contains("claude")); } + #[test] + fn direct_codex_launch_uses_normal_screen_scrollback() { + let args = vec!["resume".to_string(), "--last".to_string()]; + assert_eq!( + scrollback_safe_args("/opt/homebrew/bin/codex", &args), + vec!["--no-alt-screen", "resume", "--last"], + ); + + let explicit = vec!["--no-alt-screen".to_string(), "resume".to_string()]; + assert_eq!(scrollback_safe_args("codex-cli", &explicit), explicit); + + let claude = vec!["--model".to_string(), "opus".to_string()]; + assert_eq!(scrollback_safe_args("claude", &claude), claude); + } + /* ---------- Color → CSS ---------- */ #[test] @@ -4095,6 +4138,25 @@ mod tests { ); } + #[test] + fn terminal_history_exceeds_alacritty_default_without_truncation() { + use std::fmt::Write as _; + + // Alacritty's default history cap is 10,000 rows. Cross it so a + // future accidental TermConfig::default() regression cannot silently + // restore the exact cutoff users reported for long agent sessions. + const LINES: usize = 10_050; + let mut transcript = String::with_capacity(LINES * 14); + for i in 0..LINES { + writeln!(&mut transcript, "line-{i:05}\r").unwrap(); + } + + let rows = snapshot_transcript(&transcript, 32, 4); + assert_eq!(rows.len(), LINES); + assert!(row_text(&rows[0]).starts_with("line-00000")); + assert!(row_text(rows.last().unwrap()).starts_with("line-10049")); + } + /* ---------- native wheel delivery ---------- */ #[test] @@ -4144,10 +4206,10 @@ mod tests { assert_eq!(out, b"\x1b[<65;80;1M"); } - /// `pty_in_raw_mode` is the whole mechanism behind routing arrow - /// keys / Enter to interactive prompts (inquirer / `prompts` / fzf): - /// when the foreground child clears `ICANON`, the frontend swaps the - /// block editor for raw passthrough. This pins two things that are + /// `pty_in_raw_mode` is the mechanism behind routing direct input to + /// interactive prompts (inquirer / `prompts` / fzf / password readers): + /// when the foreground child clears `ICANON` or `ECHO`, the frontend swaps + /// the block editor for raw passthrough. This pins two things that are /// easy to get wrong and platform-specific: /// 1. `tcgetattr` works on the PTY *master* fd (the Session drops /// the slave after spawn, so the master is all we have). On @@ -4155,7 +4217,7 @@ mod tests { /// 2. The master fd reflects the slave's termios — so a child /// flipping raw mode is observable from our side. #[test] - fn pty_in_raw_mode_tracks_canonical_flag() { + fn pty_in_raw_mode_tracks_canonical_and_echo_flags() { use std::os::fd::RawFd; let pair = native_pty_system() @@ -4188,5 +4250,17 @@ mod tests { pty_in_raw_mode(pair.master.as_ref()), "after ICANON is cleared the PTY must report raw mode", ); + + // Password/token readers often keep canonical line buffering and + // disable only local echo. That must also engage passthrough so the + // secret is neither painted nor stored in the block editor/history. + tio.c_lflag |= libc::ICANON; + tio.c_lflag &= !libc::ECHO; + assert_eq!(unsafe { libc::tcsetattr(fd, libc::TCSANOW, &tio) }, 0); + + assert!( + pty_in_raw_mode(pair.master.as_ref()), + "after ECHO is cleared the PTY must require passthrough", + ); } } diff --git a/src-tauri/src/warp_term.rs b/src-tauri/src/warp_term.rs index dd615c7..30ba271 100644 --- a/src-tauri/src/warp_term.rs +++ b/src-tauri/src/warp_term.rs @@ -19,6 +19,7 @@ //! `Container`, inverse/dim folded into colors), with a block cursor. use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex, OnceLock}; use warpui::color::ColorU; @@ -81,15 +82,6 @@ const BLOCK_PAD_X: f32 = 14.0; const BLOCK_PAD_Y: f32 = 8.0; /// Width (px) of the red left stripe on a failed block. const STRIPE_W: f32 = 2.0; -/// How many of the newest closed blocks the scroll-back can page through. -/// Matches the persistence restore window (`HISTORY_LOAD_WINDOW = 500`), so the -/// user can scroll through their entire restored history. This is NO LONGER a -/// performance bound — transcript virtualization makes each frame O(viewport) -/// regardless of depth, and each block's height is cached (`NativeBlock.height`) -/// so the per-frame height sweep is O(blocks), not O(rows). Older history beyond -/// this stays on disk and would need a deeper load window to surface. -const BLOCK_RENDER_CAP: usize = 500; - /* ------------------------------------------------------------------ Assets — warpui loads fonts from the OS, so the embedded surface needs no bundled assets. Surface a clear error if it asks for one. @@ -113,6 +105,9 @@ impl warpui::AssetProvider for TermAssets { /// per-block grid snapshot (`block_rows` from term.rs) plus header metadata. /// Stacked above the live grid by `render`, oldest first — the Warp transcript. struct NativeBlock { + /// Stable per-PTY identity. Used to merge a background SQLite hydration + /// with blocks that may have closed while that read was in flight. + block_id: u64, command: String, rows: Vec, /// The block's raw output transcript (bytes, with ANSI + hard newlines). @@ -135,6 +130,7 @@ struct NativeBlock { impl NativeBlock { fn new( + block_id: u64, command: String, rows: Vec, transcript: String, @@ -143,6 +139,7 @@ impl NativeBlock { exit_code: Option, ) -> Self { let mut b = Self { + block_id, command, rows, transcript, @@ -188,6 +185,11 @@ struct TermGrid { /// into the `ClippedScrollStateHandle` each render. Only meaningful while /// `stick_bottom` is false. scroll_px: f32, + /// Canonical maximum vertical offset computed from transcript height minus + /// viewport height during render. Wheel input uses this instead of reading + /// `ClippedScrollStateHandle`, whose temporary 1e9 bottom sentinel can be + /// observed before layout clamps it and make an upward gesture snap back. + max_scroll_px: f32, /// When true (the default), the transcript auto-follows new output: render /// hands the scroll handle a huge sentinel offset that `after_layout` clamps /// to the true bottom, so the newest line is always pinned to the viewport @@ -196,6 +198,15 @@ struct TermGrid { stick_bottom: bool, /// Frames applied since attach — diagnostic only. frames: u64, + /// Whether persisted closed blocks have been hydrated for this PTY. Live + /// frames can create a hidden snapshot before its history is loaded; the + /// attach path paints that snapshot immediately and hydrates history off + /// the interaction path. + history_loaded: bool, + /// Direct-launched agents have no OSC 133 block id. Preserve their normal- + /// screen scrollback while hidden once the visible pane has identified the + /// session as an agent. + retain_unscoped_scrollback: bool, } impl TermGrid { @@ -213,15 +224,18 @@ impl TermGrid { scrollback: Vec::new(), live_block_id: 0, scroll_px: 0.0, + max_scroll_px: 0.0, stick_bottom: true, frames: 0, + history_loaded: false, + retain_unscoped_scrollback: false, } } /// Apply a sparse frame: resize to the frame's grid height, overwrite the /// dirty rows, and track cursor + dims + the live-grid gate flags. Cheap — /// clones only changed rows. - fn apply_frame(&mut self, f: &RenderFrame) { + fn apply_frame(&mut self, f: &RenderFrame, retain_unscoped_scrollback: bool) { if self.n_rows != f.rows { self.rows .resize(f.rows as usize, RowSnapshot { spans: Vec::new() }); @@ -255,13 +269,17 @@ impl TermGrid { // - a new `block_id` (new prompt/command, or the block closing to 0) → // drop, since a closed command's output is rendered from its own // closed-block transcript and would otherwise appear twice. - // We only retain while a block is live (`block_id != 0`); idle-shell - // scroll deltas are dropped (closed blocks carry that history). + // We normally retain while a shell block is live (`block_id != 0`); + // idle-shell scroll deltas are dropped because closed blocks carry that + // history. A directly-launched agent has no shell OSC 133 lifecycle, + // however, so every frame has block_id=0. The pane's explicit agent-mode + // bit keeps that session's normal-screen history instead of silently + // limiting it to the visible grid. if f.scrollback_reset || f.block_id != self.live_block_id { self.scrollback.clear(); } self.live_block_id = f.block_id; - if f.block_id != 0 && !f.alt_screen { + if (f.block_id != 0 || retain_unscoped_scrollback) && !f.alt_screen { self.scrollback.reserve(f.scrollback_appended.len()); for d in &f.scrollback_appended { self.scrollback.push(RowSnapshot { @@ -273,22 +291,37 @@ impl TermGrid { self.frames = self.frames.wrapping_add(1); } + /// Apply one vertical scroll delta against canonical transcript geometry. + /// Positive moves toward newer output; negative reveals older output. + fn scroll_by(&mut self, delta_px: f32) { + if self.max_scroll_px <= 0.0 { + self.scroll_px = 0.0; + self.stick_bottom = true; + return; + } + let current = if self.stick_bottom { + self.max_scroll_px + } else { + self.scroll_px + }; + self.scroll_px = (current + delta_px).clamp(0.0, self.max_scroll_px); + self.stick_bottom = false; + } + /// Re-wrap every stored closed block to `cols`, recomputing each cached /// height. This is the Warp reflow: a block keeps its raw `transcript` /// (bytes + hard newlines), so replaying it through the VT at the new width /// re-soft-wraps the text while real line breaks stay put — identical in - /// effect to Warp rebuilding its soft-wrap index. Bounded to the most-recent - /// `BLOCK_RENDER_CAP` (only those can be painted) and only invoked when the - /// width actually changes (the React resize is debounced), so it costs a - /// handful of replays per drag, not one per frame. Runs on the pty reader - /// thread (the frame sink), off the render thread. + /// effect to Warp rebuilding its soft-wrap index. Only invoked when the + /// width actually changes (the React resize is debounced). Runs on the pty + /// reader thread (the frame sink), off the render thread. Every retained + /// block is rewrapped because every retained block is scroll-reachable. fn rewrap_blocks(&mut self, cols: u16) { if cols == 0 { return; } let rows = self.n_rows.max(1); - let start = self.blocks.len().saturating_sub(BLOCK_RENDER_CAP); - for b in &mut self.blocks[start..] { + for b in &mut self.blocks { if b.transcript.is_empty() { continue; } @@ -308,8 +341,7 @@ struct Pane { /// Retained grid the frame sink patches and `build_pane_column` paints from. grid: Arc>, /// Scroll-back handle for this pane's `ClippedScrollable` (survives the - /// per-frame element rebuild; render mirrors scroll_px/stick_bottom in, - /// `term_native_scroll` reads the clamped offset back out). + /// per-frame element rebuild; render mirrors scroll_px/stick_bottom in). scroll: ClippedScrollStateHandle, /// Horizontal scroll-back handle for the panes whose PTY grid is wider than /// their on-screen width (the narrow right-panel side terminal pins a wide @@ -393,6 +425,141 @@ fn pane_for_pty(pty_id: &str) -> Option<&'static Pane> { panes().iter().find(|p| p.pty_id() == pty_id) } +/// Retained native models for PTYs that are not currently assigned to a pane. +/// A worktree switch moves the model between a pane and this map; it does not +/// rebuild the model from SQLite. Hidden frame/block events keep these entries +/// current at the backend's already-throttled hidden cadence. +static HIDDEN_GRIDS: OnceLock>> = OnceLock::new(); +static HISTORY_LOADS: OnceLock>> = OnceLock::new(); +/// Serializes the tiny ownership handoff between pane grids and hidden grids. +/// Frame delivery, attach/detach, and background hydration can run on different +/// threads; without one routing critical section, a completed history read +/// could merge into a pane just after that pane switched to a different PTY. +static GRID_ROUTING: Mutex<()> = Mutex::new(()); + +fn hidden_grids() -> &'static Mutex> { + HIDDEN_GRIDS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn history_loads() -> &'static Mutex> { + HISTORY_LOADS.get_or_init(|| Mutex::new(HashSet::new())) +} + +fn grid_has_retained_state(g: &TermGrid) -> bool { + g.history_loaded + || g.frames > 0 + || !g.rows.is_empty() + || !g.blocks.is_empty() + || !g.scrollback.is_empty() +} + +fn stash_pane_grid(pty_id: &str, p: &'static Pane) { + if pty_id.is_empty() { + return; + } + let old = { + let mut g = p.grid.lock().unwrap_or_else(|e| e.into_inner()); + std::mem::replace(&mut *g, TermGrid::empty()) + }; + // During a split-pane swap the destination steals the source grid before + // that source receives its own attach call. Do not overwrite the stolen, + // current cache entry with the empty placeholder left behind. + if grid_has_retained_state(&old) { + hidden_grids() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(pty_id.to_string(), old); + } +} + +fn take_retained_grid(pty_id: &str, destination: &'static Pane) -> TermGrid { + // Split-pane swaps briefly ask one pane to display the PTY still owned by + // its sibling. Move that exact model instead of falling through to disk. + for source in panes() { + if std::ptr::eq(source, destination) || source.pty_id() != pty_id { + continue; + } + let mut g = source.grid.lock().unwrap_or_else(|e| e.into_inner()); + return std::mem::replace(&mut *g, TermGrid::empty()); + } + hidden_grids() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(pty_id) + .unwrap_or_else(TermGrid::empty) +} + +fn native_block_from_saved(sb: crate::persistence::SavedBlock) -> NativeBlock { + NativeBlock::new( + sb.block_id.max(0) as u64, + sb.input, + serde_json::from_value(sb.block_rows_json).unwrap_or_default(), + sb.transcript, + sb.cwd, + sb.duration_ms.map(|d| d.max(0) as u64), + sb.exit_code, + ) +} + +fn merge_saved_history(g: &mut TermGrid, saved: Vec) { + let mut merged: Vec = saved.into_iter().map(native_block_from_saved).collect(); + let mut ids: HashSet = merged.iter().map(|b| b.block_id).collect(); + for block in std::mem::take(&mut g.blocks) { + if ids.insert(block.block_id) { + merged.push(block); + } + } + merged.sort_by_key(|b| b.block_id); + g.blocks = merged; + g.history_loaded = true; +} + +/// Hydrate persisted history away from the instance-switch command. The pane +/// paints its retained live model immediately; deep history joins it when the +/// read finishes. `HISTORY_LOADS` deduplicates StrictMode/effect races. +fn hydrate_history_in_background(pty_id: String) { + let should_start = history_loads() + .lock() + .map(|mut loads| loads.insert(pty_id.clone())) + .unwrap_or(false); + if !should_start { + return; + } + let Some(app) = APP_HANDLE.get().cloned() else { + if let Ok(mut loads) = history_loads().lock() { + loads.remove(&pty_id); + } + return; + }; + std::thread::spawn(move || { + use tauri::Manager as _; + let saved = app + .path() + .app_data_dir() + .map_err(|e| e.to_string()) + .and_then(|dir| crate::persistence::load_blocks(&dir.join("goonware.db"), &pty_id)); + let mut visible = false; + if let Ok(saved) = saved { + let _routing = GRID_ROUTING.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(p) = pane_for_pty(&pty_id) { + let mut g = p.grid.lock().unwrap_or_else(|e| e.into_inner()); + merge_saved_history(&mut g, saved); + visible = true; + } else { + let mut cache = hidden_grids().lock().unwrap_or_else(|e| e.into_inner()); + let g = cache.entry(pty_id.clone()).or_insert_with(TermGrid::empty); + merge_saved_history(g, saved); + } + } + if let Ok(mut loads) = history_loads().lock() { + loads.remove(&pty_id); + } + if visible { + let _ = app.run_on_main_thread(|| warpui::platform::poke_embedded_redraw()); + } + }); +} + /// Combined surface rect (window-content CSS px) = bounding box of the placed /// panes. The single embedded surface covers exactly this; `term_native_mouse` /// subtracts its origin to map window coords → surface coords. Recomputed each @@ -1240,7 +1407,6 @@ fn build_pane_column(p: &'static Pane, mono: FamilyId) -> Box { let cursor_on = g.cursor_visible && g.cursor_row >= 0; let cursor_row = g.cursor_row.max(0) as usize; let cursor_col = g.cursor_col; - let n_blocks = g.blocks.len(); // Reset hyperlink hit-zones each render; the active branch repopulates them // for the agent grid (shell mode leaves them empty — the Cmd-hover cursor is // an agent affordance, and shell links still Cmd-click via warpui). @@ -1344,8 +1510,8 @@ fn build_pane_column(p: &'static Pane, mono: FamilyId) -> Box { // building everything (each spacer == `est_block_height`, which now // matches a block's true laid-out height) — but layout + paint drop to // O(viewport). - let start = n_blocks.saturating_sub(BLOCK_RENDER_CAP); - let mut heights: Vec = Vec::with_capacity(g.blocks.len() - start); + let start = 0; + let mut heights: Vec = Vec::with_capacity(g.blocks.len()); let mut content_est = 0.0f32; for block in &g.blocks[start..] { content_est += block.height; @@ -1423,6 +1589,7 @@ fn build_pane_column(p: &'static Pane, mono: FamilyId) -> Box { // every scroll straight back to the bottom ("can't scroll"). let fit_spacer = (content_vp - content_est).max(0.0); let max_scroll = (content_est - content_vp).max(0.0); + g.max_scroll_px = max_scroll; if content_est <= content_vp { // Everything fits — nothing to scroll, pin to bottom. g.stick_bottom = true; @@ -1712,8 +1879,9 @@ pub fn attach(app: &tauri::AppHandle) { // thread: patch the shared grid, then poke a redraw on the main thread. let app_for_sink = app.clone(); crate::term::set_native_frame_sink(Box::new(move |pty_id: &str, frame: &RenderFrame| { + let routing = GRID_ROUTING.lock().unwrap_or_else(|e| e.into_inner()); // Route the frame to whichever pane mirrors this pty (main or side). - if let Some(p) = pane_for_pty(pty_id) { + let visible = if let Some(p) = pane_for_pty(pty_id) { let mut g = p.grid.lock().unwrap_or_else(|e| e.into_inner()); // Alt-screen scroll-glue: an alt-screen app (git log / less / man / // vim / htop) repaints its grid in place when it scrolls, so a @@ -1728,17 +1896,34 @@ pub fn attach(app: &tauri::AppHandle) { // selection with the content), so we must NOT double-shift it here. let detect = frame.alt_screen && p.sel.has_selection(); let old_rows = if detect { g.rows.clone() } else { Vec::new() }; - g.apply_frame(frame); + let retain_unscoped_scrollback = g.retain_unscoped_scrollback + || p.agent_mode.load(std::sync::atomic::Ordering::Relaxed); + g.apply_frame(frame, retain_unscoped_scrollback); if detect { let k = detect_scroll_shift(&old_rows, &g.rows); if k != 0 { p.sel.shift_relative_y(-(k as f32) * LINE_PX); } } + true + } else { + // Hidden PTYs update only their retained Rust model. No AppKit + // redraw is scheduled, so the 4 Hz hidden cadence does not create + // main-thread work; it simply makes the next switch warm. + let mut cache = hidden_grids().lock().unwrap_or_else(|e| e.into_inner()); + let g = cache + .entry(pty_id.to_string()) + .or_insert_with(TermGrid::empty); + let retain_unscoped_scrollback = g.retain_unscoped_scrollback; + g.apply_frame(frame, retain_unscoped_scrollback); + false + }; + drop(routing); + if visible { + let _ = app_for_sink.run_on_main_thread(|| { + warpui::platform::poke_embedded_redraw(); + }); } - let _ = app_for_sink.run_on_main_thread(|| { - warpui::platform::poke_embedded_redraw(); - }); use std::sync::atomic::{AtomicU32, Ordering}; static N: AtomicU32 = AtomicU32::new(0); let n = N.fetch_add(1, Ordering::Relaxed); @@ -1747,7 +1932,10 @@ pub fn attach(app: &tauri::AppHandle) { // leaving it on injects a periodic multi-ms hitch during output bursts — // bad for the 120fps / smooth-throughput budget. Set WARP_CAPTURE=1 to // re-enable for render debugging. - if (n == 4 || n == 12 || (n > 0 && n % 60 == 0)) && std::env::var("WARP_CAPTURE").is_ok() { + if visible + && (n == 4 || n == 12 || (n > 0 && n % 60 == 0)) + && std::env::var("WARP_CAPTURE").is_ok() + { if let Some(wid) = CAPTURE_WID.lock().ok().and_then(|g| *g) { let _ = app_for_sink.run_on_main_thread(move || { warpui::platform::capture_embedded( @@ -1763,9 +1951,11 @@ pub fn attach(app: &tauri::AppHandle) { // poke a redraw. Runs on the PTY reader thread, like the frame sink. let app_for_block = app.clone(); crate::term::set_native_block_sink(Box::new(move |pty_id: &str, block: &ClosedBlock| { - if let Some(p) = pane_for_pty(pty_id) { + let routing = GRID_ROUTING.lock().unwrap_or_else(|e| e.into_inner()); + let visible = if let Some(p) = pane_for_pty(pty_id) { let mut g = p.grid.lock().unwrap_or_else(|e| e.into_inner()); g.blocks.push(NativeBlock::new( + block.block_id, block.input.clone(), block.block_rows.clone(), block.transcript.clone(), @@ -1773,10 +1963,31 @@ pub fn attach(app: &tauri::AppHandle) { block.duration_ms, block.exit_code, )); + true + } else { + let mut cache = hidden_grids().lock().unwrap_or_else(|e| e.into_inner()); + let g = cache + .entry(pty_id.to_string()) + .or_insert_with(TermGrid::empty); + if !g.blocks.iter().any(|b| b.block_id == block.block_id) { + g.blocks.push(NativeBlock::new( + block.block_id, + block.input.clone(), + block.block_rows.clone(), + block.transcript.clone(), + block.cwd.clone(), + block.duration_ms, + block.exit_code, + )); + } + false + }; + drop(routing); + if visible { + let _ = app_for_block.run_on_main_thread(|| { + warpui::platform::poke_embedded_redraw(); + }); } - let _ = app_for_block.run_on_main_thread(|| { - warpui::platform::poke_embedded_redraw(); - }); })); let parent = app @@ -1946,18 +2157,46 @@ pub fn term_surface_set_rect(pane_key: String, x: f64, y: f64, width: f64, heigh } /// Tauri command: point the native surface at `id`'s pty (the active terminal -/// tab). Clears the retained grid so the prior pty's content can't bleed -/// through before the new pty's first frame / `term_start` re-emit arrives. +/// tab). The retained model moves with the PTY, making a warm instance switch +/// an in-memory state swap rather than a synchronous full-history SQLite read. #[tauri::command] pub fn term_native_attach( pane_key: String, id: String, state: tauri::State, ) { - use tauri::Manager as _; + let routing = GRID_ROUTING.lock().unwrap_or_else(|e| e.into_inner()); let p = pane(&pane_key); - // Stop mirroring this pane's previous pty, then mirror the new one. let prev = p.pty_id(); + + // React effects can legitimately republish the same attachment. Keep this + // path fully idempotent: clearing/rebuilding an already-visible Codex grid + // is both unnecessary and conspicuously slow with deep scrollback. + if prev == id { + crate::term::set_native_pty(&id); + drop(routing); + crate::term::reemit_native(&state, &id); + if let Some(app) = APP_HANDLE.get() { + let _ = app.run_on_main_thread(|| warpui::platform::poke_embedded_redraw()); + } + return; + } + + // Grab the incoming model before changing pane ownership. During a split + // swap it may still live in the sibling pane; otherwise it comes from the + // hidden cache (or starts empty on the first visit). + let next_grid = take_retained_grid(&id, p); + let needs_history = !next_grid.history_loaded; + + // Preserve the outgoing model, including live Codex scrollback and scroll + // position, then publish the new pane owner. + stash_pane_grid(&prev, p); + // Attach may reuse a pane that was previously showing an agent. Clear the + // old mode before the first re-emit so a new shell cannot inherit the prior + // PTY's unscoped-scrollback policy; BlockTerminal immediately publishes the + // correct mode for the newly attached target. + p.agent_mode + .store(false, std::sync::atomic::Ordering::Relaxed); if let Ok(mut g) = p.pty.lock() { g.clear(); g.push_str(&id); @@ -1967,49 +2206,24 @@ pub fn term_native_attach( // land back-to-back, and the second pane's "previous" pty is exactly // the one the first pane just claimed — clearing it unconditionally // froze that pane (frames stopped reaching the sink). - if !prev.is_empty() - && prev != id - && panes().iter().all(|q| q.pty_id() != prev) - { + if !prev.is_empty() && prev != id && panes().iter().all(|q| q.pty_id() != prev) { crate::term::clear_native_pty(&prev); } crate::term::set_native_pty(&id); { let mut g = p.grid.lock().unwrap_or_else(|e| e.into_inner()); - *g = TermGrid::empty(); - } - // Rehydrate saved closed blocks so history survives tab switches and - // restarts (Warp-parity: terminal history is never cut off). `load_blocks` - // windows to the most-recent; older blocks page in on scroll-back (M2.4). - // Best-effort — a missing DB or brand-new pty just yields an empty grid. - if let Some(app) = APP_HANDLE.get() { - if let Ok(dir) = app.path().app_data_dir() { - if let Ok(saved) = crate::persistence::load_blocks(&dir.join("goonware.db"), &id) { - if !saved.is_empty() { - let mut g = p.grid.lock().unwrap_or_else(|e| e.into_inner()); - g.blocks = saved - .into_iter() - .map(|sb| { - NativeBlock::new( - sb.input, - serde_json::from_value(sb.block_rows_json).unwrap_or_default(), - sb.transcript, - sb.cwd, - sb.duration_ms.map(|d| d.max(0) as u64), - sb.exit_code, - ) - }) - .collect(); - } - } - } + *g = next_grid; } + drop(routing); // Repaint immediately from the pty's current grid (no-op if it hasn't // started yet — the first `term_start` frame fills the surface then). crate::term::reemit_native(&state, &id); if let Some(app) = APP_HANDLE.get() { let _ = app.run_on_main_thread(|| warpui::platform::poke_embedded_redraw()); } + if needs_history { + hydrate_history_in_background(id); + } } /// Tauri command: stop mirroring any pty (a non-terminal tab is active). Clears @@ -2021,8 +2235,10 @@ pub fn term_native_attach( /// right panel should shrink the surface). #[tauri::command] pub fn term_native_detach(pane_key: String) { + let routing = GRID_ROUTING.lock().unwrap_or_else(|e| e.into_inner()); let p = pane(&pane_key); let prev = p.pty_id(); + stash_pane_grid(&prev, p); if let Ok(mut g) = p.pty.lock() { g.clear(); } @@ -2037,9 +2253,7 @@ pub fn term_native_detach(pane_key: String) { *r = (0.0, 0.0, 0.0, 0.0); } } - if let Ok(mut g) = p.grid.lock() { - *g = TermGrid::empty(); - } + drop(routing); reposition_surface(); if let Some(app) = APP_HANDLE.get() { let _ = app.run_on_main_thread(|| warpui::platform::poke_embedded_redraw()); @@ -2051,10 +2265,31 @@ pub fn term_native_detach(pane_key: String) { /// grid painting across the agent-exit debounce so a killed agent's shell /// prompt stays visible — see `AGENT_MODE`. #[tauri::command] -pub fn term_native_set_agent_mode(pane_key: String, active: bool) { - pane(&pane_key) +pub fn term_native_set_agent_mode( + pane_key: String, + active: bool, + state: tauri::State, +) { + let routing = GRID_ROUTING.lock().unwrap_or_else(|e| e.into_inner()); + let p = pane(&pane_key); + let was_active = p .agent_mode - .store(active, std::sync::atomic::Ordering::Relaxed); + .swap(active, std::sync::atomic::Ordering::Relaxed); + { + let mut g = p.grid.lock().unwrap_or_else(|e| e.into_inner()); + g.retain_unscoped_scrollback = active; + } + let id = p.pty_id(); + drop(routing); + // A directly-launched agent has no parent shell and therefore no OSC 133 + // block id. Frames that arrived before this mode bit was set could only + // retain the visible grid. Re-emit the complete PTY snapshot on the false → + // true edge so all pre-existing normal-screen history becomes scrollable. + if active && !was_active { + if !id.is_empty() { + crate::term::reemit_native_unscoped_agent(&state, &id); + } + } if let Some(app) = APP_HANDLE.get() { let _ = app.run_on_main_thread(|| warpui::platform::poke_embedded_redraw()); } @@ -2063,19 +2298,17 @@ pub fn term_native_set_agent_mode(pane_key: String, active: bool) { /// Tauri command: forward a wheel delta (CSS px, +down/newer) from the React /// overlay to the native shell-transcript scroll-back. The embedded child window /// has `ignoresMouseEvents: YES`, so the webview captures the wheel and relays it -/// here. We read the post-layout-clamped offset back out of the scroll handle -/// (so scrolling up from the bottom starts at the true bottom), apply the delta, -/// and drop stick-to-bottom; render re-arms stick once scrolled back down or when -/// the content fits. No-op effect in alt-screen / agent mode (render ignores the -/// offset there). +/// here. The delta is applied to `TermGrid`'s canonical offset/range rather than +/// the renderer handle: while pinned, that handle briefly carries a 1e9 sentinel +/// until layout clamps it, and reading it during that window made upward wheel +/// gestures snap straight back to the bottom. Render re-arms follow mode once +/// scrolled back down or when the content fits. #[tauri::command] pub fn term_native_scroll(pane_key: String, delta_px: f64) { let p = pane(&pane_key); { let mut g = p.grid.lock().unwrap_or_else(|e| e.into_inner()); - let cur = p.scroll.scroll_start().as_f32(); - g.scroll_px = (cur + delta_px as f32).max(0.0); - g.stick_bottom = false; + g.scroll_by(delta_px as f32); } if let Some(app) = APP_HANDLE.get() { let _ = app.run_on_main_thread(|| warpui::platform::poke_embedded_redraw()); @@ -2231,6 +2464,82 @@ mod scroll_shift_tests { .collect() } + fn scrollback_frame(text: &str, reset: bool) -> RenderFrame { + RenderFrame { + seq: 1, + block_id: 0, + cols: 80, + rows: 24, + cursor_row: 0, + cursor_col: 0, + alt_screen: false, + command_running: false, + app_cursor: false, + bracketed_paste: false, + cursor_visible: true, + raw_input: true, + dirty: Vec::new(), + scrollback_appended: vec![crate::term::DirtyRow { + row: 0, + spans: vec![plain_span(text)], + }], + scrollback_reset: reset, + } + } + + #[test] + fn direct_agent_retains_scrollback_without_shell_block_id() { + let mut grid = TermGrid::empty(); + let first = scrollback_frame("oldest", true); + + // An idle shell has the same block_id=0 shape, but its closed blocks + // already own history, so it must not duplicate raw PTY scrollback. + grid.apply_frame(&first, false); + assert!(grid.scrollback.is_empty()); + + // Explicit agent mode distinguishes a direct-launched Codex/Claude + // session and retains the exact same unscoped frame. + grid.apply_frame(&first, true); + grid.apply_frame(&scrollback_frame("newer", false), true); + assert_eq!(grid.scrollback.len(), 2); + assert_eq!(grid.scrollback[0].spans[0].text, "oldest"); + assert_eq!(grid.scrollback[1].spans[0].text, "newer"); + } + + #[test] + fn upward_scroll_from_pinned_bottom_uses_real_range_not_render_sentinel() { + let mut grid = TermGrid::empty(); + // Render has established an 800px scroll range. While stick_bottom is + // true, the renderer handle itself may still contain its temporary 1e9 + // sentinel; scroll_by must be independent of that handle. + grid.max_scroll_px = 800.0; + grid.scroll_px = 0.0; + grid.stick_bottom = true; + + grid.scroll_by(-120.0); + + assert_eq!(grid.scroll_px, 680.0); + assert!(!grid.stick_bottom); + } + + #[test] + fn scroll_delta_clamps_to_transcript_bounds() { + let mut grid = TermGrid::empty(); + grid.max_scroll_px = 500.0; + grid.scroll_px = 200.0; + grid.stick_bottom = false; + + grid.scroll_by(-1_000.0); + assert_eq!(grid.scroll_px, 0.0); + grid.scroll_by(1_000.0); + assert_eq!(grid.scroll_px, 500.0); + + grid.max_scroll_px = 0.0; + grid.scroll_by(-100.0); + assert_eq!(grid.scroll_px, 0.0); + assert!(grid.stick_bottom); + } + #[test] fn scroll_down_shifts_content_up() { // Ten distinct rows; the app scrolls DOWN by 3 (content moves up 3, three @@ -2323,6 +2632,58 @@ mod scroll_shift_tests { } } +#[cfg(test)] +mod instance_switch_tests { + use super::*; + + fn saved(block_id: i64, input: &str) -> crate::persistence::SavedBlock { + crate::persistence::SavedBlock { + block_id, + input: input.to_string(), + transcript: format!("output-{block_id}"), + block_rows_json: serde_json::json!([]), + exit_code: Some(0), + cwd: Some("/tmp/project".into()), + duration_ms: Some(1), + } + } + + #[test] + fn background_history_merge_preserves_blocks_closed_during_read() { + let mut grid = TermGrid::empty(); + // Block 2 appears in both the DB result and the live cache (the writer + // committed while hydration was in flight); block 3 exists only in the + // live cache. The merge must dedupe 2 without losing 3. + grid.blocks.push(native_block_from_saved(saved(2, "cached-two"))); + grid.blocks.push(native_block_from_saved(saved(3, "cached-three"))); + + merge_saved_history(&mut grid, vec![saved(1, "saved-one"), saved(2, "saved-two")]); + + assert_eq!( + grid.blocks.iter().map(|b| b.block_id).collect::>(), + vec![1, 2, 3] + ); + assert!(grid.history_loaded); + } + + #[test] + fn attach_never_reads_sqlite_on_the_instance_switch_path() { + let source = include_str!("warp_term.rs"); + let attach = source + .split("pub fn term_native_attach") + .nth(1) + .and_then(|tail| tail.split("pub fn term_native_detach").next()) + .expect("term_native_attach source"); + + assert!(attach.contains("take_retained_grid")); + assert!(attach.contains("hydrate_history_in_background")); + assert!( + !attach.contains("load_blocks"), + "instance switching must never synchronously rebuild history from SQLite" + ); + } +} + #[cfg(test)] mod link_tests { use super::*; diff --git a/src/lib/urlMatch.test.ts b/src/lib/urlMatch.test.ts new file mode 100644 index 0000000..17e7e4b --- /dev/null +++ b/src/lib/urlMatch.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { splitUrls } from "./urlMatch"; + +describe("splitUrls", () => { + test("preserves balanced parentheses that belong to the URL", () => { + const url = "https://en.wikipedia.org/wiki/Function_(mathematics)"; + expect(splitUrls(`Read ${url}`)).toEqual([ + { kind: "text", text: "Read " }, + { kind: "url", text: url, url }, + ]); + }); + + test("peels off only the excess closing parenthesis from prose", () => { + const url = "https://example.com/a_(b)"; + expect(splitUrls(`See (${url}).`)).toEqual([ + { kind: "text", text: "See (" }, + { kind: "url", text: url, url }, + { kind: "text", text: ")." }, + ]); + }); + + test("strips sentence punctuation and normalizes localhost links", () => { + expect(splitUrls("Open localhost:1420, then continue.")).toEqual([ + { kind: "text", text: "Open " }, + { + kind: "url", + text: "localhost:1420", + url: "http://localhost:1420", + }, + { kind: "text", text: "," }, + { kind: "text", text: " then continue." }, + ]); + }); +}); diff --git a/src/lib/urlMatch.ts b/src/lib/urlMatch.ts index bfcb900..4c7e5a1 100644 --- a/src/lib/urlMatch.ts +++ b/src/lib/urlMatch.ts @@ -26,7 +26,12 @@ function trimTrailingPunct(raw: string): { url: string; tail: string } { if (!TRIM_TAIL.has(last)) break; // Don't strip a closing paren if the URL itself contains an opening one // (Wikipedia-style links). - if (last === ")" && (url.match(/\(/g)?.length ?? 0) > (url.match(/\)/g)?.length ?? 0)) break; + if ( + last === ")" && + (url.match(/\(/g)?.length ?? 0) >= (url.match(/\)/g)?.length ?? 0) + ) { + break; + } url = url.slice(0, -1); tail = last + tail; } diff --git a/src/state/reducer.test.ts b/src/state/reducer.test.ts index a3e504e..f6f507c 100644 --- a/src/state/reducer.test.ts +++ b/src/state/reducer.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from "bun:test"; import { INITIAL_STATE, reducer } from "./reducer"; -import type { AppState, CommitTab, MarkdownTab, Tab, Worktree } from "./types"; +import type { + AppState, + CommitTab, + MarkdownTab, + Project, + Tab, + Worktree, +} from "./types"; const worktree = (id: string, projectId: string): Worktree => ({ id, @@ -59,6 +66,67 @@ const markdownTab = ( const open = (state: AppState, tab: Tab) => reducer(state, { type: "open-tab", tab }); +const project = (id: string, path: string): Project => ({ + id, + path, + name: path.split("/").filter(Boolean).pop() ?? path, + glyph: "P", + faviconDataUri: null, + pinned: false, + expanded: true, +}); + +describe("add-project dedup", () => { + test("opening an existing folder focuses it instead of creating a duplicate", () => { + const existing = project("p1", "/tmp/repo"); + const s: AppState = { + ...INITIAL_STATE, + projects: { p1: existing }, + projectOrder: ["p1"], + }; + + const next = reducer(s, { + type: "add-project", + project: project("p2", "/tmp/repo"), + }); + + expect(next.projectOrder).toEqual(["p1"]); + expect(next.projects).toEqual({ p1: existing }); + expect(next.activeProjectId).toBe("p1"); + }); + + test("trailing separators do not bypass folder deduplication", () => { + const existing = project("p1", "/tmp/repo/"); + const s: AppState = { + ...INITIAL_STATE, + projects: { p1: existing }, + projectOrder: ["p1"], + }; + + const next = reducer(s, { + type: "add-project", + project: project("p2", "/tmp/repo///"), + }); + + expect(next.projectOrder).toEqual(["p1"]); + expect(next.activeProjectId).toBe("p1"); + }); + + test("stale project customization actions cannot recreate a removed project", () => { + const actions = [ + { type: "set-project-expanded", id: "gone", expanded: false }, + { type: "set-project-color", id: "gone", color: "slate" }, + { type: "set-project-icon", id: "gone", iconName: "Folder01" }, + ] as const; + + for (const action of actions) { + const next = reducer(INITIAL_STATE, action); + expect(next).toBe(INITIAL_STATE); + expect(next.projects.gone).toBeUndefined(); + } + }); +}); + describe("open-tab dedup", () => { test("re-opening the same commit focuses the existing tab", () => { let s = seed([worktree("w1", "p1")]); diff --git a/src/state/reducer.ts b/src/state/reducer.ts index 175c97c..5cfa286 100644 --- a/src/state/reducer.ts +++ b/src/state/reducer.ts @@ -63,6 +63,14 @@ export function reducer(state: AppState, action: AppAction): AppState { if (state.projects[action.project.id]) { return { ...state, activeProjectId: action.project.id }; } + const incomingPath = normalizeProjectPath(action.project.path); + const existing = Object.values(state.projects).find( + (project) => normalizeProjectPath(project.path) === incomingPath, + ); + if (existing) { + if (state.activeProjectId === existing.id) return state; + return { ...state, activeProjectId: existing.id }; + } return { ...state, projects: { ...state.projects, [action.project.id]: action.project }, @@ -107,6 +115,7 @@ export function reducer(state: AppState, action: AppAction): AppState { return { ...state, projectOrder: action.ids }; case "set-project-expanded": + if (!state.projects[action.id]) return state; return { ...state, projects: { @@ -116,6 +125,7 @@ export function reducer(state: AppState, action: AppAction): AppState { }; case "set-project-color": + if (!state.projects[action.id]) return state; return { ...state, projects: { @@ -125,6 +135,7 @@ export function reducer(state: AppState, action: AppAction): AppState { }; case "set-project-icon": + if (!state.projects[action.id]) return state; return { ...state, projects: { @@ -670,6 +681,11 @@ export function reducer(state: AppState, action: AppAction): AppState { } } +/** Treat cosmetic trailing separators as the same project folder. */ +function normalizeProjectPath(path: string): string { + return path.replace(/\/+$/, "") || "/"; +} + /** * Whether two tab payloads address the same underlying thing — the * dedup test `open-tab` runs so re-opening a file/diff/commit focuses @@ -708,4 +724,3 @@ function updateWorktree( worktrees: { ...state.worktrees, [id]: { ...cur, ...patch(cur) } }, }; } - diff --git a/src/state/types.test.ts b/src/state/types.test.ts new file mode 100644 index 0000000..44bf437 --- /dev/null +++ b/src/state/types.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { applyBranchPrefix } from "./types"; + +describe("applyBranchPrefix", () => { + test("uses a sanitized GitHub username", () => { + expect(applyBranchPrefix("feature", "github", "@raeed-z", "")).toBe( + "raeed-z/feature", + ); + }); + + test("removes dot forms that Git rejects as ref components", () => { + expect(applyBranchPrefix("feature", "custom", "", ".")).toBe("feature"); + expect(applyBranchPrefix("feature", "custom", "", "..lock")).toBe( + "lock/feature", + ); + expect(applyBranchPrefix("feature", "custom", "", ".hidden.team")).toBe( + "hidden-team/feature", + ); + }); + + test("falls back to the bare branch when the prefix has no safe characters", () => { + expect(applyBranchPrefix("feature", "custom", "", "@...///")).toBe( + "feature", + ); + }); + + test("none mode leaves the branch untouched", () => { + expect(applyBranchPrefix("feature", "none", "owner", "custom")).toBe( + "feature", + ); + }); +}); diff --git a/src/state/types.ts b/src/state/types.ts index 5c43b09..5d0cd1e 100644 --- a/src/state/types.ts +++ b/src/state/types.ts @@ -148,8 +148,10 @@ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = { * Apply the user's branch-prefix setting to an auto-generated branch * name. Returns the bare name verbatim when the prefix can't be * resolved (no gh login resolved yet, or `custom` mode with an empty - * prefix). Prefixes are slugified to be branch-name-legal — strips - * leading `@`, collapses non-alnum into `-`, trims `-` from edges. + * prefix). Prefixes are slugified to a conservative branch-name-legal + * subset — strips leading `@`, collapses punctuation into `-`, and trims + * separators from the edges. In particular, dots are not retained because + * Git rejects leading-dot, dot-only, `..`, and `.lock` ref components. */ export function applyBranchPrefix( base: string, @@ -170,7 +172,7 @@ function sanitizeBranchPrefix(raw: string): string { return raw .trim() .replace(/^@+/, "") - .replace(/[^a-zA-Z0-9._-]+/g, "-") + .replace(/[^a-zA-Z0-9_-]+/g, "-") .replace(/^-+|-+$/g, ""); } diff --git a/src/terminal/BlockTerminal.tsx b/src/terminal/BlockTerminal.tsx index 7689e11..0d96d2b 100644 --- a/src/terminal/BlockTerminal.tsx +++ b/src/terminal/BlockTerminal.tsx @@ -37,7 +37,11 @@ import { agentScrollContainerStyle, shouldRenderBlockList, } from "./agentScrollLayout"; -import { deriveInputMode, nextRawLatch } from "./inputModeDecision"; +import { + classifyPromptSubmission, + deriveInputMode, + nextRawLatch, +} from "./inputModeDecision"; import { detectAgentCommand, makeCodexScrollable, @@ -176,6 +180,7 @@ interface Props { const DEFAULT_ROWS = 32; const DEFAULT_COLS = 100; const BELL_FLASH_MS = 480; +const terminalEncoder = new TextEncoder(); /** * Custom block-mode terminal backed by alacritty_terminal in Rust. @@ -1731,6 +1736,17 @@ export function BlockTerminal({ const onSubmit = useCallback( (text: string) => { + // PromptInput also serves canonical foreground prompts. A program such + // as `gh auth login` can ask for `Y` + Enter without switching the tty + // to raw mode, so the visible editor remains mounted. Send that answer + // straight to the existing foreground process and do NOT enqueue it as + // a new shell command; doing the latter mislabels the next OSC 133 block + // and pollutes command history with prompt answers (or secrets). + if (classifyPromptSubmission(commandRunning) === "foreground-stdin") { + onSendBytesVoid(terminalEncoder.encode(`${text}\n`)); + return; + } + // A new submission re-enables agent auto-detection that a prior // force-kill suppressed — so re-running `claude` foregrounds again. forceKilledRef.current = false; @@ -1775,7 +1791,7 @@ export function BlockTerminal({ }); } }, - [sendLine, id], + [commandRunning, onSendBytesVoid, sendLine, id], ); // Click a past block's command line → lift it into the PromptInput diff --git a/src/terminal/inputModeDecision.test.ts b/src/terminal/inputModeDecision.test.ts index 1cadb12..b9be72e 100644 --- a/src/terminal/inputModeDecision.test.ts +++ b/src/terminal/inputModeDecision.test.ts @@ -1,10 +1,24 @@ import { describe, expect, test } from "bun:test"; import { + classifyPromptSubmission, deriveInputMode, nextRawLatch, type InputModeInput, } from "./inputModeDecision"; +describe("classifyPromptSubmission", () => { + test("idle input is a new shell command", () => { + expect(classifyPromptSubmission(false)).toBe("shell-command"); + }); + + test("a line entered for a running command is foreground stdin", () => { + // Canonical prompts such as `gh auth login` may ask for `Y` + Enter + // without putting the tty into raw mode. The answer must go to gh, not + // command history or the pending label for the next shell block. + expect(classifyPromptSubmission(true)).toBe("foreground-stdin"); + }); +}); + // A bare interactive shell prompt: zsh's ZLE holds the tty in raw mode // (rawInput true) even though no command is running. This MUST stay in // shell mode — the whole reason inlineRawPrompt is gated on @@ -132,8 +146,21 @@ describe("deriveInputMode", () => { }); expect(d.inlineRawPrompt).toBe(false); expect(d.agentMode).toBe(false); - // passthroughActive ignores `exited` (it has no exited guard), but - // the JSX gates the whole input region elsewhere; agentMode is the - // authoritative "which chrome" flag and it's false here. + expect(d.passthroughActive).toBe(false); + }); + + test("after an inline agent exits, its stale foreground flag cannot keep passthrough mounted", () => { + const d = deriveInputMode({ + ...idleShell, + exited: true, + commandRunning: false, + rawInput: false, + foregroundIsAgent: true, + }); + expect(d).toEqual({ + inlineRawPrompt: false, + agentMode: false, + passthroughActive: false, + }); }); }); diff --git a/src/terminal/inputModeDecision.ts b/src/terminal/inputModeDecision.ts index cc59658..8e1bfbc 100644 --- a/src/terminal/inputModeDecision.ts +++ b/src/terminal/inputModeDecision.ts @@ -37,7 +37,7 @@ export interface InputModeInput { altScreen: boolean; /** A foreground command is producing output (OSC 133 C↔D). */ commandRunning: boolean; - /** Frame's `raw_input`: tty left canonical mode (ICANON cleared). */ + /** Frame's `raw_input`: tty left canonical mode or disabled local echo. */ rawInput: boolean; /** A known interactive agent (claude/codex/…) is foregrounded. */ foregroundIsAgent: boolean; @@ -46,7 +46,7 @@ export interface InputModeInput { } export interface InputModeDecision { - /** A child has the tty raw while running, rendered inline. */ + /** A child needs direct input while running, rendered inline. */ inlineRawPrompt: boolean; /** * Hide PromptInput + the editable status bar; the running program's @@ -60,6 +60,25 @@ export interface InputModeDecision { passthroughActive: boolean; } +export type PromptSubmissionKind = "shell-command" | "foreground-stdin"; + +/** + * Decide what Enter in the visible PromptInput means. + * + * While the shell is idle, the line is a new command and belongs in command + * history / the pending block-label queue. While a foreground child is still + * running, the exact same UI is serving a canonical interactive prompt (for + * example `gh auth login` asking for `Y` + Enter). In that case the line is + * stdin for the existing process and must not be mistaken for another shell + * command. Raw/no-echo prompts never reach this path because PtyPassthrough is + * mounted for them instead. + */ +export function classifyPromptSubmission( + commandRunning: boolean, +): PromptSubmissionKind { + return commandRunning ? "foreground-stdin" : "shell-command"; +} + /** * State transition for the raw-mode latch. Prompt libraries restore * canonical mode briefly between questions (multi-step wizards) and some @@ -93,9 +112,10 @@ export function deriveInputMode(s: InputModeInput): InputModeDecision { !s.exited && (s.altScreen || s.foregroundIsAgent || inlineRawPrompt); const passthroughActive = - (s.foregroundIsAgent && !s.altScreen) || - inlineRawPrompt || - (s.altScreen && s.nativeSurface); + !s.exited && + ((s.foregroundIsAgent && !s.altScreen) || + inlineRawPrompt || + (s.altScreen && s.nativeSurface)); return { inlineRawPrompt, agentMode, passthroughActive }; } diff --git a/src/terminal/types.ts b/src/terminal/types.ts index ed8138c..d121edd 100644 --- a/src/terminal/types.ts +++ b/src/terminal/types.ts @@ -72,12 +72,11 @@ export interface RenderFrame { */ cursor_visible: boolean; /** - * True when the PTY's line discipline has left canonical mode - * (`ICANON` cleared via `tcsetattr`) — the universal signal that the - * foreground program is reading keystrokes raw and doing its own line - * editing / key handling. Interactive prompts (inquirer / `prompts` / - * clack), `fzf`, password readers, and full TUIs all clear it, even - * the ones that never emit `app_cursor` / `bracketed_paste`. + * True when the PTY requires direct keystroke passthrough: its line + * discipline has either left canonical mode (`ICANON` cleared) or disabled + * echo (`ECHO` cleared). Interactive menus and TUIs generally do the former; + * password/token readers may do only the latter while retaining canonical + * line buffering. Both must bypass the visible block editor. * * Consumers MUST gate on `command_running` before acting: the shell's * own line editor (zsh ZLE, bash readline) also runs the tty raw at an