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..49028fe 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`, @@ -341,4 +343,7 @@ terminal, and `Ctrl+C` won't quit it. Confirm with `shell-use state` **Platform note.** On Windows ConPTY, `get output` and `get command` text can on some rare occasions be unreliable due to screen repainting; grid-based checks (`expect text`, -`expect exit-code`) are unaffected. +`expect exit-code`) are unaffected. ConPTY also gives a session a window title +before anything runs (the program's path, e.g. `C:\Program Files\Git\bin\bash.EXE`), +where a unix PTY starts with none, so treat `get title` on a fresh session as +platform-dependent and assert on a title only after a program sets one. diff --git a/bindings/js/README.md b/bindings/js/README.md index 3471a81..43d7871 100644 --- a/bindings/js/README.md +++ b/bindings/js/README.md @@ -49,7 +49,7 @@ All derive from `ShellUseError` and carry `kind` and `exitCode`. `waitX` and `ex ## API -`new ShellUse(session?, { timeouts?, artifacts? })` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `getCommand` / `getOutput` / `getExitCode` / `getCwd` / `getCursor` / `getSize`, `screenshot`, `waitText` / `waitIdle` / `waitCommand` / `waitExit` / `waitReady`, `expectText` / `expectExitCode` / `expectOutput` / `expectSnapshot`, `close`, and `closeQuiet`. +`new ShellUse(session?, { timeouts?, artifacts? })` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `getCommand` / `getOutput` / `getExitCode` / `getCwd` / `getCursor` / `getSize` / `getTitle`, `screenshot`, `waitText` / `waitTitle` / `waitIdle` / `waitCommand` / `waitExit` / `waitReady`, `expectText` / `expectTitle` / `expectExitCode` / `expectOutput` / `expectSnapshot`, `close`, and `closeQuiet`. Module-level helpers: `sessions()`, `closeAll()`, `getRecording()`, `uniqueSession()`. 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/README.md b/bindings/python/README.md index 9d649df..7f6fae1 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -54,7 +54,7 @@ All derive from `ShellUseError`. `wait_*` and `expect_*` raise `ExpectationError ## API -`ShellUse(session="default", *, timeouts=None, artifacts=None)` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `get_command` / `get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / `get_size`, `screenshot`, `wait_text` / `wait_idle` / `wait_command` / `wait_exit` / `wait_ready`, `expect_text` / `expect_exit_code` / `expect_output` / `expect_snapshot`, `close`, and `close_quiet`. +`ShellUse(session="default", *, timeouts=None, artifacts=None)` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `get_command` / `get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / `get_size` / `get_title`, `screenshot`, `wait_text` / `wait_title` / `wait_idle` / `wait_command` / `wait_exit` / `wait_ready`, `expect_text` / `expect_title` / `expect_exit_code` / `expect_output` / `expect_snapshot`, `close`, and `close_quiet`. Module-level helpers: `sessions()`, `close_all()`, `get_recording()`, `unique_session()`. 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..c7027b8 100644 --- a/crates/shell-use-cli/tests/session_lifecycle.rs +++ b/crates/shell-use-cli/tests/session_lifecycle.rs @@ -814,3 +814,99 @@ 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. +/// +/// It deliberately does not assert what the title is *before* the program sets +/// one. A session does not necessarily start without a title: Windows ConPTY +/// supplies the program's path (`C:\Program Files\Git\bin\bash.EXE`) as soon as +/// the session opens. That a fresh emulator reports no title, and that an empty +/// one resets rather than storing a blank, are claims about the emulator rather +/// than about the platform, so they are pinned in the conformance suite where +/// no PTY is involved and every backend is covered. +#[test] +fn a_window_title_is_tracked_asserted_and_drawn() { + let sandbox = Sandbox::new("title"); + sandbox.ok(&["run", "--cols", "40", "--", "bash", "--norc"]); + let before = sandbox.ok(&["get", "title"]); + + 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"]); + assert_ne!( + sandbox.ok(&["get", "title"]), + before, + "the title the program set replaced whatever the session started with" + ); + + // 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"]); +} + +/// 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..2707033 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, @@ -69,8 +69,18 @@ fn baseline() -> EmuCell { EmuCell::blank() } -/// 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 { +/// Serialize a grid into a boxed text view, optionally followed by attributes. +/// +/// The attributes are a JSON object carrying whatever the box itself cannot +/// record exactly: the full window title, and the color shifts when they are +/// asked for. It is emitted only when there is something to put in it, so a +/// plain snapshot is still just the box. +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,20 +100,52 @@ 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); + let mut attributes = Map::new(); + // The border shows a title shortened to fit, which is readable but lossy: + // two long titles differing only past the cut would otherwise record as + // the same snapshot and pass for each other. The full one is recorded here + // so the baseline stays exact however narrow the frame is. + if let Some(title) = title { + attributes.insert("title".to_string(), Value::String(title.to_string())); + } if include_colors && !shifts.is_empty() { + attributes.insert("colors".to_string(), Value::Object(shifts)); + } + if attributes.is_empty() { + view + } else { format!( "{view}\n{}", - serde_json::to_string_pretty(&Value::Object(shifts)).unwrap_or_default() + serde_json::to_string_pretty(&Value::Object(attributes)).unwrap_or_default() ) - } else { - view } } -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') { @@ -149,6 +191,14 @@ mod tests { use super::*; use crate::terminal::cell::CONTINUATION; + /// Just the framed view, dropping any attributes recorded after it. + fn box_of(serialized: &str) -> String { + match serialized.split_once("╯\n") { + Some((frame, _)) => format!("{frame}╯"), + None => serialized.to_string(), + } + } + fn cell(s: &str) -> EmuCell { EmuCell { ch: s.into(), @@ -170,7 +220,126 @@ 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::>(), + box_of(&titled).lines().skip(1).collect::>(), + "and nothing below the border changes" + ); + } + + /// The full title is recorded even when the border shows a short one. + /// + /// The border has to fit, so a long title is cut to size there. A snapshot + /// is an assertion rather than a picture: two titles differing only past + /// the cut would record identically and pass for each other, so the exact + /// one is kept alongside the frame. + #[test] + fn the_full_title_is_recorded_even_when_the_border_cannot_show_it() { + let rows = vec![vec![cell("a"); 12]]; + let long = "building module A, step 3"; + let other = "building module B, step 7"; + let out = serialize(&rows, 12, false, Some(long)); + + assert!( + box_of(&out).contains('…'), + "the border shows a shortened title: {out}" + ); + assert!( + out.contains(&format!(r#""title": "{long}""#)), + "and the exact one is recorded: {out}" + ); + assert_ne!( + out, + serialize(&rows, 12, false, Some(other)), + "two titles that shorten alike still record differently" + ); + } + + /// Colors keep their own key, so a snapshot can carry both. + #[test] + fn attributes_hold_the_title_and_the_colors_apart() { + let rows = vec![vec![ + cell("a"), + EmuCell { + fg: Some(Color::from_index(1)), + ..EmuCell::blank() + }, + ]]; + let out = serialize(&rows, 2, true, Some("t")); + let attributes: Value = + serde_json::from_str(out.split_once("╯\n").expect("a frame then attributes").1) + .expect("attributes parse as json"); + assert_eq!(attributes["title"], json!("t")); + assert!( + attributes["colors"].is_object(), + "colors stay under their own key: {out}" + ); + } + + /// 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 = box_of(&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!( + box_of(&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..e929f07 100644 --- a/crates/shell-use/src/engine.rs +++ b/crates/shell-use/src/engine.rs @@ -397,6 +397,35 @@ fn viewable(session: &TerminalSession) -> Vec> { .viewable_rows() } +/// The visible screen and the window title as of a single instant. +/// +/// Read under one lock. Taking them separately lets the reader thread advance +/// the terminal in between, which pairs a grid from one moment with a title +/// from another: a shell writes its prompt and then sets its title, so a +/// snapshot of a screen that never changed again could still come out +/// different each time. +fn grid_with_title( + session: &TerminalSession, + full: bool, + include_title: bool, +) -> (Vec>, Option) { + let state = session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let title = if include_title { + state.emu.title() + } else { + None + }; + let rows = if full { + state.emu.full_rows() + } else { + state.emu.viewable_rows() + }; + (rows, title) +} + fn grid(session: &TerminalSession, full: bool) -> Vec> { let state = session .state @@ -465,6 +494,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 +558,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 +624,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 +655,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 +690,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 +899,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 +1308,14 @@ 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); + // The title is off by default: a shell prompt routinely sets it 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 (rows, title) = grid_with_title(session, false, include_title); + 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()) @@ -1163,10 +1336,10 @@ fn screenshot( full: bool, path: Option, ) -> Result { - let rows = grid(session, full); + let (rows, title) = grid_with_title(session, full, true); 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.as_deref()); std::fs::write(&path, svg) .map_err(|error| ShellUseError::internal(error.to_string()))?; Ok(ScreenshotResult::Path(path)) @@ -1184,7 +1357,8 @@ 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 (rows, title) = grid_with_title(session, false, true); + let screen = snapshot::serialize(&rows, session.cols, false, title.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("