camouflage-tui 2.1.0-beta.1 → 2.2.2-beta.1

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/README.md CHANGED
@@ -15,9 +15,9 @@ The `postinstall` script downloads a pre-built native binary for your platform (
15
15
  ```js
16
16
  import { mount } from "camouflage-tui";
17
17
 
18
- const cam = await mount();
18
+ const cam = await mount({ ui: "inline" });
19
19
 
20
- cam.send("SessionStarted", {});
20
+ cam.send("SessionStarted", { title: "my-agent", detail: ["model · ~/project"], accent: "orange" });
21
21
  cam.send("UserMessageCreated", { text: "investigate failing test" });
22
22
  cam.send("AssistantStreamStarted", { stream_id: "s1" });
23
23
  cam.send("AssistantTokenDelta", { stream_id: "s1", token: "Looking " });
@@ -51,9 +51,35 @@ interface MountOptions {
51
51
  env?: NodeJS.ProcessEnv; // merged with process.env
52
52
  inheritStderr?: boolean; // default true; false → "stderr" event
53
53
  renderToTerminal?: boolean; // true → stdout goes to terminal, responses on fd 3
54
+ ui?: "inline" | "fullscreen"; // default "fullscreen"; see below
54
55
  }
55
56
  ```
56
57
 
58
+ `ui: "inline"` prints finished output into the terminal's normal scrollback
59
+ and redraws only a small live region (the reply being streamed, the
60
+ spinner, the input box). The transcript stays after exit, text selection
61
+ works normally, and an idle renderer uses no CPU. It draws on the terminal
62
+ (`/dev/tty`) even in the default piped mode, so it works with a plain
63
+ `mount({ ui: "inline" })`.
64
+
65
+ ### Helpers that wait for the user
66
+
67
+ `selectList`, `confirm`, `permission`, `form` and `wizard` send a prompt and
68
+ resolve with the user's answer. If the renderer exits first they resolve
69
+ as cancelled (`permission` resolves `{ choice: "deny" }`), so a host never
70
+ hangs on a dead renderer.
71
+
72
+ ```js
73
+ import { permission } from "camouflage-tui";
74
+
75
+ const { choice } = await permission(cam, {
76
+ request_id: "r1",
77
+ tool: "edit",
78
+ action: "edit src/auth/session.ts",
79
+ diff: { path: "src/auth/session.ts", before, after },
80
+ });
81
+ ```
82
+
57
83
  ### `CamouflageHandle`
58
84
 
59
85
  Extends `EventEmitter`. Send events with `send()`; subscribe to outbound events with `on()`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "camouflage-tui",
3
- "version": "2.1.0-beta.1",
3
+ "version": "2.2.2-beta.1",
4
4
  "description": "High-performance terminal renderer for AI agent applications. A React Ink alternative built for streaming, persistence, and replay.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ // Test harness only: reports the arguments it was started with as one
3
+ // UserInputSubmitted event, so tests can check what mount() passes.
4
+ process.stdout.write(
5
+ JSON.stringify({ event_type: "UserInputSubmitted", payload: { text: process.argv.slice(2).join(" ") } }) + "\n",
6
+ );
7
+ process.stdin.resume();
8
+ process.stdin.on("end", () => process.exit(0));
package/src/binding.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from "node:events";
2
- import type { Event } from "./types.js";
2
+ import type { Event, EventType, PayloadOf, PermissionRequested, TodoItem } from "./types.js";
3
3
 
4
4
  export interface MountOptions {
5
5
  /** Executable name or path. Defaults to "camouflage-tui" (PATH lookup). */
@@ -28,6 +28,11 @@ export interface MountOptions {
28
28
  * compatible programmatic mode where both directions ride on the
29
29
  * pipes the binding manages. */
30
30
  renderToTerminal?: boolean;
31
+ /** Renderer UI. `"inline"` prints finished output into the terminal's
32
+ * normal scrollback and redraws only a small live region at the bottom;
33
+ * it draws on the terminal even in the default piped mode. Default
34
+ * `"fullscreen"` (the v2 alternate-screen UI). */
35
+ ui?: "inline" | "fullscreen";
31
36
  }
32
37
 
33
38
  export interface PermissionResponseEvent {
@@ -188,9 +193,10 @@ export interface ExitEvent {
188
193
 
189
194
  export interface CamouflageHandle extends EventEmitter {
190
195
  /** Send one event INTO the renderer. */
191
- send(event_type: string, payload?: object): boolean;
196
+ /** Send one event to the renderer. The payload is typed per event. */
197
+ send<T extends EventType>(event_type: T, payload?: PayloadOf<T>): boolean;
192
198
  /** Send a pre-built Event object. */
193
- sendEvent(ev: { event_type: string; payload?: object }): boolean;
199
+ sendEvent(ev: Event): boolean;
194
200
  /** Gracefully close: end stdin, wait for child exit, resolve with code. */
195
201
  close(): Promise<number>;
196
202
  /** Force-kill the renderer. */
@@ -226,3 +232,12 @@ export interface CamouflageHandle extends EventEmitter {
226
232
  * await cam.close();
227
233
  */
228
234
  export function mount(opts?: MountOptions): Promise<CamouflageHandle>;
235
+
236
+ /**
237
+ * Ask the user for permission and resolve to their answer. Resolves with
238
+ * `{ choice: "deny" }` if the renderer exits first, so a caller never hangs.
239
+ */
240
+ export function permission(cam: CamouflageHandle, spec: PermissionRequested): Promise<PermissionResponseEvent>;
241
+
242
+ /** Replace the agent's plan checklist. Pass an empty array to clear it. */
243
+ export function tasksSet(cam: CamouflageHandle, todos: TodoItem[]): void;
package/src/binding.js CHANGED
@@ -231,7 +231,7 @@ export async function mount(opts = {}) {
231
231
  defaultArgs = ["--stdin-events", "--responses-fd", "3"];
232
232
  stdio = ["pipe", "inherit", "inherit", "pipe"];
233
233
  } else {
234
- defaultArgs = ["--stdin-events", "--emit-responses"];
234
+ defaultArgs = ["--stdin-events", "--emit-responses=true"];
235
235
  stdio = ["pipe", "pipe", stderrMode];
236
236
  }
237
237
  // Brand the header with the host app's name (explicit > auto-detected).
@@ -245,7 +245,8 @@ export async function mount(opts = {}) {
245
245
  titleArgs.push("--app-title", title);
246
246
  }
247
247
  }
248
- const args = [...defaultArgs, ...titleArgs, ...userArgs];
248
+ const uiArgs = opts.ui && !opts.skipDefaultArgs && !userArgs.includes("--ui") ? ["--ui", opts.ui] : [];
249
+ const args = [...defaultArgs, ...uiArgs, ...titleArgs, ...userArgs];
249
250
 
250
251
  const child = spawn(bin, args, {
251
252
  stdio,
@@ -267,6 +268,13 @@ export async function mount(opts = {}) {
267
268
 
268
269
  const handle = new CamouflageHandle(child, child.stdin);
269
270
 
271
+ // If the renderer exits, writes to its stdin fail with EPIPE. Unhandled,
272
+ // that 'error' event would crash the host process; treat it as closed.
273
+ child.stdin.on("error", (err) => {
274
+ handle._closed = true;
275
+ if (err && err.code !== "EPIPE") handle.emit("invalid", { line: "", error: String(err) });
276
+ });
277
+
270
278
  // Stream outbound events (UserInputSubmitted, PermissionResponse) from
271
279
  // whichever stream the renderer is writing them to. In renderToTerminal
272
280
  // mode that's fd 3 (child.stdio[3]); otherwise it's stdout.
@@ -352,45 +360,82 @@ export async function mount(opts = {}) {
352
360
  return handle;
353
361
  }
354
362
 
363
+ /**
364
+ * Resolve with the first `eventName` response whose id matches, or with
365
+ * `onExit` if the renderer exits first, so callers never hang on a dead
366
+ * renderer.
367
+ */
368
+ function awaitResponse(cam, eventName, matches, onExit) {
369
+ return new Promise((resolve) => {
370
+ if (cam._closed) {
371
+ resolve(onExit);
372
+ return;
373
+ }
374
+ const cleanup = () => {
375
+ cam.off(eventName, listener);
376
+ cam.off("exit", exitListener);
377
+ };
378
+ const listener = (resp) => {
379
+ if (!matches(resp)) return;
380
+ cleanup();
381
+ resolve(resp);
382
+ };
383
+ const exitListener = () => {
384
+ cleanup();
385
+ resolve(onExit);
386
+ };
387
+ cam.on(eventName, listener);
388
+ cam.on("exit", exitListener);
389
+ });
390
+ }
391
+
355
392
  /**
356
393
  * Convenience helper: emit a ShowSelectList and resolve to the user's
357
- * SelectListResponse for that id. Subscribes once, unsubscribes after
358
- * the response arrives.
394
+ * SelectListResponse for that id. Resolves `{ cancelled: true }` if the
395
+ * renderer exits first.
359
396
  *
360
397
  * @param {CamouflageHandle} cam
361
398
  * @param {{id: string, prompt: string, options: object[], default?: string, allow_filter?: boolean, allow_cancel?: boolean}} spec
362
399
  * @returns {Promise<{id: string, value?: string, cancelled: boolean}>}
363
400
  */
364
401
  export function selectList(cam, spec) {
365
- return new Promise((resolve) => {
366
- const listener = (resp) => {
367
- if (resp.id !== spec.id) return;
368
- cam.off("selectListResponse", listener);
369
- resolve(resp);
370
- };
371
- cam.on("selectListResponse", listener);
372
- cam.send("ShowSelectList", spec);
373
- });
402
+ const done = awaitResponse(cam, "selectListResponse", (r) => r.id === spec.id, { id: spec.id, cancelled: true });
403
+ cam.send("ShowSelectList", spec);
404
+ return done;
374
405
  }
375
406
 
376
407
  /**
377
408
  * Convenience helper: emit a ShowConfirm and resolve to the user's
378
- * ConfirmResponse for that id.
409
+ * ConfirmResponse for that id. Resolves `{ cancelled: true }` if the
410
+ * renderer exits first.
379
411
  *
380
412
  * @param {CamouflageHandle} cam
381
413
  * @param {{id: string, prompt: string, yes_label?: string, no_label?: string, default?: "yes"|"no", allow_cancel?: boolean}} spec
382
414
  * @returns {Promise<{id: string, value?: boolean, cancelled: boolean}>}
383
415
  */
384
416
  export function confirm(cam, spec) {
385
- return new Promise((resolve) => {
386
- const listener = (resp) => {
387
- if (resp.id !== spec.id) return;
388
- cam.off("confirmResponse", listener);
389
- resolve(resp);
390
- };
391
- cam.on("confirmResponse", listener);
392
- cam.send("ShowConfirm", spec);
393
- });
417
+ const done = awaitResponse(cam, "confirmResponse", (r) => r.id === spec.id, { id: spec.id, cancelled: true });
418
+ cam.send("ShowConfirm", spec);
419
+ return done;
420
+ }
421
+
422
+ /**
423
+ * Ask the user for permission and resolve to their answer. Resolves
424
+ * `{ choice: "deny" }` if the renderer exits first.
425
+ *
426
+ * @param {CamouflageHandle} cam
427
+ * @param {{request_id: string, tool: string, action: string, detail?: string, diff?: object}} spec
428
+ * @returns {Promise<{request_id: string, choice: "allow_once"|"allow_session"|"deny", feedback?: string}>}
429
+ */
430
+ export function permission(cam, spec) {
431
+ const done = awaitResponse(
432
+ cam,
433
+ "permissionResponse",
434
+ (r) => r.request_id === spec.request_id,
435
+ { request_id: spec.request_id, choice: "deny", feedback: "" },
436
+ );
437
+ cam.send("PermissionRequested", spec);
438
+ return done;
394
439
  }
395
440
 
396
441
  /**
@@ -422,15 +467,9 @@ export function keyValueView(cam, spec) {
422
467
  * @returns {Promise<{id: string, values?: Record<string, string>, cancelled: boolean}>}
423
468
  */
424
469
  export function form(cam, spec) {
425
- return new Promise((resolve) => {
426
- const listener = (resp) => {
427
- if (resp.id !== spec.id) return;
428
- cam.off("formResponse", listener);
429
- resolve(resp);
430
- };
431
- cam.on("formResponse", listener);
432
- cam.send("ShowForm", spec);
433
- });
470
+ const done = awaitResponse(cam, "formResponse", (r) => r.id === spec.id, { id: spec.id, cancelled: true });
471
+ cam.send("ShowForm", spec);
472
+ return done;
434
473
  }
435
474
 
436
475
  /**
@@ -444,25 +483,13 @@ export function form(cam, spec) {
444
483
  * @returns {Promise<{id: string, results?: Record<string, any>, cancelled?: boolean, at_step?: number}>}
445
484
  */
446
485
  export function wizard(cam, spec) {
447
- return new Promise((resolve) => {
448
- const onCompleted = (resp) => {
449
- if (resp.id !== spec.id) return;
450
- cleanup();
451
- resolve(resp);
452
- };
453
- const onCancelled = (resp) => {
454
- if (resp.id !== spec.id) return;
455
- cleanup();
456
- resolve({ id: resp.id, cancelled: true, at_step: resp.at_step });
457
- };
458
- const cleanup = () => {
459
- cam.off("wizardCompleted", onCompleted);
460
- cam.off("wizardCancelled", onCancelled);
461
- };
462
- cam.on("wizardCompleted", onCompleted);
463
- cam.on("wizardCancelled", onCancelled);
464
- cam.send("ShowWizard", spec);
465
- });
486
+ const completed = awaitResponse(cam, "wizardCompleted", (r) => r.id === spec.id, null);
487
+ const cancelled = awaitResponse(cam, "wizardCancelled", (r) => r.id === spec.id, null);
488
+ cam.send("ShowWizard", spec);
489
+ return Promise.race([
490
+ completed.then((r) => r ?? { id: spec.id, cancelled: true, at_step: 0 }),
491
+ cancelled.then((r) => ({ id: spec.id, cancelled: true, at_step: r?.at_step ?? 0 })),
492
+ ]);
466
493
  }
467
494
 
468
495
  /**
@@ -2,7 +2,7 @@ import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { dirname, join } from "node:path";
5
- import { mount, selectList, confirm } from "./index.js";
5
+ import { mount, selectList, confirm, permission } from "./index.js";
6
6
 
7
7
  const FAKE = join(dirname(fileURLToPath(import.meta.url)), "__fake-renderer.js");
8
8
 
@@ -72,10 +72,12 @@ test("permissionResponse convenience event fires", async () => {
72
72
  await cam.close();
73
73
  });
74
74
 
75
- test("send() throws after close()", async () => {
75
+ test("send() after close() is a no-op that returns false", async () => {
76
+ // Hosts send a final StatusUpdate/SessionEnded during cleanup, often after
77
+ // the renderer has exited; throwing there would crash the host (496035a).
76
78
  const cam = await mountFake();
77
79
  await cam.close();
78
- assert.throws(() => cam.send("SessionStarted", {}), /after close/);
80
+ assert.equal(cam.send("SessionStarted", {}), false);
79
81
  });
80
82
 
81
83
  test("close() resolves with the child's exit code", async () => {
@@ -135,3 +137,47 @@ async function waitUntil(predicate, deadlineMs) {
135
137
  await new Promise((r) => setTimeout(r, 10));
136
138
  }
137
139
  }
140
+
141
+ test("helpers settle instead of hanging when the renderer exits", async () => {
142
+ const cam = await mountFake();
143
+ const pending = [
144
+ selectList(cam, { id: "pick", prompt: "Pick", options: [] }),
145
+ confirm(cam, { id: "ok", prompt: "OK?" }),
146
+ permission(cam, { request_id: "r1", tool: "bash", action: "run npm test" }),
147
+ ];
148
+ // The fake echoes inbound events back; kill it before anyone answers.
149
+ cam.kill("SIGKILL");
150
+ const [sel, conf, perm] = await Promise.all(pending);
151
+ assert.deepEqual(sel, { id: "pick", cancelled: true });
152
+ assert.deepEqual(conf, { id: "ok", cancelled: true });
153
+ assert.equal(perm.choice, "deny");
154
+ });
155
+
156
+ test("permission() resolves with the user's answer", async () => {
157
+ const cam = await mountFake();
158
+ const answer = permission(cam, { request_id: "r2", tool: "edit", action: "edit a.ts" });
159
+ // Stand in for the user: the fake echoes this response back to us.
160
+ cam.send("PermissionResponse", { request_id: "r2", choice: "allow_session", feedback: "" });
161
+ assert.equal((await answer).choice, "allow_session");
162
+ await cam.close();
163
+ });
164
+
165
+ test("ui option passes --ui to the renderer", async () => {
166
+ const fakeArgv = join(dirname(fileURLToPath(import.meta.url)), "__fake-argv.js");
167
+ const cam = await mount({ bin: fakeArgv, ui: "inline", appTitle: "demo" });
168
+ const text = await new Promise((resolve) => cam.on("userInput", resolve));
169
+ assert.match(text, /--ui inline/);
170
+ await cam.close();
171
+ });
172
+
173
+ test("a renderer that exits at startup doesn't crash the host", async () => {
174
+ const cam = await mount({ bin: process.execPath, args: ["-e", "process.exit(3)"], skipDefaultArgs: true });
175
+ const exited = new Promise((resolve) => cam.on("exit", resolve));
176
+ // Keep writing while (and after) the child dies; nothing may throw.
177
+ for (let i = 0; i < 50; i++) {
178
+ cam.send("AssistantTokenDelta", { stream_id: "s", token: "x".repeat(1000) });
179
+ await new Promise((r) => setTimeout(r, 5));
180
+ }
181
+ await exited;
182
+ assert.equal(cam.send("SessionEnded", {}), false);
183
+ });
package/src/index.d.ts CHANGED
@@ -59,6 +59,13 @@ export {
59
59
  WizardStepResult,
60
60
  WizardCompleted,
61
61
  WizardCancelled,
62
+ SessionStarted,
63
+ Splash,
64
+ ShowToast,
65
+ DiffPayload,
66
+ TodoItem,
67
+ TodoListUpdate,
68
+ PayloadOf,
62
69
  // Tagged union
63
70
  Event,
64
71
  // Helpers
@@ -71,6 +78,8 @@ export {
71
78
  mount,
72
79
  selectList,
73
80
  confirm,
81
+ permission,
82
+ tasksSet,
74
83
  table,
75
84
  keyValueView,
76
85
  form,
package/src/index.js CHANGED
@@ -15,4 +15,4 @@ export {
15
15
  encode,
16
16
  } from "./types.js";
17
17
 
18
- export { mount, selectList, confirm, table, keyValueView, form, wizard } from "./binding.js";
18
+ export { mount, selectList, confirm, permission, table, keyValueView, form, wizard, tasksSet } from "./binding.js";
package/src/types.d.ts CHANGED
@@ -48,7 +48,10 @@ export type EventType =
48
48
  | "WizardCompleted"
49
49
  | "WizardCancelled"
50
50
  | "ModeChangeRequested"
51
- | "CancelRequested";
51
+ | "CancelRequested"
52
+ | "TranscriptCleared"
53
+ | "Splash"
54
+ | "ShowToast";
52
55
 
53
56
  export type Direction = "inbound" | "outbound";
54
57
 
@@ -65,11 +68,38 @@ export interface EnvelopeMeta {
65
68
  export type UserMessage = { text: string };
66
69
  export type AssistantStreamStarted = { stream_id: string };
67
70
  export type AssistantTokenDelta = { stream_id: string; token: string };
68
- export type AssistantMessageCompleted = { stream_id: string };
71
+ export type AssistantMessageCompleted = {
72
+ stream_id: string;
73
+ /** Optional final text; replaces what was streamed (e.g. after cleanup). */
74
+ text?: string;
75
+ };
69
76
 
70
- export type ToolStarted = { tool_id: string; tool: string; command: string };
77
+ export type ToolStarted = {
78
+ tool_id: string;
79
+ /** Tool name shown in bold, e.g. "Read" or "Bash". */
80
+ tool: string;
81
+ /** Arguments shown after the name, e.g. a path or a command line. */
82
+ command: string;
83
+ /** When the tool started (epoch ms). Defaults to when the event arrives. */
84
+ started_at_ms?: number;
85
+ };
71
86
  export type ToolOutput = { tool_id: string; chunk: string };
72
- export type ToolFinished = { tool_id: string; exit_code: number };
87
+ export type ToolFinished = {
88
+ tool_id: string;
89
+ exit_code: number;
90
+ /** Overrides the status derived from exit_code. */
91
+ status?: "done" | "error" | "cancelled" | "rejected";
92
+ /** One-line result under the tool row, e.g. "142 lines" or "14 passed". */
93
+ summary?: string;
94
+ /** Full output; replaces anything streamed via ToolExecutionStdout. */
95
+ output?: string;
96
+ /** Output lines shown before "ctrl+o to expand". Default 0 (12 on error). */
97
+ preview?: number;
98
+ /** Highlight the output as this language (e.g. "ts" for a written file). */
99
+ output_lang?: string;
100
+ /** Show this diff under the tool row. */
101
+ diff?: DiffPayload;
102
+ };
73
103
 
74
104
  export type PatchProposed = {
75
105
  path: string;
@@ -83,8 +113,11 @@ export type PatchApplied = { path: string };
83
113
  export type PermissionRequested = {
84
114
  request_id: string;
85
115
  tool: string;
116
+ /** Prompt title, e.g. "edit src/auth/session.ts". */
86
117
  action: string;
87
118
  detail?: string;
119
+ /** Diff preview for edits. */
120
+ diff?: DiffPayload;
88
121
  };
89
122
  export type PermissionGranted = { request_id: string };
90
123
  export type PermissionDenied = { request_id: string };
@@ -104,6 +137,14 @@ export type RuntimeError = {
104
137
  cta?: Cta;
105
138
  };
106
139
 
140
+ /**
141
+ * Status segments. Well-known keys: `mode` ("edit" | "plan" | "auto"),
142
+ * `phase` ("thinking" | "streaming" | "tool" | "running" shows the spinner;
143
+ * anything else, e.g. "idle", ends the turn and settles spinners),
144
+ * `activity` (spinner verb, e.g. "Reading files"), `model`, `tokens`,
145
+ * `cost`, `branch`, `warn`. Other keys are shown in the footer. An empty
146
+ * value removes a segment.
147
+ */
107
148
  export type StatusUpdate = {
108
149
  segments: Record<string, string>;
109
150
  };
@@ -261,11 +302,42 @@ export type ModeChangeRequested = {
261
302
  direction: "next" | "prev";
262
303
  };
263
304
 
305
+ /** Optional welcome and branding for the inline renderer. */
306
+ export type SessionStarted = {
307
+ /** First welcome line, shown in the accent color. Defaults to the app title. */
308
+ title?: string;
309
+ /** Dim lines under the title (model, directory, hints). */
310
+ detail?: string[];
311
+ /** Accent color: a terminal color name ("orange", "blue", …) or "#rrggbb". */
312
+ accent?: string;
313
+ /** Your agent's name, used in prompts like "tell <name> what to do". */
314
+ assistant_label?: string;
315
+ user_label?: string;
316
+ };
317
+
318
+ /** Multi-line text (may contain ANSI colors) printed as-is, e.g. a logo. */
319
+ export type Splash = { text: string };
320
+
321
+ /** A short message shown in the footer (inline) or as a toast (full screen). */
322
+ export type ShowToast = {
323
+ text: string;
324
+ kind?: "info" | "warn" | "error" | "success";
325
+ ttl_ms?: number;
326
+ };
327
+
328
+ /** A diff to show inline: full before/after text, or a unified diff. */
329
+ export type DiffPayload = {
330
+ path: string;
331
+ before?: string;
332
+ after?: string;
333
+ unified?: string;
334
+ };
335
+
264
336
  // Tagged union --------------------------------------------------------------
265
337
 
266
338
  export type Event = EnvelopeMeta &
267
339
  (
268
- | { event_type: "SessionStarted"; payload?: Record<string, never> }
340
+ | { event_type: "SessionStarted"; payload?: SessionStarted }
269
341
  | { event_type: "SessionEnded"; payload?: Record<string, never> }
270
342
  | { event_type: "SessionCompacted"; payload: SessionCompacted }
271
343
  | { event_type: "UserMessageCreated"; payload: UserMessage }
@@ -303,8 +375,14 @@ export type Event = EnvelopeMeta &
303
375
  | { event_type: "WizardCancelled"; payload: WizardCancelled }
304
376
  | { event_type: "ModeChangeRequested"; payload: ModeChangeRequested }
305
377
  | { event_type: "CancelRequested"; payload?: Record<string, never> }
378
+ | { event_type: "TranscriptCleared"; payload?: Record<string, never> }
379
+ | { event_type: "Splash"; payload: Splash }
380
+ | { event_type: "ShowToast"; payload: ShowToast }
306
381
  );
307
382
 
383
+ /** The payload type for a given event type, e.g. `PayloadOf<"ShowToast">`. */
384
+ export type PayloadOf<T extends EventType> = Extract<Event, { event_type: T }> extends { payload?: infer P } ? P : never;
385
+
308
386
  // Reader --------------------------------------------------------------------
309
387
 
310
388
  import type { Readable } from "node:stream";
package/src/types.js CHANGED
@@ -13,7 +13,7 @@ const KNOWN_TYPES = new Set([
13
13
  "ToolExecutionStarted", "ToolExecutionStdout", "ToolExecutionStderr", "ToolExecutionFinished",
14
14
  "PatchProposed", "PatchApplied",
15
15
  "PermissionRequested", "PermissionGranted", "PermissionDenied",
16
- "RuntimeError", "StatusUpdate", "BackgroundTaskUpdate", "ViewportMarker",
16
+ "RuntimeError", "StatusUpdate", "BackgroundTaskUpdate", "TodoListUpdate", "ViewportMarker",
17
17
  "UserInputSubmitted", "PermissionResponse",
18
18
  "SlashCommandsRegistered", "MentionCandidatesRegistered",
19
19
  "ShowSelectList", "SelectListResponse",
@@ -22,6 +22,7 @@ const KNOWN_TYPES = new Set([
22
22
  "ShowForm", "FormResponse",
23
23
  "ShowWizard", "WizardCompleted", "WizardCancelled",
24
24
  "ModeChangeRequested", "CancelRequested",
25
+ "TranscriptCleared", "Splash", "ShowToast",
25
26
  ]);
26
27
 
27
28
  /**