From 116bb841c2c46b813379a32d5d2eaa7012666dd6 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sun, 11 Oct 2026 14:46:49 +0000 Subject: [PATCH] feat(terminal): prompt and command marks from shell integration (#614) Shell integration reported only the working directory, so a terminal knew a prompt had been drawn but not where a command's output started or how it exited. The bash, zsh and PowerShell hooks now also send OSC 133 A (prompt start), C (command start) and D (exit status), for local shells and for SSH, WSL and container sessions. Each terminal records them as command regions: prompt line, output start, end line, exit code and timing, read through getCommandRegions(sessionId). Regions follow scrollback trimming and are not opened on the alternate screen. Nothing consumes them yet. The hooks never edit the prompt, so B (input start) is not sent; it is recorded when the user's own prompt sends it. bash below 4.4 has no PS0 and sends no C. The POSIX sh fallback and cmd send no marks. fish relies on its own. tmux control mode passes the marks through. After a reattach the list restarts empty, and the first command typed at the restored prompt is not tracked. The hook text lived in five copies in shell_integration.rs and is now built from shared fragments. Comments that travelled inside the SSH bootstrap moved to Rust doc comments, which keeps the payload clear of the 8192-byte cap. Closes #614 --- src-tauri/src/shell_integration.rs | 332 +++++++++++++------ src/components/terminal/commandMarks.test.ts | 128 +++++++ src/components/terminal/commandMarks.ts | 108 ++++++ src/hooks/useTerminal.ts | 19 ++ 4 files changed, 488 insertions(+), 99 deletions(-) create mode 100644 src/components/terminal/commandMarks.test.ts create mode 100644 src/components/terminal/commandMarks.ts diff --git a/src-tauri/src/shell_integration.rs b/src-tauri/src/shell_integration.rs index c0912950a..c8e691097 100644 --- a/src-tauri/src/shell_integration.rs +++ b/src-tauri/src/shell_integration.rs @@ -123,49 +123,126 @@ pub fn cleanup(tempfiles: &[PathBuf]) { } } -const BASH_RC: &str = "if [ -r /etc/profile ]; then . /etc/profile; fi\n\ -if [ -r \"$HOME/.bash_profile\" ]; then . \"$HOME/.bash_profile\"\n\ -elif [ -r \"$HOME/.bash_login\" ]; then . \"$HOME/.bash_login\"\n\ -elif [ -r \"$HOME/.profile\" ]; then . \"$HOME/.profile\"\n\ -else\n\ - [ -r /etc/bash.bashrc ] && . /etc/bash.bashrc\n\ - [ -r \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"\n\ -fi\n\ -__voltius_pwd() { printf '\\e]7;file://%s%s\\a' \"$HOSTNAME\" \"$PWD\"; }\n\ -case \";${PROMPT_COMMAND-};\" in\n\ - *\";__voltius_pwd;\"*) ;;\n\ - *) PROMPT_COMMAND=\"__voltius_pwd${PROMPT_COMMAND:+;${PROMPT_COMMAND}}\" ;;\n\ -esac\n\ -__voltius_pwd 2>/dev/null\n"; +// D (last exit status) and A (prompt start) ride on the OSC 7 printf, so each +// prompt is still one write and nothing touches PS1. +macro_rules! pwd_fn { + ($url:literal, $host:literal) => { + concat!( + r##"__voltius_pwd() { printf '\e]133;D;%s\a\e]133;A\a\e]7;"##, + $url, + r##"\a' "$?" ""##, + $host, + r##"" "$PWD"; } +"## + ) + }; +} + +// PS0 is bash's only pre-exec point that needs no DEBUG trap; bash < 4.4 +// ignores it, so those hosts send no C. +macro_rules! bash_hooks { + ($url:literal, $host:literal) => { + concat!( + pwd_fn!($url, $host), + r##"case ";${PROMPT_COMMAND-};" in + *";__voltius_pwd;"*) ;; + *) PROMPT_COMMAND="__voltius_pwd${PROMPT_COMMAND:+;${PROMPT_COMMAND}}" ;; +esac +PS0='\e]133;C\a'"${PS0-}" +__voltius_pwd 2>/dev/null +"## + ) + }; +} + +// --rcfile skips bash's own startup files, so replay the login chain: PS1 and +// profile-driven welcome text live there. +macro_rules! bash_login_chain { + () => { + r##"if [ -r /etc/profile ]; then . /etc/profile; fi +if [ -r "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" +elif [ -r "$HOME/.bash_login" ]; then . "$HOME/.bash_login" +elif [ -r "$HOME/.profile" ]; then . "$HOME/.profile" +else + [ -r /etc/bash.bashrc ] && . /etc/bash.bashrc + [ -r "$HOME/.bashrc" ] && . "$HOME/.bashrc" +fi +"## + }; +} + +macro_rules! zsh_hooks { + ($url:literal, $host:literal) => { + concat!( + pwd_fn!($url, $host), + r##"__voltius_run() { printf '\e]133;C\a'; } +typeset -ag precmd_functions preexec_functions +(($precmd_functions[(I)__voltius_pwd])) || precmd_functions+=(__voltius_pwd) +(($preexec_functions[(I)__voltius_run])) || preexec_functions+=(__voltius_run) +__voltius_pwd 2>/dev/null +"## + ) + }; +} // .zshenv trampoline (kitty/ghostty technique): restores ZDOTDIR from // ZDOTDIR_ORIG then sources the real .zshenv so zsh continues startup with // the user's files. Fixes configs like zsh4humans that define functions in -// ~/.zshenv before .zshrc runs. The hook installs at .zshenv time, so rc -// files that overwrite precmd_functions (instead of appending) drop cwd -// tracking — accepted trade-off shared with kitty/ghostty. -const ZSH_ZSHENV: &str = - "if [ -n \"${ZDOTDIR_ORIG-}\" ]; then ZDOTDIR=\"$ZDOTDIR_ORIG\"; else unset ZDOTDIR; fi\n\ -unset ZDOTDIR_ORIG\n\ -[ -f \"${ZDOTDIR:-$HOME}/.zshenv\" ] && source \"${ZDOTDIR:-$HOME}/.zshenv\"\n\ -__voltius_pwd() { printf '\\e]7;file://%s%s\\a' \"${HOST}\" \"$PWD\"; }\n\ -typeset -ag precmd_functions\n\ -(($precmd_functions[(I)__voltius_pwd])) || precmd_functions+=(__voltius_pwd)\n\ -__voltius_pwd 2>/dev/null\n"; +// ~/.zshenv before .zshrc runs. The hooks install at .zshenv time, so rc +// files that overwrite precmd_functions or preexec_functions (instead of +// appending) drop them — accepted trade-off shared with kitty/ghostty. +macro_rules! zsh_env { + ($url:literal, $host:literal) => { + concat!( + r##"if [ -n "${ZDOTDIR_ORIG-}" ]; then ZDOTDIR="$ZDOTDIR_ORIG"; else unset ZDOTDIR; fi +unset ZDOTDIR_ORIG +[ -f "${ZDOTDIR:-$HOME}/.zshenv" ] && source "${ZDOTDIR:-$HOME}/.zshenv" +"##, + zsh_hooks!($url, $host) + ) + }; +} + +// POSIX sh has no pre-exec or pre-prompt hook, so it reports the cwd only. +macro_rules! sh_hooks { + () => { + r##"__voltius_pwd() { printf '\033]7;file://%s%s\007' "${HOSTNAME:-}" "$PWD"; } +PS1='$(__voltius_pwd)'"${PS1:-$ }" +"## + }; +} + +const BASH_RC: &str = concat!(bash_login_chain!(), bash_hooks!("file://%s%s", "$HOSTNAME")); + +const ZSH_ZSHENV: &str = zsh_env!("file://%s%s", "${HOST}"); // $E = ESC, $P = path with backslashes, $G = >, $S = space. The $E\\ sequence // is ESC + backslash = ST (string terminator), closing the OSC 7. The path // uses backslashes; the frontend OSC handler normalizes to forward slashes. const CMD_BAT: &str = "@echo off\r\nprompt $E]7;file://localhost/$P$E\\$P$G$S\r\n"; -const PWSH_SCRIPT: &str = "if (Test-Path $PROFILE) { . $PROFILE }\n\ -$global:__voltiusOldPrompt = if (Test-Path Function:\\prompt) { $function:prompt } else { $null }\n\ -function global:prompt {\n\ - $cwd = (Get-Location).Path -replace '\\\\', '/'\n\ - $hostName = if ($env:COMPUTERNAME) { $env:COMPUTERNAME } else { 'localhost' }\n\ - [Console]::Write([char]27 + ']7;file://' + $hostName + '/' + $cwd + [char]7)\n\ - if ($global:__voltiusOldPrompt) { & $global:__voltiusOldPrompt } else { \"PS $cwd> \" }\n\ -}\n"; +// PSReadLine defines PSConsoleHostReadLine only once it has loaded, so the C +// wrapper is installed from the first prompt. Every variable is set or tested +// before it is read: a profile may turn strict mode on. +const PWSH_SCRIPT: &str = r##"if (Test-Path $PROFILE) { . $PROFILE } +$global:__voltiusOldPrompt = if (Test-Path Function:\prompt) { $function:prompt } else { $null } +$global:__voltiusReadLine = $null +function global:prompt { + $code = if ($global:?) { 0 } elseif ((Test-Path Variable:\LASTEXITCODE) -and $global:LASTEXITCODE) { $global:LASTEXITCODE } else { 1 } + if (-not $global:__voltiusReadLine -and (Test-Path Function:\PSConsoleHostReadLine)) { + $global:__voltiusReadLine = $function:PSConsoleHostReadLine + function global:PSConsoleHostReadLine { + $line = & $global:__voltiusReadLine + if ("$line".Trim()) { [Console]::Write([char]27 + ']133;C' + [char]7) } + $line + } + } + $cwd = (Get-Location).Path -replace '\\', '/' + $hostName = if ($env:COMPUTERNAME) { $env:COMPUTERNAME } else { 'localhost' } + [Console]::Write([char]27 + ']133;D;' + $code + [char]7 + [char]27 + ']133;A' + [char]7 + [char]27 + ']7;file://' + $hostName + '/' + $cwd + [char]7) + if ($global:__voltiusOldPrompt) { & $global:__voltiusOldPrompt } else { "PS $cwd> " } +} +"##; /// POSIX wrapper that detects $SHELL at runtime, writes a per-session rcfile /// under /tmp, and execs into a hooked interactive shell. NOT invoked @@ -190,19 +267,23 @@ function global:prompt {\n\ /// /// The temp file leaks intentionally — /tmp is cleared on reboot, and trying /// to rm it from inside the rcfile races with bash/zsh reading it. -const SSH_WRAPPER: &str = r#"case "$(basename "${SHELL:-/bin/sh}")" in +/// +/// No bash (busybox/dash-only host): hook a POSIX sh through an `$ENV` file. +/// Without that branch `exec bash` fails with 127 and the session loops +/// disconnect/reconnect. A login sh reads `$ENV` only after /etc/profile, which +/// may repoint it (OpenWrt: /etc/shinit), so the file replays that chain. +/// +/// Comments stay out of the script itself: every byte counts against +/// `MAX_EXEC_COMMAND_LEN`. +const SSH_WRAPPER: &str = concat!( + r##"case "$(basename "${SHELL:-/bin/sh}")" in zsh) ZDOTDIR_TMP=$(mktemp -d 2>/dev/null) || exec zsh -l -i <&2 export ZDOTDIR_ORIG="${ZDOTDIR:-$HOME}" cat > "$ZDOTDIR_TMP/.zshenv" <<'EOF' -if [ -n "${ZDOTDIR_ORIG-}" ]; then ZDOTDIR="$ZDOTDIR_ORIG"; else unset ZDOTDIR; fi -unset ZDOTDIR_ORIG -[ -f "${ZDOTDIR:-$HOME}/.zshenv" ] && source "${ZDOTDIR:-$HOME}/.zshenv" -__voltius_pwd() { printf '\e]7;file://%s%s\a' "${HOST}" "$PWD"; } -typeset -ag precmd_functions -(($precmd_functions[(I)__voltius_pwd])) || precmd_functions+=(__voltius_pwd) -__voltius_pwd 2>/dev/null -EOF +"##, + zsh_env!("file://%s%s", "${HOST}"), + r##"EOF ZDOTDIR="$ZDOTDIR_TMP" exec zsh -l -i <&2 ;; fish) @@ -212,47 +293,28 @@ fish) if command -v bash >/dev/null 2>&1; then RCFILE_TMP=$(mktemp 2>/dev/null) || exec bash -l -i <&2 cat > "$RCFILE_TMP" <<'EOF' -# Replicate bash's own startup so the session matches a normal interactive -# login: --rcfile otherwise skips /etc/profile, /etc/bash.bashrc and the -# profile chain, which is where PS1 and profile-driven welcome text live. -if [ -r /etc/profile ]; then . /etc/profile; fi -if [ -r "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" -elif [ -r "$HOME/.bash_login" ]; then . "$HOME/.bash_login" -elif [ -r "$HOME/.profile" ]; then . "$HOME/.profile" -else - [ -r /etc/bash.bashrc ] && . /etc/bash.bashrc - [ -r "$HOME/.bashrc" ] && . "$HOME/.bashrc" -fi -__voltius_pwd() { printf '\e]7;file://%s%s\a' "$HOSTNAME" "$PWD"; } -case ";${PROMPT_COMMAND-};" in - *";__voltius_pwd;"*) ;; - *) PROMPT_COMMAND="__voltius_pwd${PROMPT_COMMAND:+;${PROMPT_COMMAND}}" ;; -esac -__voltius_pwd 2>/dev/null -EOF +"##, + bash_login_chain!(), + bash_hooks!("file://%s%s", "$HOSTNAME"), + r##"EOF exec bash --rcfile "$RCFILE_TMP" -i <&2 else - # No bash on the remote (busybox/dash-only host). Hooking OSC 7 into a POSIX - # sh via an $ENV file keeps integration working; without this branch the - # `exec bash` above would fail with 127, the sh would exit, and the session - # would loop disconnect/reconnect. ENVF=$(mktemp 2>/dev/null) || exec sh -l -i <&2 cat > "$ENVF" <<'EOF' -# A login shell would read $ENV only after /etc/profile, which may repoint it -# (OpenWrt: /etc/shinit), so run that chain here. __voltius_env=$ENV [ -r /etc/profile ] && . /etc/profile [ -r "$HOME/.profile" ] && . "$HOME/.profile" [ "${ENV-}" != "$__voltius_env" ] && [ -r "${ENV-}" ] && . "$ENV" unset __voltius_env -__voltius_pwd() { printf '\033]7;file://%s%s\007' "${HOSTNAME:-}" "$PWD"; } -PS1='$(__voltius_pwd)'"${PS1:-$ }" -EOF +"##, + sh_hooks!(), + r##"EOF ENV="$ENVF" exec sh -i <&2 fi ;; esac -"#; +"## +); /// sshd emits the MOTD only for an interactive `shell` request, not the `exec` /// path integration/persistence use, so reproduce it. Single quote-free line so @@ -752,28 +814,26 @@ fn printf_escape(script: &str) -> String { /// - No `/dev/null 2>&1; then +const CONTAINER_WRAPPER: &str = concat!( + r##"if command -v bash >/dev/null 2>&1; then RC=$(mktemp 2>/dev/null || echo /tmp/.voltius_rc) cat > "$RC" <<'EOF' [ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc" -__voltius_pwd() { printf '\033]7;file://%s%s\007' "${HOSTNAME:-}" "$PWD"; } -case ";${PROMPT_COMMAND-};" in - *";__voltius_pwd;"*) ;; - *) PROMPT_COMMAND="__voltius_pwd${PROMPT_COMMAND:+;${PROMPT_COMMAND}}" ;; -esac -__voltius_pwd 2>/dev/null -EOF +"##, + bash_hooks!("file://%s%s", "$HOSTNAME"), + r##"EOF exec bash --rcfile "$RC" -i else ENVF=$(mktemp 2>/dev/null || echo /tmp/.voltius_env) cat > "$ENVF" <<'EOF' -__voltius_pwd() { printf '\033]7;file://%s%s\007' "${HOSTNAME:-}" "$PWD"; } PROMPT_COMMAND="__voltius_pwd" -PS1='$(__voltius_pwd)'"${PS1:-$ }" -EOF +"##, + sh_hooks!(), + r##"EOF ENV="$ENVF" exec sh -i fi -"#; +"## +); /// Build the argument for `sh -c '<…>'` inside a container (the caller prefixes /// `docker exec -it ` or `pct exec --`). `eval`s the wrapper so the @@ -788,21 +848,17 @@ pub fn container_exec_payload() -> String { /// WSL flavor: same structure as SSH_WRAPPER but the printf format embeds /// the distro into the OSC 7 path. The frontend recognizes the /// `wsl.localhost` host and constructs a UNC path Windows can read. -const WSL_WRAPPER: &str = r#"WSL_DISTRO_NAME="${WSL_DISTRO_NAME:-Linux}" +const WSL_WRAPPER: &str = concat!( + r##"WSL_DISTRO_NAME="${WSL_DISTRO_NAME:-Linux}" export WSL_DISTRO_NAME case "$(basename "${SHELL:-/bin/sh}")" in zsh) ZDOTDIR_TMP=$(mktemp -d 2>/dev/null) || exec zsh -i <&2 export ZDOTDIR_ORIG="${ZDOTDIR:-$HOME}" cat > "$ZDOTDIR_TMP/.zshenv" <<'EOF' -if [ -n "${ZDOTDIR_ORIG-}" ]; then ZDOTDIR="$ZDOTDIR_ORIG"; else unset ZDOTDIR; fi -unset ZDOTDIR_ORIG -[ -f "${ZDOTDIR:-$HOME}/.zshenv" ] && source "${ZDOTDIR:-$HOME}/.zshenv" -__voltius_pwd() { printf '\e]7;file://wsl.localhost/%s%s\a' "$WSL_DISTRO_NAME" "$PWD"; } -typeset -ag precmd_functions -(($precmd_functions[(I)__voltius_pwd])) || precmd_functions+=(__voltius_pwd) -__voltius_pwd 2>/dev/null -EOF +"##, + zsh_env!("file://wsl.localhost/%s%s", "$WSL_DISTRO_NAME"), + r##"EOF ZDOTDIR="$ZDOTDIR_TMP" exec zsh -i <&2 ;; fish) @@ -812,17 +868,14 @@ fish) RCFILE_TMP=$(mktemp 2>/dev/null) || exec bash -i <&2 cat > "$RCFILE_TMP" <<'EOF' [ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" -__voltius_pwd() { printf '\e]7;file://wsl.localhost/%s%s\a' "$WSL_DISTRO_NAME" "$PWD"; } -case ";${PROMPT_COMMAND-};" in - *";__voltius_pwd;"*) ;; - *) PROMPT_COMMAND="__voltius_pwd${PROMPT_COMMAND:+;${PROMPT_COMMAND}}" ;; -esac -__voltius_pwd 2>/dev/null -EOF +"##, + bash_hooks!("file://wsl.localhost/%s%s", "$WSL_DISTRO_NAME"), + r##"EOF exec bash --rcfile "$RCFILE_TMP" -i <&2 ;; esac -"#; +"## +); #[cfg(test)] #[allow(clippy::disallowed_methods)] @@ -1405,6 +1458,87 @@ mod tests { } } + #[test] + fn bash_and_zsh_wrappers_carry_prompt_marks() { + let container = unescape_printf(&printf_escape(CONTAINER_WRAPPER)); + for script in [ + BASH_RC.to_string(), + ZSH_ZSHENV.to_string(), + decode_bootstrap(&ssh_exec_command("")), + decode_bootstrap(&wsl_exec_command()), + container, + ] { + assert!(script.contains(r"\e]133;D;%s\a\e]133;A\a\e]7;file://")); + assert!(script.contains(r"\e]133;C\a")); + } + assert!(BASH_RC.contains("PS0=")); + assert!(ZSH_ZSHENV.contains("preexec_functions+=(__voltius_run)")); + assert!(!sh_hooks!().contains("133;")); + assert!(PWSH_SCRIPT.contains("']133;D;' + $code")); + assert!(PWSH_SCRIPT.contains("']133;C'")); + } + + #[cfg(target_os = "linux")] + fn prompt_marks(shell_command: &str, hooks: &str, rc_name: &str) -> Vec { + let dir = std::env::temp_dir().join(format!( + "voltius-marks-{}-{}", + std::process::id(), + rc_name.trim_start_matches('.') + )); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(rc_name), hooks).unwrap(); + let out = std::process::Command::new("sh") + .arg("-c") + .arg(format!( + "printf 'true\\nfalse\\n\\n' | {shell_command} 2>&1" + )) + .env("RC_DIR", &dir) + .output() + .expect("sh runs"); + let _ = std::fs::remove_dir_all(&dir); + let text = String::from_utf8_lossy(&out.stdout).into_owned(); + text.split("\x1b]133;") + .skip(1) + .filter_map(|rest| rest.split('\x07').next().map(str::to_string)) + .collect() + } + + // One D;A pair from the rc's own call, then per line: C when a command + // ran, and the D that reports it. The empty line re-reports the last status. + #[cfg(target_os = "linux")] + const EXPECTED_MARKS: [&str; 12] = [ + "D;0", "A", "D;0", "A", "C", "D;0", "A", "C", "D;1", "A", "D;1", "A", + ]; + + #[cfg(target_os = "linux")] + #[test] + fn bash_hooks_mark_each_command() { + let marks = prompt_marks( + r#"bash --rcfile "$RC_DIR/rc" -i"#, + bash_hooks!("file://%s%s", "$HOSTNAME"), + "rc", + ); + assert_eq!(marks, EXPECTED_MARKS); + } + + #[cfg(target_os = "linux")] + #[test] + fn zsh_hooks_mark_each_command() { + let has_zsh = std::process::Command::new("sh") + .args(["-c", "command -v zsh"]) + .output() + .is_ok_and(|o| o.status.success()); + if !has_zsh { + return; + } + let marks = prompt_marks( + r#"ZDOTDIR="$RC_DIR" zsh -i"#, + zsh_hooks!("file://%s%s", "${HOST}"), + ".zshenv", + ); + assert_eq!(marks, EXPECTED_MARKS); + } + #[test] fn persistent_exec_command_fits_dropbear_max_string_len() { // dropbear exits the connection with "String too long" past 9000 bytes, diff --git a/src/components/terminal/commandMarks.test.ts b/src/components/terminal/commandMarks.test.ts new file mode 100644 index 000000000..79a146c4b --- /dev/null +++ b/src/components/terminal/commandMarks.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { createCommandTracker, type LineMark } from "./commandMarks"; + +function harness() { + const state = { line: 0, col: 0, now: 1000, alternate: false }; + const marks: (LineMark & { line: number; isDisposed: boolean })[] = []; + const tracker = createCommandTracker({ + mark: () => { + if (state.alternate) return null; + const mark = { line: state.line, isDisposed: false, dispose() { this.isDisposed = true; } }; + marks.push(mark); + return mark; + }, + col: () => state.col, + now: () => state.now, + }); + const at = (line: number, payload: string, col = 0) => { + state.line = line; + state.col = col; + tracker.handle(payload); + }; + /** Drop `count` lines off the top of the scrollback, as xterm does when it trims. */ + const trim = (count: number) => { + for (const mark of marks) { + if (mark.isDisposed) continue; + if (mark.line < count) mark.isDisposed = true; + else mark.line -= count; + } + }; + return { state, tracker, at, trim }; +} + +const idle = { input: null, outputLine: null, endLine: null, exitCode: null, startedAt: null, endedAt: null }; + +describe("createCommandTracker", () => { + it("records a command from prompt to exit", () => { + const { state, tracker, at } = harness(); + at(0, "A"); + at(0, "C"); + state.now = 1500; + at(3, "D;2"); + expect(tracker.regions()).toEqual([ + { promptLine: 0, input: null, outputLine: 0, endLine: 3, exitCode: 2, startedAt: 1000, endedAt: 1500 }, + ]); + }); + + it("keeps a prompt nothing ran from, and ignores the status it re-reports", () => { + const { tracker, at } = harness(); + at(0, "A"); + at(1, "D;1"); + at(1, "A"); + expect(tracker.regions()).toEqual([{ promptLine: 0, ...idle }, { promptLine: 1, ...idle }]); + }); + + it("counts a prompt marked twice on one line once", () => { + const { tracker, at } = harness(); + at(4, "A"); + at(4, "A;click_events=1"); + expect(tracker.regions()).toHaveLength(1); + }); + + it("takes the last B before the command runs", () => { + const { tracker, at } = harness(); + at(0, "A"); + at(0, "B", 2); + at(0, "B", 9); + at(0, "C;cmdline_url=ls"); + at(0, "B", 30); + expect(tracker.regions()[0].input).toEqual({ line: 0, col: 9 }); + }); + + it("keeps the first C and the first D of a command", () => { + const { state, tracker, at } = harness(); + at(0, "A"); + at(1, "C"); + at(2, "C"); + at(5, "D;0"); + state.now = 9000; + at(6, "D;7"); + expect(tracker.regions()[0]).toMatchObject({ outputLine: 1, endLine: 5, exitCode: 0, endedAt: 1000 }); + }); + + it("ends a command whose status is missing or not a number", () => { + const { tracker, at } = harness(); + at(0, "A"); + at(0, "C"); + at(1, "D"); + at(1, "A"); + at(1, "C"); + at(2, "D;err=x"); + expect(tracker.regions().map((r) => [r.exitCode, r.endedAt])).toEqual([[null, 1000], [null, 1000]]); + }); + + it("ignores marks with no prompt before them and unknown kinds", () => { + const { tracker, at } = harness(); + at(0, "C"); + at(0, "D;0"); + at(0, "B"); + at(0, "P;k=s"); + at(0, "toString"); + expect(tracker.regions()).toEqual([]); + }); + + it("opens no prompt on the alternate screen but still ends the command that entered it", () => { + const { state, tracker, at } = harness(); + at(0, "A"); + at(0, "C"); + state.alternate = true; + at(0, "A"); + at(0, "D;0"); + expect(tracker.regions()).toEqual([ + { promptLine: 0, input: null, outputLine: 0, endLine: null, exitCode: 0, startedAt: 1000, endedAt: 1000 }, + ]); + }); + + it("forgets commands whose prompt scrolled out of the scrollback", () => { + const { tracker, at, trim } = harness(); + at(0, "A"); + at(0, "C"); + at(2, "D;0"); + at(2, "A"); + at(2, "C"); + trim(2); + expect(tracker.regions()).toEqual([ + { promptLine: 0, input: null, outputLine: 0, endLine: null, exitCode: null, startedAt: 1000, endedAt: null }, + ]); + }); +}); diff --git a/src/components/terminal/commandMarks.ts b/src/components/terminal/commandMarks.ts new file mode 100644 index 000000000..268050a0d --- /dev/null +++ b/src/components/terminal/commandMarks.ts @@ -0,0 +1,108 @@ +/** A buffer position that follows scrollback trimming: an xterm marker, or a fake in tests. */ +export interface LineMark { + readonly line: number; + readonly isDisposed: boolean; + dispose(): void; +} + +export interface CommandMarkSource { + /** A mark on the cursor's line, or null where marks make no sense (alternate screen). */ + mark(): LineMark | null; + col(): number; + now(): number; +} + +/** One prompt and the command run from it. Lines are absolute buffer lines. */ +export interface CommandRegion { + promptLine: number; + /** Where typed input starts. Null unless the shell's own prompt sends B. */ + input: { line: number; col: number } | null; + /** Null while no command has run from this prompt. */ + outputLine: number | null; + /** First line after the output, where the next prompt starts. */ + endLine: number | null; + exitCode: number | null; + startedAt: number | null; + /** Null while the command is still running. */ + endedAt: number | null; +} + +export interface CommandTracker { + /** Feed one OSC 133 payload (`A`, `B`, `C`, `D;`, with optional extra parameters). */ + handle(payload: string): void; + regions(): CommandRegion[]; +} + +interface Tracked { + prompt: LineMark; + input: { mark: LineMark; col: number } | null; + output: LineMark | null; + end: LineMark | null; + exitCode: number | null; + startedAt: number | null; + endedAt: number | null; +} + +const liveLine = (mark: LineMark | null) => (mark && !mark.isDisposed ? mark.line : null); + +export function createCommandTracker(source: CommandMarkSource): CommandTracker { + let tracked: Tracked[] = []; + + const dropTrimmed = () => { + if (tracked.some((t) => t.prompt.isDisposed)) tracked = tracked.filter((t) => !t.prompt.isDisposed); + }; + + const handlers = new Map void>(Object.entries({ + A: (current) => { + const mark = source.mark(); + if (!mark) return; + // Two emitters (ours and the user's prompt) mark the same prompt twice. + if (current && !current.output && current.prompt.line === mark.line) return mark.dispose(); + tracked.push({ prompt: mark, input: null, output: null, end: null, exitCode: null, startedAt: null, endedAt: null }); + }, + B: (current) => { + if (!current || current.output) return; + const mark = source.mark(); + if (!mark) return; + current.input?.mark.dispose(); + current.input = { mark, col: source.col() }; + }, + C: (current) => { + if (!current || current.output) return; + current.output = source.mark(); + if (current.output) current.startedAt = source.now(); + }, + // Shells report the last status at every prompt, so a D with no command before it means nothing. + D: (current, args) => { + if (!current?.output || current.endedAt !== null) return; + current.end = source.mark(); + current.exitCode = /^-?\d+$/.test(args[0] ?? "") ? Number(args[0]) : null; + current.endedAt = source.now(); + }, + })); + + return { + handle(payload) { + const [kind, ...args] = payload.split(";"); + const handler = handlers.get(kind); + if (!handler) return; + dropTrimmed(); + handler(tracked[tracked.length - 1], args); + }, + regions() { + dropTrimmed(); + return tracked.map((t) => { + const inputLine = liveLine(t.input?.mark ?? null); + return { + promptLine: t.prompt.line, + input: t.input && inputLine !== null ? { line: inputLine, col: t.input.col } : null, + outputLine: liveLine(t.output), + endLine: liveLine(t.end), + exitCode: t.exitCode, + startedAt: t.startedAt, + endedAt: t.endedAt, + }; + }); + }, + }; +} diff --git a/src/hooks/useTerminal.ts b/src/hooks/useTerminal.ts index 6f20ea5ee..ab6fd3292 100644 --- a/src/hooks/useTerminal.ts +++ b/src/hooks/useTerminal.ts @@ -9,6 +9,7 @@ import { openUrl } from "@tauri-apps/plugin-opener"; import { onSshOutput, onSshClosed, onSshCwd, onSshMuxMode, sshSetOutputPaused, sshSetTerminalColors } from "@/services/ssh"; import { createOutputFlow, type OutputFlow } from "@/components/terminal/outputFlow"; import { suppressTerminalQueries } from "@/components/terminal/terminalQueries"; +import { createCommandTracker, type CommandRegion, type CommandTracker } from "@/components/terminal/commandMarks"; import { currentTerminalColors } from "@/utils/terminalColors"; import { localReady, onLocalOutput, onLocalClosed } from "@/services/local"; import { onSerialOutput, onSerialClosed } from "@/services/serial"; @@ -169,6 +170,7 @@ type CacheEntry = CachedTerminal & { searchAddon: SearchAddon; search: SearchState; minimap: MinimapState; + commands: CommandTracker; sessionType: "ssh" | "local" | "serial"; connectedRef: { current: boolean }; /** The session's terminal encoding (undefined = UTF-8), for input and output alike. */ @@ -574,6 +576,12 @@ export function readTerminalSelection(sessionId: string): string { } /** Test seam: replace the module-private terminal cache. Do not use in app code. */ +/** The session's prompts and commands, oldest first, as reported by OSC 133. Empty + * for shells that send no marks. */ +export function getCommandRegions(sessionId: string): CommandRegion[] { + return terminalCache.get(sessionId)?.commands.regions() ?? []; +} + export function __setTerminalCacheForTest(next: typeof terminalCache): void { terminalCache.clear(); for (const [k, v] of next) terminalCache.set(k, v); @@ -835,6 +843,11 @@ export function useTerminal({ sessionId, sessionType, onClosed, inputGate, encod subscribers: new Set(), frame: null, }, + commands: createCommandTracker({ + mark: () => (term.buffer.active.type === "normal" ? term.registerMarker(0) ?? null : null), + col: () => term.buffer.active.cursorX, + now: Date.now, + }), sessionType, // Serial is live the moment its port opens (the store marks it // connected before the terminal mounts); ssh and local flip in the @@ -1027,6 +1040,11 @@ export function useTerminal({ sessionId, sessionType, onClosed, inputGate, encod return true; }); + const oscMarkDispose = term.parser.registerOscHandler(133, (data) => { + entry.commands.handle(data); + return true; + }); + const onDataDispose = term.onData((data) => { if (inputGate && !inputGate.current?.()) return; if (!entry.connectedRef.current) return; @@ -1123,6 +1141,7 @@ export function useTerminal({ sessionId, sessionType, onClosed, inputGate, encod onDataDispose.dispose(); onResizeDispose.dispose(); oscCwdDispose.dispose(); + oscMarkDispose.dispose(); searchResultsDispose.dispose(); scrollDispose.dispose(); bufferChangeDispose.dispose();