Skip to content
Open
20 changes: 3 additions & 17 deletions crates/carrot-app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
24 changes: 14 additions & 10 deletions crates/carrot-block-render/src/block_element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GridOriginStore>,
}

/// 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<std::cell::Cell<Option<Pixels>>>;
/// 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<std::cell::Cell<Option<Point<Pixels>>>>;

impl BlockElement {
pub fn new(
Expand Down Expand Up @@ -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();
Expand Down
124 changes: 111 additions & 13 deletions crates/carrot-shell-integration/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
}

impl ShellContext {
Expand Down Expand Up @@ -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();
}
}
}

Expand All @@ -92,6 +122,7 @@ impl Default for ShellContext {
git_branch: None,
git_stats: None,
git_root: None,
command: None,
}
}
}
Expand Down Expand Up @@ -148,6 +179,73 @@ fn detect_git_root(cwd: &std::path::Path) -> Option<String> {
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<GitStats> {
let output = Command::new("git")
.args(["diff", "--stat", "--shortstat", "HEAD"])
Expand Down
71 changes: 68 additions & 3 deletions crates/carrot-shell-integration/src/metadata.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub username: Option<String>,
pub hostname: Option<String>,
pub git_branch: Option<String>,
Expand All @@ -17,6 +23,65 @@ pub struct ShellMetadataPayload {
pub last_exit_code: Option<i32>,
pub last_duration_ms: Option<u64>,
pub shell: Option<String>,
/// 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<String>,
}

#[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.
Expand Down
Loading
Loading