Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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. |
Expand All @@ -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`).

Expand Down
13 changes: 9 additions & 4 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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. |
Expand All @@ -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`).

Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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.
11 changes: 11 additions & 0 deletions bindings/js/native/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export declare class NativeSession {
mouseScroll(direction: string, amount?: number | undefined | null): Promise<void>
resize(cols: number, rows: number): Promise<void>
signal(name: string): Promise<void>
getTitle(): Promise<string | null>
waitTitle(text: string, options?: TitleOptions | undefined | null): Promise<void>
expectTitle(text: string, options?: TitleOptions | undefined | null): Promise<void>
waitText(text: string, options?: WaitTextOptions | undefined | null): Promise<void>
waitIdle(timeoutMs?: number | undefined | null): Promise<void>
waitCommand(timeoutMs?: number | undefined | null): Promise<void>
Expand Down Expand Up @@ -170,6 +173,7 @@ export interface Size {
export interface SnapshotOptions {
update?: boolean
includeColors?: boolean
includeTitle?: boolean
cwd?: string
}

Expand All @@ -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
Expand All @@ -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',
Expand Down
72 changes: 72 additions & 0 deletions bindings/js/native/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ pub struct State {
pub cols: u16,
pub rows: u16,
pub cursor: Cursor,
pub title: Option<String>,
pub cwd: Option<String>,
#[napi(js_name = "last_command")]
pub last_command: Option<String>,
Expand All @@ -176,6 +177,7 @@ impl From<CoreState> 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,
Expand Down Expand Up @@ -302,6 +304,13 @@ pub struct WaitTextOptions {
pub timeout_ms: Option<f64>,
}

#[napi(object)]
pub struct TitleOptions {
pub regex: Option<bool>,
pub not: Option<bool>,
pub timeout_ms: Option<f64>,
}

#[napi(object)]
pub struct ExpectTextOptions {
pub regex: Option<bool>,
Expand All @@ -317,6 +326,7 @@ pub struct ExpectTextOptions {
pub struct SnapshotOptions {
pub update: Option<bool>,
pub include_colors: Option<bool>,
pub include_title: Option<bool>,
pub cwd: Option<String>,
}

Expand Down Expand Up @@ -872,6 +882,66 @@ impl NativeSession {
self.unit("signal", Operation::Signal { name }).await
}

#[napi]
pub async fn get_title(&self) -> Result<Option<String>> {
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<TitleOptions>) -> 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<TitleOptions>) -> 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<WaitTextOptions>) -> Result<()> {
let options = options.unwrap_or(WaitTextOptions {
Expand Down Expand Up @@ -1001,6 +1071,7 @@ impl NativeSession {
let options = options.unwrap_or(SnapshotOptions {
update: None,
include_colors: None,
include_title: None,
cwd: None,
});
execute(
Expand All @@ -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 {
Expand Down
33 changes: 32 additions & 1 deletion bindings/js/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -305,6 +311,10 @@ export class ShellUse {
return this.#runtime.getCwd();
}

async getTitle(): Promise<string | null> {
return this.#runtime.getTitle();
}

async getCursor(): Promise<Cursor> {
return this.#runtime.getCursor();
}
Expand All @@ -331,6 +341,16 @@ export class ShellUse {
);
}

async waitTitle(text: string, opts: TitleOptions = {}): Promise<void> {
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<void> {
await this.#guard("waitIdle", () =>
this.#runtime.waitIdle(this.#timeout("idle", opts.timeout)),
Expand All @@ -355,6 +375,16 @@ export class ShellUse {
);
}

async expectTitle(text: string, opts: TitleOptions = {}): Promise<void> {
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<void> {
await this.#guard("expectText", () =>
this.#runtime.expectText(text, {
Expand Down Expand Up @@ -383,12 +413,13 @@ export class ShellUse {

async expectSnapshot(
name: string,
opts: { update?: boolean; includeColors?: boolean } = {},
opts: { update?: boolean; includeColors?: boolean; includeTitle?: boolean } = {},
): Promise<string> {
return this.#guard("expectSnapshot", () =>
this.#runtime.snapshot(name, {
update: opts.update ?? false,
includeColors: opts.includeColors ?? false,
includeTitle: opts.includeTitle ?? false,
cwd: process.cwd(),
}),
);
Expand Down
1 change: 1 addition & 0 deletions bindings/js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { ShellUse } from "./client.js";
export type {
ExpectTextOptions,
MouseButtonOptions,
TitleOptions,
WaitTextOptions,
} from "./client.js";
export { uniqueSession } from "./ephemeral.js";
Expand Down
13 changes: 13 additions & 0 deletions bindings/js/src/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
SnapshotOptions,
State,
Timeouts,
TitleOptions,
WaitTextOptions,
} from "../native/index.js";

Expand Down Expand Up @@ -191,6 +192,10 @@ export class NativeRuntime {
return this.#call((session) => session.getCwd());
}

getTitle(): Promise<string | null> {
return this.#call((session) => session.getTitle());
}

getCursor(): Promise<Cursor> {
return this.#call((session) => session.getCursor());
}
Expand Down Expand Up @@ -257,6 +262,10 @@ export class NativeRuntime {
return this.#call((session) => session.waitText(text, options));
}

waitTitle(text: string, options?: TitleOptions): Promise<void> {
return this.#call((session) => session.waitTitle(text, options));
}

waitIdle(timeoutMs?: number): Promise<void> {
return this.#call((session) => session.waitIdle(timeoutMs));
}
Expand All @@ -273,6 +282,10 @@ export class NativeRuntime {
return this.#call((session) => session.waitReady(timeoutMs));
}

expectTitle(text: string, options?: TitleOptions): Promise<void> {
return this.#call((session) => session.expectTitle(text, options));
}

expectText(text: string, options?: ExpectTextOptions): Promise<void> {
return this.#call((session) => session.expectText(text, options));
}
Expand Down
5 changes: 3 additions & 2 deletions bindings/js/test/conformance.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const MAPPING = {
["client", "getCwd"],
["client", "getCursor"],
["client", "getSize"],
["client", "getTitle"],
],
type: [["client", "type"]],
submit: [["client", "submit"]],
Expand All @@ -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"]],
};

Expand Down
Loading
Loading