From a21200070f703dd454cf375edce954ff6fce8af6 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 4 Aug 2026 20:06:55 -0400 Subject: [PATCH 1/3] feat(terminal): track, draw, and assert on the window title A program announces what it is doing by setting the window title with `OSC 0` or `OSC 2`, and shell-use threw that away. An agent driving a terminal could see what a program printed but not what it called itself, which is exactly what long-running tools report progress through. The emulator now tracks the title, and it is reachable everywhere the other terminal properties are: `state`, `get title`, `expect title`, and `wait title`, in the CLI and in both bindings. Screenshots draw it in the title bar. An empty title reports as none rather than as a blank one. Programs clear the title that way on exit, so keeping the two apart would only give every caller the same special case to write. Widths are measured in terminal columns, not characters. A CJK title is half as many characters as columns, so counting characters drew a snapshot frame four columns out of true and, in the centred title bar, spilled the title over the window controls at both ends. Snapshots only record the title when asked with `--include-title`. A shell prompt routinely sets it to a username, hostname, and absolute path, so recording it by default would pin every stored baseline to one machine and make it change on `cd` while the screen stayed the same. The title stack (`CSI 22 t` / `CSI 23 t`) comes free with this, since alacritty implements a pop as setting the title it popped; there is a test rather than any code for it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 8 +- SKILL.md | 8 +- bindings/js/native/index.d.ts | 11 ++ bindings/js/native/lib.rs | 72 ++++++++ bindings/js/src/client.ts | 33 +++- bindings/js/src/index.ts | 1 + bindings/js/src/native.ts | 13 ++ bindings/js/test/conformance.test.mjs | 5 +- bindings/python/native/src/lib.rs | 77 ++++++++- bindings/python/src/shell_use/_native.pyi | 5 +- bindings/python/src/shell_use/client.py | 36 +++- bindings/python/src/shell_use/types.py | 2 + bindings/python/stub-gen/src/main.rs | 16 ++ bindings/python/tests/test_conformance.py | 5 +- crates/shell-use-cli/src/cli.rs | 38 +++++ crates/shell-use-cli/src/main.rs | 31 +++- crates/shell-use-cli/src/protocol.rs | 43 +++++ .../shell-use-cli/tests/session_lifecycle.rs | 93 +++++++++++ crates/shell-use/Cargo.toml | 1 + crates/shell-use/src/api.rs | 16 ++ crates/shell-use/src/assert/snapshot.rs | 110 +++++++++++- crates/shell-use/src/engine.rs | 157 ++++++++++++++++- crates/shell-use/src/render/svg.rs | 158 ++++++++++++++++-- crates/shell-use/src/terminal/alacritty.rs | 32 +++- crates/shell-use/src/terminal/cell.rs | 38 +++++ crates/shell-use/src/terminal/conformance.rs | 80 ++++++++- crates/shell-use/src/terminal/emu.rs | 8 + crates/shell-use/src/terminal/locator.rs | 12 ++ 30 files changed, 1071 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f3fb95b..67d08be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1167,6 +1167,7 @@ dependencies = [ "serde_json", "sha2", "ttf-parser", + "unicode-width", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8085310..bc0dae1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" sha2 = "0.10.9" shell-use = { path = "crates/shell-use" } +unicode-width = "0.2.2" ttf-parser = { version = "0.25.1", default-features = false, features = ["std"] } [profile.release] diff --git a/README.md b/README.md index 7a53063..0995ae5 100644 --- a/README.md +++ b/README.md @@ -179,11 +179,11 @@ way. | Command | Description | | --------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| `state` | cwd, size, cursor, last command + exit code, effective timeouts, text snapshot. | +| `state` | cwd, size, cursor, window title, last command + exit code, effective timeouts, text snapshot. | | `text [--full]` | Plain text of the viewport (or scrollback). | | `screenshot [-o file.svg] [--full]` | Terminal text to stdout, or a crisp full-color SVG image (svg-term-style window) to a file. | | `cells X Y [W H]` | Per-cell attributes (char, fg, bg, flags). | -| `get command\|output\|exit-code\|cwd\|cursor\|size` | Structured getters. | +| `get command\|output\|exit-code\|cwd\|cursor\|size\|title` | Structured getters. | `state` prints `key: value` lines then the screen; `text` and `screenshot` print the screen bare. @@ -212,6 +212,7 @@ print the screen bare. | Command | Description | | --------------------------------------------------- | ----------------------------------- | | `wait text "T" [--regex --full --not --timeout MS]` | Until text is (not) visible. | +| `wait title "T" [--regex --not --timeout MS]` | Until the window title (OSC 0/2) matches. | | `wait idle` | Until the screen stops changing. | | `wait command` | Until the current command finishes. | | `wait exit` | Until the session exits. | @@ -222,9 +223,10 @@ print the screen bare. | Command | Description | | ------------------------------------------------------------------------------- | ------------------------------------------ | | `expect text "T" [--regex --full --no-strict --not --fg C --bg C --timeout MS]` | Visibility + optional color. | +| `expect title "T" [--regex --not --timeout MS]` | Window title set with OSC 0/2. | | `expect exit-code N [--timeout MS]` | Last command's exit code. | | `expect output "T" [--regex]` | Last command's captured output. | -| `expect snapshot NAME [-u] [--include-colors]` | Compare against `__snapshots__/NAME.snap`. | +| `expect snapshot NAME [-u] [--include-colors --include-title]` | Compare against `__snapshots__/NAME.snap`. `--include-title` adds the window title to the frame. | Colors accept ANSI-256 (`9`), hex (`#ff0000`), or rgb (`255,0,0`). diff --git a/SKILL.md b/SKILL.md index d673019..08fd6ca 100644 --- a/SKILL.md +++ b/SKILL.md @@ -76,7 +76,7 @@ without parsing text: | `text [--full]` | Rendered viewport text, or full scrollback with `--full`. | | `screenshot [PATH] [-o FILE] [--full]` | Terminal text to stdout, or a full-color SVG image (crisp at any zoom, svg-term-style window) when a path is given. | | `cells X Y [W H]` | Per-cell attributes (char, fg, bg, flags) for a region. | -| `get command\|output\|exit-code\|cwd\|cursor\|size` | One structured field. | +| `get command\|output\|exit-code\|cwd\|cursor\|size\|title` | One structured field. | ### Input @@ -103,6 +103,7 @@ without parsing text: | Command | Description | | --------------------------------------------------- | ------------------------------------------------------------------------------------------ | | `wait text "T" [--regex --full --not --timeout MS]` | Until text/regex is (with `--not`, is not) visible. Most precise wait. | +| `wait title "T" [--regex --not --timeout MS]` | Until the window title (`OSC 0`/`OSC 2`) matches. Programs announce progress there. | | `wait idle [--timeout MS]` | Until the screen stops repainting (~250ms quiet). | | `wait command [--timeout MS]` | Until the current foreground command finishes (needs shell integration). | | `wait exit [--timeout MS]` | Until the session's program/shell itself exits. | @@ -113,9 +114,10 @@ without parsing text: | Command | Description | | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | `expect text "T" [--regex --full --no-strict --not --fg C --bg C --timeout MS]` | Visibility plus optional color. `--no-strict` relaxes a strict single-match. | +| `expect title "T" [--regex --not --timeout MS]` | The window title set with `OSC 0`/`OSC 2`. An unset title matches nothing. | | `expect exit-code N [--timeout MS]` | The last command's exit code. Waits for the command to finish first. | | `expect output "T" [--regex]` | The last command's captured output. | -| `expect snapshot NAME [-u] [--include-colors]` | Compare the screen against `__snapshots__/NAME.snap`; `-u` writes/updates it. | +| `expect snapshot NAME [-u] [--include-colors --include-title]` | Compare the screen against `__snapshots__/NAME.snap`; `-u` writes/updates it. `--include-title` records the window title in the frame; off by default because a prompt often sets it to a host and path. | Colors accept ansi-256 (`9`), hex (`#ff0000`), or rgb (`255,0,0`). @@ -284,7 +286,7 @@ await su.close(); Methods mirror the cli commands: `open` / `run`, `submit` / `type` / `write`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, the dedicated `get_command` / `get_output` / -`get_exit_code` / `get_cwd` / `get_cursor` / `get_size` methods, +`get_exit_code` / `get_cwd` / `get_cursor` / `get_size` / `get_title` methods, `screenshot`, `wait_text` / `wait_idle` / `wait_command` / `wait_exit` / `wait_ready`, `expect_text` / `expect_exit_code` / `expect_output` / `expect_snapshot`, and `close`. Python module-level helpers are `sessions`, diff --git a/bindings/js/native/index.d.ts b/bindings/js/native/index.d.ts index 89f98e1..629959b 100644 --- a/bindings/js/native/index.d.ts +++ b/bindings/js/native/index.d.ts @@ -28,6 +28,9 @@ export declare class NativeSession { mouseScroll(direction: string, amount?: number | undefined | null): Promise resize(cols: number, rows: number): Promise signal(name: string): Promise + getTitle(): Promise + waitTitle(text: string, options?: TitleOptions | undefined | null): Promise + expectTitle(text: string, options?: TitleOptions | undefined | null): Promise waitText(text: string, options?: WaitTextOptions | undefined | null): Promise waitIdle(timeoutMs?: number | undefined | null): Promise waitCommand(timeoutMs?: number | undefined | null): Promise @@ -170,6 +173,7 @@ export interface Size { export interface SnapshotOptions { update?: boolean includeColors?: boolean + includeTitle?: boolean cwd?: string } @@ -184,6 +188,7 @@ export interface State { cols: number rows: number cursor: Cursor + title: string | null cwd: string | null last_command: string | null last_exit: number | null @@ -201,6 +206,12 @@ export interface Timeouts { ready?: number } +export interface TitleOptions { + regex?: boolean + not?: boolean + timeoutMs?: number +} + export declare const enum UnderlineStyle { None = 'none', Single = 'single', diff --git a/bindings/js/native/lib.rs b/bindings/js/native/lib.rs index beabd81..c6ad785 100644 --- a/bindings/js/native/lib.rs +++ b/bindings/js/native/lib.rs @@ -158,6 +158,7 @@ pub struct State { pub cols: u16, pub rows: u16, pub cursor: Cursor, + pub title: Option, pub cwd: Option, #[napi(js_name = "last_command")] pub last_command: Option, @@ -176,6 +177,7 @@ impl From for State { cols: value.cols, rows: value.rows, cursor: value.cursor.into(), + title: value.title, cwd: value.cwd, last_command: value.last_command, last_exit: value.last_exit, @@ -302,6 +304,13 @@ pub struct WaitTextOptions { pub timeout_ms: Option, } +#[napi(object)] +pub struct TitleOptions { + pub regex: Option, + pub not: Option, + pub timeout_ms: Option, +} + #[napi(object)] pub struct ExpectTextOptions { pub regex: Option, @@ -317,6 +326,7 @@ pub struct ExpectTextOptions { pub struct SnapshotOptions { pub update: Option, pub include_colors: Option, + pub include_title: Option, pub cwd: Option, } @@ -872,6 +882,66 @@ impl NativeSession { self.unit("signal", Operation::Signal { name }).await } + #[napi] + pub async fn get_title(&self) -> Result> { + execute( + self.handle.clone(), + "getTitle", + Operation::GetTitle, + |result| match result { + OperationResult::Title(value) => Ok(value), + _ => Err(unexpected("getTitle")), + }, + ) + .await + } + + #[napi] + pub async fn wait_title(&self, text: String, options: Option) -> Result<()> { + let options = options.unwrap_or(TitleOptions { + regex: None, + not: None, + timeout_ms: None, + }); + let handle = self.handle.clone(); + blocking("waitTitle", move || { + let operation = Operation::WaitTitle { + text, + regex: options.regex.unwrap_or(false), + timeout_ms: timeout(options.timeout_ms, "timeoutMs")?, + not: options.not.unwrap_or(false), + }; + match handle.execute(operation)? { + OperationResult::Unit => Ok(()), + _ => Err(unexpected("waitTitle")), + } + }) + .await + } + + #[napi] + pub async fn expect_title(&self, text: String, options: Option) -> Result<()> { + let options = options.unwrap_or(TitleOptions { + regex: None, + not: None, + timeout_ms: None, + }); + let handle = self.handle.clone(); + blocking("expectTitle", move || { + let operation = Operation::ExpectTitle { + text, + regex: options.regex.unwrap_or(false), + not: options.not.unwrap_or(false), + timeout_ms: timeout(options.timeout_ms, "timeoutMs")?, + }; + match handle.execute(operation)? { + OperationResult::Unit => Ok(()), + _ => Err(unexpected("expectTitle")), + } + }) + .await + } + #[napi] pub async fn wait_text(&self, text: String, options: Option) -> Result<()> { let options = options.unwrap_or(WaitTextOptions { @@ -1001,6 +1071,7 @@ impl NativeSession { let options = options.unwrap_or(SnapshotOptions { update: None, include_colors: None, + include_title: None, cwd: None, }); execute( @@ -1010,6 +1081,7 @@ impl NativeSession { name, update: options.update.unwrap_or(false), include_colors: options.include_colors.unwrap_or(false), + include_title: options.include_title.unwrap_or(false), cwd: options.cwd, }, |result| match result { diff --git a/bindings/js/src/client.ts b/bindings/js/src/client.ts index 8abfc05..74d466b 100644 --- a/bindings/js/src/client.ts +++ b/bindings/js/src/client.ts @@ -32,6 +32,12 @@ export interface WaitTextOptions { timeout?: number; } +export interface TitleOptions { + regex?: boolean; + not?: boolean; + timeout?: number; +} + export interface ExpectTextOptions { regex?: boolean; full?: boolean; @@ -305,6 +311,10 @@ export class ShellUse { return this.#runtime.getCwd(); } + async getTitle(): Promise { + return this.#runtime.getTitle(); + } + async getCursor(): Promise { return this.#runtime.getCursor(); } @@ -331,6 +341,16 @@ export class ShellUse { ); } + async waitTitle(text: string, opts: TitleOptions = {}): Promise { + await this.#guard("waitTitle", () => + this.#runtime.waitTitle(text, { + regex: opts.regex ?? false, + not: opts.not ?? false, + timeoutMs: this.#timeout("text", opts.timeout), + }), + ); + } + async waitIdle(opts: { timeout?: number } = {}): Promise { await this.#guard("waitIdle", () => this.#runtime.waitIdle(this.#timeout("idle", opts.timeout)), @@ -355,6 +375,16 @@ export class ShellUse { ); } + async expectTitle(text: string, opts: TitleOptions = {}): Promise { + await this.#guard("expectTitle", () => + this.#runtime.expectTitle(text, { + regex: opts.regex ?? false, + not: opts.not ?? false, + timeoutMs: this.#timeout("text", opts.timeout), + }), + ); + } + async expectText(text: string, opts: ExpectTextOptions = {}): Promise { await this.#guard("expectText", () => this.#runtime.expectText(text, { @@ -383,12 +413,13 @@ export class ShellUse { async expectSnapshot( name: string, - opts: { update?: boolean; includeColors?: boolean } = {}, + opts: { update?: boolean; includeColors?: boolean; includeTitle?: boolean } = {}, ): Promise { return this.#guard("expectSnapshot", () => this.#runtime.snapshot(name, { update: opts.update ?? false, includeColors: opts.includeColors ?? false, + includeTitle: opts.includeTitle ?? false, cwd: process.cwd(), }), ); diff --git a/bindings/js/src/index.ts b/bindings/js/src/index.ts index 6c3c151..65422ab 100644 --- a/bindings/js/src/index.ts +++ b/bindings/js/src/index.ts @@ -2,6 +2,7 @@ export { ShellUse } from "./client.js"; export type { ExpectTextOptions, MouseButtonOptions, + TitleOptions, WaitTextOptions, } from "./client.js"; export { uniqueSession } from "./ephemeral.js"; diff --git a/bindings/js/src/native.ts b/bindings/js/src/native.ts index f8863c1..8864623 100644 --- a/bindings/js/src/native.ts +++ b/bindings/js/src/native.ts @@ -14,6 +14,7 @@ import type { SnapshotOptions, State, Timeouts, + TitleOptions, WaitTextOptions, } from "../native/index.js"; @@ -191,6 +192,10 @@ export class NativeRuntime { return this.#call((session) => session.getCwd()); } + getTitle(): Promise { + return this.#call((session) => session.getTitle()); + } + getCursor(): Promise { return this.#call((session) => session.getCursor()); } @@ -257,6 +262,10 @@ export class NativeRuntime { return this.#call((session) => session.waitText(text, options)); } + waitTitle(text: string, options?: TitleOptions): Promise { + return this.#call((session) => session.waitTitle(text, options)); + } + waitIdle(timeoutMs?: number): Promise { return this.#call((session) => session.waitIdle(timeoutMs)); } @@ -273,6 +282,10 @@ export class NativeRuntime { return this.#call((session) => session.waitReady(timeoutMs)); } + expectTitle(text: string, options?: TitleOptions): Promise { + return this.#call((session) => session.expectTitle(text, options)); + } + expectText(text: string, options?: ExpectTextOptions): Promise { return this.#call((session) => session.expectText(text, options)); } diff --git a/bindings/js/test/conformance.test.mjs b/bindings/js/test/conformance.test.mjs index 2fac9ea..ae683d5 100644 --- a/bindings/js/test/conformance.test.mjs +++ b/bindings/js/test/conformance.test.mjs @@ -35,6 +35,7 @@ const MAPPING = { ["client", "getCwd"], ["client", "getCursor"], ["client", "getSize"], + ["client", "getTitle"], ], type: [["client", "type"]], submit: [["client", "submit"]], @@ -45,8 +46,8 @@ const MAPPING = { write: [["client", "write"]], signal: [["client", "signal"]], kill: [["client", "kill"]], - wait: [["client", "waitText"], ["client", "waitIdle"], ["client", "waitCommand"], ["client", "waitExit"]], - expect: [["client", "expectText"], ["client", "expectExitCode"], ["client", "expectOutput"], ["client", "expectSnapshot"]], + wait: [["client", "waitTitle"], ["client", "waitText"], ["client", "waitIdle"], ["client", "waitCommand"], ["client", "waitExit"]], + expect: [["client", "expectTitle"], ["client", "expectText"], ["client", "expectExitCode"], ["client", "expectOutput"], ["client", "expectSnapshot"]], "get-recording": [["module", "getRecording"]], }; diff --git a/bindings/python/native/src/lib.rs b/bindings/python/native/src/lib.rs index 4e756e7..09b5b12 100644 --- a/bindings/python/native/src/lib.rs +++ b/bindings/python/native/src/lib.rs @@ -281,6 +281,15 @@ impl NativeSession { ) } + fn get_title<'py>(&self, py: Python<'py>) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || execute_title(&name, Operation::GetTitle), + optional_string_to_py, + ) + } + fn get_cursor<'py>(&self, py: Python<'py>) -> PyResult> { let name = self.name.clone(); future_blocking( @@ -604,6 +613,34 @@ impl NativeSession { ) } + #[pyo3(signature = (text, regex, not_, timeout_ms))] + fn wait_title<'py>( + &self, + py: Python<'py>, + text: String, + regex: bool, + not_: bool, + timeout_ms: Option>, + ) -> PyResult> { + let timeout_ms = capture_optional_integer(timeout_ms); + let name = self.name.clone(); + future_blocking( + py, + move || { + execute_unit( + &name, + Operation::WaitTitle { + text, + regex, + timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, + not: not_, + }, + ) + }, + unit_to_py, + ) + } + #[pyo3(signature = (timeout_ms))] fn wait_idle<'py>( &self, @@ -692,6 +729,34 @@ impl NativeSession { ) } + #[pyo3(signature = (text, regex, not_, timeout_ms))] + fn expect_title<'py>( + &self, + py: Python<'py>, + text: String, + regex: bool, + not_: bool, + timeout_ms: Option>, + ) -> PyResult> { + let timeout_ms = capture_optional_integer(timeout_ms); + let name = self.name.clone(); + future_blocking( + py, + move || { + execute_unit( + &name, + Operation::ExpectTitle { + text, + regex, + not: not_, + timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, + }, + ) + }, + unit_to_py, + ) + } + #[pyo3(signature = (text, regex, full, strict, not_, fg, bg, timeout_ms))] #[allow(clippy::too_many_arguments)] fn expect_text<'py>( @@ -768,13 +833,14 @@ impl NativeSession { ) } - #[pyo3(signature = (name, update, include_colors, cwd))] + #[pyo3(signature = (name, update, include_colors, include_title, cwd))] fn snapshot<'py>( &self, py: Python<'py>, name: String, update: bool, include_colors: bool, + include_title: bool, cwd: Option, ) -> PyResult> { let session = self.name.clone(); @@ -787,6 +853,7 @@ impl NativeSession { name, update, include_colors, + include_title, cwd, }, ) @@ -1118,6 +1185,13 @@ fn execute_cwd(name: &str, operation: Operation) -> Result, Shell } } +fn execute_title(name: &str, operation: Operation) -> Result, ShellUseError> { + match global_registry().execute(name, operation)? { + OperationResult::Title(value) => Ok(value), + _ => Err(unexpected_result("the window title")), + } +} + fn execute_cursor(name: &str, operation: Operation) -> Result { match global_registry().execute(name, operation)? { OperationResult::Cursor(value) => Ok(value), @@ -1195,6 +1269,7 @@ fn state_to_py(py: Python<'_>, value: State) -> PyResult> { result.set_item("cols", value.cols)?; result.set_item("rows", value.rows)?; result.set_item("cursor", cursor_dict(py, value.cursor)?)?; + result.set_item("title", value.title)?; result.set_item("cwd", value.cwd)?; result.set_item("last_command", value.last_command)?; result.set_item("last_exit", value.last_exit)?; diff --git a/bindings/python/src/shell_use/_native.pyi b/bindings/python/src/shell_use/_native.pyi index 19f3e81..f39fe4d 100644 --- a/bindings/python/src/shell_use/_native.pyi +++ b/bindings/python/src/shell_use/_native.pyi @@ -52,6 +52,7 @@ class NativeSession: def get_output(self) -> typing.Awaitable[typing.Optional[str]]: ... def get_exit_code(self) -> typing.Awaitable[typing.Optional[int]]: ... def get_cwd(self) -> typing.Awaitable[typing.Optional[str]]: ... + def get_title(self) -> typing.Awaitable[typing.Optional[str]]: ... def get_cursor(self) -> typing.Awaitable[typing.Dict[str, int]]: ... def get_size(self) -> typing.Awaitable[typing.Dict[str, int]]: ... def write(self, data: str) -> typing.Awaitable[None]: ... @@ -69,14 +70,16 @@ class NativeSession: def signal(self, signal: str) -> typing.Awaitable[None]: ... def kill(self) -> typing.Awaitable[None]: ... def wait_text(self, text: str, regex: bool, full: bool, not_: bool, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... + def wait_title(self, text: str, regex: bool, not_: bool, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def wait_idle(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def wait_command(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def wait_exit(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def wait_ready(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def expect_text(self, text: str, regex: bool, full: bool, strict: bool, not_: bool, fg: typing.Optional[str], bg: typing.Optional[str], timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... + def expect_title(self, text: str, regex: bool, not_: bool, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def expect_exit_code(self, code: int, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def expect_output(self, text: str, regex: bool) -> typing.Awaitable[None]: ... - def snapshot(self, name: str, update: bool, include_colors: bool, cwd: typing.Optional[str]) -> typing.Awaitable[str]: ... + def snapshot(self, name: str, update: bool, include_colors: bool, include_title: bool, cwd: typing.Optional[str]) -> typing.Awaitable[str]: ... def screenshot(self, path: typing.Optional[str], full: bool) -> typing.Awaitable[str]: ... def recording(self) -> typing.Awaitable[str]: ... diff --git a/bindings/python/src/shell_use/client.py b/bindings/python/src/shell_use/client.py index 456e4be..2989052 100644 --- a/bindings/python/src/shell_use/client.py +++ b/bindings/python/src/shell_use/client.py @@ -328,6 +328,9 @@ async def get_exit_code(self) -> Optional[int]: async def get_cwd(self) -> Optional[str]: return await self._await(self._native.get_cwd()) + async def get_title(self) -> Optional[str]: + return await self._await(self._native.get_title()) + async def get_cursor(self) -> Dict[str, int]: return await self._await(self._native.get_cursor()) @@ -355,6 +358,21 @@ async def wait_text( ), ) + async def wait_title( + self, + text: str, + *, + regex: bool = False, + not_: bool = False, + timeout: Optional[int] = None, + ) -> None: + await self._guarded( + "wait_title", + self._native.wait_title( + text, regex, not_, self._timeout("text", timeout) + ), + ) + async def wait_idle(self, *, timeout: Optional[int] = None) -> None: await self._guarded( "wait_idle", @@ -379,6 +397,21 @@ async def wait_ready(self, *, timeout: Optional[int] = None) -> None: self._native.wait_ready(self._timeout("ready", timeout)), ) + async def expect_title( + self, + text: str, + *, + regex: bool = False, + not_: bool = False, + timeout: Optional[int] = None, + ) -> None: + await self._guarded( + "expect_title", + self._native.expect_title( + text, regex, not_, self._timeout("text", timeout) + ), + ) + async def expect_text( self, text: str, @@ -426,11 +459,12 @@ async def expect_snapshot( *, update: bool = False, include_colors: bool = False, + include_title: bool = False, ) -> str: return await self._guarded( "expect_snapshot", self._native.snapshot( - name, update, include_colors, os.getcwd() + name, update, include_colors, include_title, os.getcwd() ), ) diff --git a/bindings/python/src/shell_use/types.py b/bindings/python/src/shell_use/types.py index 86c25f5..38bfd1f 100644 --- a/bindings/python/src/shell_use/types.py +++ b/bindings/python/src/shell_use/types.py @@ -48,6 +48,7 @@ class State: cols: int rows: int cursor: Dict[str, int] + title: Optional[str] cwd: Optional[str] last_command: Optional[str] last_exit: Optional[int] @@ -63,6 +64,7 @@ def from_dict(cls, d: Dict[str, Any]) -> "State": cols=d.get("cols", 0), rows=d.get("rows", 0), cursor=d.get("cursor", {"x": 0, "y": 0}), + title=d.get("title"), cwd=d.get("cwd"), last_command=d.get("last_command"), last_exit=d.get("last_exit"), diff --git a/bindings/python/stub-gen/src/main.rs b/bindings/python/stub-gen/src/main.rs index 5fe0cd1..7dc2870 100644 --- a/bindings/python/stub-gen/src/main.rs +++ b/bindings/python/stub-gen/src/main.rs @@ -132,6 +132,7 @@ mod stubs { def get_output(self) -> typing.Awaitable[typing.Optional[str]]: ... def get_exit_code(self) -> typing.Awaitable[typing.Optional[int]]: ... def get_cwd(self) -> typing.Awaitable[typing.Optional[str]]: ... + def get_title(self) -> typing.Awaitable[typing.Optional[str]]: ... def get_cursor(self) -> typing.Awaitable[typing.Dict[str, int]]: ... def get_size(self) -> typing.Awaitable[typing.Dict[str, int]]: ... def write(self, data: str) -> typing.Awaitable[None]: ... @@ -163,6 +164,13 @@ mod stubs { not_: bool, timeout_ms: typing.Optional[int], ) -> typing.Awaitable[None]: ... + def wait_title( + self, + text: str, + regex: bool, + not_: bool, + timeout_ms: typing.Optional[int], + ) -> typing.Awaitable[None]: ... def wait_idle(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def wait_command(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def wait_exit(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... @@ -178,6 +186,13 @@ mod stubs { bg: typing.Optional[str], timeout_ms: typing.Optional[int], ) -> typing.Awaitable[None]: ... + def expect_title( + self, + text: str, + regex: bool, + not_: bool, + timeout_ms: typing.Optional[int], + ) -> typing.Awaitable[None]: ... def expect_exit_code(self, code: int, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def expect_output(self, text: str, regex: bool) -> typing.Awaitable[None]: ... def snapshot( @@ -185,6 +200,7 @@ mod stubs { name: str, update: bool, include_colors: bool, + include_title: bool, cwd: typing.Optional[str], ) -> typing.Awaitable[str]: ... def screenshot(self, path: typing.Optional[str], full: bool) -> typing.Awaitable[str]: ... diff --git a/bindings/python/tests/test_conformance.py b/bindings/python/tests/test_conformance.py index bc5d640..0079f12 100644 --- a/bindings/python/tests/test_conformance.py +++ b/bindings/python/tests/test_conformance.py @@ -24,6 +24,7 @@ ("client", "get_cwd"), ("client", "get_cursor"), ("client", "get_size"), + ("client", "get_title"), ], "type": [("client", "type")], "submit": [("client", "submit")], @@ -34,8 +35,8 @@ "write": [("client", "write")], "signal": [("client", "signal")], "kill": [("client", "kill")], - "wait": [("client", "wait_text"), ("client", "wait_idle"), ("client", "wait_command"), ("client", "wait_exit")], - "expect": [("client", "expect_text"), ("client", "expect_exit_code"), ("client", "expect_output"), ("client", "expect_snapshot")], + "wait": [("client", "wait_title"), ("client", "wait_text"), ("client", "wait_idle"), ("client", "wait_command"), ("client", "wait_exit")], + "expect": [("client", "expect_title"), ("client", "expect_text"), ("client", "expect_exit_code"), ("client", "expect_output"), ("client", "expect_snapshot")], "get-recording": [("module", "get_recording")], } diff --git a/crates/shell-use-cli/src/cli.rs b/crates/shell-use-cli/src/cli.rs index 194c1aa..581d1c3 100644 --- a/crates/shell-use-cli/src/cli.rs +++ b/crates/shell-use-cli/src/cli.rs @@ -548,6 +548,8 @@ pub enum GetArg { Cursor, /// Terminal size. Size, + /// Window title, as set with OSC 0/2. + Title, } #[derive(Subcommand)] @@ -658,6 +660,23 @@ pub enum WaitCmd { #[arg(long, value_name = "MS")] timeout: Option, }, + /// Wait until the window title (set with OSC 0/2) matches text/regex. + /// + /// Programs set the title to announce what they are doing, so this is how + /// to wait for one that reports progress there rather than on screen. + Title { + /// Text or regex to wait for in the title. + text: String, + /// Treat as a regular expression. + #[arg(long)] + regex: bool, + /// Invert: wait until the title does NOT match. + #[arg(long)] + not: bool, + /// Timeout in milliseconds. + #[arg(long, value_name = "MS")] + timeout: Option, + }, /// Wait until the screen stops repainting (visual idle, NOT command done). /// /// A silent command (e.g. `sleep 100`) counts as idle right away. To wait @@ -723,6 +742,20 @@ pub enum ExpectCmd { #[arg(long, value_name = "MS")] timeout: Option, }, + /// Assert the window title (set with OSC 0/2) matches text/regex. + Title { + /// Text or regex to match against the title. + text: String, + /// Treat as a regular expression. + #[arg(long)] + regex: bool, + /// Invert: assert the title does NOT match. + #[arg(long)] + not: bool, + /// Timeout in milliseconds. + #[arg(long, value_name = "MS")] + timeout: Option, + }, /// Assert the last command's exit code. /// Waits for the foreground command first, so this is safe right after `submit`. ExitCode { @@ -750,5 +783,10 @@ pub enum ExpectCmd { /// Include cell colors in the snapshot. #[arg(long)] include_colors: bool, + /// Include the window title in the snapshot's frame. Off by default: + /// a shell prompt often sets the title to a hostname and path, which + /// would tie the snapshot to one machine. + #[arg(long)] + include_title: bool, }, } diff --git a/crates/shell-use-cli/src/main.rs b/crates/shell-use-cli/src/main.rs index a027da8..01b64bc 100644 --- a/crates/shell-use-cli/src/main.rs +++ b/crates/shell-use-cli/src/main.rs @@ -215,6 +215,7 @@ fn map_field(f: GetArg) -> GetField { GetArg::Cwd => GetField::Cwd, GetArg::Cursor => GetField::Cursor, GetArg::Size => GetField::Size, + GetArg::Title => GetField::Title, } } @@ -271,6 +272,17 @@ fn map_wait(what: WaitCmd) -> Request { timeout_ms: timeout, not, }, + WaitCmd::Title { + text, + regex, + not, + timeout, + } => Request::WaitTitle { + text, + regex, + timeout_ms: timeout, + not, + }, WaitCmd::Idle { timeout } => Request::WaitIdle { timeout_ms: timeout, }, @@ -307,6 +319,17 @@ fn map_expect(what: ExpectCmd) -> Request { bg, timeout_ms: timeout, }, + ExpectCmd::Title { + text, + regex, + not, + timeout, + } => Request::ExpectTitle { + text, + regex, + not, + timeout_ms: timeout, + }, ExpectCmd::ExitCode { code, timeout } => Request::ExpectExitCode { code, timeout_ms: timeout, @@ -316,10 +339,12 @@ fn map_expect(what: ExpectCmd) -> Request { name, update, include_colors, + include_title, } => Request::Snapshot { name, update, include_colors, + include_title, cwd: std::env::current_dir() .ok() .map(|p| p.to_string_lossy().into_owned()), @@ -648,15 +673,17 @@ SESSION open [--shell S] [--cols N --rows N] [--cwd D] [--env K=V]\n\ run [args...]\n\ sessions | close [--all] | daemon start|status | daemon stop --session N|--all\n\ INSPECT state | text [--full] | screenshot [-o file.svg] [--full]\n\ - cells X Y [W H] | get command|output|exit-code|cwd|cursor|size\n\ + cells X Y [W H] | get command|output|exit-code|cwd|cursor|size|title\n\ INPUT type \"text\" | submit [\"text\"] | press | keys \"Ctrl+a\"\n\ mouse click X Y | mouse click --on-text \"OK\" | mouse move|down|up|drag|scroll\n\ PTY resize COLS ROWS | write | signal INT|TERM|KILL|QUIT | kill\n\ WAIT wait text \"T\" [--regex --full --not --timeout MS]\n\ + wait title \"T\" [--regex --not --timeout MS]\n\ wait idle | wait command | wait exit | wait ready\n\ EXPECT expect text \"T\" [--regex --full --not --fg C --bg C --timeout MS]\n\ + expect title \"T\" [--regex --not --timeout MS]\n\ expect exit-code N | expect output \"T\" [--regex]\n\ - expect snapshot NAME [-u] [--include-colors]\n\ + expect snapshot NAME [-u] [--include-colors --include-title]\n\ RECORD sessions auto-record; get-recording [session] > out.cast (asciinema v2)\n\ play with `asciinema play out.cast`, render GIF with `agg out.cast out.gif`\n\ WATCH monitor (live full-color view in another terminal; q/Esc/Ctrl-C to detach)\n\ diff --git a/crates/shell-use-cli/src/protocol.rs b/crates/shell-use-cli/src/protocol.rs index b86ccd6..233c11d 100644 --- a/crates/shell-use-cli/src/protocol.rs +++ b/crates/shell-use-cli/src/protocol.rs @@ -65,6 +65,13 @@ pub enum Request { timeout_ms: Option, not: bool, }, + WaitTitle { + text: String, + regex: bool, + #[serde(default)] + timeout_ms: Option, + not: bool, + }, WaitIdle { #[serde(default)] timeout_ms: Option, @@ -92,6 +99,13 @@ pub enum Request { #[serde(default)] timeout_ms: Option, }, + ExpectTitle { + text: String, + regex: bool, + not: bool, + #[serde(default)] + timeout_ms: Option, + }, ExpectExitCode { code: i32, #[serde(default)] @@ -106,6 +120,8 @@ pub enum Request { update: bool, include_colors: bool, #[serde(default)] + include_title: bool, + #[serde(default)] cwd: Option, }, Screenshot { @@ -177,6 +193,7 @@ impl Request { GetField::Cwd => Operation::GetCwd, GetField::Cursor => Operation::GetCursor, GetField::Size => Operation::GetSize, + GetField::Title => Operation::GetTitle, }), Request::Write { data } => Ok(Operation::Write { data }), Request::Submit { data } => Ok(Operation::Submit { data }), @@ -197,6 +214,17 @@ impl Request { timeout_ms, not, }), + Request::WaitTitle { + text, + regex, + timeout_ms, + not, + } => Ok(Operation::WaitTitle { + text, + regex, + timeout_ms, + not, + }), Request::WaitIdle { timeout_ms } => Ok(Operation::WaitIdle { timeout_ms }), Request::WaitCommand { timeout_ms } => Ok(Operation::WaitCommand { timeout_ms }), Request::WaitExit { timeout_ms } => Ok(Operation::WaitExit { timeout_ms }), @@ -220,6 +248,17 @@ impl Request { bg, timeout_ms, }), + Request::ExpectTitle { + text, + regex, + not, + timeout_ms, + } => Ok(Operation::ExpectTitle { + text, + regex, + not, + timeout_ms, + }), Request::ExpectExitCode { code, timeout_ms } => { Ok(Operation::ExpectExitCode { code, timeout_ms }) } @@ -228,11 +267,13 @@ impl Request { name, update, include_colors, + include_title, cwd, } => Ok(Operation::Snapshot { name, update, include_colors, + include_title, cwd, }), Request::Screenshot { full, path } => Ok(Operation::Screenshot { full, path }), @@ -254,6 +295,7 @@ pub enum GetField { Cwd, Cursor, Size, + Title, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -323,6 +365,7 @@ fn operation_data(result: OperationResult) -> Result, OperationResult::Output(value) => Ok(json!({ "value": value })), OperationResult::ExitCode(value) => Ok(json!({ "value": value })), OperationResult::Cwd(value) => Ok(json!({ "value": value })), + OperationResult::Title(value) => Ok(json!({ "value": value })), OperationResult::Cursor(value) => Ok(json!({ "value": value })), OperationResult::Size(value) => Ok(json!({ "value": value })), OperationResult::Snapshot(status) => Ok(json!({ "status": status })), diff --git a/crates/shell-use-cli/tests/session_lifecycle.rs b/crates/shell-use-cli/tests/session_lifecycle.rs index 7e2e6f0..9e0e520 100644 --- a/crates/shell-use-cli/tests/session_lifecycle.rs +++ b/crates/shell-use-cli/tests/session_lifecycle.rs @@ -814,3 +814,96 @@ fn status_reports_the_daemon_pid_not_the_child() { "the child pid should be reported separately: {live}" ); } + +/// A program that sets the window title is tracked, asserted on, and drawn. +/// +/// This drives the whole path in one session: the emulator picking `OSC 2` out +/// of the PTY stream, the getter, the assertion, the screenshot, and the reset +/// that an empty title performs. Each of those is unit tested on its own; what +/// only an end-to-end run proves is that a title set by a real program in a +/// real shell arrives intact. +#[test] +fn a_window_title_is_tracked_asserted_and_drawn() { + let sandbox = Sandbox::new("title"); + sandbox.ok(&["run", "--cols", "40", "--", "bash", "--norc"]); + + assert_eq!( + sandbox.ok(&["get", "title"]).trim(), + "{\n \"value\": null\n}", + "a session whose program set no title reports none" + ); + + sandbox.ok(&["submit", r#"printf '\033]2;vim: notes.md\007'"#]); + sandbox.ok(&["expect", "title", "vim", "--timeout", "5000"]); + sandbox.ok(&["expect", "title", "notes\\.\\w+", "--regex"]); + sandbox.ok(&["expect", "title", "emacs", "--not"]); + + // The title is drawn in the window chrome, not in the grid. + let svg = sandbox.home.join("titled.svg"); + sandbox.ok(&["screenshot", "--out", svg.to_str().expect("utf-8 path")]); + let image = std::fs::read_to_string(&svg).expect("read svg"); + assert!( + image.contains(">vim: notes.md") && image.contains(r#"text-anchor="middle""#), + "the title is drawn centred in the title bar: {image}" + ); + + // An empty title clears it, which is how programs tidy up on exit. + sandbox.ok(&["submit", r#"printf '\033]2;\007'"#]); + sandbox.ok(&["wait", "title", "vim", "--not", "--timeout", "5000"]); + assert_eq!( + sandbox.ok(&["get", "title"]).trim(), + "{\n \"value\": null\n}", + "an empty title resets rather than storing a blank one" + ); +} + +/// A snapshot leaves the window title out unless it is asked for. +/// +/// A shell prompt routinely sets the title to a username, hostname, and +/// absolute path, so recording it by default would pin every stored baseline +/// to one machine and make it change on `cd` while the screen stayed the same. +#[test] +fn a_snapshot_records_the_title_only_when_asked() { + let sandbox = Sandbox::new("snap-title"); + // Wide enough that the title is not truncated, so the assertion is about + // whether it was recorded at all rather than about how it was shortened. + sandbox.ok(&["run", "--cols", "40", "--", "bash", "--norc"]); + sandbox.ok(&[ + "submit", + r#"clear; printf '\033]2;ayman@host: /some/path\007'"#, + ]); + sandbox.ok(&["expect", "title", "ayman@host", "--timeout", "5000"]); + + let plain = sandbox.ok(&["expect", "snapshot", "plain", "-u"]); + assert!(!plain.contains("ayman@host"), "default keeps the title out"); + let stored = std::fs::read_to_string( + std::env::current_dir() + .expect("cwd") + .join("__snapshots__/plain.snap"), + ) + .expect("read snapshot"); + assert!( + stored.starts_with("╭────") && !stored.contains("ayman@host"), + "the border is plain, so a baseline is not tied to a machine: {stored}" + ); + + sandbox.ok(&["expect", "snapshot", "titled", "-u", "--include-title"]); + let titled = std::fs::read_to_string( + std::env::current_dir() + .expect("cwd") + .join("__snapshots__/titled.snap"), + ) + .expect("read snapshot"); + assert!( + titled.contains("ayman@host: /some/path"), + "asking for it puts it in the border: {titled}" + ); + + for name in ["plain", "titled"] { + let _ = std::fs::remove_file( + std::env::current_dir() + .expect("cwd") + .join(format!("__snapshots__/{name}.snap")), + ); + } +} diff --git a/crates/shell-use/Cargo.toml b/crates/shell-use/Cargo.toml index 4871ebb..3a95afb 100644 --- a/crates/shell-use/Cargo.toml +++ b/crates/shell-use/Cargo.toml @@ -25,3 +25,4 @@ serde.workspace = true serde_json.workspace = true sha2.workspace = true ttf-parser.workspace = true +unicode-width.workspace = true diff --git a/crates/shell-use/src/api.rs b/crates/shell-use/src/api.rs index 5a593c7..3d7f49e 100644 --- a/crates/shell-use/src/api.rs +++ b/crates/shell-use/src/api.rs @@ -88,6 +88,7 @@ pub enum Operation { GetCwd, GetCursor, GetSize, + GetTitle, Write { data: String, }, @@ -114,6 +115,12 @@ pub enum Operation { timeout_ms: Option, not: bool, }, + WaitTitle { + text: String, + regex: bool, + timeout_ms: Option, + not: bool, + }, WaitIdle { timeout_ms: Option, }, @@ -136,6 +143,12 @@ pub enum Operation { bg: Option, timeout_ms: Option, }, + ExpectTitle { + text: String, + regex: bool, + not: bool, + timeout_ms: Option, + }, ExpectExitCode { code: i32, timeout_ms: Option, @@ -148,6 +161,7 @@ pub enum Operation { name: String, update: bool, include_colors: bool, + include_title: bool, cwd: Option, }, Screenshot { @@ -168,6 +182,7 @@ pub enum OperationResult { Output(Option), ExitCode(Option), Cwd(Option), + Title(Option), Cursor(Cursor), Size(Size), Snapshot(SnapshotResult), @@ -280,6 +295,7 @@ pub struct State { pub cols: u16, pub rows: u16, pub cursor: Cursor, + pub title: Option, pub cwd: Option, pub last_command: Option, pub last_exit: Option, diff --git a/crates/shell-use/src/assert/snapshot.rs b/crates/shell-use/src/assert/snapshot.rs index ca0f00a..c9ee3f6 100644 --- a/crates/shell-use/src/assert/snapshot.rs +++ b/crates/shell-use/src/assert/snapshot.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use serde_json::{json, Map, Value}; -use super::super::terminal::cell::{Attrs, Color, EmuCell}; +use super::super::terminal::cell::{display_width, truncate_to_columns, Attrs, Color, EmuCell}; pub enum SnapshotStatus { Passed, @@ -70,7 +70,12 @@ fn baseline() -> EmuCell { } /// Serialize a grid into a boxed text view plus (optionally) a color shift map. -pub fn serialize(rows: &[Vec], cols: u16, include_colors: bool) -> String { +pub fn serialize( + rows: &[Vec], + cols: u16, + include_colors: bool, + title: Option<&str>, +) -> String { let mut lines = Vec::with_capacity(rows.len()); let mut shifts = Map::new(); let mut prev = baseline(); @@ -90,7 +95,7 @@ pub fn serialize(rows: &[Vec], cols: u16, include_colors: bool) -> Stri lines.push(line); } - let view = box_view(&lines.join("\n"), cols); + let view = box_view(&lines.join("\n"), cols, title); if include_colors && !shifts.is_empty() { format!( "{view}\n{}", @@ -101,9 +106,30 @@ pub fn serialize(rows: &[Vec], cols: u16, include_colors: bool) -> Stri } } -fn box_view(view: &str, width: u16) -> String { - let bar = "─".repeat(width as usize); - let top = format!("╭{bar}╮"); +/// Frame the view, putting the window title in the top border when there is +/// one. +/// +/// The title rides in the border rather than on a line of its own so that a +/// snapshot taken without a title is byte-identical to one taken before titles +/// were recorded at all, which keeps every stored baseline valid. A title too +/// long for the border is truncated so the frame stays rectangular. +fn box_view(view: &str, width: u16, title: Option<&str>) -> String { + let width = width as usize; + let bar = "─".repeat(width); + // `╭─ title ───╮`: one leading dash, the spaced title, then at least one + // trailing dash. A title with no room for even one character is dropped + // rather than allowed to push the corner out of line. + let label = title.and_then(|title| { + let room = width.checked_sub(4).filter(|room| *room > 0)?; + Some(format!(" {} ", truncate_to_columns(title, room))) + }); + let top = match label { + Some(label) => format!( + "╭─{label}{}╮", + "─".repeat(width - 1 - display_width(&label)) + ), + None => format!("╭{bar}╮"), + }; let bottom = format!("╰{bar}╯"); let mut out = vec![top]; for line in view.split('\n') { @@ -170,7 +196,77 @@ mod tests { cell(" "), cell(" "), ]]; - assert_eq!(serialize(&rows, 6, false), "╭──────╮\n│你b │\n╰──────╯"); + assert_eq!( + serialize(&rows, 6, false, None), + "╭──────╮\n│你b │\n╰──────╯" + ); + } + + /// The window title rides in the top border. + /// + /// It goes there rather than on a line of its own so that the frame keeps + /// its shape and a snapshot taken without a title is byte-identical to one + /// taken before titles were recorded, which is what keeps stored baselines + /// valid. + #[test] + fn the_title_rides_in_the_top_border() { + let rows = vec![vec![cell("a"); 20]]; + let bare = serialize(&rows, 20, false, None); + let titled = serialize(&rows, 20, false, Some("vim")); + + assert!( + bare.starts_with("╭────────────────────╮"), + "no title leaves the border untouched: {bare}" + ); + assert!( + titled.starts_with("╭─ vim ──────────────╮"), + "the title is set into the border: {titled}" + ); + assert_eq!( + bare.lines().skip(1).collect::>(), + titled.lines().skip(1).collect::>(), + "and nothing below the border changes" + ); + } + + /// Every border line stays the same width whatever the title. + /// + /// A title wider than the frame would otherwise push the corner out and + /// produce a snapshot that never matches and cannot be read. + #[test] + fn a_title_never_changes_the_frame_width() { + let rows = vec![vec![cell("a"); 10]]; + // Measured in columns, not characters. A CJK title is half as many + // characters as columns, so a character count would report a square + // frame while the drawn one is four columns out. + for title in [ + "", + "x", + "fits", + "a title far wider than the frame", + "你好世界你好世界", + "🚀 build", + "e\u{301}clair", + ] { + let out = serialize(&rows, 10, false, Some(title)); + let widths: Vec = out.lines().map(display_width).collect(); + assert!( + widths.iter().all(|w| *w == 12), + "title {title:?} bent the frame: {widths:?}\n{out}" + ); + } + } + + /// A frame with no room for a title keeps its plain border rather than + /// losing a corner to make space. + #[test] + fn a_frame_too_narrow_for_a_title_stays_plain() { + let rows = vec![vec![cell("a"); 3]]; + assert_eq!( + serialize(&rows, 3, false, Some("title")), + serialize(&rows, 3, false, None), + "three columns cannot hold a title, so none is drawn" + ); } /// Snapshots recorded a bare "is underlined", so a curly underline turning diff --git a/crates/shell-use/src/engine.rs b/crates/shell-use/src/engine.rs index a3d3c3c..bb4c0ee 100644 --- a/crates/shell-use/src/engine.rs +++ b/crates/shell-use/src/engine.rs @@ -465,6 +465,7 @@ fn dispatch( .cwd() .map(str::to_string), )), + Operation::GetTitle => Ok(OperationResult::Title(title_of(session))), Operation::GetCursor => { let (x, y) = session .state @@ -528,6 +529,21 @@ fn dispatch( )?; Ok(OperationResult::Unit) } + Operation::WaitTitle { + text, + regex, + timeout_ms, + not, + } => { + wait_title( + session, + &text, + regex, + timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)), + not, + )?; + Ok(OperationResult::Unit) + } Operation::WaitIdle { timeout_ms } => { wait_idle( session, @@ -579,6 +595,21 @@ fn dispatch( )?; Ok(OperationResult::Unit) } + Operation::ExpectTitle { + text, + regex, + not, + timeout_ms, + } => { + expect_title( + session, + &text, + regex, + not, + timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)), + )?; + Ok(OperationResult::Unit) + } Operation::ExpectExitCode { code, timeout_ms } => { expect_exit_code( session, @@ -595,12 +626,14 @@ fn dispatch( name, update, include_colors, + include_title, cwd, } => Ok(OperationResult::Snapshot(do_snapshot( session, &name, update, include_colors, + include_title, cwd, )?)), Operation::Screenshot { full, path } => Ok(OperationResult::Screenshot(screenshot( @@ -628,6 +661,7 @@ fn state(session: &TerminalSession) -> crate::api::State { cols, rows, cursor: Cursor { x, y }, + title: state.emu.title(), cwd: state.tracker.cwd().map(str::to_string), last_command: state.tracker.last_command().map(str::to_string), last_exit: state.tracker.last_exit(), @@ -836,6 +870,112 @@ fn wait_text( } } +/// The window title the terminal is currently reporting. +fn title_of(session: &TerminalSession) -> Option { + session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .emu + .title() +} + +/// Whether the title matches now. An unset title matches nothing, so `--not` +/// on a session that never set one succeeds. +fn title_matches(session: &TerminalSession, pattern: &Pattern) -> bool { + title_of(session).is_some_and(|title| pattern.matches(&title)) +} + +fn wait_title( + session: &TerminalSession, + text: &str, + regex: bool, + timeout_ms: u64, + not: bool, +) -> Result<(), ShellUseError> { + let pattern = Pattern::new(text, regex) + .map_err(|error| ShellUseError::usage(format!("invalid regex: {error}")))?; + let mut matched = false; + poll_until( + || { + matched = title_matches(session, &pattern) != not; + matched || session_stopped(session) + }, + timeout_ms, + ); + if matched { + Ok(()) + } else if session_stopped(session) { + Err(ShellUseError::assertion(format!( + "session exited before the title '{}' became {}", + pattern.describe(), + if not { "hidden" } else { "visible" } + ))) + } else { + Err(ShellUseError::assertion(title_timeout_message( + session, + &pattern.describe(), + timeout_ms, + not, + ))) + } +} + +fn expect_title( + session: &TerminalSession, + text: &str, + regex: bool, + not: bool, + timeout_ms: u64, +) -> Result<(), ShellUseError> { + let pattern = Pattern::new(text, regex) + .map_err(|error| ShellUseError::usage(format!("invalid regex: {error}")))?; + let mut matched = false; + poll_until( + || { + matched = title_matches(session, &pattern) != not; + matched || session_stopped(session) + }, + timeout_ms, + ); + if matched { + Ok(()) + } else if session_stopped(session) { + Err(ShellUseError::assertion(format!( + "session exited before the title '{}' became {}", + pattern.describe(), + if not { "hidden" } else { "visible" } + ))) + } else { + Err(ShellUseError::assertion(title_timeout_message( + session, + &pattern.describe(), + timeout_ms, + not, + ))) + } +} + +/// Naming the title actually seen turns "expected X" into a diff a caller can +/// act on, which matters more here than for text because the title is a single +/// short string that the terminal screen does not show. +fn title_timeout_message( + session: &TerminalSession, + pattern: &str, + timeout_ms: u64, + not: bool, +) -> String { + let actual = match title_of(session) { + Some(title) => format!("'{title}'"), + None => "no title set".to_string(), + }; + format!( + "timed out after {} waiting for the title '{pattern}' to be {}; the title is {actual}", + format_timeout(timeout_ms), + if not { "hidden" } else { "visible" }, + ) +} + fn wait_idle(session: &TerminalSession, timeout_ms: u64) -> Result<(), ShellUseError> { let quiet = Duration::from_millis(250); if poll_until( @@ -1139,10 +1279,15 @@ fn do_snapshot( name: &str, update: bool, include_colors: bool, + include_title: bool, cwd: Option, ) -> Result { let rows = viewable(session); - let content = snapshot::serialize(&rows, session.cols, include_colors); + // Off by default: a shell prompt routinely sets the title to a username, + // hostname, and absolute path, which would pin every baseline to one + // machine and make it change on `cd` while the screen stayed the same. + let title = include_title.then(|| title_of(session)).flatten(); + let content = snapshot::serialize(&rows, session.cols, include_colors, title.as_deref()); let base = cwd .map(std::path::PathBuf::from) .or_else(|| std::env::current_dir().ok()) @@ -1166,7 +1311,8 @@ fn screenshot( let rows = grid(session, full); match path { Some(path) => { - let svg = crate::render::svg::render_svg(&rows, session.cols); + let svg = + crate::render::svg::render_svg(&rows, session.cols, title_of(session).as_deref()); std::fs::write(&path, svg) .map_err(|error| ShellUseError::internal(error.to_string()))?; Ok(ScreenshotResult::Path(path)) @@ -1184,7 +1330,12 @@ fn timeout_message(pattern: &str, timeout_ms: u64, not: bool) -> String { } fn assertion_message(session: &TerminalSession, message: &str) -> String { - let screen = snapshot::serialize(&viewable(session), session.cols, false); + let screen = snapshot::serialize( + &viewable(session), + session.cols, + false, + title_of(session).as_deref(), + ); format!("{message}\n\nTerminal content:\n{screen}") } diff --git a/crates/shell-use/src/render/svg.rs b/crates/shell-use/src/render/svg.rs index 9278220..2453be0 100644 --- a/crates/shell-use/src/render/svg.rs +++ b/crates/shell-use/src/render/svg.rs @@ -11,7 +11,7 @@ use std::fmt::Write; use super::nerd_font::NerdFont; -use crate::terminal::cell::{Attrs, Color, EmuCell}; +use crate::terminal::cell::{truncate_to_columns, Attrs, Color, EmuCell}; const CELL_W: f32 = 10.0; const CELL_H: f32 = 21.0; @@ -21,6 +21,12 @@ const MARGIN_X: f32 = 15.0; const HEADER_H: f32 = 38.0; const MARGIN_BOTTOM: f32 = 14.0; const DOT_R: f32 = 7.0; +/// Title bar text, smaller than the grid font so the chrome does not compete +/// with the terminal content itself. +const TITLE_FONT_SIZE: f32 = 13.0; +/// Where the rightmost traffic light ends. A centred title is kept clear of +/// this on both sides, so it can never be drawn over the controls. +const DOTS_RIGHT: f32 = MARGIN_X + 5.0 + 2.0 * 20.0 + DOT_R; const FONT_STACK: &str = "'Cascadia Code','JetBrains Mono','Fira Code',Menlo,Consolas,'DejaVu Sans Mono',monospace"; @@ -150,8 +156,45 @@ fn run_text(row: &[EmuCell], start: usize, end: usize) -> String { text } +/// Draw the window title centred in the title bar. +/// +/// The title is chrome rather than grid content, so unlike a cell run it is +/// not forced to a `textLength`: stretching a proportional string to a +/// computed width would distort it. It is instead truncated to what fits, and +/// kept clear of the traffic lights by reserving the same margin on both +/// sides, which also keeps it centred on the space that remains. +fn write_title(out: &mut String, title: &str, width: f32, theme: &Theme) { + const GAP: f32 = 8.0; + let available = width - 2.0 * (DOTS_RIGHT + GAP); + // A monospace advance, scaled from the grid font's known cell width. + let advance = TITLE_FONT_SIZE * (CELL_W / FONT_SIZE); + let fits = (available / advance).floor().max(0.0) as usize; + if fits == 0 { + return; + } + + // Budgeted in columns, not characters: the title bar inherits the + // monospace stack, so a CJK glyph takes two advances and a title sized by + // character count would be twice as wide as measured and, being centred, + // would spill over the window controls at both ends. + let shown = truncate_to_columns(title, fits); + let _ = write!( + out, + r#"{esc}"#, + cx = width / 2.0, + baseline = HEADER_H / 2.0 + TITLE_FONT_SIZE * 0.35, + // The dim grey of the palette, so the title reads as chrome next to + // the terminal's own foreground. + fill = hex(theme.palette[8]), + esc = escape(&shown), + ); +} + /// Render a grid to a standalone SVG document. -pub fn render_svg(rows: &[Vec], cols: u16) -> String { +/// +/// `title` is the window title a program set, drawn in the title bar. `None` +/// leaves the bar bare, exactly as it was before titles were tracked. +pub fn render_svg(rows: &[Vec], cols: u16, title: Option<&str>) -> String { let theme = Theme::default(); let nerd_font = NerdFont::new(rows, FONT_SIZE); let cols = cols as usize; @@ -179,6 +222,9 @@ pub fn render_svg(rows: &[Vec], cols: u16) -> String { cy = HEADER_H / 2.0, ); } + if let Some(title) = title { + write_title(&mut out, title, width, &theme); + } for (y, row) in rows.iter().enumerate() { let mut x = 0; @@ -281,7 +327,7 @@ mod tests { cell("h", Some(Color::from_index(1)), None), cell("i", Some(Color::from_index(1)), None), ]]; - let svg = render_svg(&rows, 2); + let svg = render_svg(&rows, 2, None); assert!(svg.starts_with("")); assert!(svg.contains("textLength")); @@ -293,7 +339,7 @@ mod tests { #[test] fn emits_window_chrome() { - let svg = render_svg(&[vec![cell(" ", None, None)]], 1); + let svg = render_svg(&[vec![cell(" ", None, None)]], 1, None); assert!(svg.contains("<")); } @@ -318,7 +364,7 @@ mod tests { #[test] fn background_run_emitted_for_non_default_bg() { let rows = vec![vec![cell(" ", None, Some(Color::from_index(4)))]]; - let svg = render_svg(&rows, 1); + let svg = render_svg(&rows, 1, None); assert!(svg.contains(&hex((113, 190, 242)))); } @@ -330,7 +376,7 @@ mod tests { cell(glyph, None, None), cell("b", None, None), ]]; - let svg = render_svg(&rows, 3); + let svg = render_svg(&rows, 3, None); assert!(svg.contains(r#"")); assert!(!svg.contains(r#"vim: notes.md") && titled.contains("text-anchor=\"middle\""), + "the title is drawn, centred: {titled}" + ); + // The panel is 2*15 margin + 40 cells of 10, so its middle is 215. + assert!( + titled.contains(r#"") + .nth(1) + .and_then(|rest| rest.split("").next()) + .expect("a title element"); + assert!(drawn.ends_with('…'), "truncation is marked: {drawn}"); + assert_fits_clear_of_the_controls(drawn, 20.0); + } + + /// A wide-glyph title is budgeted by the columns it really occupies. + /// + /// The bar inherits the monospace stack, so a CJK glyph takes two + /// advances. Sized by character count it would be drawn twice as wide as + /// measured and, being centred, would spill over the controls at one end + /// and past the panel at the other. + #[test] + fn budgets_a_wide_glyph_title_by_column() { + let rows = vec![vec![cell("x", None, None); 24]]; + let svg = render_svg(&rows, 24, Some(&"你".repeat(40))); + let drawn = svg + .split("text-anchor=\"middle\" xml:space=\"preserve\">") + .nth(1) + .and_then(|rest| rest.split("").next()) + .expect("a title element"); + assert_fits_clear_of_the_controls(drawn, 24.0); + } + + /// The drawn title must sit inside the space between the traffic lights + /// and the mirrored margin on the right. + fn assert_fits_clear_of_the_controls(drawn: &str, cols: f32) { + let panel = MARGIN_X * 2.0 + cols * CELL_W; + let advance = TITLE_FONT_SIZE * (CELL_W / FONT_SIZE); + let drawn_width = crate::terminal::cell::display_width(drawn) as f32 * advance; + assert!( + drawn_width <= panel - 2.0 * DOTS_RIGHT, + "title {drawn:?} is {drawn_width} wide, past the {} available", + panel - 2.0 * DOTS_RIGHT + ); + } + + /// A title is markup-escaped like any other text. It comes from whatever + /// the program chose to send, so an unescaped one would let that program + /// inject elements into the image. + #[test] + fn escapes_markup_in_the_title() { + let rows = vec![vec![cell("x", None, None); 40]]; + let svg = render_svg(&rows, 40, Some("")); + + assert!(!svg.contains("