@cueloop/client 0.1.0-alpha.42 → 0.1.0-alpha.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/native/build-pty.sh +51 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/package.json +5 -6
- package/src/App.plan-review.test.tsx +56 -16
- package/src/components/ReviewRail.tsx +1 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +146 -146
- package/src/components/agent-launcher.stories.tsx +1 -1
- package/src/components/agent-launcher.test.tsx +32 -4
- package/src/components/agent-launcher.tsx +20 -53
- package/src/components/primitives/Button.tsx +4 -1
- package/src/components/terminal-pane.ts +15 -8
- package/src/key-bindings.ts +6 -0
- package/src/pty.test.ts +78 -0
- package/src/pty.ts +222 -0
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* An OpenTUI renderable that runs a real child process (a shell, cc, pi, codex)
|
|
3
3
|
* on a PTY and paints its live screen into the box - the terminal-in-the-rail
|
|
4
|
-
* primitive. It wires
|
|
4
|
+
* primitive. It wires the forkpty shim (child + tty, ../pty) to a Ghostty VT emulator
|
|
5
5
|
* (ghostty-terminal.ts) and blits the emulator's cell grid every frame via
|
|
6
6
|
* OptimizedBuffer.setCell. Register once with `registerTerminalPane`, then use
|
|
7
|
-
* `<terminalPane command="
|
|
7
|
+
* `<terminalPane command="claude" ... />` in the OpenTUI React tree.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { Renderable, RGBA, createTextAttributes, type RenderContext } from "@opentui/core";
|
|
11
11
|
import type { OptimizedBuffer } from "@opentui/core";
|
|
12
12
|
import type { RenderableOptions } from "@opentui/core";
|
|
13
13
|
import { extend } from "@opentui/react";
|
|
14
|
-
import { spawn, type IPty } from "
|
|
14
|
+
import { spawn, ptyAvailable, type IPty } from "../pty";
|
|
15
15
|
import {
|
|
16
16
|
loadGhosttyTerminals,
|
|
17
17
|
type GhosttyColor,
|
|
@@ -26,14 +26,15 @@ function factory(): GhosttyTerminalFactory | null {
|
|
|
26
26
|
return ghosttyFactory;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/** Whether the embedded terminal can run here
|
|
29
|
+
/** Whether the embedded terminal can run here: both native shims ship for this
|
|
30
|
+
* platform (the Ghostty VT renderer and the forkpty PTY). */
|
|
30
31
|
export function embeddedTerminalAvailable(): boolean {
|
|
31
|
-
return factory() !== null;
|
|
32
|
+
return factory() !== null && ptyAvailable();
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/** Props for `<terminalPane>`: the child to run plus its cwd/env and a plan-context seed. */
|
|
35
36
|
export interface TerminalPaneOptions extends RenderableOptions {
|
|
36
|
-
/** The program to run, e.g. "
|
|
37
|
+
/** The program to run, e.g. "claude" / "pi" / "codex" / a shell. */
|
|
37
38
|
command?: string;
|
|
38
39
|
args?: string[];
|
|
39
40
|
cwd?: string;
|
|
@@ -85,7 +86,7 @@ export class TerminalPaneRenderable extends Renderable {
|
|
|
85
86
|
env: (this.opts.env ?? process.env) as Record<string, string>,
|
|
86
87
|
});
|
|
87
88
|
this.pty.onData((data) => {
|
|
88
|
-
//
|
|
89
|
+
// the shim streams a UTF-8-decoded string (split multibyte is handled); a
|
|
89
90
|
// rare non-UTF-8 byte arrives as U+FFFD - acceptable for agent TUIs.
|
|
90
91
|
this.vt?.write(encoder.encode(data));
|
|
91
92
|
if (this.pendingSeed !== undefined) {
|
|
@@ -149,11 +150,17 @@ export class TerminalPaneRenderable extends Renderable {
|
|
|
149
150
|
}
|
|
150
151
|
}
|
|
151
152
|
|
|
152
|
-
|
|
153
|
+
/** Kill the child and free the VT. Idempotent - the detach path calls this
|
|
154
|
+
* directly because the React reconciler removes a child without destroying it. */
|
|
155
|
+
shutdown(): void {
|
|
153
156
|
this.pty?.kill();
|
|
154
157
|
this.vt?.free();
|
|
155
158
|
this.pty = null;
|
|
156
159
|
this.vt = null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
protected destroySelf(): void {
|
|
163
|
+
this.shutdown();
|
|
157
164
|
super.destroySelf();
|
|
158
165
|
}
|
|
159
166
|
}
|
package/src/key-bindings.ts
CHANGED
|
@@ -34,6 +34,9 @@ export type HintMode =
|
|
|
34
34
|
| "walk"
|
|
35
35
|
| "read-only";
|
|
36
36
|
|
|
37
|
+
/** Display glyph for the Agent-tab terminal detach chord (ctrl+], App-owned). */
|
|
38
|
+
export const AGENT_DETACH_HINT = "⌃]";
|
|
39
|
+
|
|
37
40
|
export interface CheatsheetEntry {
|
|
38
41
|
keys: string;
|
|
39
42
|
label: string;
|
|
@@ -417,6 +420,9 @@ export class KeyBindings {
|
|
|
417
420
|
build({ overlay: "none", spanMode: true }, "span", "Selection"),
|
|
418
421
|
build({ overlay: "submit", spanMode: false }, "submit", "Submit"),
|
|
419
422
|
build({ overlay: "walk", spanMode: false }, "walk", "Walk"),
|
|
423
|
+
// The Agent-tab terminal chord is owned by App (not the rebindable keymap),
|
|
424
|
+
// so it is listed statically rather than resolved from the active keys.
|
|
425
|
+
{ title: "Agent terminal", entries: [{ keys: AGENT_DETACH_HINT, label: "detach" }] },
|
|
420
426
|
];
|
|
421
427
|
this.context = saved;
|
|
422
428
|
return sections.filter((section) => section.entries.length > 0);
|
package/src/pty.test.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ptyAvailable, spawn } from "./pty";
|
|
3
|
+
|
|
4
|
+
// The forkpty shim is a per-platform prebuilt; skip where none ships (e.g. CI
|
|
5
|
+
// without the built dylib), exactly as the caller degrades to a herdr split.
|
|
6
|
+
const ptyTest = ptyAvailable() ? test : test.skip;
|
|
7
|
+
|
|
8
|
+
/** Run a child on a PTY, collect its output, and resolve with the exit code. */
|
|
9
|
+
function runOnPty(file: string, args: string[]): Promise<{ output: string; exitCode: number }> {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const pty = spawn(file, args, { name: "xterm-256color", cols: 80, rows: 24 });
|
|
12
|
+
let output = "";
|
|
13
|
+
const timeout = setTimeout(() => reject(new Error("pty child never exited")), 5000);
|
|
14
|
+
pty.onData((chunk) => {
|
|
15
|
+
output += chunk;
|
|
16
|
+
});
|
|
17
|
+
pty.onExit(({ exitCode }) => {
|
|
18
|
+
clearTimeout(timeout);
|
|
19
|
+
resolve({ output, exitCode });
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("pty", () => {
|
|
25
|
+
ptyTest("streams a child's output and reports its exit code", async () => {
|
|
26
|
+
// Act
|
|
27
|
+
const { output, exitCode } = await runOnPty("sh", ["-c", "printf 'PTY-OK'; exit 7"]);
|
|
28
|
+
|
|
29
|
+
// Assert
|
|
30
|
+
expect(output).toContain("PTY-OK");
|
|
31
|
+
expect(exitCode).toBe(7);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
ptyTest("forwards written input to the child", async () => {
|
|
35
|
+
// Arrange - `cat` echoes stdin back through the tty until it closes
|
|
36
|
+
const pty = spawn("cat", [], { name: "xterm-256color", cols: 80, rows: 24 });
|
|
37
|
+
let output = "";
|
|
38
|
+
pty.onData((chunk) => {
|
|
39
|
+
output += chunk;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Act
|
|
43
|
+
pty.write("ping\n");
|
|
44
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
45
|
+
pty.kill();
|
|
46
|
+
|
|
47
|
+
// Assert - the tty echoes the written line back
|
|
48
|
+
expect(output).toContain("ping");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
ptyTest("passes env through to the child", async () => {
|
|
52
|
+
// Act
|
|
53
|
+
const { output } = await runOnPty("sh", ["-c", "printf '%s' \"$CUELOOP_PTY_MARKER\""]);
|
|
54
|
+
|
|
55
|
+
// Assert - a child with no inherited marker prints nothing for it
|
|
56
|
+
expect(output).not.toContain("marker-value");
|
|
57
|
+
|
|
58
|
+
// Act - now inject the marker via env
|
|
59
|
+
const withEnv = await new Promise<string>((resolve, reject) => {
|
|
60
|
+
const pty = spawn("sh", ["-c", "printf '%s' \"$CUELOOP_PTY_MARKER\""], {
|
|
61
|
+
name: "xterm-256color",
|
|
62
|
+
env: { ...process.env, CUELOOP_PTY_MARKER: "marker-value" },
|
|
63
|
+
});
|
|
64
|
+
let collected = "";
|
|
65
|
+
const timeout = setTimeout(() => reject(new Error("no exit")), 5000);
|
|
66
|
+
pty.onData((chunk) => {
|
|
67
|
+
collected += chunk;
|
|
68
|
+
});
|
|
69
|
+
pty.onExit(() => {
|
|
70
|
+
clearTimeout(timeout);
|
|
71
|
+
resolve(collected);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Assert
|
|
76
|
+
expect(withEnv).toContain("marker-value");
|
|
77
|
+
});
|
|
78
|
+
});
|
package/src/pty.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pseudo-terminal for the embedded Agent-tab terminal, over cueloop's own
|
|
3
|
+
* forkpty(3) FFI shim (native/src/pty.zig). `spawn` returns a `Pty` with
|
|
4
|
+
* `onData`/`onExit` events and `write`/`resize`/`kill`; a poll loop drains the
|
|
5
|
+
* child's output and decodes it as streaming UTF-8. `ptyAvailable()` returns
|
|
6
|
+
* false when no prebuilt shim ships for this platform, so callers fall back to a
|
|
7
|
+
* herdr split, exactly like the Ghostty VT loader.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { dlopen, FFIType, ptr } from "bun:ffi";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_COLS = 80;
|
|
15
|
+
const DEFAULT_ROWS = 24;
|
|
16
|
+
/** How long the read loop sleeps when the child produced no output this tick. */
|
|
17
|
+
const READ_IDLE_MS = 8;
|
|
18
|
+
const READ_BUFFER_BYTES = 4096;
|
|
19
|
+
/** cueloop_pty_read's sentinel: the child has exited and its output is drained. */
|
|
20
|
+
const CHILD_EXITED = -2;
|
|
21
|
+
|
|
22
|
+
export interface PtyForkOptions {
|
|
23
|
+
name?: string;
|
|
24
|
+
cols?: number;
|
|
25
|
+
rows?: number;
|
|
26
|
+
cwd?: string;
|
|
27
|
+
env?: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ExitEvent {
|
|
31
|
+
exitCode: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Disposable {
|
|
35
|
+
dispose(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The pseudo-terminal surface the embedded terminal consumes. */
|
|
39
|
+
export interface IPty {
|
|
40
|
+
readonly pid: number;
|
|
41
|
+
readonly cols: number;
|
|
42
|
+
readonly rows: number;
|
|
43
|
+
readonly onData: (listener: (data: string) => void) => Disposable;
|
|
44
|
+
readonly onExit: (listener: (event: ExitEvent) => void) => Disposable;
|
|
45
|
+
write(data: string): void;
|
|
46
|
+
resize(cols: number, rows: number): void;
|
|
47
|
+
kill(signal?: string): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const PTY_SYMBOLS = {
|
|
51
|
+
cueloop_pty_spawn: {
|
|
52
|
+
args: [FFIType.ptr, FFIType.cstring, FFIType.ptr, FFIType.i32, FFIType.i32],
|
|
53
|
+
returns: FFIType.i32,
|
|
54
|
+
},
|
|
55
|
+
cueloop_pty_write: { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
56
|
+
cueloop_pty_read: { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
57
|
+
cueloop_pty_resize: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
58
|
+
cueloop_pty_kill: { args: [FFIType.i32], returns: FFIType.i32 },
|
|
59
|
+
cueloop_pty_get_pid: { args: [FFIType.i32], returns: FFIType.i32 },
|
|
60
|
+
cueloop_pty_get_exit_code: { args: [FFIType.i32], returns: FFIType.i32 },
|
|
61
|
+
cueloop_pty_close: { args: [FFIType.i32], returns: FFIType.void },
|
|
62
|
+
} as const;
|
|
63
|
+
|
|
64
|
+
type PtyLib = ReturnType<typeof dlopen<typeof PTY_SYMBOLS>>["symbols"];
|
|
65
|
+
|
|
66
|
+
/** The prebuilt pty shim for this platform, or null when none ships for it. */
|
|
67
|
+
function nativeLibraryPath(): string | null {
|
|
68
|
+
const suffix = process.platform === "darwin" ? "dylib" : "so";
|
|
69
|
+
const dir = `${process.platform}-${process.arch}`;
|
|
70
|
+
const path = join(import.meta.dir, "..", "native", dir, `libcuelooppty.${suffix}`);
|
|
71
|
+
return existsSync(path) ? path : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One shared load of the pty shim; null when no dylib ships for the platform. */
|
|
75
|
+
let ptyLib: PtyLib | null | undefined;
|
|
76
|
+
function library(): PtyLib | null {
|
|
77
|
+
if (ptyLib !== undefined) return ptyLib;
|
|
78
|
+
const path = nativeLibraryPath();
|
|
79
|
+
if (!path) return (ptyLib = null);
|
|
80
|
+
try {
|
|
81
|
+
ptyLib = dlopen(path, PTY_SYMBOLS).symbols;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.error(`cueloop: failed to load ${path}:`, error);
|
|
84
|
+
ptyLib = null;
|
|
85
|
+
}
|
|
86
|
+
return ptyLib;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True when this platform ships a prebuilt pty shim (embedding is possible). */
|
|
90
|
+
export function ptyAvailable(): boolean {
|
|
91
|
+
return library() !== null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
class EventEmitter<T> {
|
|
95
|
+
private listeners: ((value: T) => void)[] = [];
|
|
96
|
+
readonly event = (listener: (value: T) => void): Disposable => {
|
|
97
|
+
this.listeners.push(listener);
|
|
98
|
+
return {
|
|
99
|
+
dispose: () => {
|
|
100
|
+
const index = this.listeners.indexOf(listener);
|
|
101
|
+
if (index !== -1) this.listeners.splice(index, 1);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
fire(value: T): void {
|
|
106
|
+
for (const listener of this.listeners) listener(value);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Pack tokens into the C shim's "tok\0tok\0...\0\0" (double-NUL-terminated) form. */
|
|
111
|
+
function packTokens(tokens: string[]): Buffer {
|
|
112
|
+
return Buffer.from(tokens.map((token) => `${token}\0`).join("") + "\0", "utf8");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
class Pty implements IPty {
|
|
116
|
+
private handle: number;
|
|
117
|
+
private processId: number;
|
|
118
|
+
private columns: number;
|
|
119
|
+
private rowCount: number;
|
|
120
|
+
private closing = false;
|
|
121
|
+
private reading = false;
|
|
122
|
+
private readonly decoder = new TextDecoder("utf-8");
|
|
123
|
+
private readonly dataEvent = new EventEmitter<string>();
|
|
124
|
+
private readonly exitEvent = new EventEmitter<ExitEvent>();
|
|
125
|
+
|
|
126
|
+
constructor(
|
|
127
|
+
private readonly lib: PtyLib,
|
|
128
|
+
file: string,
|
|
129
|
+
args: string[],
|
|
130
|
+
options: PtyForkOptions,
|
|
131
|
+
) {
|
|
132
|
+
this.columns = options.cols ?? DEFAULT_COLS;
|
|
133
|
+
this.rowCount = options.rows ?? DEFAULT_ROWS;
|
|
134
|
+
const cwd = options.cwd ?? process.cwd();
|
|
135
|
+
const env = options.env
|
|
136
|
+
? Object.entries(options.env).map(([key, value]) => `${key}=${value}`)
|
|
137
|
+
: [];
|
|
138
|
+
|
|
139
|
+
const argvPacked = packTokens([file, ...args]);
|
|
140
|
+
const envPacked = packTokens(env);
|
|
141
|
+
this.handle = lib.cueloop_pty_spawn(
|
|
142
|
+
ptr(argvPacked),
|
|
143
|
+
Buffer.from(`${cwd}\0`, "utf8"),
|
|
144
|
+
ptr(envPacked),
|
|
145
|
+
this.columns,
|
|
146
|
+
this.rowCount,
|
|
147
|
+
);
|
|
148
|
+
if (this.handle < 0) throw new Error("PTY spawn failed");
|
|
149
|
+
this.processId = lib.cueloop_pty_get_pid(this.handle);
|
|
150
|
+
// Let the caller attach onData/onExit before the first bytes arrive.
|
|
151
|
+
queueMicrotask(() => void this.readLoop());
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
get pid(): number {
|
|
155
|
+
return this.processId;
|
|
156
|
+
}
|
|
157
|
+
get cols(): number {
|
|
158
|
+
return this.columns;
|
|
159
|
+
}
|
|
160
|
+
get rows(): number {
|
|
161
|
+
return this.rowCount;
|
|
162
|
+
}
|
|
163
|
+
get onData() {
|
|
164
|
+
return this.dataEvent.event;
|
|
165
|
+
}
|
|
166
|
+
get onExit() {
|
|
167
|
+
return this.exitEvent.event;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
write(data: string): void {
|
|
171
|
+
if (this.closing) return;
|
|
172
|
+
const buffer = Buffer.from(data, "utf8");
|
|
173
|
+
this.lib.cueloop_pty_write(this.handle, ptr(buffer), buffer.length);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
resize(cols: number, rows: number): void {
|
|
177
|
+
if (this.closing) return;
|
|
178
|
+
this.columns = cols;
|
|
179
|
+
this.rowCount = rows;
|
|
180
|
+
this.lib.cueloop_pty_resize(this.handle, cols, rows);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
kill(): void {
|
|
184
|
+
if (this.closing) return;
|
|
185
|
+
this.closing = true;
|
|
186
|
+
this.lib.cueloop_pty_kill(this.handle);
|
|
187
|
+
this.lib.cueloop_pty_close(this.handle);
|
|
188
|
+
this.exitEvent.fire({ exitCode: 0 });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async readLoop(): Promise<void> {
|
|
192
|
+
if (this.reading) return;
|
|
193
|
+
this.reading = true;
|
|
194
|
+
const buffer = Buffer.allocUnsafe(READ_BUFFER_BYTES);
|
|
195
|
+
while (!this.closing) {
|
|
196
|
+
const count = this.lib.cueloop_pty_read(this.handle, ptr(buffer), buffer.length);
|
|
197
|
+
if (count > 0) {
|
|
198
|
+
// Stream mode buffers a multibyte char split across reads (box-drawing etc.).
|
|
199
|
+
const text = this.decoder.decode(buffer.subarray(0, count), { stream: true });
|
|
200
|
+
if (text) this.dataEvent.fire(text);
|
|
201
|
+
} else if (count === CHILD_EXITED || count < 0) {
|
|
202
|
+
// Both the drained-exit sentinel and a hard read error end the session:
|
|
203
|
+
// flush the decoder, reap for the code, close, and fire the exit once.
|
|
204
|
+
const tail = this.decoder.decode();
|
|
205
|
+
if (tail) this.dataEvent.fire(tail);
|
|
206
|
+
const exitCode = this.lib.cueloop_pty_get_exit_code(this.handle);
|
|
207
|
+
this.lib.cueloop_pty_close(this.handle);
|
|
208
|
+
this.closing = true;
|
|
209
|
+
this.exitEvent.fire({ exitCode });
|
|
210
|
+
} else {
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, READ_IDLE_MS));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Spawn `file` with `args` on a fresh PTY. Throws when no shim ships (gate on ptyAvailable). */
|
|
218
|
+
export function spawn(file: string, args: string[], options: PtyForkOptions = {}): IPty {
|
|
219
|
+
const lib = library();
|
|
220
|
+
if (!lib) throw new Error("cueloop: no pty shim for this platform");
|
|
221
|
+
return new Pty(lib, file, args, options);
|
|
222
|
+
}
|