camouflage-tui 2.0.0-beta.1 → 2.2.1-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.0.0-beta.1",
3
+ "version": "2.2.1-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,24 @@ 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
+ ]);
493
+ }
494
+
495
+ /**
496
+ * Convenience helper: set the agent's todo/plan checklist. Replaces the
497
+ * entire list (last-write-wins); pass an empty array to clear the panel.
498
+ *
499
+ * @param {CamouflageHandle} cam
500
+ * @param {{id: string, title: string, status: "pending"|"in_progress"|"completed", started_at_ms?: number, token_delta?: number, progress?: number}[]} todos
501
+ */
502
+ export function tasksSet(cam, todos) {
503
+ cam.send("TodoListUpdate", { todos });
466
504
  }
467
505
 
468
506
  function spawnError(err, bin) {
@@ -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
@@ -30,6 +30,7 @@ export type EventType =
30
30
  | "RuntimeError"
31
31
  | "StatusUpdate"
32
32
  | "BackgroundTaskUpdate"
33
+ | "TodoListUpdate"
33
34
  | "ViewportMarker"
34
35
  | "UserInputSubmitted"
35
36
  | "PermissionResponse"
@@ -47,7 +48,10 @@ export type EventType =
47
48
  | "WizardCompleted"
48
49
  | "WizardCancelled"
49
50
  | "ModeChangeRequested"
50
- | "CancelRequested";
51
+ | "CancelRequested"
52
+ | "TranscriptCleared"
53
+ | "Splash"
54
+ | "ShowToast";
51
55
 
52
56
  export type Direction = "inbound" | "outbound";
53
57
 
@@ -64,11 +68,38 @@ export interface EnvelopeMeta {
64
68
  export type UserMessage = { text: string };
65
69
  export type AssistantStreamStarted = { stream_id: string };
66
70
  export type AssistantTokenDelta = { stream_id: string; token: string };
67
- 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
+ };
68
76
 
69
- 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
+ };
70
86
  export type ToolOutput = { tool_id: string; chunk: string };
71
- 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
+ };
72
103
 
73
104
  export type PatchProposed = {
74
105
  path: string;
@@ -82,8 +113,11 @@ export type PatchApplied = { path: string };
82
113
  export type PermissionRequested = {
83
114
  request_id: string;
84
115
  tool: string;
116
+ /** Prompt title, e.g. "edit src/auth/session.ts". */
85
117
  action: string;
86
118
  detail?: string;
119
+ /** Diff preview for edits. */
120
+ diff?: DiffPayload;
87
121
  };
88
122
  export type PermissionGranted = { request_id: string };
89
123
  export type PermissionDenied = { request_id: string };
@@ -103,6 +137,14 @@ export type RuntimeError = {
103
137
  cta?: Cta;
104
138
  };
105
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
+ */
106
148
  export type StatusUpdate = {
107
149
  segments: Record<string, string>;
108
150
  };
@@ -115,6 +157,20 @@ export type BackgroundTaskUpdate = {
115
157
  progress?: number;
116
158
  };
117
159
 
160
+ export type TodoStatus = "pending" | "in_progress" | "completed";
161
+ export type TodoItem = {
162
+ id: string;
163
+ title: string;
164
+ status: TodoStatus;
165
+ /** Epoch ms when the task began. Used for elapsed-time ticker. */
166
+ started_at_ms?: number;
167
+ /** Tokens consumed on completion. Shown as `· 1.2k tok`. */
168
+ token_delta?: number;
169
+ /** 0.0..=1.0 for in-progress tasks. Shown as progress bar/percentage. */
170
+ progress?: number;
171
+ };
172
+ export type TodoListUpdate = { todos: TodoItem[] };
173
+
118
174
  export type SessionCompacted = { old_seq?: number; new_seq?: number };
119
175
  export type ViewportMarker = { label?: string };
120
176
 
@@ -246,11 +302,42 @@ export type ModeChangeRequested = {
246
302
  direction: "next" | "prev";
247
303
  };
248
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
+
249
336
  // Tagged union --------------------------------------------------------------
250
337
 
251
338
  export type Event = EnvelopeMeta &
252
339
  (
253
- | { event_type: "SessionStarted"; payload?: Record<string, never> }
340
+ | { event_type: "SessionStarted"; payload?: SessionStarted }
254
341
  | { event_type: "SessionEnded"; payload?: Record<string, never> }
255
342
  | { event_type: "SessionCompacted"; payload: SessionCompacted }
256
343
  | { event_type: "UserMessageCreated"; payload: UserMessage }
@@ -269,6 +356,7 @@ export type Event = EnvelopeMeta &
269
356
  | { event_type: "RuntimeError"; payload: RuntimeError }
270
357
  | { event_type: "StatusUpdate"; payload: StatusUpdate }
271
358
  | { event_type: "BackgroundTaskUpdate"; payload: BackgroundTaskUpdate }
359
+ | { event_type: "TodoListUpdate"; payload: TodoListUpdate }
272
360
  | { event_type: "ViewportMarker"; payload: ViewportMarker }
273
361
  | { event_type: "UserInputSubmitted"; payload: UserInputSubmitted }
274
362
  | { event_type: "PermissionResponse"; payload: PermissionResponse }
@@ -287,8 +375,14 @@ export type Event = EnvelopeMeta &
287
375
  | { event_type: "WizardCancelled"; payload: WizardCancelled }
288
376
  | { event_type: "ModeChangeRequested"; payload: ModeChangeRequested }
289
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 }
290
381
  );
291
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
+
292
386
  // Reader --------------------------------------------------------------------
293
387
 
294
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
  /**