From 8266593fc88cdceb59f92ae10a31376b3a1a9415 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:58:43 +0000 Subject: [PATCH 1/3] Add ptywright wait revisions and mode-aware special keys Fix waitFor lost-wakeups with a revision counter, and encode SpecialKey presses from Ghostty's live terminal modes without changing press() raw-string behavior. --- packages/ptywright/README.md | 14 +- packages/ptywright/native/src/addon.cc | 35 +++++ .../ptywright/native/src/ghostty_bridge.c | 122 ++++++++++++++++++ .../ptywright/native/src/ghostty_bridge.h | 5 + packages/ptywright/src/keys.ts | 38 ++++++ packages/ptywright/src/native-loader.ts | 1 + packages/ptywright/src/session.ts | 35 ++++- packages/ptywright/src/terminal.ts | 58 +++++++++ packages/ptywright/src/test/session.test.ts | 66 +++++++++- packages/ptywright/src/test/terminal.test.ts | 22 ++++ 10 files changed, 390 insertions(+), 6 deletions(-) diff --git a/packages/ptywright/README.md b/packages/ptywright/README.md index 2dd76322..5e065b76 100644 --- a/packages/ptywright/README.md +++ b/packages/ptywright/README.md @@ -304,7 +304,7 @@ Writes text followed by `Enter`. ##### `session.press(key)` -Writes a key sequence, typically one of the exported key constants. +Writes a raw key sequence. The exported `Key*` constants are those raw strings and stay pass-through. ```ts import { KeyArrowDown, KeyEnter } from "@onkernel/ptywright"; @@ -313,6 +313,16 @@ session.press(KeyArrowDown); session.press(KeyEnter); ``` +##### `session.pressKey(key)` + +Encodes a `SpecialKey` from the live terminal modes (for example DECCKM application cursor keys) and writes those bytes. + +```ts +import { SpecialArrowUp } from "@onkernel/ptywright"; + +session.pressKey(SpecialArrowUp); +``` + #### Lifecycle and snapshots ##### `session.resize(cols, rows)` @@ -438,6 +448,8 @@ The package exports common terminal key sequences as strings: - `KeyArrowLeft` - `KeyArrowRight` +Those `Key*` values are raw bytes for `press()` / `send()`. For mode-aware arrows and the other specials, use `SpecialArrowUp` and `session.pressKey(...)`. + 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..a99f9f4b 100644 --- a/packages/ptywright/native/src/addon.cc +++ b/packages/ptywright/native/src/addon.cc @@ -175,6 +175,40 @@ 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); + ptywright_ghostty_free_bytes(bytes); + 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 +245,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/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..1f4bfdd7 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; @@ -66,14 +67,14 @@ export class PtySession { if (replyBytes && replyBytes.length > 0) { this.pty.write(Buffer.from(replyBytes).toString("utf8")); } - 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(); }); } @@ -91,6 +92,12 @@ export class PtySession { this.send(key); } + pressKey(key: SpecialKey): void { + this.ensureOpen(); + const bytes = this.terminal.encodeSpecialKey(key); + this.pty.write(Buffer.from(bytes).toString("latin1")); + } + resize(cols: number, rows: number): void { this.ensureOpen(); if (cols <= 0 || rows <= 0) { @@ -98,7 +105,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 +143,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,6 +151,9 @@ export class PtySession { if (this.exitedAt) { throw this.buildWaitError(description, snapshot, new Error("process exited before condition was satisfied")); } + if (this.revision !== seen) { + continue; + } await waitForUpdate(this.events, controller.signal); } } catch (error) { @@ -166,6 +177,7 @@ export class PtySession { try { while (true) { + const seen = this.revision; const snapshot = this.snapshot(); if (snapshot.visible !== lastVisible) { lastVisible = snapshot.visible; @@ -177,6 +189,9 @@ export class PtySession { if (this.exitedAt) { return snapshot; } + if (this.revision !== seen) { + continue; + } await waitForUpdate(this.events, controller.signal, stableForMs); } } catch (error) { @@ -197,6 +212,13 @@ export class PtySession { const controller = createWaitController(options); try { while (!this.exitedAt) { + const seen = this.revision; + if (this.exitedAt) { + break; + } + if (this.revision !== seen) { + continue; + } await waitForUpdate(this.events, controller.signal); } return this.status(); @@ -254,6 +276,11 @@ export class PtySession { // Best-effort teardown only. } this.terminal.dispose(); + this.noteUpdate(); + } + + private noteUpdate(): void { + this.revision += 1; this.events.emit("update"); } diff --git a/packages/ptywright/src/terminal.ts b/packages/ptywright/src/terminal.ts index 1ece2a5d..c48e64bd 100644 --- a/packages/ptywright/src/terminal.ts +++ b/packages/ptywright/src/terminal.ts @@ -1,3 +1,20 @@ +import { + KeyArrowDown, + KeyArrowLeft, + KeyArrowRight, + KeyArrowUp, + KeyBacktab, + KeyDelete, + KeyEnd, + KeyEscape, + KeyHome, + KeyInsert, + KeyPageDown, + KeyPageUp, + SPECIAL_KEY_KIND, + type SpecialKey, + type SpecialKeyName, +} from "./keys"; import { loadNativeBinding, type NativeSnapshot, type NativeTerminalHandle } from "./native-loader"; export interface CreateTerminalOptions { @@ -66,6 +83,18 @@ 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"); + } + const encoded = this.native.encodeSpecialKey(key.name); + if (encoded && encoded.length > 0) { + return encoded; + } + return Buffer.from(legacySpecialKey(key.name), "latin1"); + } + dispose(): void { if (this.disposed) { return; @@ -99,6 +128,35 @@ function normalizeSnapshot(snapshot: NativeSnapshot): TerminalSnapshot { }; } +function legacySpecialKey(name: SpecialKeyName): string { + switch (name) { + case "arrow_up": + return KeyArrowUp; + case "arrow_down": + return KeyArrowDown; + case "arrow_left": + return KeyArrowLeft; + case "arrow_right": + return KeyArrowRight; + case "home": + return KeyHome; + case "end": + return KeyEnd; + case "page_up": + return KeyPageUp; + case "page_down": + return KeyPageDown; + case "insert": + return KeyInsert; + case "delete": + return KeyDelete; + case "escape": + return KeyEscape; + case "backtab": + return KeyBacktab; + } +} + function splitLines(text: string): string[] { if (!text) { return []; diff --git a/packages/ptywright/src/test/session.test.ts b/packages/ptywright/src/test/session.test.ts index 7e7bc67b..7c93801d 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("pressKey encodes 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.pressKey(SpecialArrowUp); + await session.waitForTranscript("ready-norm", { timeoutMs: 5_000 }); + session.pressKey(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 { From 84b45c5fb4315039f440336dcebc22b59ccbc250 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:09:48 +0000 Subject: [PATCH 2/3] Make ptywright wait revisions span listener registration Recheck the session revision after once("update") so a PTY update between snapshot and sleep cannot be missed. Drop the silent CSI fallback, write encoded bytes through one helper, and run ptywright tests in CI. --- .github/workflows/ci.yml | 2 + packages/ptywright/native/src/addon.cc | 1 - packages/ptywright/package.json | 2 +- packages/ptywright/src/session.ts | 42 +++++++++++--------- packages/ptywright/src/terminal.ts | 53 +------------------------- 5 files changed, 28 insertions(+), 72 deletions(-) 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/native/src/addon.cc b/packages/ptywright/native/src/addon.cc index a99f9f4b..86b537f4 100644 --- a/packages/ptywright/native/src/addon.cc +++ b/packages/ptywright/native/src/addon.cc @@ -196,7 +196,6 @@ Napi::Value EncodeSpecialKey(const Napi::CallbackInfo &info) { &len); if (result != 0) { ThrowGhosttyError(env, "ptywright_ghostty_terminal_encode_special_key", result); - ptywright_ghostty_free_bytes(bytes); return env.Undefined(); } if (bytes == nullptr || len == 0) { 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/session.ts b/packages/ptywright/src/session.ts index 1f4bfdd7..712d57f1 100644 --- a/packages/ptywright/src/session.ts +++ b/packages/ptywright/src/session.ts @@ -65,7 +65,7 @@ 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.noteUpdate(); }); @@ -94,8 +94,7 @@ export class PtySession { pressKey(key: SpecialKey): void { this.ensureOpen(); - const bytes = this.terminal.encodeSpecialKey(key); - this.pty.write(Buffer.from(bytes).toString("latin1")); + this.writeBytes(this.terminal.encodeSpecialKey(key)); } resize(cols: number, rows: number): void { @@ -151,10 +150,7 @@ export class PtySession { if (this.exitedAt) { throw this.buildWaitError(description, snapshot, new Error("process exited before condition was satisfied")); } - if (this.revision !== seen) { - continue; - } - await waitForUpdate(this.events, controller.signal); + await waitForUpdate(this.events, controller.signal, undefined, () => this.revision !== seen); } } catch (error) { if (controller.signal.aborted) { @@ -189,10 +185,7 @@ export class PtySession { if (this.exitedAt) { return snapshot; } - if (this.revision !== seen) { - continue; - } - await waitForUpdate(this.events, controller.signal, stableForMs); + await waitForUpdate(this.events, controller.signal, stableForMs, () => this.revision !== seen); } } catch (error) { if (controller.signal.aborted) { @@ -213,13 +206,7 @@ export class PtySession { try { while (!this.exitedAt) { const seen = this.revision; - if (this.exitedAt) { - break; - } - if (this.revision !== seen) { - continue; - } - await waitForUpdate(this.events, controller.signal); + await waitForUpdate(this.events, controller.signal, undefined, () => this.revision !== seen || Boolean(this.exitedAt)); } return this.status(); } catch (error) { @@ -284,6 +271,13 @@ export class PtySession { 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"); @@ -356,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); } @@ -381,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 c48e64bd..1a5c70ab 100644 --- a/packages/ptywright/src/terminal.ts +++ b/packages/ptywright/src/terminal.ts @@ -1,20 +1,4 @@ -import { - KeyArrowDown, - KeyArrowLeft, - KeyArrowRight, - KeyArrowUp, - KeyBacktab, - KeyDelete, - KeyEnd, - KeyEscape, - KeyHome, - KeyInsert, - KeyPageDown, - KeyPageUp, - SPECIAL_KEY_KIND, - type SpecialKey, - type SpecialKeyName, -} from "./keys"; +import { SPECIAL_KEY_KIND, type SpecialKey } from "./keys"; import { loadNativeBinding, type NativeSnapshot, type NativeTerminalHandle } from "./native-loader"; export interface CreateTerminalOptions { @@ -88,11 +72,7 @@ export class TerminalSurface { if (key.kind !== SPECIAL_KEY_KIND) { throw new Error("encodeSpecialKey expects a SpecialKey"); } - const encoded = this.native.encodeSpecialKey(key.name); - if (encoded && encoded.length > 0) { - return encoded; - } - return Buffer.from(legacySpecialKey(key.name), "latin1"); + return this.native.encodeSpecialKey(key.name) ?? new Uint8Array(); } dispose(): void { @@ -128,35 +108,6 @@ function normalizeSnapshot(snapshot: NativeSnapshot): TerminalSnapshot { }; } -function legacySpecialKey(name: SpecialKeyName): string { - switch (name) { - case "arrow_up": - return KeyArrowUp; - case "arrow_down": - return KeyArrowDown; - case "arrow_left": - return KeyArrowLeft; - case "arrow_right": - return KeyArrowRight; - case "home": - return KeyHome; - case "end": - return KeyEnd; - case "page_up": - return KeyPageUp; - case "page_down": - return KeyPageDown; - case "insert": - return KeyInsert; - case "delete": - return KeyDelete; - case "escape": - return KeyEscape; - case "backtab": - return KeyBacktab; - } -} - function splitLines(text: string): string[] { if (!text) { return []; From 0a267ac2d705a1256d7356a21a0f6982579a13d3 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:23:54 +0000 Subject: [PATCH 3/3] Accept SpecialKey on press and drop pressKey One input method: strings stay raw, SpecialKey values encode from live terminal modes. --- packages/ptywright/README.md | 18 ++++-------------- packages/ptywright/src/session.ts | 10 +++++----- packages/ptywright/src/test/session.test.ts | 6 +++--- 3 files changed, 12 insertions(+), 22 deletions(-) diff --git a/packages/ptywright/README.md b/packages/ptywright/README.md index 5e065b76..f57f6bea 100644 --- a/packages/ptywright/README.md +++ b/packages/ptywright/README.md @@ -304,23 +304,13 @@ Writes text followed by `Enter`. ##### `session.press(key)` -Writes a raw key sequence. The exported `Key*` constants are those raw strings and stay pass-through. +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.pressKey(key)` - -Encodes a `SpecialKey` from the live terminal modes (for example DECCKM application cursor keys) and writes those bytes. - -```ts -import { SpecialArrowUp } from "@onkernel/ptywright"; - -session.pressKey(SpecialArrowUp); +session.press(SpecialArrowUp); ``` #### Lifecycle and snapshots @@ -448,7 +438,7 @@ The package exports common terminal key sequences as strings: - `KeyArrowLeft` - `KeyArrowRight` -Those `Key*` values are raw bytes for `press()` / `send()`. For mode-aware arrows and the other specials, use `SpecialArrowUp` and `session.pressKey(...)`. +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(...)`. diff --git a/packages/ptywright/src/session.ts b/packages/ptywright/src/session.ts index 712d57f1..d71537ed 100644 --- a/packages/ptywright/src/session.ts +++ b/packages/ptywright/src/session.ts @@ -88,12 +88,12 @@ export class PtySession { this.press(KeyEnter); } - press(key: Key): void { - this.send(key); - } - - pressKey(key: SpecialKey): void { + press(key: Key | SpecialKey): void { this.ensureOpen(); + if (typeof key === "string") { + this.send(key); + return; + } this.writeBytes(this.terminal.encodeSpecialKey(key)); } diff --git a/packages/ptywright/src/test/session.test.ts b/packages/ptywright/src/test/session.test.ts index 7c93801d..a11a6e15 100644 --- a/packages/ptywright/src/test/session.test.ts +++ b/packages/ptywright/src/test/session.test.ts @@ -69,7 +69,7 @@ test("press leaves raw key strings unchanged", async (t) => { assert.match(session.snapshot().transcript, /raw:1b5b41/); }); -test("pressKey encodes arrows from live DECCKM state", async (t) => { +test("press encodes SpecialKey arrows from live DECCKM state", async (t) => { const script = [ "import os, sys, termios, tty", "fd = sys.stdin.fileno()", @@ -96,9 +96,9 @@ test("pressKey encodes arrows from live DECCKM state", async (t) => { t.after(() => session.close()); await session.waitForVisible("ready-app", { timeoutMs: 5_000 }); - session.pressKey(SpecialArrowUp); + session.press(SpecialArrowUp); await session.waitForTranscript("ready-norm", { timeoutMs: 5_000 }); - session.pressKey(SpecialArrowUp); + session.press(SpecialArrowUp); await session.waitForTranscript("norm:", { timeoutMs: 5_000 }); const transcript = session.snapshot().transcript;