diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed49e504..13e228af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,8 @@ jobs: run: npx tsc -b - name: Build ptywright (native binding) run: npm run build --workspace @onkernel/ptywright + - name: Ptywright tests + run: npm test --workspace @onkernel/ptywright - name: CLI unit tests env: PTYWRIGHT_REQUIRED: "1" diff --git a/packages/ptywright/README.md b/packages/ptywright/README.md index 2dd76322..f57f6bea 100644 --- a/packages/ptywright/README.md +++ b/packages/ptywright/README.md @@ -304,13 +304,13 @@ Writes text followed by `Enter`. ##### `session.press(key)` -Writes a key sequence, typically one of the exported key constants. +Writes a key. A string is sent as raw bytes (`Key*` constants stay pass-through). A `SpecialKey` is encoded from the live terminal modes (for example DECCKM application cursor keys). ```ts -import { KeyArrowDown, KeyEnter } from "@onkernel/ptywright"; +import { KeyEnter, SpecialArrowUp } from "@onkernel/ptywright"; -session.press(KeyArrowDown); session.press(KeyEnter); +session.press(SpecialArrowUp); ``` #### Lifecycle and snapshots @@ -438,6 +438,8 @@ The package exports common terminal key sequences as strings: - `KeyArrowLeft` - `KeyArrowRight` +Those `Key*` values are raw bytes. Pass `SpecialArrowUp` (and the other `Special*` keys) to `press()` when the sequence should follow live terminal modes. + You can also pass any raw sequence directly to `session.send(...)`. ## Examples diff --git a/packages/ptywright/native/src/addon.cc b/packages/ptywright/native/src/addon.cc index d77f9d02..86b537f4 100644 --- a/packages/ptywright/native/src/addon.cc +++ b/packages/ptywright/native/src/addon.cc @@ -175,6 +175,39 @@ Napi::Value Snapshot(const Napi::CallbackInfo &info) { return output; } +Napi::Value EncodeSpecialKey(const Napi::CallbackInfo &info) { + Napi::Env env = info.Env(); + TerminalState *state = GetState(info); + if (!EnsureOpen(env, state)) { + return env.Undefined(); + } + if (info.Length() < 1 || !info[0].IsString()) { + Napi::TypeError::New(env, "encodeSpecialKey expects a key name").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + std::string name = info[0].As().Utf8Value(); + uint8_t *bytes = nullptr; + size_t len = 0; + int result = ptywright_ghostty_terminal_encode_special_key( + state->terminal, + name.c_str(), + &bytes, + &len); + if (result != 0) { + ThrowGhosttyError(env, "ptywright_ghostty_terminal_encode_special_key", result); + return env.Undefined(); + } + if (bytes == nullptr || len == 0) { + ptywright_ghostty_free_bytes(bytes); + return env.Undefined(); + } + + Napi::Buffer output = Napi::Buffer::Copy(env, bytes, len); + ptywright_ghostty_free_bytes(bytes); + return output; +} + Napi::Value Dispose(const Napi::CallbackInfo &info) { TerminalState *state = GetState(info); if (state && state->terminal) { @@ -211,6 +244,7 @@ Napi::Value CreateTerminal(const Napi::CallbackInfo &info) { object.Set("feed", Napi::Function::New(env, Feed, "feed", state)); object.Set("resize", Napi::Function::New(env, Resize, "resize", state)); object.Set("snapshot", Napi::Function::New(env, Snapshot, "snapshot", state)); + object.Set("encodeSpecialKey", Napi::Function::New(env, EncodeSpecialKey, "encodeSpecialKey", state)); object.Set("dispose", Napi::Function::New(env, Dispose, "dispose", state)); return object; } diff --git a/packages/ptywright/native/src/ghostty_bridge.c b/packages/ptywright/native/src/ghostty_bridge.c index 64318014..b684efe0 100644 --- a/packages/ptywright/native/src/ghostty_bridge.c +++ b/packages/ptywright/native/src/ghostty_bridge.c @@ -17,6 +17,7 @@ enum { struct PtywrightGhosttyTerminal { GhosttyTerminal handle; + GhosttyKeyEncoder encoder; uint8_t *reply_buf; size_t reply_len; size_t reply_cap; @@ -27,6 +28,44 @@ struct PtywrightGhosttyTerminal { int reply_error; }; +static int ptywright_ghostty_special_key(const char *name, GhosttyKey *out_key, + GhosttyMods *out_mods) { + if (name == NULL || out_key == NULL || out_mods == NULL) { + return GHOSTTY_INVALID_VALUE; + } + + *out_mods = 0; + if (strcmp(name, "arrow_up") == 0) { + *out_key = GHOSTTY_KEY_ARROW_UP; + } else if (strcmp(name, "arrow_down") == 0) { + *out_key = GHOSTTY_KEY_ARROW_DOWN; + } else if (strcmp(name, "arrow_left") == 0) { + *out_key = GHOSTTY_KEY_ARROW_LEFT; + } else if (strcmp(name, "arrow_right") == 0) { + *out_key = GHOSTTY_KEY_ARROW_RIGHT; + } else if (strcmp(name, "home") == 0) { + *out_key = GHOSTTY_KEY_HOME; + } else if (strcmp(name, "end") == 0) { + *out_key = GHOSTTY_KEY_END; + } else if (strcmp(name, "page_up") == 0) { + *out_key = GHOSTTY_KEY_PAGE_UP; + } else if (strcmp(name, "page_down") == 0) { + *out_key = GHOSTTY_KEY_PAGE_DOWN; + } else if (strcmp(name, "insert") == 0) { + *out_key = GHOSTTY_KEY_INSERT; + } else if (strcmp(name, "delete") == 0) { + *out_key = GHOSTTY_KEY_DELETE; + } else if (strcmp(name, "escape") == 0) { + *out_key = GHOSTTY_KEY_ESCAPE; + } else if (strcmp(name, "backtab") == 0) { + *out_key = GHOSTTY_KEY_TAB; + *out_mods = GHOSTTY_MODS_SHIFT; + } else { + return GHOSTTY_INVALID_VALUE; + } + return GHOSTTY_SUCCESS; +} + static int ptywright_ghostty_reserve_buffer(uint8_t **buf, size_t *cap, size_t needed) { @@ -218,6 +257,13 @@ int ptywright_ghostty_terminal_create(uint16_t cols, uint16_t rows, size_t scrol return result; } + result = ghostty_key_encoder_new(NULL, &terminal->encoder); + if (result != GHOSTTY_SUCCESS) { + ghostty_terminal_free(terminal->handle); + free(terminal); + return result; + } + *out_terminal = terminal; return GHOSTTY_SUCCESS; } @@ -263,6 +309,78 @@ int ptywright_ghostty_terminal_resize(PtywrightGhosttyTerminal *terminal, return ghostty_terminal_resize(terminal->handle, cols, rows, kCellWidthPx, kCellHeightPx); } +int ptywright_ghostty_terminal_encode_special_key(PtywrightGhosttyTerminal *terminal, + const char *name, + uint8_t **out_bytes, + size_t *out_len) { + if (terminal == NULL || terminal->handle == NULL || terminal->encoder == NULL || + name == NULL || out_bytes == NULL || out_len == NULL) { + return GHOSTTY_INVALID_VALUE; + } + + *out_bytes = NULL; + *out_len = 0; + + GhosttyKey key = GHOSTTY_KEY_UNIDENTIFIED; + GhosttyMods mods = 0; + int mapped = ptywright_ghostty_special_key(name, &key, &mods); + if (mapped != GHOSTTY_SUCCESS) { + return mapped; + } + + GhosttyKeyEvent event = NULL; + GhosttyResult result = ghostty_key_event_new(NULL, &event); + if (result != GHOSTTY_SUCCESS) { + return result; + } + + ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_PRESS); + ghostty_key_event_set_key(event, key); + ghostty_key_event_set_mods(event, mods); + ghostty_key_encoder_setopt_from_terminal(terminal->encoder, terminal->handle); + + char stack[128]; + size_t written = 0; + result = ghostty_key_encoder_encode(terminal->encoder, event, stack, sizeof(stack), &written); + if (result == GHOSTTY_OUT_OF_SPACE) { + char *dynamic = malloc(written > 0 ? written : 1); + if (dynamic == NULL) { + ghostty_key_event_free(event); + return GHOSTTY_OUT_OF_MEMORY; + } + result = ghostty_key_encoder_encode(terminal->encoder, event, dynamic, written, &written); + if (result != GHOSTTY_SUCCESS) { + free(dynamic); + ghostty_key_event_free(event); + return result; + } + *out_bytes = (uint8_t *)dynamic; + *out_len = written; + ghostty_key_event_free(event); + return GHOSTTY_SUCCESS; + } + if (result != GHOSTTY_SUCCESS) { + ghostty_key_event_free(event); + return result; + } + + if (written == 0) { + ghostty_key_event_free(event); + return GHOSTTY_SUCCESS; + } + + uint8_t *copy = malloc(written); + if (copy == NULL) { + ghostty_key_event_free(event); + return GHOSTTY_OUT_OF_MEMORY; + } + memcpy(copy, stack, written); + *out_bytes = copy; + *out_len = written; + ghostty_key_event_free(event); + return GHOSTTY_SUCCESS; +} + int ptywright_ghostty_terminal_snapshot(PtywrightGhosttyTerminal *terminal, int trim, int unwrap, PtywrightGhosttySnapshot *out_snapshot) { @@ -416,6 +534,10 @@ void ptywright_ghostty_terminal_destroy(PtywrightGhosttyTerminal *terminal) { return; } + if (terminal->encoder != NULL) { + ghostty_key_encoder_free(terminal->encoder); + terminal->encoder = NULL; + } if (terminal->handle != NULL) { ghostty_terminal_free(terminal->handle); terminal->handle = NULL; diff --git a/packages/ptywright/native/src/ghostty_bridge.h b/packages/ptywright/native/src/ghostty_bridge.h index feff6aa2..d77562c0 100644 --- a/packages/ptywright/native/src/ghostty_bridge.h +++ b/packages/ptywright/native/src/ghostty_bridge.h @@ -36,6 +36,11 @@ int ptywright_ghostty_terminal_feed(PtywrightGhosttyTerminal *terminal, int ptywright_ghostty_terminal_resize(PtywrightGhosttyTerminal *terminal, uint16_t cols, uint16_t rows); +int ptywright_ghostty_terminal_encode_special_key(PtywrightGhosttyTerminal *terminal, + const char *name, + uint8_t **out_bytes, + size_t *out_len); + int ptywright_ghostty_terminal_snapshot(PtywrightGhosttyTerminal *terminal, int trim, int unwrap, PtywrightGhosttySnapshot *out_snapshot); diff --git a/packages/ptywright/package.json b/packages/ptywright/package.json index c977db4d..45a8cd6f 100644 --- a/packages/ptywright/package.json +++ b/packages/ptywright/package.json @@ -18,7 +18,7 @@ "build:native": "node ./scripts/build-ghostty.mjs && node-gyp rebuild --directory native", "clean": "tsc -b --clean", "clean:native": "node-gyp clean --directory native", - "test": "node --test dist/test/*.test.js" + "test": "tsx --test src/test/*.test.ts" }, "gypfile": false, "dependencies": { diff --git a/packages/ptywright/src/keys.ts b/packages/ptywright/src/keys.ts index 75564d0e..0e4ad470 100644 --- a/packages/ptywright/src/keys.ts +++ b/packages/ptywright/src/keys.ts @@ -17,3 +17,41 @@ export const KeyArrowUp: Key = "\x1b[A"; export const KeyArrowDown: Key = "\x1b[B"; export const KeyArrowLeft: Key = "\x1b[D"; export const KeyArrowRight: Key = "\x1b[C"; + +export const SPECIAL_KEY_KIND = "special" as const; + +export type SpecialKeyName = + | "arrow_up" + | "arrow_down" + | "arrow_left" + | "arrow_right" + | "home" + | "end" + | "page_up" + | "page_down" + | "insert" + | "delete" + | "escape" + | "backtab"; + +export interface SpecialKey { + readonly kind: typeof SPECIAL_KEY_KIND; + readonly name: SpecialKeyName; +} + +export function specialKey(name: SpecialKeyName): SpecialKey { + return { kind: SPECIAL_KEY_KIND, name }; +} + +export const SpecialArrowUp = specialKey("arrow_up"); +export const SpecialArrowDown = specialKey("arrow_down"); +export const SpecialArrowLeft = specialKey("arrow_left"); +export const SpecialArrowRight = specialKey("arrow_right"); +export const SpecialHome = specialKey("home"); +export const SpecialEnd = specialKey("end"); +export const SpecialPageUp = specialKey("page_up"); +export const SpecialPageDown = specialKey("page_down"); +export const SpecialInsert = specialKey("insert"); +export const SpecialDelete = specialKey("delete"); +export const SpecialEscape = specialKey("escape"); +export const SpecialBacktab = specialKey("backtab"); diff --git a/packages/ptywright/src/native-loader.ts b/packages/ptywright/src/native-loader.ts index 3885b3bc..6f37da16 100644 --- a/packages/ptywright/src/native-loader.ts +++ b/packages/ptywright/src/native-loader.ts @@ -26,6 +26,7 @@ export interface NativeTerminalHandle { feed(data: string | Uint8Array): Uint8Array | undefined; resize(cols: number, rows: number): void; snapshot(options?: { trim?: boolean; unwrap?: boolean }): NativeSnapshot; + encodeSpecialKey(name: string): Uint8Array | undefined; dispose(): void; } diff --git a/packages/ptywright/src/session.ts b/packages/ptywright/src/session.ts index 62eb7e70..d71537ed 100644 --- a/packages/ptywright/src/session.ts +++ b/packages/ptywright/src/session.ts @@ -1,7 +1,7 @@ import { EventEmitter } from "node:events"; import { mkdir, writeFile } from "node:fs/promises"; import { spawn, type IPty } from "node-pty"; -import { KeyEnter, type Key } from "./keys"; +import { KeyEnter, type Key, type SpecialKey } from "./keys"; import { createTerminal, type SnapshotOptions, type TerminalSnapshot, type TerminalSurface } from "./terminal"; const DEFAULT_COLS = 120; @@ -42,6 +42,7 @@ export class PtySession { private readonly events = new EventEmitter(); private transcript = ""; private closed = false; + private revision = 0; private exitCode: number | undefined; private exitSignal: number | undefined; private exitedAt: Date | undefined; @@ -64,16 +65,16 @@ export class PtySession { this.transcript += data; const { replyBytes } = this.terminal.feed(data); if (replyBytes && replyBytes.length > 0) { - this.pty.write(Buffer.from(replyBytes).toString("utf8")); + this.writeBytes(replyBytes); } - this.events.emit("update"); + this.noteUpdate(); }); this.pty.onExit((event) => { this.exitCode = event.exitCode; this.exitSignal = event.signal; this.exitedAt = new Date(); - this.events.emit("update"); + this.noteUpdate(); }); } @@ -87,8 +88,13 @@ export class PtySession { this.press(KeyEnter); } - press(key: Key): void { - this.send(key); + press(key: Key | SpecialKey): void { + this.ensureOpen(); + if (typeof key === "string") { + this.send(key); + return; + } + this.writeBytes(this.terminal.encodeSpecialKey(key)); } resize(cols: number, rows: number): void { @@ -98,7 +104,7 @@ export class PtySession { } this.pty.resize(cols, rows); this.terminal.resize(cols, rows); - this.events.emit("update"); + this.noteUpdate(); } snapshot(options: SnapshotOptions = {}): SessionSnapshot { @@ -136,6 +142,7 @@ export class PtySession { const controller = createWaitController(options); try { while (true) { + const seen = this.revision; const snapshot = this.snapshot(); if (match(snapshot)) { return snapshot; @@ -143,7 +150,7 @@ export class PtySession { if (this.exitedAt) { throw this.buildWaitError(description, snapshot, new Error("process exited before condition was satisfied")); } - await waitForUpdate(this.events, controller.signal); + await waitForUpdate(this.events, controller.signal, undefined, () => this.revision !== seen); } } catch (error) { if (controller.signal.aborted) { @@ -166,6 +173,7 @@ export class PtySession { try { while (true) { + const seen = this.revision; const snapshot = this.snapshot(); if (snapshot.visible !== lastVisible) { lastVisible = snapshot.visible; @@ -177,7 +185,7 @@ export class PtySession { if (this.exitedAt) { return snapshot; } - await waitForUpdate(this.events, controller.signal, stableForMs); + await waitForUpdate(this.events, controller.signal, stableForMs, () => this.revision !== seen); } } catch (error) { if (controller.signal.aborted) { @@ -197,7 +205,8 @@ export class PtySession { const controller = createWaitController(options); try { while (!this.exitedAt) { - await waitForUpdate(this.events, controller.signal); + const seen = this.revision; + await waitForUpdate(this.events, controller.signal, undefined, () => this.revision !== seen || Boolean(this.exitedAt)); } return this.status(); } catch (error) { @@ -254,9 +263,21 @@ export class PtySession { // Best-effort teardown only. } this.terminal.dispose(); + this.noteUpdate(); + } + + private noteUpdate(): void { + this.revision += 1; this.events.emit("update"); } + private writeBytes(bytes: Uint8Array): void { + if (bytes.length === 0) { + return; + } + this.pty.write(Buffer.from(bytes).toString("utf8")); + } + private ensureOpen(): void { if (this.closed) { throw new Error("session already closed"); @@ -329,7 +350,12 @@ function createWaitController(options?: WaitOptions): { signal: AbortSignal; cle }; } -async function waitForUpdate(events: EventEmitter, signal: AbortSignal, timeoutMs?: number): Promise { +async function waitForUpdate( + events: EventEmitter, + signal: AbortSignal, + timeoutMs?: number, + alreadyChanged?: () => boolean, +): Promise { if (signal.aborted) { throw abortReason(signal); } @@ -354,6 +380,11 @@ async function waitForUpdate(events: EventEmitter, signal: AbortSignal, timeoutM events.once("update", onUpdate); signal.addEventListener("abort", onAbort, { once: true }); + if (alreadyChanged?.()) { + cleanup(); + resolve(); + return; + } if (timeoutMs !== undefined) { timer = setTimeout(() => { cleanup(); diff --git a/packages/ptywright/src/terminal.ts b/packages/ptywright/src/terminal.ts index 1ece2a5d..1a5c70ab 100644 --- a/packages/ptywright/src/terminal.ts +++ b/packages/ptywright/src/terminal.ts @@ -1,3 +1,4 @@ +import { SPECIAL_KEY_KIND, type SpecialKey } from "./keys"; import { loadNativeBinding, type NativeSnapshot, type NativeTerminalHandle } from "./native-loader"; export interface CreateTerminalOptions { @@ -66,6 +67,14 @@ export class TerminalSurface { return normalizeSnapshot(snapshot); } + encodeSpecialKey(key: SpecialKey): Uint8Array { + this.ensureOpen(); + if (key.kind !== SPECIAL_KEY_KIND) { + throw new Error("encodeSpecialKey expects a SpecialKey"); + } + return this.native.encodeSpecialKey(key.name) ?? new Uint8Array(); + } + dispose(): void { if (this.disposed) { return; diff --git a/packages/ptywright/src/test/session.test.ts b/packages/ptywright/src/test/session.test.ts index 7e7bc67b..a11a6e15 100644 --- a/packages/ptywright/src/test/session.test.ts +++ b/packages/ptywright/src/test/session.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { KeyCtrlD, spawnSession } from "../index"; +import { KeyArrowUp, KeyCtrlD, SpecialArrowUp, spawnSession } from "../index"; test("session captures transcript and visible screen", async (t) => { const session = spawnSession({ @@ -42,6 +42,70 @@ test("session resize updates the virtual terminal dimensions", async (t) => { assert.equal(snapshot.height, 30); }); +test("press leaves raw key strings unchanged", async (t) => { + const script = [ + "import os, sys, termios, tty", + "fd = sys.stdin.fileno()", + "old = termios.tcgetattr(fd)", + "try:", + " tty.setraw(fd)", + " os.write(sys.stdout.fileno(), b'\\nready\\n')", + " key = os.read(fd, 16)", + " os.write(sys.stdout.fileno(), b'\\nraw:' + key.hex().encode() + b'\\n')", + "finally:", + " termios.tcsetattr(fd, termios.TCSANOW, old)", + ].join("\n"); + const session = spawnSession({ + command: "python3", + args: ["-c", script], + cols: 80, + rows: 12, + }); + t.after(() => session.close()); + + await session.waitForVisible("ready", { timeoutMs: 5_000 }); + session.press(KeyArrowUp); + await session.waitForTranscript("raw:", { timeoutMs: 5_000 }); + assert.match(session.snapshot().transcript, /raw:1b5b41/); +}); + +test("press encodes SpecialKey arrows from live DECCKM state", async (t) => { + const script = [ + "import os, sys, termios, tty", + "fd = sys.stdin.fileno()", + "old = termios.tcgetattr(fd)", + "try:", + " tty.setraw(fd)", + " os.write(sys.stdout.fileno(), b'\\x1b[?1h')", + " os.write(sys.stdout.fileno(), b'\\nready-app\\n')", + " app = os.read(fd, 16)", + " os.write(sys.stdout.fileno(), b'\\napp:' + app.hex().encode() + b'\\n')", + " os.write(sys.stdout.fileno(), b'\\x1b[?1l')", + " os.write(sys.stdout.fileno(), b'\\nready-norm\\n')", + " norm = os.read(fd, 16)", + " os.write(sys.stdout.fileno(), b'\\nnorm:' + norm.hex().encode() + b'\\n')", + "finally:", + " termios.tcsetattr(fd, termios.TCSANOW, old)", + ].join("\n"); + const session = spawnSession({ + command: "python3", + args: ["-c", script], + cols: 80, + rows: 12, + }); + t.after(() => session.close()); + + await session.waitForVisible("ready-app", { timeoutMs: 5_000 }); + session.press(SpecialArrowUp); + await session.waitForTranscript("ready-norm", { timeoutMs: 5_000 }); + session.press(SpecialArrowUp); + await session.waitForTranscript("norm:", { timeoutMs: 5_000 }); + + const transcript = session.snapshot().transcript; + assert.match(transcript, /app:1b4f41/); + assert.match(transcript, /norm:1b5b41/); +}); + test("session writes terminal query replies back to the child PTY", async (t) => { const script = [ "import os, sys, termios, tty", diff --git a/packages/ptywright/src/test/terminal.test.ts b/packages/ptywright/src/test/terminal.test.ts index df01634c..06e09d51 100644 --- a/packages/ptywright/src/test/terminal.test.ts +++ b/packages/ptywright/src/test/terminal.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { SpecialArrowUp, SpecialBacktab } from "../keys"; import { createTerminal } from "../terminal"; test("terminal snapshots formatted visible text", () => { @@ -32,6 +33,27 @@ test("terminal snapshots include title and pwd metadata", () => { } }); +test("encodeSpecialKey follows application cursor keys", () => { + const terminal = createTerminal({ cols: 40, rows: 6 }); + try { + const normal = Buffer.from(terminal.encodeSpecialKey(SpecialArrowUp)).toString("latin1"); + assert.equal(normal, "\x1b[A"); + + terminal.feed("\x1b[?1h"); + const application = Buffer.from(terminal.encodeSpecialKey(SpecialArrowUp)).toString("latin1"); + assert.equal(application, "\x1bOA"); + + terminal.feed("\x1b[?1l"); + const restored = Buffer.from(terminal.encodeSpecialKey(SpecialArrowUp)).toString("latin1"); + assert.equal(restored, "\x1b[A"); + + const backtab = Buffer.from(terminal.encodeSpecialKey(SpecialBacktab)).toString("latin1"); + assert.equal(backtab, "\x1b[Z"); + } finally { + terminal.dispose(); + } +}); + test("terminal feed returns reply bytes for mode queries", () => { const terminal = createTerminal({ cols: 40, rows: 6 }); try {