diff --git a/crates/carrot-app/src/main.rs b/crates/carrot-app/src/main.rs index bcc3e314..b2de06e6 100644 --- a/crates/carrot-app/src/main.rs +++ b/crates/carrot-app/src/main.rs @@ -30,23 +30,9 @@ actions!( ] ); -// Terminal actions -actions!( - terminal, - [ - Copy, - Paste, - Clear, - NewTab, - NextTab, - PreviousTab, - ScrollPageUp, - ScrollPageDown, - ScrollToTop, - ScrollToBottom, - Find, - ] -); +// `terminal::*` actions live in `carrot_terminal_view::terminal_panel` +// — they own the surface those keystrokes act on, and Layer-4 +// `carrot-app` is bootstrap-only per the architecture rules. // Input actions (Carrot Mode) actions!( diff --git a/crates/carrot-block-render/src/block_element.rs b/crates/carrot-block-render/src/block_element.rs index ff2e0c58..74b2f2d0 100644 --- a/crates/carrot-block-render/src/block_element.rs +++ b/crates/carrot-block-render/src/block_element.rs @@ -144,17 +144,19 @@ pub struct BlockElement { search_match_color: Oklch, search_active_color: Oklch, /// Optional slot into which the element's actual paint-time - /// origin Y is written during prepaint. Consumers install one per - /// block so hit-testing can map mouse-y → data-row with zero - /// sub-pixel drift (no re-derivation from layout). + /// origin (both x and y) is written during prepaint. Consumers + /// install one per block so hit-testing can map mouse coordinates + /// → data row + column with zero sub-pixel drift and without + /// re-deriving positions from the layout tree. origin_store: Option, } /// Shared cell into which [`BlockElement`] writes its actual paint- -/// time origin Y coordinate. Used by the owning list view for -/// pixel-accurate mouse-to-row hit testing — avoids recomputing -/// positions from the layout tree after the frame paints. -pub type GridOriginStore = std::rc::Rc>>; +/// time origin. Used by the owning list view for pixel-accurate +/// mouse-to-row/column hit testing — the stored point reflects the +/// real painted origin after every layout pass, so hit-test never +/// re-applies layout-side padding tokens itself. +pub type GridOriginStore = std::rc::Rc>>>; impl BlockElement { pub fn new( @@ -275,11 +277,13 @@ impl Element for BlockElement { let font_id = window.text_system().resolve_font(&self.font); let effective_cols = self.viewport_cols.max(1); - // Stash the actual paint origin Y for the owning view's hit + // Stash the actual paint origin for the owning view's hit // tester. Writing it here (in prepaint) captures the - // post-layout coordinate — no sub-pixel drift later. + // post-layout coordinate, including any inset applied by the + // parent envelope — no sub-pixel drift later, no constants to + // re-apply downstream. if let Some(ref store) = self.origin_store { - store.set(Some(bounds.origin.y)); + store.set(Some(bounds.origin)); } let mut glyphs = Vec::new(); diff --git a/crates/carrot-shell-integration/src/context.rs b/crates/carrot-shell-integration/src/context.rs index cd4c5342..209ce33d 100644 --- a/crates/carrot-shell-integration/src/context.rs +++ b/crates/carrot-shell-integration/src/context.rs @@ -27,6 +27,11 @@ pub struct ShellContext { /// Populated opportunistically — `None` when outside a git repo /// or before the first prompt fires with metadata. pub git_root: Option, + /// Latest command line surfaced via `ShellMetadataPayload.command` + /// at preexec time. The OSC 133;C dispatch consumes this with + /// `take()` into `RouterBlockMetadata.command`, then resets to + /// `None` so the next prompt cycle starts clean. + pub command: Option, } impl ShellContext { @@ -55,30 +60,55 @@ impl ShellContext { git_branch, git_stats, git_root, + command: None, } } /// Update context dynamically from shell metadata (OSC 7777). + /// + /// Field-by-field optional merge: every payload field that is + /// `Some` overwrites the corresponding context field; `None` + /// fields are left untouched. This lets two emits per command + /// cycle layer correctly — precmd fills cwd/git/user/host, + /// preexec fills command, neither clobbers the other. + /// + /// Boolean / structured fields without an Option (`git_stats`) + /// follow the same rule by treating "unspecified" as preserve. pub fn update_from_metadata(&mut self, payload: &ShellMetadataPayload) { - self.cwd = payload.cwd.clone(); - self.cwd_short = shorten_path(&payload.cwd); + if let Some(ref cwd) = payload.cwd { + self.cwd = cwd.clone(); + self.cwd_short = shorten_path(cwd); + } if let Some(ref u) = payload.username { self.username = u.clone(); } if let Some(ref h) = payload.hostname { self.hostname = h.clone(); } - self.git_branch = payload.git_branch.clone(); - self.git_stats = if payload.git_dirty == Some(true) { - Some(GitStats { - files_changed: 1, - insertions: 0, - deletions: 0, - }) - } else { - None - }; - self.git_root = payload.git_root.clone(); + if payload.git_branch.is_some() { + self.git_branch = payload.git_branch.clone(); + } + // `git_dirty` is the only signal the shell sends about repo + // state; absence means "no opinion this emit", so leave the + // existing `git_stats` untouched. Presence with `false` means + // "explicitly clean", which clears any prior dirty flag. + if let Some(dirty) = payload.git_dirty { + self.git_stats = if dirty { + Some(GitStats { + files_changed: 1, + insertions: 0, + deletions: 0, + }) + } else { + None + }; + } + if payload.git_root.is_some() { + self.git_root = payload.git_root.clone(); + } + if payload.command.is_some() { + self.command = payload.command.clone(); + } } } @@ -92,6 +122,7 @@ impl Default for ShellContext { git_branch: None, git_stats: None, git_root: None, + command: None, } } } @@ -148,6 +179,73 @@ fn detect_git_root(cwd: &std::path::Path) -> Option { if root.is_empty() { None } else { Some(root) } } +#[cfg(test)] +mod merge_tests { + use super::*; + + fn ctx_with(cwd: &str, branch: Option<&str>) -> ShellContext { + ShellContext { + cwd: cwd.into(), + cwd_short: cwd.into(), + hostname: "host".into(), + username: "user".into(), + git_branch: branch.map(str::to_string), + git_stats: None, + git_root: None, + command: None, + } + } + + #[test] + fn partial_command_payload_preserves_cwd_and_git() { + // The preexec emit carries only `command`. Ensure cwd / git + // / user fields captured at precmd survive intact. + let mut ctx = ctx_with("/proj", Some("main")); + let payload = ShellMetadataPayload { + command: Some("ls -la".into()), + ..Default::default() + }; + ctx.update_from_metadata(&payload); + assert_eq!(ctx.cwd, "/proj"); + assert_eq!(ctx.git_branch.as_deref(), Some("main")); + assert_eq!(ctx.command.as_deref(), Some("ls -la")); + } + + #[test] + fn precmd_then_preexec_layers_correctly() { + // Real flow: precmd emit fills cwd/git, preexec emit fills + // command. Both survive, neither clobbers the other. + let mut ctx = ctx_with("/old", None); + ctx.update_from_metadata(&ShellMetadataPayload { + cwd: Some("/new".into()), + git_branch: Some("main".into()), + ..Default::default() + }); + ctx.update_from_metadata(&ShellMetadataPayload { + command: Some("git status".into()), + ..Default::default() + }); + assert_eq!(ctx.cwd, "/new"); + assert_eq!(ctx.git_branch.as_deref(), Some("main")); + assert_eq!(ctx.command.as_deref(), Some("git status")); + } + + #[test] + fn cwd_only_payload_does_not_clear_command() { + // Edge case: a metadata emit between two CommandStart cycles + // (e.g. shell internal that re-fires precmd) must not erase + // a command we already have queued. + let mut ctx = ctx_with("/x", None); + ctx.command = Some("echo first".into()); + ctx.update_from_metadata(&ShellMetadataPayload { + cwd: Some("/y".into()), + ..Default::default() + }); + assert_eq!(ctx.cwd, "/y"); + assert_eq!(ctx.command.as_deref(), Some("echo first")); + } +} + fn detect_git_stats(cwd: &std::path::Path) -> Option { let output = Command::new("git") .args(["diff", "--stat", "--shortstat", "HEAD"]) diff --git a/crates/carrot-shell-integration/src/metadata.rs b/crates/carrot-shell-integration/src/metadata.rs index d25d1f27..5a02ea7c 100644 --- a/crates/carrot-shell-integration/src/metadata.rs +++ b/crates/carrot-shell-integration/src/metadata.rs @@ -1,14 +1,20 @@ /// Shell metadata received via OSC 7777;carrot-precmd. /// -/// Deserialized from hex-encoded JSON sent by the shell precmd hook. -/// Contains environment context for updating UI chips and block headers. +/// Deserialized from hex-encoded JSON sent by the shell precmd hook AND +/// the preexec hook. Two emits per command-cycle: the precmd-time emit +/// carries cwd / git / user / host / exit / duration; the preexec-time +/// emit carries the about-to-execute `command` line. +/// +/// Every field is `Option`-typed so a partial emit (e.g. preexec with +/// only `command`) does not clobber the values captured at precmd. +/// `ShellContext::update_from_metadata` performs field-by-field merge. /// /// Extensible with `#[serde(default)]` — new fields can be added without /// breaking shells that don't send them yet. #[derive(Debug, Clone, Default, serde::Deserialize)] #[serde(default)] pub struct ShellMetadataPayload { - pub cwd: String, + pub cwd: Option, pub username: Option, pub hostname: Option, pub git_branch: Option, @@ -17,6 +23,65 @@ pub struct ShellMetadataPayload { pub last_exit_code: Option, pub last_duration_ms: Option, pub shell: Option, + /// Command line about to be executed. Set by the preexec-time + /// emit; consumed by the OSC 133;C dispatch into the new block's + /// `RouterBlockMetadata.command`. Authoritative source — wins + /// over the cmdline-side `pending_command` slot, since the shell + /// sees what actually runs (handles paste-and-execute, scripts, + /// agent-PTY-writes that bypass the cmdline). + pub command: Option, +} + +#[cfg(test)] +mod metadata_payload_tests { + use super::*; + + #[test] + fn payload_with_only_command_parses() { + // Preexec-time emit carries only the command field. cwd / + // git / user fields stay None — the precmd-time emit owns + // those, and field-by-field merge in update_from_metadata + // ensures they don't get clobbered. + let json = r#"{"command":"cd Projects"}"#; + let payload: ShellMetadataPayload = + serde_json::from_str(json).expect("partial payload should parse"); + assert_eq!(payload.command.as_deref(), Some("cd Projects")); + assert!(payload.cwd.is_none()); + assert!(payload.git_branch.is_none()); + } + + #[test] + fn payload_with_only_cwd_parses_without_command() { + // Precmd-time emit (existing path) — no command field, must + // still parse. + let json = r#"{"cwd":"/home/x","username":"x","hostname":"y","shell":"zsh"}"#; + let payload: ShellMetadataPayload = + serde_json::from_str(json).expect("legacy payload should parse"); + assert_eq!(payload.cwd.as_deref(), Some("/home/x")); + assert!(payload.command.is_none()); + } + + #[test] + fn payload_with_escaped_command_roundtrips() { + // JSON escapes for the kinds of characters real shells emit + // — quotes, backslashes, newlines for multiline commands. + let json = r#"{"command":"echo \"hi\\nthere\""}"#; + let payload: ShellMetadataPayload = + serde_json::from_str(json).expect("escaped payload should parse"); + assert_eq!(payload.command.as_deref(), Some("echo \"hi\\nthere\"")); + } + + #[test] + fn unknown_fields_are_skipped() { + // Forward-compat: future shells may add fields the current + // build doesn't know yet. `#[serde(default)]` on the struct + // skips them without erroring. + let json = r#"{"command":"ls","future_field":42,"cwd":"/x"}"#; + let payload: ShellMetadataPayload = + serde_json::from_str(json).expect("unknown fields should be skipped"); + assert_eq!(payload.command.as_deref(), Some("ls")); + assert_eq!(payload.cwd.as_deref(), Some("/x")); + } } /// TUI-mode hint received via OSC 7777;carrot-tui-hint. diff --git a/crates/carrot-term/src/block/active.rs b/crates/carrot-term/src/block/active.rs index bf8203e7..57965dce 100644 --- a/crates/carrot-term/src/block/active.rs +++ b/crates/carrot-term/src/block/active.rs @@ -10,6 +10,7 @@ //! Arc-wrappable, cheap to share across the render thread without locks. use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Instant; use carrot_grid::{ @@ -62,6 +63,18 @@ pub struct ActiveBlock { /// **sticky**, never reverts. Renderers route on this to pick /// inline (Shell) vs PinnedFooter (Tui). kind: super::kind::BlockKind, + /// Lamport-style monotonic counter. Incremented on every observable + /// mutation (grid write, atlas intern, hyperlink/grapheme/image + /// insert, metadata change, kind promotion, …). Render-side caches + /// trust this single field as the dirty signal — they re-extract a + /// snapshot iff the counter advanced since their last frame. + /// + /// Mirrors `text::Buffer::version` in Zed: one source of truth at + /// the producer, atomic to keep the VT thread lock-free against the + /// render thread, `Relaxed` ordering because the surrounding + /// `Mutex` already provides synchronization for every actual + /// mutation site. + generation: AtomicU64, } impl ActiveBlock { @@ -86,9 +99,22 @@ impl ActiveBlock { selection: None, live_frame: None, kind: super::kind::BlockKind::default(), + generation: AtomicU64::new(1), } } + /// Current generation. Render-side caches compare against their + /// last-seen value to decide whether to re-extract a snapshot. + #[inline] + pub fn generation(&self) -> u64 { + self.generation.load(Ordering::Relaxed) + } + + #[inline] + fn bump_generation(&self) { + self.generation.fetch_add(1, Ordering::Relaxed); + } + /// Lifecycle marker — `Shell` until the TUI detector promotes it. #[inline] pub fn kind(&self) -> super::kind::BlockKind { @@ -98,7 +124,11 @@ impl ActiveBlock { /// Internal write access for the TUI detector path. Promotes the /// kind in place — sticky, idempotent. pub(super) fn promote_kind_to_tui(&mut self) { + let was_tui = self.kind.is_tui(); self.kind.promote_to_tui(); + if !was_tui { + self.bump_generation(); + } } /// Access the selection slot (internal — the public surface lives @@ -110,6 +140,7 @@ impl ActiveBlock { /// Mutable access to the selection slot. pub(super) fn selection_slot_mut(&mut self) -> &mut Option { + self.bump_generation(); &mut self.selection } @@ -123,6 +154,7 @@ impl ActiveBlock { pub(super) fn live_frame_slot_mut( &mut self, ) -> &mut Option { + self.bump_generation(); &mut self.live_frame } @@ -136,7 +168,10 @@ impl ActiveBlock { /// before dispatching to the actual state machine. Truncation is /// silent; check `replay().is_truncated()` to detect it. pub fn record_bytes(&mut self, bytes: &[u8]) { - self.replay.extend(bytes); + if !bytes.is_empty() { + self.replay.extend(bytes); + self.bump_generation(); + } } /// Access the underlying page list (read-only). @@ -147,6 +182,7 @@ impl ActiveBlock { /// Mutable access to the underlying page list. The VT writer uses /// this for row-level edits (insert/delete lines, scroll region). pub fn grid_mut(&mut self) -> &mut PageList { + self.bump_generation(); &mut self.grid } @@ -157,6 +193,7 @@ impl ActiveBlock { /// Mutable style atlas for interning styles as SGR state changes. pub fn atlas_mut(&mut self) -> &mut CellStyleAtlas { + self.bump_generation(); &mut self.atlas } @@ -173,6 +210,7 @@ impl ActiveBlock { /// Mutable hyperlink store — the VT writer interns here when the /// remote emits `ESC ] 8 ; ... ; uri ST`. pub fn hyperlinks_mut(&mut self) -> &mut HyperlinkStore { + self.bump_generation(); &mut self.hyperlinks } @@ -186,6 +224,7 @@ impl ActiveBlock { /// Mutable grapheme store — VtWriter interns here when a /// zero-width combiner attaches to the previous cell. pub fn graphemes_mut(&mut self) -> &mut GraphemeStore { + self.bump_generation(); &mut self.graphemes } @@ -196,27 +235,32 @@ impl ActiveBlock { /// Mutably access metadata to set command/cwd/etc. at command-start. pub fn metadata_mut(&mut self) -> &mut ActiveMetadata { + self.bump_generation(); &mut self.metadata } /// Intern a style and return its id. See [`CellStyleAtlas::intern`]. pub fn intern_style(&mut self, style: CellStyle) -> CellStyleId { + self.bump_generation(); self.atlas.intern(style) } /// Append a row of cells to the grid. O(1) amortized. pub fn append_row(&mut self, row: &[Cell]) { self.grid.append_row(row); + self.bump_generation(); } /// Mark a specific row dirty for the next render pass. pub fn mark_dirty(&mut self, row: usize) { self.grid.mark_dirty(row); + self.bump_generation(); } /// Access the image store mutably — the VT state machine appends /// entries here when it sees image protocol sequences. pub fn images_mut(&mut self) -> &mut ImageStore { + self.bump_generation(); &mut self.images } @@ -248,3 +292,84 @@ impl ActiveBlock { )) } } + +#[cfg(test)] +mod generation_tests { + use super::*; + use carrot_grid::{Cell, CellStyle}; + + #[test] + fn generation_starts_above_zero() { + let block = ActiveBlock::new(80); + // Zero is the never-observed sentinel for cache consumers; the + // initial state is already a real value. + assert!(block.generation() > 0); + } + + #[test] + fn generation_advances_on_append_row() { + let mut block = ActiveBlock::new(4); + let g0 = block.generation(); + block.append_row(&[Cell::ascii(b'x', CellStyleId(0)); 4]); + assert!(block.generation() > g0); + } + + #[test] + fn generation_advances_on_grid_mut() { + let mut block = ActiveBlock::new(4); + let g0 = block.generation(); + let _ = block.grid_mut(); + assert!(block.generation() > g0); + } + + #[test] + fn generation_advances_on_intern_style() { + let mut block = ActiveBlock::new(4); + let g0 = block.generation(); + let _ = block.intern_style(CellStyle::DEFAULT); + assert!(block.generation() > g0); + } + + #[test] + fn generation_advances_on_record_bytes() { + let mut block = ActiveBlock::new(4); + let g0 = block.generation(); + block.record_bytes(b"hello"); + assert!(block.generation() > g0); + } + + #[test] + fn generation_holds_on_empty_record() { + let mut block = ActiveBlock::new(4); + let g0 = block.generation(); + block.record_bytes(&[]); + // Empty input is not a mutation — the cache must not invalidate. + assert_eq!(block.generation(), g0); + } + + #[test] + fn generation_advances_on_in_place_grid_writes() { + // The bug this whole counter exists to fix: write to a single + // row repeatedly, total_rows stays flat, generation must still + // advance so the render-side cache invalidates. + let mut block = ActiveBlock::new(4); + let pre_rows = block.total_rows(); + let g0 = block.generation(); + let _ = block.grid_mut(); + let _ = block.grid_mut(); + let _ = block.grid_mut(); + assert_eq!(block.total_rows(), pre_rows); + assert!(block.generation() >= g0 + 3); + } + + #[test] + fn generation_holds_on_idempotent_tui_promotion() { + let mut block = ActiveBlock::new(4); + block.promote_kind_to_tui(); + let g_after_first = block.generation(); + block.promote_kind_to_tui(); + // Already-TUI second call must not bump — the kind is sticky and + // re-promotion is a true no-op, not a state change. + assert_eq!(block.generation(), g_after_first); + } +} diff --git a/crates/carrot-term/src/block/render_view.rs b/crates/carrot-term/src/block/render_view.rs index 746558c9..fd3ce96e 100644 --- a/crates/carrot-term/src/block/render_view.rs +++ b/crates/carrot-term/src/block/render_view.rs @@ -81,13 +81,14 @@ pub struct ActiveBlockView { /// Lifecycle marker — `Shell` until promoted to `Tui` by the /// detector. Sticky for the block's lifetime. pub kind: BlockKind, - /// Monotonic frame id. Bumped every time the active block's - /// `sync_update_frame_id` advances — Layer 5 memoizes its - /// rendered snapshot keyed on `(block_id, frame_id)`. + /// Monotonic frame id sourced from `ActiveBlock::generation` — the + /// single Lamport counter the producer ticks on every observable + /// mutation. Layer 5 memoizes its rendered snapshot keyed on + /// `(block_id, frame_id)`. /// - /// Implemented as a simple content-hash-adjacent counter in - /// later commits; today we use the row count as a proxy (monotonic - /// with appends, ~identity for a given block state). + /// Was previously a row-count proxy; that mis-keyed in-place + /// updates (progress bars, `\r` overwrites, partial-line emission) + /// because the row count stayed flat while cell content advanced. pub sync_update_frame_id: u64, } @@ -151,7 +152,7 @@ fn active_view( let graphemes = Arc::new(block.graphemes().clone()); ActiveBlockView { id: entry.id, - sync_update_frame_id: snapshot.total_rows() as u64, + sync_update_frame_id: block.generation(), snapshot, hyperlinks, graphemes, diff --git a/crates/carrot-term/src/block/router.rs b/crates/carrot-term/src/block/router.rs index 3e6dcc1f..02bed0d5 100644 --- a/crates/carrot-term/src/block/router.rs +++ b/crates/carrot-term/src/block/router.rs @@ -276,6 +276,22 @@ impl BlockRouter { self.prompt = ActiveBlock::new(self.cols); } + /// User-initiated full reset (Cmd+K / Ctrl+L). Drops every frozen + /// block, ends any in-flight active block, resets the prompt + /// buffer, and zeroes the display scroll state. `next_id` stays + /// monotonic so any external observers that cached an old id + /// continue to detect the rotation cleanly. + /// + /// The router is left in the same shape as right after `new()` + /// except for the preserved `next_id` and `cols`. + pub fn clear(&mut self) { + self.blocks.clear(); + self.active_id = None; + self.pending_command = None; + self.prompt = ActiveBlock::with_page_bytes(self.cols, self.page_bytes); + self.display = super::display::DisplayState::new(); + } + /// `OSC 133 ; C` — command execution started. Allocates a new /// active block, wires it as the routing target, returns its /// freshly-minted ID. @@ -642,4 +658,51 @@ mod tests { m.exit_code = Some(-1); assert!(m.is_error(), "any nonzero code is error"); } + + #[test] + fn clear_drops_all_blocks_and_resets_active() { + let mut r = BlockRouter::new(80); + r.on_command_start(); + r.on_command_end(0); + r.on_command_start(); + r.on_command_end(0); + r.on_command_start(); + assert!(r.has_active_block()); + assert_eq!(r.len(), 3); + + r.clear(); + assert_eq!(r.len(), 0); + assert!(!r.has_active_block()); + assert!(r.entries().is_empty()); + } + + #[test] + fn clear_keeps_id_counter_monotonic() { + let mut r = BlockRouter::new(80); + let id_a = r.on_command_start(); + r.on_command_end(0); + r.clear(); + let id_b = r.on_command_start(); + assert!( + id_b.0 > id_a.0, + "next id after clear must not collide with pre-clear ids \ + (a={}, b={})", + id_a.0, + id_b.0, + ); + } + + #[test] + fn clear_resets_pending_command_too() { + let mut r = BlockRouter::new(80); + r.set_pending_command("cargo build"); + r.clear(); + let id = r.on_command_start(); + let entry = r.entry(id).expect("active block"); + assert!( + entry.metadata.command.is_none(), + "stale pending command leaked across clear: {:?}", + entry.metadata.command, + ); + } } diff --git a/crates/carrot-term/src/term.rs b/crates/carrot-term/src/term.rs index 6833afbd..affb95ba 100644 --- a/crates/carrot-term/src/term.rs +++ b/crates/carrot-term/src/term.rs @@ -265,11 +265,6 @@ impl Term { self.block_router.on_prompt_start(); } - pub fn route_to_new_block(&mut self, command: String) { - self.block_router.set_pending_command(command); - let _ = self.block_router.on_command_start(); - } - pub fn route_finalize_block(&mut self, exit_code: i32) { if self.mode.contains(TermMode::SYNC_UPDATE) { self.pending_finalize = Some(exit_code); diff --git a/crates/carrot-terminal-view/src/block_list.rs b/crates/carrot-terminal-view/src/block_list.rs index f967264b..c89d7a44 100644 --- a/crates/carrot-terminal-view/src/block_list.rs +++ b/crates/carrot-terminal-view/src/block_list.rs @@ -71,6 +71,13 @@ pub struct BlockListView { /// (DEC 2026 sync update or between PTY chunks). Cleared when /// either key component changes. pub(crate) last_active_render: Option<(RouterBlockId, u64, BlockSnapshot)>, + /// Last pinned-footer id we asked `BlockState` to hold, or `None` + /// when nothing was pinned. Lets `Render` skip the + /// `BlockState::pin / unpin` borrow_mut entirely when the desired + /// pin matches the live pin — pin/unpin are pseudo-idempotent on + /// the state side but still take the inner `RefCell`, which is + /// wasteful at 60–120 fps with one render per PTY tick. + pub(crate) last_pinned: Option, } impl BlockListView { @@ -94,6 +101,7 @@ impl BlockListView { search_highlights: Vec::new(), active_highlight_index: None, last_active_render: None, + last_pinned: None, } } @@ -108,20 +116,35 @@ impl BlockListView { (font, font_size, line_height, symbol_maps) } - /// Clear all blocks. The v2 router doesn't expose a bulk-clear - /// API yet, so we drive a fresh prompt cycle (which empties the - /// router on the next on_prompt_start) and drop our local state. + /// Clear all blocks. Drops every frozen entry plus any in-flight + /// active block, resets the prompt buffer, zeroes scroll state, + /// and clears local view-side caches (selection, search + /// highlights, snapshot memoization, pin tracking, layout cache). + /// + /// Triggered by the user-facing `terminal::Clear` action + /// (Cmd+K / Ctrl+L) — the room visibly empties out and the next + /// shell prompt lands on a blank scrollback. pub fn clear(&mut self) { let handle = self.terminal.clone(); let mut term = handle.lock(); - // Switching to prompt resets the active id without evicting - // frozen entries. The renderer re-reads entries every frame - // from the router, so dropping our cache is enough to stop - // showing stale rows. - term.block_router_mut().on_prompt_start(); + term.block_router_mut().clear(); drop(term); + + // Producer-side cleared. Mirror the wipe in every consumer + // that holds block ids: the inazuma `BlockState` (sumtree + + // id-to-index map), the local id mappings, layout cache, + // selection / search / pin / snapshot caches. + self.list_state.clear(); + self.list_ids.clear(); + self.router_ids.clear(); + self.block_layout.clear(); self.selected_block = None; self.selecting_block = None; + self.pending_block_toggle = None; + self.search_highlights.clear(); + self.active_highlight_index = None; + self.last_active_render = None; + self.last_pinned = None; } /// Get the selected block index. @@ -340,16 +363,21 @@ impl Render for BlockListView { // TUI block is present we explicitly unpin — a previously // pinned TUI block that's now Shell (impossible by sticky // promotion, but defensive) or pruned would otherwise leave a - // stale pin. + // stale pin. Skip the call entirely when the desired pin + // matches the live pin — avoids a per-frame `borrow_mut` on + // `BlockState`'s inner `RefCell`. let pinned_tui = entries.iter().enumerate().rev().find_map(|(i, e)| { e.kind .is_tui() .then_some(()) .and_then(|_| self.list_ids.get(i).copied()) }); - match pinned_tui { - Some(id) => self.list_state.pin(id), - None => self.list_state.unpin(), + if pinned_tui != self.last_pinned { + match pinned_tui { + Some(id) => self.list_state.pin(id), + None => self.list_state.unpin(), + } + self.last_pinned = pinned_tui; } // Cache the v2 id mapping + layout per entry. @@ -450,6 +478,7 @@ impl Render for BlockListView { .w_full() .flex_shrink_0() .flex_col() + .px(px(BLOCK_HEADER_PAD_X)) .border_b_1() .border_color(Oklch::white().opacity(0.12)); @@ -581,8 +610,15 @@ fn render_block_entry( let error_bg = inazuma::oklcha(0.25, 0.08, 25.0, 0.15); + // Block envelope owns the horizontal inset. Both children — header + // chips and grid — render flush against the inset edge, so the + // content edge is the same x-coordinate everywhere a renderer or + // hit-tester might look at it. PTY column-budget at + // `terminal_pane.rs` and the prepainted `GridOriginStore` are then + // the only consumers of `BLOCK_HEADER_PAD_X`. div() .w_full() + .px(px(BLOCK_HEADER_PAD_X)) .when(is_selected, |d| d.bg(block_selected_bg(theme))) .when(is_error && !is_selected, |d| d.bg(error_bg)) .border_t_1() @@ -594,7 +630,6 @@ fn render_block_entry( }) .child( div() - .px(px(BLOCK_HEADER_PAD_X)) .pt(header_top_padding()) .pb(header_top_padding()) .flex() @@ -644,6 +679,24 @@ fn render_block_entry( ) }), ) + // Command echo — the line the user typed. Rendered in the + // terminal font + size so it aligns with the cells of the + // output grid below; without it, silent-success commands + // (`cd`, `touch`, `export …`) would render as empty headers + // with no signal of what was actually run. Skipped when the + // shell hooks didn't emit a command (rare — only for the + // very first prompt before the integration loads). + .when(!entry.command.trim().is_empty(), |d| { + d.child( + div() + .w_full() + .pb(px(2.0)) + .font(font.clone()) + .text_size(px(font_size)) + .text_color(theme.colors().foreground) + .child(inazuma::SharedString::from(entry.command.clone())), + ) + }) .child(element) } diff --git a/crates/carrot-terminal-view/src/block_list/fold.rs b/crates/carrot-terminal-view/src/block_list/fold.rs index c95f18da..74266983 100644 --- a/crates/carrot-terminal-view/src/block_list/fold.rs +++ b/crates/carrot-terminal-view/src/block_list/fold.rs @@ -46,7 +46,6 @@ pub fn render_fold_line( .flex() .flex_row() .items_center() - .px(px(BLOCK_HEADER_PAD_X)) .gap(px(8.0)) .when(is_error, |d| d.bg(error_bg)) .cursor_pointer() @@ -99,7 +98,6 @@ pub fn render_fold_counter( .w_full() .flex() .items_center() - .px(px(BLOCK_HEADER_PAD_X)) .text_size(px(11.0)) .text_color(header_metadata_fg(theme)) .cursor_pointer() diff --git a/crates/carrot-terminal-view/src/block_list/hit_test.rs b/crates/carrot-terminal-view/src/block_list/hit_test.rs index 0931dd58..8294b63d 100644 --- a/crates/carrot-terminal-view/src/block_list/hit_test.rs +++ b/crates/carrot-terminal-view/src/block_list/hit_test.rs @@ -9,7 +9,7 @@ use carrot_term::index::{Column, Line, Point, Side}; use inazuma::{Pixels, Point as GpuiPoint, px}; use crate::block_list::BlockListView; -use crate::constants::*; +use crate::constants::BLOCK_BODY_PAD_BOTTOM; impl BlockListView { /// Convert a pixel position (relative to this view) to a @@ -27,21 +27,17 @@ impl BlockListView { if let Some(bounds) = self.list_state.bounds_for_block(inazuma_id) && bounds.contains(&pos) { + // Grid origin: prefer the value the grid element wrote + // during prepaint (post-layout, includes any envelope + // padding). Fall back to a geometric estimate only when + // the element hasn't laid out yet — first-frame race. let grid_height = cell_height * entry.content_rows as f32; - let grid_start_y = entry.grid_origin_store.get().unwrap_or_else(|| { - bounds.origin.y + bounds.size.height - px(BLOCK_BODY_PAD_BOTTOM) - grid_height - }); - let y_in_grid = pos.y - grid_start_y; - - log::debug!( - "hit_test: pos.y={:.1} grid_origin={:.1} y_in_grid={:.1} \ - cell_h={:.1} visual_row={}", - f32::from(pos.y), - f32::from(grid_start_y), - f32::from(y_in_grid), - f32::from(cell_height), - (f32::from(y_in_grid) / f32::from(cell_height)) as i32, - ); + let grid_origin = entry.grid_origin_store.get().unwrap_or(GpuiPoint::new( + bounds.origin.x, + bounds.origin.y + bounds.size.height - px(BLOCK_BODY_PAD_BOTTOM) - grid_height, + )); + let y_in_grid = pos.y - grid_origin.y; + let x_in_grid = pos.x - grid_origin.x; // Click above grid = header area → block selection. if y_in_grid < px(0.0) { @@ -59,7 +55,7 @@ impl BlockListView { let row = visual_row - entry.command_row_count as i32 - entry.grid_history_size as i32; - let col_f = (f32::from(pos.x) - BLOCK_HEADER_PAD_X) / f32::from(cell_width); + let col_f = f32::from(x_in_grid) / f32::from(cell_width); let col = col_f.max(0.0) as usize; let side = if col_f.fract() < 0.5 { Side::Left diff --git a/crates/carrot-terminal-view/src/block_list/layout.rs b/crates/carrot-terminal-view/src/block_list/layout.rs index b19ba13a..3dae8b2a 100644 --- a/crates/carrot-terminal-view/src/block_list/layout.rs +++ b/crates/carrot-terminal-view/src/block_list/layout.rs @@ -8,14 +8,8 @@ use std::cell::Cell; use std::rc::Rc; +pub(crate) use carrot_block_render::GridOriginStore; use carrot_term::BlockId; -use inazuma::Pixels; - -/// Shared slot that `TerminalGridElement` writes the grid's Y origin -/// into during prepaint, and `hit_test` reads during input handling. -/// `None` = the element hasn't laid out yet this frame, so the caller -/// falls back to a geometric estimate from the block's bounds. -pub(crate) type GridOriginStore = Rc>>; /// Cached layout info for one block — used for pixel→grid hit testing. #[derive(Clone)] @@ -25,9 +19,10 @@ pub(crate) struct BlockLayoutEntry { pub(crate) content_rows: usize, pub(crate) command_row_count: usize, pub(crate) grid_history_size: usize, - /// Shared store: the grid element writes actual grid origin Y - /// during prepaint, hit_test reads it for exact pixel→row mapping - /// without sub-pixel drift. + /// Shared slot the grid element writes its actual paint-time origin + /// into during prepaint. Hit-test reads both x and y from this slot, + /// so layout-side padding tokens never have to be re-applied + /// downstream. pub(crate) grid_origin_store: GridOriginStore, } diff --git a/crates/carrot-terminal-view/src/terminal_pane.rs b/crates/carrot-terminal-view/src/terminal_pane.rs index dac98515..95dfda5f 100644 --- a/crates/carrot-terminal-view/src/terminal_pane.rs +++ b/crates/carrot-terminal-view/src/terminal_pane.rs @@ -479,6 +479,7 @@ impl Render for TerminalPane { .bg(bg_color) .track_focus(&self.focus_handle) .on_action(cx.listener(Self::on_send_interrupt)) + .on_action(cx.listener(Self::on_clear)) .on_action(cx.listener(Self::on_insert_into_input)) .on_key_down(cx.listener(Self::on_key_down_interactive)); diff --git a/crates/carrot-terminal-view/src/terminal_pane/lifecycle.rs b/crates/carrot-terminal-view/src/terminal_pane/lifecycle.rs index 3e3156ad..9ef2c1e9 100644 --- a/crates/carrot-terminal-view/src/terminal_pane/lifecycle.rs +++ b/crates/carrot-terminal-view/src/terminal_pane/lifecycle.rs @@ -38,6 +38,17 @@ impl TerminalPane { cx.notify(); } + /// Wipe the visible block list (Cmd+K / Ctrl+L). + pub(crate) fn on_clear( + &mut self, + _: &crate::terminal_panel::Clear, + _window: &mut Window, + cx: &mut Context, + ) { + self.block_list.update(cx, |list, _cx| list.clear()); + cx.notify(); + } + /// Paste a command line into the input editor without executing. Used /// by the command palette's History source (`Cmd+R`) so recalling a /// past command gives the user a chance to edit before hitting enter. diff --git a/crates/carrot-terminal-view/src/terminal_pane/shell.rs b/crates/carrot-terminal-view/src/terminal_pane/shell.rs index b899f4bc..2068fabf 100644 --- a/crates/carrot-terminal-view/src/terminal_pane/shell.rs +++ b/crates/carrot-terminal-view/src/terminal_pane/shell.rs @@ -66,20 +66,37 @@ impl TerminalPane { if let carrot_terminal::ShellMarker::Metadata(ref json) = marker { match serde_json::from_str::(json) { Ok(payload) => { - let cwd_changed = self.shell_context.cwd != payload.cwd; - let new_cwd = std::path::PathBuf::from(&payload.cwd); + // The preexec emit carries only `command`; the + // precmd emit carries cwd / git / etc. Each branch + // below short-circuits when the relevant field is + // absent so a partial emit doesn't trigger + // worktree-scope reclassification or cache + // invalidation that the user didn't ask for. + let cwd_changed = payload + .cwd + .as_ref() + .is_some_and(|c| self.shell_context.cwd != *c); + let new_cwd = payload.cwd.as_deref().map(std::path::PathBuf::from); self.shell_context.update_from_metadata(&payload); - self.shell_completion.update_cwd(new_cwd.clone()); - self.detection_cache.invalidate(); - self.last_exit_code = payload.last_exit_code; - self.last_duration_ms = payload.last_duration_ms; - - let new_git_root = payload.git_root.as_ref().map(std::path::PathBuf::from); - if new_git_root != self.current_git_root { - self.current_git_root = new_git_root; + if let Some(cwd) = new_cwd.as_ref() { + self.shell_completion.update_cwd(cwd.clone()); + self.detection_cache.invalidate(); + } + if let Some(exit) = payload.last_exit_code { + self.last_exit_code = Some(exit); + } + if let Some(dur) = payload.last_duration_ms { + self.last_duration_ms = Some(dur); + } + + if let Some(git_root) = payload.git_root.as_ref() { + let new_git_root = Some(std::path::PathBuf::from(git_root)); + if new_git_root != self.current_git_root { + self.current_git_root = new_git_root; + } } - if cwd_changed { + if cwd_changed && let Some(new_cwd) = new_cwd { use carrot_shell::scope_policy::{ProjectKind, WorktreeRoot, classify}; use inazuma_settings_framework::Settings as _; @@ -158,9 +175,22 @@ impl TerminalPane { { let handle = self.terminal.handle(); let mut term = handle.lock(); + // Priority for the command field: shell-metadata + // (preexec emit) > cmdline pending slot > None. + // The pending slot was already consumed into + // `entries().last().metadata.command` by + // `on_command_start`; preserve that value when our + // shell_context has nothing to add. `take()` + // because the next command cycle starts clean. + let existing_command = term + .block_router() + .entries() + .last() + .and_then(|e| e.metadata.command.clone()); + let command = self.shell_context.command.take().or(existing_command); term.block_router_mut().set_last_metadata( carrot_term::block::RouterBlockMetadata { - command: None, + command, cwd: Some(self.shell_context.cwd.clone()), username: Some(self.shell_context.username.clone()), hostname: Some(self.shell_context.hostname.clone()), diff --git a/crates/carrot-terminal-view/src/terminal_panel.rs b/crates/carrot-terminal-view/src/terminal_panel.rs index fd4660ed..e79c309d 100644 --- a/crates/carrot-terminal-view/src/terminal_panel.rs +++ b/crates/carrot-terminal-view/src/terminal_panel.rs @@ -164,6 +164,32 @@ actions!( OpenTerminal, ToggleFocus, Toggle, - SendInterrupt + SendInterrupt, + /// User-initiated "clear all blocks" — bound to Cmd+K and Ctrl+L + /// in the default keymaps. Empties the block router, resets + /// the prompt buffer, drops every consumer-side cache, leaves + /// the next shell prompt on a blank scrollback. + Clear, + /// Copy the current selection to the clipboard. + Copy, + /// Paste the clipboard contents into the focused terminal. + Paste, + /// Open a new terminal tab in the focused workspace. + NewTab, + /// Move focus to the next terminal tab. + NextTab, + /// Move focus to the previous terminal tab. + PreviousTab, + /// Scroll the block list one page up. + ScrollPageUp, + /// Scroll the block list one page down. + ScrollPageDown, + /// Scroll to the top of the block list. + ScrollToTop, + /// Scroll to the bottom of the block list, re-engaging + /// follow-tail. + ScrollToBottom, + /// Open the in-pane find bar. + Find, ] ); diff --git a/crates/carrot-terminal/src/terminal.rs b/crates/carrot-terminal/src/terminal.rs index ff718907..23b65688 100644 --- a/crates/carrot-terminal/src/terminal.rs +++ b/crates/carrot-terminal/src/terminal.rs @@ -255,7 +255,25 @@ impl Terminal { term.route_to_prompt(); } crate::osc_parser::ShellMarker::CommandStart => { - term.route_to_new_block(String::new()); + // Direct call into the + // primitive — the wrapper + // `route_to_new_block` was + // deleted because it + // combined "set pending + // command + start", which + // makes sense only for the + // cmdline-submit path that + // owns the command text. + // Here at OSC 133;C we are + // the start signal, not the + // command source — pending + // (if any) was set earlier + // by the cmdline; the new + // block consumes whichever + // wins via the priority + // ladder in + // `handle_shell_marker`. + let _ = term.block_router_mut().on_command_start(); } crate::osc_parser::ShellMarker::CommandEnd { exit_code, diff --git a/crates/inazuma/src/elements/block/element.rs b/crates/inazuma/src/elements/block/element.rs index 776b1fe3..871487d9 100644 --- a/crates/inazuma/src/elements/block/element.rs +++ b/crates/inazuma/src/elements/block/element.rs @@ -175,7 +175,14 @@ impl Element for Block { block_state .0 .borrow_mut() - .scroll(&scroll_top, height, pixel_delta) + .scroll(&scroll_top, height, pixel_delta); + // Mutating the inner state alone does not invalidate the + // window — without this `refresh()` the new scroll + // position only reaches the GPU on the next unrelated + // frame trigger (PTY tick, window event, …), so trackpad + // motion looks chunky. Mirrors Zed's `cx.notify(view)` + // at the end of `gpui::list::scroll`. + window.refresh(); } }); } diff --git a/crates/inazuma/src/elements/block/layout.rs b/crates/inazuma/src/elements/block/layout.rs index 4a97a02b..fd5b5b75 100644 --- a/crates/inazuma/src/elements/block/layout.rs +++ b/crates/inazuma/src/elements/block/layout.rs @@ -145,9 +145,23 @@ pub(super) fn layout_entries( }); } + // When the rendered content overflows the viewport, the topmost + // entry has to be clipped from above by exactly the overshoot — + // otherwise the LAST visible rows of the LAST entry slide off + // the bottom edge as new content streams in. The clip lives on + // the first entry's `offset_in_entry`; the per-entry paint loop + // in `element.rs` consumes it via `start_y - offset_in_entry`. + // + // Mirrors Zed's `gpui::list::layout` + // (`.reference/zed/crates/gpui/src/elements/list.rs:821-838`): + // ports are line-for-line, but the overshoot assignment was + // dropped on the way in. Recovering it fixes the + // "active block taller than viewport ⇒ content scrolls out the + // bottom while the user watches" regression. + let overshoot = (rendered_height - main_available_height).max(px(0.0)); scroll_top = BlockOffset { entry_ix: cursor.start().0, - offset_in_entry: px(0.0), + offset_in_entry: overshoot, }; if rendered_height < main_available_height { diff --git a/crates/inazuma/src/elements/block/state.rs b/crates/inazuma/src/elements/block/state.rs index 6d4f2135..e053ee30 100644 --- a/crates/inazuma/src/elements/block/state.rs +++ b/crates/inazuma/src/elements/block/state.rs @@ -243,6 +243,27 @@ impl BlockState { id } + /// Drop every block. Resets entries, id-to-index map, pinned + /// footer slot, scroll position, and layout caches. `next_id` + /// stays monotonic so any external observer that cached an old + /// id detects the rotation cleanly. `follow_tail` is restored to + /// its initial value derived from `scroll_behavior`. + /// + /// Used by view-level "clear all" actions (e.g. terminal Cmd+K) + /// when the producer has already dropped its underlying entries + /// and the list state has to follow. + pub fn clear(&self) { + let mut state = self.0.borrow_mut(); + state.entries = SumTree::default(); + state.id_to_ix.clear(); + state.pinned_footer = None; + state.logical_scroll_top = None; + state.last_resolved_scroll_top = None; + state.last_leading_space = px(0.0); + state.measured_all = false; + state.follow_tail = matches!(state.config.scroll_behavior, ScrollBehavior::FollowTail); + } + /// Remove the block with the given id. No-op if the id is not known. /// If the removed block was the pinned footer, the pin is cleared /// — leaving a stale `pinned_footer` id pointing at a removed entry diff --git a/crates/inazuma/src/elements/block/tests.rs b/crates/inazuma/src/elements/block/tests.rs index 7281778a..714c6aff 100644 --- a/crates/inazuma/src/elements/block/tests.rs +++ b/crates/inazuma/src/elements/block/tests.rs @@ -133,6 +133,67 @@ fn follow_tail_sticks_to_tail_on_new_entries(cx: &mut TestAppContext) { assert_eq!(scroll.entry_ix, 6); } +#[inazuma::test] +fn follow_tail_clips_topmost_entry_when_content_overflows(cx: &mut TestAppContext) { + // Regression: with `VisualAnchor::Bottom` + `FollowTail`, when the + // last entry alone is taller than the viewport, the layout pass must + // clip the topmost visible entry from above by the overshoot — not + // pin it to the top edge. Otherwise newly-streaming content inside a + // tall active block scrolls off the bottom of the viewport while the + // user watches. Verified through `bounds_for_block`: the entry's + // bottom edge must coincide with the viewport's bottom edge, and + // its top edge must sit `overshoot` pixels above the viewport top. + let mut cx = cx.add_empty_window(); + let state = BlockState::new( + BlockConfig::default() + .visual_anchor(VisualAnchor::Bottom) + .scroll_behavior(ScrollBehavior::FollowTail) + .measuring_behavior(BlockMeasuringBehavior::All), + ); + let id = state.push(BlockMetadata::default(), None); + let height = Rc::new(Cell::new(1000.0)); + draw_view(&mut cx, &state, &height, size(px(100.), px(400.))); + + let bounds = state.bounds_for_block(id).expect("entry is in viewport"); + // Bottom of the only entry sits exactly on the bottom edge. + assert_eq!(bounds.bottom(), px(400.)); + // Top of the only entry is 600 px above the viewport top. + assert_eq!(bounds.top(), px(-600.)); +} + +#[inazuma::test] +fn clear_drops_every_block_and_resets_caches(cx: &mut TestAppContext) { + // `BlockState::clear` is the consumer-side mirror of + // `BlockRouter::clear`. Both have to drop every entry, otherwise + // the next render reads stale ids out of the sumtree and hits + // the unwrap-or-default branch in layout. + let mut cx = cx.add_empty_window(); + let state = BlockState::new( + BlockConfig::default() + .visual_anchor(VisualAnchor::Bottom) + .scroll_behavior(ScrollBehavior::FollowTail), + ); + for _ in 0..5 { + state.push(BlockMetadata::default(), None); + } + let height = Rc::new(Cell::new(80.0)); + draw_view(&mut cx, &state, &height, size(px(100.), px(400.))); + assert_eq!(state.entry_count(), 5); + + state.clear(); + assert_eq!(state.entry_count(), 0); + + // After clear, push a fresh entry and confirm it lands at index 0 + // — id-to-index map was reset. + let id = state.push(BlockMetadata::default(), None); + draw_view(&mut cx, &state, &height, size(px(100.), px(400.))); + assert_eq!(state.entry_count(), 1); + let bounds = state + .bounds_for_block(id) + .expect("freshly-pushed entry is visible after clear"); + assert!(bounds.bottom() <= px(400.)); +} + #[inazuma::test] fn manual_scroll_breaks_follow_tail(cx: &mut TestAppContext) { let mut cx = cx.add_empty_window(); diff --git a/shell/carrot.bash b/shell/carrot.bash index c9347aa7..e1f1cd13 100644 --- a/shell/carrot.bash +++ b/shell/carrot.bash @@ -81,6 +81,21 @@ _carrot_preexec() { _carrot_cmd_start=$(date +%s%3N 2>/dev/null || echo 0) + # Emit the about-to-run command line via the OSC 7777 channel that + # the precmd-time emit uses. ShellContext merges the two emits + # field-by-field. Sent BEFORE OSC 133;C so it's buffered when + # CommandStart fires. + local _cmd="$BASH_COMMAND" + local _esc="${_cmd//\\/\\\\}" + _esc="${_esc//\"/\\\"}" + _esc="${_esc//$'\n'/\\n}" + _esc="${_esc//$'\r'/\\r}" + _esc="${_esc//$'\t'/\\t}" + local _cmd_json='{"command":"'"$_esc"'"}' + local _cmd_hex + _cmd_hex=$(builtin printf '%s' "$_cmd_json" | xxd -p | tr -d '\n') + builtin printf '\e]7777;carrot-precmd;%s\a' "$_cmd_hex" >&$_carrot_fd + builtin printf '\e]133;B\a' >&$_carrot_fd builtin printf '\e]133;C\a' >&$_carrot_fd diff --git a/shell/carrot.fish b/shell/carrot.fish index 469a8702..56c45464 100644 --- a/shell/carrot.fish +++ b/shell/carrot.fish @@ -69,6 +69,21 @@ end function _carrot_preexec --on-event fish_preexec set -g _carrot_cmd_start (date +%s%3N 2>/dev/null; or echo 0) + + # Emit the about-to-run command line via the OSC 7777 channel that + # the precmd-time emit uses. ShellContext merges the two emits + # field-by-field. Sent BEFORE OSC 133;C so it's buffered when + # CommandStart fires. + set -l _cmd "$argv[1]" + set -l _esc (string replace -a '\\' '\\\\' -- $_cmd) + set _esc (string replace -a '"' '\\"' -- $_esc) + set _esc (string replace -a \n '\\n' -- $_esc) + set _esc (string replace -a \r '\\r' -- $_esc) + set _esc (string replace -a \t '\\t' -- $_esc) + set -l _cmd_json '{"command":"'$_esc'"}' + set -l _cmd_hex (printf '%s' "$_cmd_json" | xxd -p | string join '') + printf '\e]7777;carrot-precmd;%s\a' "$_cmd_hex" + printf '\e]133;B\a' printf '\e]133;C\a' diff --git a/shell/carrot.zsh b/shell/carrot.zsh index 72802e9e..0dfea062 100644 --- a/shell/carrot.zsh +++ b/shell/carrot.zsh @@ -88,6 +88,24 @@ _carrot_preexec() { zmodload -F zsh/datetime p:EPOCHREALTIME 2>/dev/null _carrot_cmd_start=$EPOCHREALTIME + # Emit the about-to-run command line via the same OSC 7777 channel + # the precmd-time emit uses. Reusing `carrot-precmd` keeps the + # parser path single — a new emit just merges into ShellContext + # field-by-field. JSON escapes backslashes / quotes / newlines so + # multiline commands and embedded quotes survive intact. Sent + # BEFORE OSC 133;C so the metadata is buffered when CommandStart + # fires. + local _cmd="$1" + local _esc="${_cmd//\\/\\\\}" + _esc="${_esc//\"/\\\"}" + _esc="${_esc//$'\n'/\\n}" + _esc="${_esc//$'\r'/\\r}" + _esc="${_esc//$'\t'/\\t}" + local _cmd_json='{"command":"'$_esc'"}' + local _cmd_hex + _cmd_hex=$(builtin printf '%s' "$_cmd_json" | xxd -p | tr -d '\n') + builtin printf '\e]7777;carrot-precmd;%s\a' "$_cmd_hex" >&$_carrot_fd + # Mark prompt end / input region start builtin printf '\e]133;B\a' >&$_carrot_fd diff --git a/shell/nushell/vendor/autoload/carrot.nu b/shell/nushell/vendor/autoload/carrot.nu index 23aa4342..8b4a2b22 100644 --- a/shell/nushell/vendor/autoload/carrot.nu +++ b/shell/nushell/vendor/autoload/carrot.nu @@ -53,19 +53,28 @@ if "sudo" in $features { } } -# TUI hint: emit OSC 7777 carrot-tui-hint for known TUI commands via the -# pre_execution hook, before the command writes its first byte. Matches -# the colon-separated $env.CARROT_KNOWN_TUIS list set by pty.rs. +# Pre-execution emit: carries the about-to-run command line via the +# carrot-precmd channel, plus the TUI hint when the command matches +# $env.CARROT_KNOWN_TUIS. Both pieces of information land while the +# command is queued but before its first byte arrives, so the +# CommandStart marker (emitted by reedline immediately after) sees +# the metadata already buffered. $env.config = ($env.config | upsert hooks {|config| let existing = ($config.hooks? | default {}) let existing_pre_exec = ($existing.pre_execution? | default []) $existing | upsert pre_execution ($existing_pre_exec | append {|cmd| + # Always emit the command line itself — `to json -r` handles + # quoting / escaping for any input. ShellContext merges this + # with the precmd-time emit (cwd / git / etc.) field-by-field. + let cmd_hex = ({command: $cmd} | to json -r | encode hex) + print -n $"\e]7777;carrot-precmd;($cmd_hex)\u{07}" + let tuis = ($env.CARROT_KNOWN_TUIS? | default "" | split row ":") if ($tuis | is-empty) { return } - # Skip when the command carries a non-interactive flag. + # Skip the TUI hint when the command carries a non-interactive flag. if ($cmd =~ ' (--version|--help|-V|-h|-\?)( |$)') { return }