Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 5 additions & 3 deletions packages/ptywright/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions packages/ptywright/native/src/addon.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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<Napi::String>().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<uint8_t> output = Napi::Buffer<uint8_t>::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) {
Expand Down Expand Up @@ -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;
}
Expand Down
122 changes: 122 additions & 0 deletions packages/ptywright/native/src/ghostty_bridge.c
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ enum {

struct PtywrightGhosttyTerminal {
GhosttyTerminal handle;
GhosttyKeyEncoder encoder;
uint8_t *reply_buf;
size_t reply_len;
size_t reply_cap;
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions packages/ptywright/native/src/ghostty_bridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/ptywright/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
38 changes: 38 additions & 0 deletions packages/ptywright/src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
1 change: 1 addition & 0 deletions packages/ptywright/src/native-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading
Loading