zidane 4.0.2 → 4.1.3

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.
Files changed (77) hide show
  1. package/README.md +196 -614
  2. package/dist/agent-BoV5Twdl.d.ts +2347 -0
  3. package/dist/agent-BoV5Twdl.d.ts.map +1 -0
  4. package/dist/contexts-3Arvn7yR.js +321 -0
  5. package/dist/contexts-3Arvn7yR.js.map +1 -0
  6. package/dist/contexts.d.ts +2 -25
  7. package/dist/contexts.js +2 -10
  8. package/dist/errors-D1lhd6mX.js +118 -0
  9. package/dist/errors-D1lhd6mX.js.map +1 -0
  10. package/dist/index-28otmfLX.d.ts +400 -0
  11. package/dist/index-28otmfLX.d.ts.map +1 -0
  12. package/dist/index-BfSdALzk.d.ts +113 -0
  13. package/dist/index-BfSdALzk.d.ts.map +1 -0
  14. package/dist/index-DPsd0qwm.d.ts +254 -0
  15. package/dist/index-DPsd0qwm.d.ts.map +1 -0
  16. package/dist/index.d.ts +5 -95
  17. package/dist/index.js +141 -271
  18. package/dist/index.js.map +1 -0
  19. package/dist/interpolate-CukJwP2G.js +887 -0
  20. package/dist/interpolate-CukJwP2G.js.map +1 -0
  21. package/dist/mcp-8wClKY-3.js +771 -0
  22. package/dist/mcp-8wClKY-3.js.map +1 -0
  23. package/dist/mcp.d.ts +2 -4
  24. package/dist/mcp.js +2 -13
  25. package/dist/messages-z5Pq20p7.js +1020 -0
  26. package/dist/messages-z5Pq20p7.js.map +1 -0
  27. package/dist/presets-Cs7_CsMk.js +39 -0
  28. package/dist/presets-Cs7_CsMk.js.map +1 -0
  29. package/dist/presets.d.ts +2 -43
  30. package/dist/presets.js +2 -17
  31. package/dist/providers-CX-R-Oy-.js +969 -0
  32. package/dist/providers-CX-R-Oy-.js.map +1 -0
  33. package/dist/providers.d.ts +2 -4
  34. package/dist/providers.js +3 -23
  35. package/dist/session/sqlite.d.ts +7 -12
  36. package/dist/session/sqlite.d.ts.map +1 -0
  37. package/dist/session/sqlite.js +67 -79
  38. package/dist/session/sqlite.js.map +1 -0
  39. package/dist/session-Cn68UASv.js +440 -0
  40. package/dist/session-Cn68UASv.js.map +1 -0
  41. package/dist/session.d.ts +2 -4
  42. package/dist/session.js +3 -27
  43. package/dist/skills.d.ts +3 -322
  44. package/dist/skills.js +24 -47
  45. package/dist/skills.js.map +1 -0
  46. package/dist/stats-DoKUtF5T.js +58 -0
  47. package/dist/stats-DoKUtF5T.js.map +1 -0
  48. package/dist/tools-DpeWKzP1.js +3941 -0
  49. package/dist/tools-DpeWKzP1.js.map +1 -0
  50. package/dist/tools.d.ts +3 -95
  51. package/dist/tools.js +2 -40
  52. package/dist/tui.d.ts +533 -0
  53. package/dist/tui.d.ts.map +1 -0
  54. package/dist/tui.js +2004 -0
  55. package/dist/tui.js.map +1 -0
  56. package/dist/types-Bx_F8jet.js +39 -0
  57. package/dist/types-Bx_F8jet.js.map +1 -0
  58. package/dist/types.d.ts +4 -55
  59. package/dist/types.js +4 -28
  60. package/package.json +38 -4
  61. package/dist/agent-BAHrGtqu.d.ts +0 -2425
  62. package/dist/chunk-4ILGBQ23.js +0 -803
  63. package/dist/chunk-4LPBN547.js +0 -3540
  64. package/dist/chunk-64LLNY7F.js +0 -28
  65. package/dist/chunk-6STZTA4N.js +0 -830
  66. package/dist/chunk-7GQ7P6DM.js +0 -566
  67. package/dist/chunk-IC7FT4OD.js +0 -37
  68. package/dist/chunk-JCOB6IYO.js +0 -22
  69. package/dist/chunk-JH6IAAFA.js +0 -28
  70. package/dist/chunk-LNN5UTS2.js +0 -97
  71. package/dist/chunk-PMCQOMV4.js +0 -490
  72. package/dist/chunk-UD25QF3H.js +0 -304
  73. package/dist/chunk-W57VY6DJ.js +0 -834
  74. package/dist/sandbox-D7v6Wy62.d.ts +0 -28
  75. package/dist/skills-use-DwZrNmcw.d.ts +0 -80
  76. package/dist/types-Bai5rKpa.d.ts +0 -89
  77. package/dist/validation-Pm--dQEU.d.ts +0 -185
package/dist/tui.js ADDED
@@ -0,0 +1,2004 @@
1
+ import { d as createAgent } from "./tools-DpeWKzP1.js";
2
+ import { n as toolResultToText } from "./types-Bx_F8jet.js";
3
+ import { r as basic_default } from "./presets-Cs7_CsMk.js";
4
+ import { i as anthropic, n as openai, r as cerebras, t as openrouter } from "./providers-CX-R-Oy-.js";
5
+ import { n as loadSession, t as createSession } from "./session-Cn68UASv.js";
6
+ import { createSqliteStore } from "./session/sqlite.js";
7
+ import { dirname, resolve } from "node:path";
8
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { getModel, getModels } from "@mariozechner/pi-ai";
11
+ import { RGBA, SyntaxStyle, createCliRenderer, defaultTextareaKeyBindings } from "@opentui/core";
12
+ import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
13
+ import { heal, init } from "md4x/wasm";
14
+ import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
15
+ import { jsx, jsxs } from "@opentui/react/jsx-runtime";
16
+ //#region src/tui/format.ts
17
+ /** Compact token formatter — 12_415 → "12.4k", 1_234_567 → "1.23M". */
18
+ function fmtTokens(n) {
19
+ if (n < 1e3) return String(n);
20
+ if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 2 : 1)}k`;
21
+ return `${(n / 1e6).toFixed(2)}M`;
22
+ }
23
+ /** Compact relative-time formatter — "just now / 5m / 3h / 2d". */
24
+ function ageString(ts, now = Date.now()) {
25
+ const m = Math.floor((now - ts) / 6e4);
26
+ if (m < 1) return "just now";
27
+ if (m < 60) return `${m}m ago`;
28
+ const h = Math.floor(m / 60);
29
+ if (h < 24) return `${h}h ago`;
30
+ return `${Math.floor(h / 24)}d ago`;
31
+ }
32
+ /** Six-char short form of a session id for headers and lists. */
33
+ function shortId(id) {
34
+ return id.replace(/-/g, "").slice(0, 6);
35
+ }
36
+ //#endregion
37
+ //#region src/tui/theme.ts
38
+ /**
39
+ * Shared color palette. Kept as plain hex strings so it can be consumed by
40
+ * OpenTUI props that accept `string | RGBA`. The names describe role, not
41
+ * literal hue, so the theme can be swapped without touching call sites.
42
+ */
43
+ const COLOR = {
44
+ brand: "#FFCC00",
45
+ accent: "#00FF88",
46
+ model: "#88CCFF",
47
+ warn: "#FFAA66",
48
+ error: "#FF6666",
49
+ dim: "#888888",
50
+ mute: "#555555",
51
+ border: "#333333",
52
+ borderActive: "#555555"
53
+ };
54
+ /**
55
+ * Shared select styling — keeps the highlight bar from filling with a
56
+ * different background than the surrounding box. The `▶` marker and the
57
+ * brand-colored selected text carry the focus affordance.
58
+ */
59
+ const SELECT_THEME = {
60
+ backgroundColor: "transparent",
61
+ focusedBackgroundColor: "transparent",
62
+ selectedBackgroundColor: "transparent",
63
+ selectedTextColor: COLOR.brand,
64
+ textColor: COLOR.dim,
65
+ descriptionColor: COLOR.mute,
66
+ selectedDescriptionColor: COLOR.dim
67
+ };
68
+ /**
69
+ * Theme for markdown token highlighting. Token names map to Tree-sitter highlight
70
+ * captures emitted by OpenTUI's markdown parser; the `default` entry is the
71
+ * fallback for unstyled text.
72
+ */
73
+ const MD_STYLE = SyntaxStyle.fromStyles({
74
+ "default": { fg: RGBA.fromHex("#E6EDF3") },
75
+ "markup.heading": {
76
+ fg: RGBA.fromHex(COLOR.brand),
77
+ bold: true
78
+ },
79
+ "markup.heading.1": {
80
+ fg: RGBA.fromHex(COLOR.brand),
81
+ bold: true
82
+ },
83
+ "markup.heading.2": {
84
+ fg: RGBA.fromHex("#FFD84D"),
85
+ bold: true
86
+ },
87
+ "markup.heading.3": {
88
+ fg: RGBA.fromHex("#FFE680"),
89
+ bold: true
90
+ },
91
+ "markup.bold": {
92
+ fg: RGBA.fromHex("#FFFFFF"),
93
+ bold: true
94
+ },
95
+ "markup.italic": {
96
+ fg: RGBA.fromHex("#E6EDF3"),
97
+ italic: true
98
+ },
99
+ "markup.link": {
100
+ fg: RGBA.fromHex(COLOR.model),
101
+ underline: true
102
+ },
103
+ "markup.link.url": {
104
+ fg: RGBA.fromHex(COLOR.model),
105
+ underline: true
106
+ },
107
+ "markup.list": { fg: RGBA.fromHex(COLOR.warn) },
108
+ "markup.raw": { fg: RGBA.fromHex("#A5D6FF") },
109
+ "markup.raw.block": { fg: RGBA.fromHex("#A5D6FF") },
110
+ "markup.quote": {
111
+ fg: RGBA.fromHex(COLOR.dim),
112
+ italic: true
113
+ },
114
+ "punctuation": { fg: RGBA.fromHex(COLOR.mute) }
115
+ });
116
+ //#endregion
117
+ //#region src/tui/components.tsx
118
+ /**
119
+ * Memoized so a flush that mutates only the trailing event doesn't force the
120
+ * entire transcript to re-render. Each event holds a stable reference until
121
+ * its content changes (we only ever recreate the streaming-markdown tail).
122
+ *
123
+ * The outer wrapper handles top-margin per kind (and per neighbor) so spacing
124
+ * is the single source of truth for inter-event breathing room.
125
+ */
126
+ const EventLine = memo(({ event, previous }) => /* @__PURE__ */ jsx("box", {
127
+ style: { marginTop: marginTopFor(event, previous) },
128
+ children: /* @__PURE__ */ jsx(EventLineImpl, { event })
129
+ }));
130
+ /**
131
+ * `@opentui/react` extends `React.JSX.IntrinsicElements`, so `onSubmit` on `<input>`
132
+ * gets intersected with the DOM `SubmitEvent` shape and demands an unhelpful overload.
133
+ * The OpenTUI input runtime fires `(value: string) => void`; this helper isolates
134
+ * the cast so each call site stays readable.
135
+ */
136
+ function onInputSubmit(handler) {
137
+ return handler;
138
+ }
139
+ function Footer({ hints, picked, context }) {
140
+ return /* @__PURE__ */ jsxs("box", {
141
+ style: {
142
+ flexDirection: "row",
143
+ height: 1,
144
+ paddingLeft: 1,
145
+ paddingRight: 1
146
+ },
147
+ children: [
148
+ /* @__PURE__ */ jsx("text", {
149
+ fg: COLOR.dim,
150
+ children: hints.map((h, i) => /* @__PURE__ */ jsxs("span", { children: [
151
+ i > 0 && /* @__PURE__ */ jsx("span", {
152
+ fg: COLOR.mute,
153
+ children: " · "
154
+ }),
155
+ /* @__PURE__ */ jsx("span", {
156
+ fg: COLOR.warn,
157
+ children: h.key
158
+ }),
159
+ /* @__PURE__ */ jsx("span", {
160
+ fg: COLOR.dim,
161
+ children: ` ${h.label}`
162
+ })
163
+ ] }, i))
164
+ }),
165
+ picked && /* @__PURE__ */ jsx(ProviderBadge, { picked }),
166
+ /* @__PURE__ */ jsx("box", { style: { flexGrow: 1 } }),
167
+ context && /* @__PURE__ */ jsx(ContextIndicator, { context })
168
+ ]
169
+ });
170
+ }
171
+ function ProviderBadge({ picked }) {
172
+ const source = picked.provider.methods[0].source;
173
+ return /* @__PURE__ */ jsxs("text", {
174
+ fg: COLOR.dim,
175
+ children: [
176
+ /* @__PURE__ */ jsx("span", {
177
+ fg: COLOR.mute,
178
+ children: " · "
179
+ }),
180
+ /* @__PURE__ */ jsx("span", {
181
+ fg: COLOR.accent,
182
+ children: picked.provider.label
183
+ }),
184
+ /* @__PURE__ */ jsx("span", {
185
+ fg: COLOR.mute,
186
+ children: " · "
187
+ }),
188
+ /* @__PURE__ */ jsx("span", {
189
+ fg: COLOR.model,
190
+ children: picked.model
191
+ }),
192
+ /* @__PURE__ */ jsx("span", {
193
+ fg: COLOR.mute,
194
+ children: " · "
195
+ }),
196
+ /* @__PURE__ */ jsx("span", {
197
+ fg: source === "oauth" ? COLOR.accent : COLOR.warn,
198
+ children: source
199
+ })
200
+ ]
201
+ });
202
+ }
203
+ function ContextIndicator({ context }) {
204
+ const ratio = context.max > 0 ? context.used / context.max : 0;
205
+ const pct = Math.round(ratio * 100);
206
+ const color = ratio >= .85 ? COLOR.error : ratio >= .6 ? COLOR.warn : COLOR.dim;
207
+ return /* @__PURE__ */ jsxs("text", {
208
+ fg: COLOR.dim,
209
+ children: [
210
+ /* @__PURE__ */ jsx("span", {
211
+ fg: COLOR.mute,
212
+ children: "ctx "
213
+ }),
214
+ /* @__PURE__ */ jsx("span", {
215
+ fg: color,
216
+ children: fmtTokens(context.used)
217
+ }),
218
+ /* @__PURE__ */ jsx("span", {
219
+ fg: COLOR.mute,
220
+ children: ` / ${fmtTokens(context.max)} `
221
+ }),
222
+ /* @__PURE__ */ jsx("span", {
223
+ fg: color,
224
+ children: `(${pct}%)`
225
+ })
226
+ ]
227
+ });
228
+ }
229
+ const SPINNER_FRAMES = [
230
+ "⠋",
231
+ "⠙",
232
+ "⠹",
233
+ "⠸",
234
+ "⠼",
235
+ "⠴",
236
+ "⠦",
237
+ "⠧",
238
+ "⠇",
239
+ "⠏"
240
+ ];
241
+ const SPINNER_INTERVAL_MS = 80;
242
+ function Spinner({ label }) {
243
+ const [frame, setFrame] = useState(0);
244
+ useEffect(() => {
245
+ const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), SPINNER_INTERVAL_MS);
246
+ return () => clearInterval(id);
247
+ }, []);
248
+ return /* @__PURE__ */ jsxs("text", {
249
+ fg: COLOR.warn,
250
+ children: [SPINNER_FRAMES[frame], /* @__PURE__ */ jsx("span", {
251
+ fg: COLOR.dim,
252
+ children: ` ${label}`
253
+ })]
254
+ });
255
+ }
256
+ function Transcript({ events, settings }) {
257
+ const visible = events.filter((e) => isVisible(e, settings));
258
+ if (visible.length === 0) return /* @__PURE__ */ jsx(EmptyState$1, {});
259
+ return /* @__PURE__ */ jsx("scrollbox", {
260
+ focusable: false,
261
+ style: {
262
+ flexGrow: 1,
263
+ paddingLeft: 1,
264
+ paddingRight: 1
265
+ },
266
+ stickyScroll: true,
267
+ stickyStart: "bottom",
268
+ children: visible.map((evt, i) => /* @__PURE__ */ jsx(EventLine, {
269
+ event: evt,
270
+ previous: visible[i - 1]
271
+ }, i))
272
+ });
273
+ }
274
+ function isVisible(event, settings) {
275
+ switch (event.kind) {
276
+ case "thinking": return settings.showThinking;
277
+ case "tool": return settings.showToolCalls;
278
+ case "tool-result": return settings.showToolResults;
279
+ default: return true;
280
+ }
281
+ }
282
+ function EmptyState$1() {
283
+ return /* @__PURE__ */ jsx("box", {
284
+ style: {
285
+ flexGrow: 1,
286
+ alignItems: "center",
287
+ justifyContent: "center"
288
+ },
289
+ children: /* @__PURE__ */ jsx("text", {
290
+ fg: COLOR.mute,
291
+ children: "no messages yet — type below to start"
292
+ })
293
+ });
294
+ }
295
+ /** Left-pad applied per depth level (in columns). */
296
+ const INDENT_PER_DEPTH = 2;
297
+ function indentFor(depth) {
298
+ return depth && depth > 0 ? depth * INDENT_PER_DEPTH : 0;
299
+ }
300
+ function isChild(event) {
301
+ return (event.depth ?? 0) > 0;
302
+ }
303
+ /**
304
+ * Default top-margin per kind. Spacing intent:
305
+ * - `info` / `markdown` / `tool` / `error` / `spawn-start` open a new block
306
+ * so they each get one row of breathing room above.
307
+ * - `thinking` / `tool-result` / `spawn-end` continue the previous block
308
+ * and stay flush.
309
+ *
310
+ * Context-aware overrides live in `marginTopFor` — e.g. consecutive tool
311
+ * round-trips collapse to a tight list regardless of whether outputs are shown.
312
+ */
313
+ const MARGIN_TOP = {
314
+ "separator": 0,
315
+ "info": 1,
316
+ "thinking": 0,
317
+ "tool": 1,
318
+ "tool-result": 0,
319
+ "error": 1,
320
+ "markdown": 1,
321
+ "spawn-start": 1,
322
+ "spawn-end": 0
323
+ };
324
+ const TOOL_KINDS = new Set(["tool", "tool-result"]);
325
+ /**
326
+ * Resolve the top margin for an event given the one rendered just before it.
327
+ *
328
+ * The only context-aware rule today: a tool/tool-result event that follows
329
+ * another tool/tool-result event collapses its margin to zero, so a chain of
330
+ * tool calls reads as a tight list — whether the user has hidden tool outputs
331
+ * or not, and whether the agent emits back-to-back calls or call→result pairs.
332
+ *
333
+ * Exported so the spacing matrix can be unit-tested without rendering.
334
+ */
335
+ function marginTopFor(event, previous) {
336
+ if (TOOL_KINDS.has(event.kind) && previous && TOOL_KINDS.has(previous.kind)) return 0;
337
+ return MARGIN_TOP[event.kind] ?? 0;
338
+ }
339
+ function EventLineImpl({ event }) {
340
+ const safeText = event.text === "" ? " " : event.text;
341
+ const paddingLeft = indentFor(event.depth);
342
+ const child = isChild(event);
343
+ switch (event.kind) {
344
+ case "separator": return /* @__PURE__ */ jsx("text", { children: " " });
345
+ case "info": return /* @__PURE__ */ jsx(UserPromptBlock, { text: safeText });
346
+ case "thinking": return /* @__PURE__ */ jsx("box", {
347
+ style: { paddingLeft },
348
+ children: /* @__PURE__ */ jsx("text", {
349
+ fg: COLOR.dim,
350
+ children: safeText
351
+ })
352
+ });
353
+ case "tool": return /* @__PURE__ */ jsx("box", {
354
+ style: { paddingLeft },
355
+ children: /* @__PURE__ */ jsxs("text", {
356
+ fg: child ? COLOR.dim : COLOR.model,
357
+ children: [/* @__PURE__ */ jsx("span", {
358
+ fg: COLOR.mute,
359
+ children: "↳ "
360
+ }), safeText]
361
+ })
362
+ });
363
+ case "tool-result": return /* @__PURE__ */ jsx(ToolResultBlock, {
364
+ text: event.text,
365
+ indent: paddingLeft
366
+ });
367
+ case "error": return /* @__PURE__ */ jsx("box", {
368
+ style: { paddingLeft },
369
+ children: /* @__PURE__ */ jsxs("text", {
370
+ fg: COLOR.error,
371
+ children: [/* @__PURE__ */ jsx("span", {
372
+ fg: COLOR.error,
373
+ children: "✗ "
374
+ }), safeText]
375
+ })
376
+ });
377
+ case "markdown": return /* @__PURE__ */ jsx("box", {
378
+ style: { paddingLeft },
379
+ children: /* @__PURE__ */ jsx(MarkdownBlock, {
380
+ text: event.text,
381
+ streaming: event.streaming ?? false,
382
+ dim: child
383
+ })
384
+ });
385
+ case "spawn-start": return /* @__PURE__ */ jsx("box", {
386
+ style: { paddingLeft },
387
+ children: /* @__PURE__ */ jsxs("text", {
388
+ fg: COLOR.dim,
389
+ children: [
390
+ /* @__PURE__ */ jsx("span", {
391
+ fg: COLOR.accent,
392
+ children: "🌱 "
393
+ }),
394
+ /* @__PURE__ */ jsx("span", {
395
+ fg: COLOR.dim,
396
+ children: `[${event.childId ?? "child"}] `
397
+ }),
398
+ /* @__PURE__ */ jsx("span", {
399
+ fg: COLOR.dim,
400
+ children: safeText
401
+ })
402
+ ]
403
+ })
404
+ });
405
+ case "spawn-end": return /* @__PURE__ */ jsx("box", {
406
+ style: { paddingLeft },
407
+ children: /* @__PURE__ */ jsxs("text", {
408
+ fg: COLOR.dim,
409
+ children: [
410
+ /* @__PURE__ */ jsx("span", {
411
+ fg: COLOR.accent,
412
+ children: "✓ "
413
+ }),
414
+ /* @__PURE__ */ jsx("span", {
415
+ fg: COLOR.dim,
416
+ children: `[${event.childId ?? "child"}] `
417
+ }),
418
+ /* @__PURE__ */ jsx("span", {
419
+ fg: COLOR.mute,
420
+ children: safeText
421
+ })
422
+ ]
423
+ })
424
+ });
425
+ default: return /* @__PURE__ */ jsx("text", { children: safeText });
426
+ }
427
+ }
428
+ /** User prompt — bordered to rhyme with the prompt input box below. */
429
+ function UserPromptBlock({ text }) {
430
+ return /* @__PURE__ */ jsx("box", {
431
+ style: {
432
+ border: true,
433
+ borderColor: COLOR.borderActive,
434
+ paddingLeft: 1,
435
+ paddingRight: 1
436
+ },
437
+ children: /* @__PURE__ */ jsx("text", {
438
+ fg: COLOR.brand,
439
+ children: text
440
+ })
441
+ });
442
+ }
443
+ /**
444
+ * Markdown block. While `streaming` is true, content is passed through
445
+ * `md4x.heal()` so unclosed delimiters (bold, italic, code, link, table) render
446
+ * as if already complete. OpenTUI's `streaming` prop keeps its parser from
447
+ * committing to the final layout for the trailing block.
448
+ */
449
+ function MarkdownBlock({ text, streaming, dim }) {
450
+ return /* @__PURE__ */ jsx("markdown", {
451
+ content: useMemo(() => streaming ? heal(text) : text, [text, streaming]),
452
+ syntaxStyle: MD_STYLE,
453
+ streaming,
454
+ fg: dim ? COLOR.dim : void 0
455
+ });
456
+ }
457
+ const TOOL_RESULT_MAX_LINES = 6;
458
+ function ToolResultBlock({ text, indent }) {
459
+ const lines = text.split("\n");
460
+ const visible = lines.slice(0, TOOL_RESULT_MAX_LINES);
461
+ const omitted = Math.max(0, lines.length - TOOL_RESULT_MAX_LINES);
462
+ return /* @__PURE__ */ jsxs("box", {
463
+ style: {
464
+ paddingLeft: indent,
465
+ flexDirection: "column"
466
+ },
467
+ children: [visible.map((line, i) => /* @__PURE__ */ jsxs("text", {
468
+ fg: COLOR.mute,
469
+ children: [/* @__PURE__ */ jsx("span", {
470
+ fg: COLOR.borderActive,
471
+ children: "┃ "
472
+ }), line || " "]
473
+ }, i)), omitted > 0 && /* @__PURE__ */ jsxs("text", {
474
+ fg: COLOR.mute,
475
+ children: [/* @__PURE__ */ jsx("span", {
476
+ fg: COLOR.borderActive,
477
+ children: "┃ "
478
+ }), `… ${omitted} more line${omitted === 1 ? "" : "s"}`]
479
+ })]
480
+ });
481
+ }
482
+ //#endregion
483
+ //#region src/tui/auth.ts
484
+ const ENV_KEYS = {
485
+ anthropic: "ANTHROPIC_API_KEY",
486
+ openai: "OPENAI_CODEX_API_KEY",
487
+ openrouter: "OPENROUTER_API_KEY",
488
+ cerebras: "CEREBRAS_API_KEY"
489
+ };
490
+ /** Maps a provider to the credentials.json key written by `bun run auth`. */
491
+ const OAUTH_KEYS = {
492
+ anthropic: "anthropic",
493
+ openai: "openai-codex"
494
+ };
495
+ const LABELS = {
496
+ anthropic: "Anthropic",
497
+ openai: "OpenAI Codex",
498
+ openrouter: "OpenRouter",
499
+ cerebras: "Cerebras"
500
+ };
501
+ function envKeyFor(key) {
502
+ return ENV_KEYS[key];
503
+ }
504
+ /**
505
+ * Detect available auth across the providers the harness ships with.
506
+ *
507
+ * Mirrors the resolution order used by the providers at runtime:
508
+ * - explicit env var (highest)
509
+ * - OAuth credentials in `.credentials.json` (anthropic + openai-codex only)
510
+ *
511
+ * Pure read — never refreshes or rewrites the credentials file.
512
+ */
513
+ function detectAuth(env = process.env) {
514
+ const credsPath = resolve(process.cwd(), ".credentials.json");
515
+ let creds = {};
516
+ if (existsSync(credsPath)) try {
517
+ const parsed = JSON.parse(readFileSync(credsPath, "utf-8"));
518
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) creds = parsed;
519
+ } catch {}
520
+ return Object.keys(LABELS).map((key) => {
521
+ const methods = [];
522
+ const envKey = ENV_KEYS[key];
523
+ if (env[envKey]) methods.push({
524
+ source: "env",
525
+ detail: envKey
526
+ });
527
+ const oauthKey = OAUTH_KEYS[key];
528
+ if (oauthKey) {
529
+ const entry = creds[oauthKey];
530
+ if (entry?.access && entry.refresh) {
531
+ const detail = entry.expires ? `oauth · expires ${new Date(entry.expires).toLocaleString()}` : "oauth · .credentials.json";
532
+ methods.push({
533
+ source: "oauth",
534
+ detail
535
+ });
536
+ }
537
+ }
538
+ return {
539
+ key,
540
+ label: LABELS[key],
541
+ available: methods.length > 0,
542
+ methods
543
+ };
544
+ });
545
+ }
546
+ //#endregion
547
+ //#region src/tui/providers.ts
548
+ /**
549
+ * Construct a fresh provider instance for a given key.
550
+ *
551
+ * Providers are cheap to build — credentials are resolved lazily at first
552
+ * stream call — so we instantiate on demand rather than caching a singleton.
553
+ * This also avoids leaking state across session/provider switches.
554
+ */
555
+ const FACTORIES = {
556
+ anthropic,
557
+ openai,
558
+ openrouter,
559
+ cerebras
560
+ };
561
+ /** zidane provider key → pi-ai provider id (some don't match 1:1). */
562
+ const PI_PROVIDER_ID = {
563
+ anthropic: "anthropic",
564
+ openai: "openai-codex",
565
+ openrouter: "openrouter",
566
+ cerebras: "cerebras"
567
+ };
568
+ /**
569
+ * Look up the model's max context window via pi-ai's model registry.
570
+ * Returns `null` when the model isn't known (e.g. a custom openrouter slug);
571
+ * callers should hide the context indicator in that case.
572
+ */
573
+ function getContextWindow(key, modelId) {
574
+ try {
575
+ const providerId = PI_PROVIDER_ID[key];
576
+ return getModel(providerId, modelId)?.contextWindow ?? null;
577
+ } catch {
578
+ return null;
579
+ }
580
+ }
581
+ //#endregion
582
+ //#region src/tui/store.ts
583
+ function ensureDir(path) {
584
+ const dir = dirname(path);
585
+ if (existsSync(dir)) return;
586
+ try {
587
+ mkdirSync(dir, { recursive: true });
588
+ } catch (err) {
589
+ const message = err instanceof Error ? err.message : String(err);
590
+ throw new Error(`Could not create TUI storage directory at "${dir}". Override the location via \`runTui({ storageDir, prefix })\` or the \`ZIDANE_STORAGE_DIR\` env var. Original error: ${message}`);
591
+ }
592
+ }
593
+ function createTuiStore(dbPath) {
594
+ ensureDir(dbPath);
595
+ return createSqliteStore({ path: dbPath });
596
+ }
597
+ function createStateStore(path) {
598
+ return {
599
+ load: () => loadState(path),
600
+ save: (state) => saveState(path, state)
601
+ };
602
+ }
603
+ function loadState(path) {
604
+ if (!existsSync(path)) return {};
605
+ try {
606
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
607
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
608
+ } catch {}
609
+ return {};
610
+ }
611
+ function saveState(path, state) {
612
+ ensureDir(path);
613
+ const tmp = `${path}.${process.pid}.tmp`;
614
+ writeFileSync(tmp, JSON.stringify(state, null, 2));
615
+ renameSync(tmp, path);
616
+ }
617
+ /**
618
+ * Load every session and project it to the compact `SessionMeta` shape used by
619
+ * the picker. Sorted by recency via the underlying store's `list()` contract
620
+ * (sqlite store returns by `updated_at DESC`).
621
+ */
622
+ async function listSessionMeta(store) {
623
+ const ids = await store.list();
624
+ return (await Promise.all(ids.map(async (id) => {
625
+ const data = await store.load(id);
626
+ if (!data) return null;
627
+ return {
628
+ id,
629
+ title: titleFromTurns(data.turns) ?? "untitled",
630
+ turnCount: data.turns.length,
631
+ updatedAt: data.updatedAt
632
+ };
633
+ }))).filter((m) => m !== null);
634
+ }
635
+ /** Derive a short title from the first user message — returns null when empty. */
636
+ function titleFromTurns(turns) {
637
+ const first = turns.find((t) => t.role === "user");
638
+ if (!first) return null;
639
+ for (const block of first.content) if (block.type === "text" && block.text.trim()) {
640
+ const oneLine = block.text.replace(/\s+/g, " ").trim();
641
+ return oneLine.length > 60 ? `${oneLine.slice(0, 60)}…` : oneLine;
642
+ }
643
+ return null;
644
+ }
645
+ /**
646
+ * Replay persisted turns as a viewable transcript. Mirrors the event shape produced
647
+ * live by the agent hooks so loaded and streaming history render identically.
648
+ *
649
+ * Skips `tool_result` blocks (they're not user-visible by default), and inserts a
650
+ * `separator` event between turn groups so the eye can parse turn boundaries.
651
+ */
652
+ function eventsFromTurns(turns) {
653
+ const events = [];
654
+ for (let i = 0; i < turns.length; i++) {
655
+ const turn = turns[i];
656
+ if (i > 0) events.push({
657
+ kind: "separator",
658
+ text: ""
659
+ });
660
+ if (turn.role === "user") {
661
+ for (const block of turn.content) if (block.type === "text" && block.text.trim()) events.push({
662
+ kind: "info",
663
+ text: `❯ ${block.text}`
664
+ });
665
+ else if (block.type === "tool_result") events.push({
666
+ kind: "tool-result",
667
+ text: toolResultText(block.output)
668
+ });
669
+ continue;
670
+ }
671
+ if (turn.role === "assistant") {
672
+ for (const block of turn.content) if (block.type === "text" && block.text.trim()) events.push({
673
+ kind: "markdown",
674
+ text: block.text,
675
+ streaming: false
676
+ });
677
+ else if (block.type === "tool_call") events.push({
678
+ kind: "tool",
679
+ text: toolCallPreview(block.name, block.input)
680
+ });
681
+ }
682
+ }
683
+ return events;
684
+ }
685
+ /** Shared formatter for the `↳ name(args)` line shown on tool calls. */
686
+ function toolCallPreview(name, input) {
687
+ const args = JSON.stringify(input);
688
+ return args && args !== "{}" ? `${name}(${args})` : name;
689
+ }
690
+ /** Render tool output as plain text, whether it's a string or structured content. */
691
+ function toolResultText(output) {
692
+ return typeof output === "string" ? output : toolResultToText(output);
693
+ }
694
+ /** Effective context size of the most recent assistant turn — drives the footer indicator. */
695
+ function lastContextSizeFromTurns(turns) {
696
+ for (let i = turns.length - 1; i >= 0; i--) {
697
+ const turn = turns[i];
698
+ if (turn.role === "assistant" && turn.usage) return (turn.usage.input ?? 0) + (turn.usage.cacheRead ?? 0) + (turn.usage.cacheCreation ?? 0);
699
+ }
700
+ return 0;
701
+ }
702
+ //#endregion
703
+ //#region src/tui/config.tsx
704
+ /** Resolve user options into a fully-bound runtime config. Pure aside from disk reads. */
705
+ function resolveConfig(options = {}) {
706
+ const prefix = options.prefix ?? process.env.ZIDANE_PREFIX ?? ".zidane";
707
+ const storageDir = options.storageDir ?? process.env.ZIDANE_STORAGE_DIR ?? homedir();
708
+ const dir = resolve(storageDir, prefix);
709
+ const paths = {
710
+ dir,
711
+ db: resolve(dir, "sessions.db"),
712
+ state: resolve(dir, "state.json")
713
+ };
714
+ const store = options.store ?? createTuiStore(paths.db);
715
+ const stateStore = createStateStore(paths.state);
716
+ const initialState = stateStore.load();
717
+ const providers = {
718
+ ...FACTORIES,
719
+ ...options.providers ?? {}
720
+ };
721
+ const preset = options.preset ?? basic_default;
722
+ const modelsFor = makeModelsResolver(options.models);
723
+ const resumeProvider = resolveResumeProvider(initialState, providers);
724
+ const initialPicked = resumeProvider ? pickInitial(resumeProvider, providers, initialState) : null;
725
+ return {
726
+ prefix,
727
+ storageDir,
728
+ paths,
729
+ providers,
730
+ preset,
731
+ store,
732
+ stateStore,
733
+ modelsFor,
734
+ initialState,
735
+ initialSettings: initialState.settings ?? {},
736
+ resumeProvider,
737
+ initialPicked
738
+ };
739
+ }
740
+ function makeModelsResolver(custom) {
741
+ return (key) => {
742
+ const overridden = custom?.[key];
743
+ if (overridden) return overridden;
744
+ try {
745
+ const piId = PI_PROVIDER_ID[key];
746
+ return getModels(piId);
747
+ } catch {
748
+ return [];
749
+ }
750
+ };
751
+ }
752
+ function resolveResumeProvider(state, providers) {
753
+ if (!state.lastProvider) return null;
754
+ if (!providers[state.lastProvider]) return null;
755
+ return detectAuth().find((p) => p.key === state.lastProvider && p.available) ?? null;
756
+ }
757
+ function pickInitial(auth, providers, state) {
758
+ const factory = providers[auth.key];
759
+ if (!factory) return null;
760
+ const provider = factory();
761
+ return {
762
+ provider: auth,
763
+ model: state.lastModelByProvider?.[auth.key] ?? provider.meta.defaultModel
764
+ };
765
+ }
766
+ const ConfigContext = createContext(null);
767
+ function ConfigProvider({ config, children }) {
768
+ return /* @__PURE__ */ jsx(ConfigContext.Provider, {
769
+ value: config,
770
+ children
771
+ });
772
+ }
773
+ function useConfig() {
774
+ const ctx = useContext(ConfigContext);
775
+ if (!ctx) throw new Error("useConfig must be used inside <ConfigProvider>");
776
+ return ctx;
777
+ }
778
+ //#endregion
779
+ //#region src/tui/modal.tsx
780
+ const ModalContext = createContext(null);
781
+ function ModalRoot({ children }) {
782
+ const [active, setActive] = useState(null);
783
+ const api = useMemo(() => ({
784
+ open: (node) => setActive(node),
785
+ close: () => setActive(null),
786
+ get isOpen() {
787
+ return active !== null;
788
+ }
789
+ }), [active]);
790
+ return /* @__PURE__ */ jsxs(ModalContext.Provider, {
791
+ value: api,
792
+ children: [/* @__PURE__ */ jsx("box", {
793
+ style: {
794
+ flexDirection: "column",
795
+ flexGrow: 1
796
+ },
797
+ children
798
+ }), active && /* @__PURE__ */ jsx("box", {
799
+ style: {
800
+ position: "absolute",
801
+ top: 0,
802
+ left: 0,
803
+ right: 0,
804
+ bottom: 0,
805
+ alignItems: "center",
806
+ justifyContent: "center",
807
+ zIndex: 100
808
+ },
809
+ children: active
810
+ })]
811
+ });
812
+ }
813
+ function useModal() {
814
+ const ctx = useContext(ModalContext);
815
+ if (!ctx) throw new Error("useModal must be used inside <ModalRoot>");
816
+ return ctx;
817
+ }
818
+ /**
819
+ * Focus computed against the modal layer.
820
+ *
821
+ * Pass a component's preferred focus state and this returns `false` whenever a
822
+ * modal is open — so focused inputs (textarea, selects) release their focus and
823
+ * stop intercepting keys behind the overlay. Pair with `focusable={false}` on
824
+ * "passive" focusables (scrollbox) so the renderer doesn't cycle focus into
825
+ * them when the primary input blurs.
826
+ */
827
+ function useModalAwareFocus(preferred = true) {
828
+ const { isOpen } = useModal();
829
+ return preferred && !isOpen;
830
+ }
831
+ /**
832
+ * Responsive modal — picks a width based on the live terminal size.
833
+ *
834
+ * - On a wide terminal, the modal grows to `maxWidth` so descriptions sit on
835
+ * one line and don't wrap.
836
+ * - On a narrow terminal, the modal shrinks down to `minWidth`, keeping a
837
+ * small horizontal margin from the screen edges. Text inside wraps naturally.
838
+ *
839
+ * Uses `useTerminalDimensions()` so it reflows on `SIGWINCH` without remount.
840
+ */
841
+ function Modal({ title, onClose, children, maxWidth = 92, minWidth = 44, horizontalMargin = 4 }) {
842
+ const ctx = useContext(ModalContext);
843
+ const dismiss = onClose ?? ctx?.close;
844
+ useKeyboard((key) => {
845
+ if (key.name === "escape") dismiss?.();
846
+ });
847
+ const { width: termWidth } = useTerminalDimensions();
848
+ const width = Math.max(minWidth, Math.min(maxWidth, termWidth - horizontalMargin * 2));
849
+ return /* @__PURE__ */ jsx("box", {
850
+ title: title ? ` ${title} ` : void 0,
851
+ style: {
852
+ border: true,
853
+ borderColor: COLOR.borderActive,
854
+ backgroundColor: "#101010",
855
+ paddingTop: 1,
856
+ paddingBottom: 1,
857
+ paddingLeft: 2,
858
+ paddingRight: 2,
859
+ width,
860
+ flexDirection: "column",
861
+ gap: 1
862
+ },
863
+ children
864
+ });
865
+ }
866
+ //#endregion
867
+ //#region src/tui/model-picker.tsx
868
+ /** Cap the visible scroll window so a 30-model list doesn't push the modal off-screen. */
869
+ const VISIBLE_ROW_CAP = 12;
870
+ /**
871
+ * Modal that lists the available models for the current provider and lets the
872
+ * user pick one. Options come from `runTui({ models })` if supplied, otherwise
873
+ * from pi-ai's built-in registry.
874
+ *
875
+ * Each row shows: `● selected · name (ctx N · reasoning · vision)`.
876
+ */
877
+ function ModelPickerModal({ models, currentModelId, onPick }) {
878
+ const initialIndex = useMemo(() => models.findIndex((m) => m.id === currentModelId), [models, currentModelId]);
879
+ const options = useMemo(() => models.map((m) => ({
880
+ name: `${m.id === currentModelId ? "● " : " "}${m.name ?? m.id}`,
881
+ description: describeModel(m),
882
+ value: m.id
883
+ })), [models, currentModelId]);
884
+ if (models.length === 0) return /* @__PURE__ */ jsx(EmptyState, {});
885
+ const visibleRows = Math.min(options.length, VISIBLE_ROW_CAP);
886
+ const currentMissing = initialIndex < 0;
887
+ const safeIndex = currentMissing ? 0 : initialIndex;
888
+ return /* @__PURE__ */ jsxs(Modal, {
889
+ title: "select model",
890
+ children: [
891
+ currentMissing && /* @__PURE__ */ jsx("text", {
892
+ fg: COLOR.warn,
893
+ children: `Current model "${currentModelId}" is not in this registry — pick one below to switch.`
894
+ }),
895
+ /* @__PURE__ */ jsx("select", {
896
+ ...SELECT_THEME,
897
+ focused: true,
898
+ options,
899
+ wrapSelection: true,
900
+ selectedIndex: safeIndex,
901
+ showScrollIndicator: options.length > visibleRows,
902
+ style: { height: visibleRows },
903
+ onSelect: (_idx, option) => {
904
+ if (option) onPick(option.value);
905
+ }
906
+ }),
907
+ /* @__PURE__ */ jsxs("text", {
908
+ fg: COLOR.mute,
909
+ children: [
910
+ /* @__PURE__ */ jsx("span", {
911
+ fg: COLOR.warn,
912
+ children: "↑↓"
913
+ }),
914
+ " navigate · ",
915
+ /* @__PURE__ */ jsx("span", {
916
+ fg: COLOR.warn,
917
+ children: "↵"
918
+ }),
919
+ " select · ",
920
+ /* @__PURE__ */ jsx("span", {
921
+ fg: COLOR.warn,
922
+ children: "esc"
923
+ }),
924
+ " close"
925
+ ]
926
+ })
927
+ ]
928
+ });
929
+ }
930
+ function EmptyState() {
931
+ return /* @__PURE__ */ jsxs(Modal, {
932
+ title: "select model",
933
+ children: [/* @__PURE__ */ jsx("text", {
934
+ fg: COLOR.dim,
935
+ children: "No models available for this provider."
936
+ }), /* @__PURE__ */ jsx("text", {
937
+ fg: COLOR.mute,
938
+ children: "Pass a `models` registry to `runTui()` to populate this list."
939
+ })]
940
+ });
941
+ }
942
+ /** "ctx 200k · reasoning · vision" — compact per-model description. */
943
+ function describeModel(m) {
944
+ const parts = [`ctx ${fmtTokens(m.contextWindow)}`];
945
+ if (m.reasoning) parts.push("reasoning");
946
+ if (m.input?.includes("image")) parts.push("vision");
947
+ return parts.join(" · ");
948
+ }
949
+ //#endregion
950
+ //#region src/tui/screens.tsx
951
+ /**
952
+ * Textarea bindings: plain `enter` submits; `shift+enter` inserts a newline.
953
+ * All `return` defaults are stripped and replaced so the user's preferred
954
+ * binding wins regardless of modifier state.
955
+ */
956
+ const TEXTAREA_BINDINGS = [
957
+ ...defaultTextareaKeyBindings.filter((b) => b.name !== "return"),
958
+ {
959
+ name: "return",
960
+ action: "submit"
961
+ },
962
+ {
963
+ name: "return",
964
+ shift: true,
965
+ action: "newline"
966
+ }
967
+ ];
968
+ function AuthScreen({ onPick }) {
969
+ const { providers: registry } = useConfig();
970
+ const focused = useModalAwareFocus();
971
+ const providers = useMemo(() => detectAuth().filter((p) => p.key in registry), [registry]);
972
+ const available = useMemo(() => providers.filter((p) => p.available), [providers]);
973
+ if (available.length === 0) return /* @__PURE__ */ jsx(NoAuthScreen, { providers });
974
+ const options = available.map((p) => ({
975
+ name: p.label,
976
+ description: p.methods.map((m) => m.detail).join(" · "),
977
+ value: p.key
978
+ }));
979
+ return /* @__PURE__ */ jsx("box", {
980
+ title: " pick a provider ",
981
+ style: {
982
+ border: true,
983
+ borderColor: COLOR.border,
984
+ padding: 1,
985
+ flexDirection: "column",
986
+ flexGrow: 1
987
+ },
988
+ children: /* @__PURE__ */ jsx("select", {
989
+ ...SELECT_THEME,
990
+ focused,
991
+ options,
992
+ wrapSelection: true,
993
+ onSelect: (_idx, option) => {
994
+ if (!option) return;
995
+ const provider = available.find((p) => p.key === option.value);
996
+ if (provider) onPick(provider);
997
+ },
998
+ style: { flexGrow: 1 }
999
+ })
1000
+ });
1001
+ }
1002
+ function NoAuthScreen({ providers }) {
1003
+ return /* @__PURE__ */ jsxs("box", {
1004
+ title: " no authentication detected ",
1005
+ style: {
1006
+ border: true,
1007
+ borderColor: COLOR.error,
1008
+ padding: 1,
1009
+ flexDirection: "column",
1010
+ gap: 1,
1011
+ flexGrow: 1
1012
+ },
1013
+ children: [
1014
+ /* @__PURE__ */ jsx("text", {
1015
+ fg: COLOR.error,
1016
+ children: "No provider credentials found."
1017
+ }),
1018
+ /* @__PURE__ */ jsx("text", {
1019
+ fg: COLOR.dim,
1020
+ children: "Set one of these env vars (or run `bun run auth` for OAuth):"
1021
+ }),
1022
+ providers.map((p) => /* @__PURE__ */ jsxs("text", {
1023
+ fg: COLOR.dim,
1024
+ children: [
1025
+ " · ",
1026
+ /* @__PURE__ */ jsx("span", {
1027
+ fg: COLOR.brand,
1028
+ children: p.label
1029
+ }),
1030
+ " → ",
1031
+ /* @__PURE__ */ jsx("span", {
1032
+ fg: COLOR.model,
1033
+ children: envKeyFor(p.key)
1034
+ })
1035
+ ]
1036
+ }, p.key))
1037
+ ]
1038
+ });
1039
+ }
1040
+ const NEW_VALUE = "__new__";
1041
+ function SessionsScreen({ sessions, currentId, onPick, onCreate }) {
1042
+ const focused = useModalAwareFocus();
1043
+ const options = useMemo(() => {
1044
+ const items = [{
1045
+ name: "+ new session",
1046
+ description: "start fresh",
1047
+ value: NEW_VALUE
1048
+ }];
1049
+ for (const s of sessions) {
1050
+ const marker = s.id === currentId ? "● " : " ";
1051
+ const turnLabel = `${s.turnCount} turn${s.turnCount === 1 ? "" : "s"}`;
1052
+ items.push({
1053
+ name: `${marker}${s.title}`,
1054
+ description: `#${shortId(s.id)} · ${turnLabel} · ${ageString(s.updatedAt)}`,
1055
+ value: s.id
1056
+ });
1057
+ }
1058
+ return items;
1059
+ }, [sessions, currentId]);
1060
+ return /* @__PURE__ */ jsx("box", {
1061
+ title: " sessions ",
1062
+ style: {
1063
+ border: true,
1064
+ borderColor: COLOR.border,
1065
+ padding: 1,
1066
+ flexDirection: "column",
1067
+ flexGrow: 1
1068
+ },
1069
+ children: /* @__PURE__ */ jsx("select", {
1070
+ ...SELECT_THEME,
1071
+ focused,
1072
+ options,
1073
+ wrapSelection: true,
1074
+ onSelect: (_idx, option) => {
1075
+ if (!option) return;
1076
+ if (option.value === NEW_VALUE) onCreate();
1077
+ else onPick(option.value);
1078
+ },
1079
+ style: { flexGrow: 1 }
1080
+ })
1081
+ });
1082
+ }
1083
+ /** Visible content lines: 1 minimum, 5 maximum (textarea scrolls past 5). */
1084
+ const MIN_CONTENT_LINES = 1;
1085
+ const MAX_CONTENT_LINES = 5;
1086
+ function ChatScreen({ events, busy, settings, onSubmit, session }) {
1087
+ const title = useMemo(() => {
1088
+ if (!session) return " untitled ";
1089
+ const turns = `${session.turnCount} turn${session.turnCount === 1 ? "" : "s"}`;
1090
+ return ` ${session.title} · #${shortId(session.id)} · ${turns} `;
1091
+ }, [session]);
1092
+ const userPrompts = useMemo(() => events.filter((e) => e.kind === "info").map((e) => e.text.replace(/^❯ /, "")), [events]);
1093
+ return /* @__PURE__ */ jsxs("box", {
1094
+ style: {
1095
+ flexDirection: "column",
1096
+ flexGrow: 1
1097
+ },
1098
+ children: [/* @__PURE__ */ jsx("box", {
1099
+ title,
1100
+ style: {
1101
+ border: true,
1102
+ borderColor: COLOR.border,
1103
+ flexGrow: 1,
1104
+ flexDirection: "column"
1105
+ },
1106
+ children: /* @__PURE__ */ jsx(Transcript, {
1107
+ events,
1108
+ settings
1109
+ })
1110
+ }), busy ? /* @__PURE__ */ jsx(BusyBlock, {}) : /* @__PURE__ */ jsx(PromptBlock, {
1111
+ userPrompts,
1112
+ onSubmit
1113
+ })]
1114
+ });
1115
+ }
1116
+ function BusyBlock() {
1117
+ return /* @__PURE__ */ jsx("box", {
1118
+ style: {
1119
+ border: true,
1120
+ borderColor: COLOR.warn,
1121
+ paddingLeft: 1,
1122
+ paddingRight: 1,
1123
+ height: 3
1124
+ },
1125
+ children: /* @__PURE__ */ jsx(Spinner, { label: "streaming response — esc to abort" })
1126
+ });
1127
+ }
1128
+ function PromptBlock({ userPrompts, onSubmit }) {
1129
+ const focused = useModalAwareFocus();
1130
+ const textareaRef = useRef(null);
1131
+ /** Auto-grow: visible content rows the textarea currently occupies (clamped 1..5). */
1132
+ const [contentLines, setContentLines] = useState(MIN_CONTENT_LINES);
1133
+ /**
1134
+ * History navigation state. `null` = not navigating (textarea owns its content).
1135
+ * Once the user enters history (up at top), we snapshot the draft and cycle.
1136
+ */
1137
+ const historyRef = useRef(null);
1138
+ const syncLines = useCallback(() => {
1139
+ const lines = textareaRef.current?.lineCount ?? MIN_CONTENT_LINES;
1140
+ setContentLines(Math.max(MIN_CONTENT_LINES, lines));
1141
+ }, []);
1142
+ const submit = useCallback(() => {
1143
+ const value = textareaRef.current?.plainText ?? "";
1144
+ if (!value.trim()) return;
1145
+ onSubmit(value);
1146
+ textareaRef.current?.clear();
1147
+ historyRef.current = null;
1148
+ setContentLines(MIN_CONTENT_LINES);
1149
+ }, [onSubmit]);
1150
+ const cycleHistory = useCallback((direction) => {
1151
+ if (userPrompts.length === 0 || !textareaRef.current) return;
1152
+ if (historyRef.current === null) historyRef.current = {
1153
+ idx: userPrompts.length,
1154
+ draft: textareaRef.current.plainText
1155
+ };
1156
+ const nextIdx = historyRef.current.idx + direction;
1157
+ if (nextIdx < 0) return;
1158
+ if (nextIdx >= userPrompts.length) {
1159
+ textareaRef.current.setText(historyRef.current.draft);
1160
+ textareaRef.current.gotoBufferEnd();
1161
+ historyRef.current = null;
1162
+ } else {
1163
+ textareaRef.current.setText(userPrompts[nextIdx]);
1164
+ textareaRef.current.gotoBufferEnd();
1165
+ historyRef.current.idx = nextIdx;
1166
+ }
1167
+ syncLines();
1168
+ }, [userPrompts, syncLines]);
1169
+ /**
1170
+ * Up/Down at the buffer boundary cycles prompt history (fish/zsh pattern).
1171
+ * Mid-buffer up/down move the cursor normally — handled by the default
1172
+ * `move-up` / `move-down` actions in `TEXTAREA_BINDINGS`.
1173
+ */
1174
+ const onKeyDown = useCallback((event) => {
1175
+ if (event.ctrl || event.shift || event.meta) return;
1176
+ if (event.name !== "up" && event.name !== "down") return;
1177
+ const buffer = textareaRef.current;
1178
+ if (!buffer) return;
1179
+ const cursorRow = buffer.logicalCursor.row;
1180
+ if (event.name === "up" && cursorRow === 0) cycleHistory(-1);
1181
+ else if (event.name === "down" && cursorRow === buffer.lineCount - 1) cycleHistory(1);
1182
+ }, [cycleHistory]);
1183
+ const boxHeight = Math.min(MAX_CONTENT_LINES, contentLines) + 2;
1184
+ return /* @__PURE__ */ jsx("box", {
1185
+ style: {
1186
+ border: true,
1187
+ borderColor: COLOR.borderActive,
1188
+ paddingLeft: 1,
1189
+ paddingRight: 1,
1190
+ height: boxHeight,
1191
+ flexDirection: "column"
1192
+ },
1193
+ children: /* @__PURE__ */ jsx("textarea", {
1194
+ ref: textareaRef,
1195
+ focused,
1196
+ keyBindings: TEXTAREA_BINDINGS,
1197
+ placeholder: "Ask zidane… (enter = send · shift+enter = newline · ↑↓ at edges = history)",
1198
+ style: {
1199
+ flexGrow: 1,
1200
+ height: "100%"
1201
+ },
1202
+ onSubmit: submit,
1203
+ onContentChange: syncLines,
1204
+ onKeyDown
1205
+ })
1206
+ });
1207
+ }
1208
+ //#endregion
1209
+ //#region src/tui/settings.tsx
1210
+ const DEFAULT_SETTINGS = {
1211
+ showThinking: true,
1212
+ showToolCalls: true,
1213
+ showToolResults: true
1214
+ };
1215
+ const SettingsContext = createContext(null);
1216
+ function SettingsProvider({ initial, onChange, children }) {
1217
+ const [settings, setSettings] = useState(initial);
1218
+ const toggle = useCallback((key) => {
1219
+ setSettings((prev) => {
1220
+ const next = {
1221
+ ...prev,
1222
+ [key]: !prev[key]
1223
+ };
1224
+ onChange?.(next);
1225
+ return next;
1226
+ });
1227
+ }, [onChange]);
1228
+ const value = useMemo(() => ({
1229
+ settings,
1230
+ toggle
1231
+ }), [settings, toggle]);
1232
+ return /* @__PURE__ */ jsx(SettingsContext.Provider, {
1233
+ value,
1234
+ children
1235
+ });
1236
+ }
1237
+ function useSettings() {
1238
+ const ctx = useContext(SettingsContext);
1239
+ if (!ctx) throw new Error("useSettings must be used inside <SettingsProvider>");
1240
+ return ctx;
1241
+ }
1242
+ const ROWS = [
1243
+ {
1244
+ key: "showThinking",
1245
+ label: "Thinking blocks",
1246
+ description: "agent reasoning shown inline"
1247
+ },
1248
+ {
1249
+ key: "showToolCalls",
1250
+ label: "Tool calls",
1251
+ description: "the ↳ name(args) lines"
1252
+ },
1253
+ {
1254
+ key: "showToolResults",
1255
+ label: "Tool outputs",
1256
+ description: "the ┃ result blocks under tool calls"
1257
+ }
1258
+ ];
1259
+ function SettingsModal() {
1260
+ const { settings, toggle } = useSettings();
1261
+ const [cursor, setCursor] = useState(0);
1262
+ useKeyboard((key) => {
1263
+ if (key.name === "up" || key.ctrl && key.name === "p") setCursor((c) => Math.max(0, c - 1));
1264
+ else if (key.name === "down" || key.ctrl && key.name === "n") setCursor((c) => Math.min(ROWS.length - 1, c + 1));
1265
+ else if (key.name === "return" || key.name === "space") toggle(ROWS[cursor].key);
1266
+ });
1267
+ return /* @__PURE__ */ jsxs(Modal, {
1268
+ title: "settings",
1269
+ children: [/* @__PURE__ */ jsx("box", {
1270
+ style: { flexDirection: "column" },
1271
+ children: ROWS.map((row, i) => /* @__PURE__ */ jsx(SettingRowView, {
1272
+ row,
1273
+ enabled: settings[row.key],
1274
+ focused: i === cursor
1275
+ }, row.key))
1276
+ }), /* @__PURE__ */ jsxs("text", {
1277
+ fg: COLOR.mute,
1278
+ children: [
1279
+ /* @__PURE__ */ jsx("span", {
1280
+ fg: COLOR.warn,
1281
+ children: "↑↓"
1282
+ }),
1283
+ " navigate · ",
1284
+ /* @__PURE__ */ jsx("span", {
1285
+ fg: COLOR.warn,
1286
+ children: "↵"
1287
+ }),
1288
+ " toggle · ",
1289
+ /* @__PURE__ */ jsx("span", {
1290
+ fg: COLOR.warn,
1291
+ children: "esc"
1292
+ }),
1293
+ " close"
1294
+ ]
1295
+ })]
1296
+ });
1297
+ }
1298
+ /**
1299
+ * A single setting row — `▶` marker · checkbox · label · description.
1300
+ *
1301
+ * Rendered as one `<text>` so OpenTUI's word-wrap handles narrow terminals
1302
+ * automatically: on wide screens everything sits on one line; on narrow ones
1303
+ * the trailing description wraps under the label without breaking the row's
1304
+ * structure.
1305
+ */
1306
+ function SettingRowView({ row, enabled, focused }) {
1307
+ return /* @__PURE__ */ jsxs("text", {
1308
+ fg: focused ? COLOR.brand : COLOR.dim,
1309
+ children: [
1310
+ /* @__PURE__ */ jsx("span", {
1311
+ fg: focused ? COLOR.brand : COLOR.mute,
1312
+ children: focused ? "▶ " : " "
1313
+ }),
1314
+ /* @__PURE__ */ jsx("span", {
1315
+ fg: enabled ? COLOR.accent : COLOR.mute,
1316
+ children: enabled ? "[✓] " : "[ ] "
1317
+ }),
1318
+ /* @__PURE__ */ jsx("span", {
1319
+ fg: focused ? COLOR.brand : COLOR.dim,
1320
+ children: row.label
1321
+ }),
1322
+ /* @__PURE__ */ jsx("span", {
1323
+ fg: COLOR.mute,
1324
+ children: ` ${row.description}`
1325
+ })
1326
+ ]
1327
+ });
1328
+ }
1329
+ //#endregion
1330
+ //#region src/tui/streaming.ts
1331
+ /** Target one flush per ~33ms (one frame at the default renderer targetFps=30). */
1332
+ const FLUSH_INTERVAL_MS = 33;
1333
+ const PARENT_OWNER = "parent";
1334
+ function emptyBucket(owner, depth) {
1335
+ return {
1336
+ markdown: "",
1337
+ thinking: "",
1338
+ owner,
1339
+ depth
1340
+ };
1341
+ }
1342
+ function applyBucket(prev, bucket) {
1343
+ let result = prev;
1344
+ if (bucket.thinking) result = appendThinkingLines(result, bucket.thinking, bucket.owner, bucket.depth);
1345
+ if (bucket.markdown) result = appendMarkdownDelta(result, bucket.markdown, bucket.owner, bucket.depth);
1346
+ return result;
1347
+ }
1348
+ function appendMarkdownDelta(prev, delta, owner, depth) {
1349
+ const last = prev[prev.length - 1];
1350
+ if (last && last.kind === "markdown" && last.streaming && ownerOf(last) === owner) {
1351
+ const next = prev.slice(0, -1);
1352
+ next.push({
1353
+ ...last,
1354
+ text: last.text + delta
1355
+ });
1356
+ return next;
1357
+ }
1358
+ return [...prev, eventWithOwner({
1359
+ kind: "markdown",
1360
+ text: delta,
1361
+ streaming: true
1362
+ }, owner, depth)];
1363
+ }
1364
+ function appendThinkingLines(prev, delta, owner, depth) {
1365
+ const lines = delta.split("\n");
1366
+ const result = [...prev];
1367
+ const last = result[result.length - 1];
1368
+ if (last && last.kind === "thinking" && ownerOf(last) === owner) result[result.length - 1] = {
1369
+ ...last,
1370
+ text: last.text + lines[0]
1371
+ };
1372
+ else if (lines[0] || lines.length > 1) result.push(eventWithOwner({
1373
+ kind: "thinking",
1374
+ text: lines[0]
1375
+ }, owner, depth));
1376
+ for (let i = 1; i < lines.length; i++) result.push(eventWithOwner({
1377
+ kind: "thinking",
1378
+ text: lines[i]
1379
+ }, owner, depth));
1380
+ return result;
1381
+ }
1382
+ function ownerOf(evt) {
1383
+ return evt.childId ?? PARENT_OWNER;
1384
+ }
1385
+ function eventWithOwner(evt, owner, depth) {
1386
+ if (owner === PARENT_OWNER) return evt;
1387
+ return {
1388
+ ...evt,
1389
+ childId: owner,
1390
+ depth
1391
+ };
1392
+ }
1393
+ /** Flip any trailing streaming markdown blocks (any owner) to finalized. */
1394
+ function finalizeStreamingMarkdown(events) {
1395
+ let changed = false;
1396
+ const next = events.map((e) => {
1397
+ if (e.kind === "markdown" && e.streaming) {
1398
+ changed = true;
1399
+ return {
1400
+ ...e,
1401
+ streaming: false
1402
+ };
1403
+ }
1404
+ return e;
1405
+ });
1406
+ return changed ? next : events;
1407
+ }
1408
+ /** Flip the trailing streaming markdown block for one specific owner. */
1409
+ function finalizeStreamingMarkdownForOwner(events, owner) {
1410
+ for (let i = events.length - 1; i >= 0; i--) {
1411
+ const e = events[i];
1412
+ if (e.kind !== "markdown") continue;
1413
+ if (!e.streaming) continue;
1414
+ if (ownerOf(e) !== owner) continue;
1415
+ const next = events.slice();
1416
+ next[i] = {
1417
+ ...e,
1418
+ streaming: false
1419
+ };
1420
+ return next;
1421
+ }
1422
+ return events;
1423
+ }
1424
+ /**
1425
+ * Effective context size for a single turn.
1426
+ *
1427
+ * `usage.input` is misleading on its own when prompt caching is active: providers
1428
+ * (Anthropic, OpenRouter→Anthropic, Gemini) report `input` as the *new uncached*
1429
+ * tokens only — the cached prefix shows up in `cacheRead`, and newly-cached
1430
+ * tokens in `cacheCreation`. The model still saw all three buckets, so the real
1431
+ * context-window utilization is their sum.
1432
+ *
1433
+ * Non-caching providers leave `cacheRead`/`cacheCreation` undefined, so this
1434
+ * collapses to plain `input` for them.
1435
+ */
1436
+ function turnContextSize(usage) {
1437
+ if (!usage) return 0;
1438
+ return (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheCreation ?? 0);
1439
+ }
1440
+ function useStreamBuffer(setEvents) {
1441
+ const bucketsRef = useRef(/* @__PURE__ */ new Map());
1442
+ const flushTimerRef = useRef(null);
1443
+ const drainPendingInto = useCallback((updater) => {
1444
+ if (flushTimerRef.current) {
1445
+ clearTimeout(flushTimerRef.current);
1446
+ flushTimerRef.current = null;
1447
+ }
1448
+ const buckets = Array.from(bucketsRef.current.values());
1449
+ bucketsRef.current.clear();
1450
+ if (!buckets.some((b) => b.markdown.length > 0 || b.thinking.length > 0) && !updater) return;
1451
+ setEvents((prev) => {
1452
+ let merged = prev;
1453
+ for (const bucket of buckets) merged = applyBucket(merged, bucket);
1454
+ return updater ? updater(merged) : merged;
1455
+ });
1456
+ }, [setEvents]);
1457
+ const flush = useCallback(() => drainPendingInto(), [drainPendingInto]);
1458
+ const flushAndUpdate = useCallback((update) => drainPendingInto(update), [drainPendingInto]);
1459
+ const appendImmediate = useCallback((evt) => drainPendingInto((events) => [...events, evt]), [drainPendingInto]);
1460
+ const queueStreamDelta = useCallback((kind, delta, source) => {
1461
+ if (!delta) return;
1462
+ const owner = source?.childId ?? PARENT_OWNER;
1463
+ const depth = source?.depth ?? 0;
1464
+ let bucket = bucketsRef.current.get(owner);
1465
+ if (!bucket) {
1466
+ bucket = emptyBucket(owner, depth);
1467
+ bucketsRef.current.set(owner, bucket);
1468
+ }
1469
+ bucket[kind] += delta;
1470
+ if (!flushTimerRef.current) flushTimerRef.current = setTimeout(flush, FLUSH_INTERVAL_MS);
1471
+ }, [flush]);
1472
+ const reset = useCallback(() => {
1473
+ if (flushTimerRef.current) {
1474
+ clearTimeout(flushTimerRef.current);
1475
+ flushTimerRef.current = null;
1476
+ }
1477
+ bucketsRef.current.clear();
1478
+ }, []);
1479
+ return useMemo(() => ({
1480
+ queueStreamDelta,
1481
+ appendImmediate,
1482
+ flushAndUpdate,
1483
+ flush,
1484
+ reset
1485
+ }), [
1486
+ queueStreamDelta,
1487
+ appendImmediate,
1488
+ flushAndUpdate,
1489
+ flush,
1490
+ reset
1491
+ ]);
1492
+ }
1493
+ //#endregion
1494
+ //#region src/tui/app.tsx
1495
+ /**
1496
+ * Surface failures that are normally silenced (teardown / save) when the
1497
+ * `ZIDANE_DEBUG` env var is set. Logging via `console.error` would otherwise
1498
+ * trigger OpenTUI's error console overlay and clutter the UI for end users.
1499
+ */
1500
+ const debugLog = process.env.ZIDANE_DEBUG ? (label, err) => console.error(`[zidane/tui] ${label}:`, err) : () => {};
1501
+ /**
1502
+ * Top-level TUI component. Accepts a fully-resolved `ResolvedConfig` and wires
1503
+ * everything (settings, modal layer, screens, footer) underneath it.
1504
+ *
1505
+ * Hosts can either drive this via `runTui()` for the standard bootstrap or
1506
+ * mount `<App config={resolveConfig(...)} />` themselves inside a renderer
1507
+ * they already own.
1508
+ */
1509
+ function App({ config }) {
1510
+ return /* @__PURE__ */ jsx(ConfigProvider, {
1511
+ config,
1512
+ children: /* @__PURE__ */ jsx(SettingsProvider, {
1513
+ initial: useMemo(() => ({
1514
+ ...DEFAULT_SETTINGS,
1515
+ ...config.initialSettings
1516
+ }), [config.initialSettings]),
1517
+ onChange: useCallback((settings) => config.stateStore.save({
1518
+ ...config.stateStore.load(),
1519
+ settings
1520
+ }), [config.stateStore]),
1521
+ children: /* @__PURE__ */ jsx(ModalRoot, { children: /* @__PURE__ */ jsx(AppShell, {}) })
1522
+ })
1523
+ });
1524
+ }
1525
+ function AppShell() {
1526
+ const renderer = useRenderer();
1527
+ const modal = useModal();
1528
+ const config = useConfig();
1529
+ const { settings } = useSettings();
1530
+ const { providers: providerRegistry, preset, store, stateStore, modelsFor, resumeProvider, initialPicked, initialState } = config;
1531
+ const lastResumedSessionId = initialState.lastSessionId;
1532
+ const [screen, setScreen] = useState(() => {
1533
+ if (!resumeProvider) return "auth";
1534
+ return lastResumedSessionId ? "chat" : "sessions";
1535
+ });
1536
+ const [picked, setPicked] = useState(() => initialPicked);
1537
+ const [sessions, setSessions] = useState([]);
1538
+ const [currentSession, setCurrentSession] = useState(null);
1539
+ const [events, setEvents] = useState([]);
1540
+ const [busy, setBusy] = useState(false);
1541
+ /** Token count from the most recent assistant turn (caching-aware). */
1542
+ const [lastInputTokens, setLastInputTokens] = useState(0);
1543
+ const agentRef = useRef(null);
1544
+ const sessionRef = useRef(null);
1545
+ const stream = useStreamBuffer(setEvents);
1546
+ const makePicked = useCallback((provider, modelId) => {
1547
+ const factory = providerRegistry[provider.key];
1548
+ if (!factory) return null;
1549
+ const remembered = initialState.lastModelByProvider?.[provider.key];
1550
+ return {
1551
+ provider,
1552
+ model: modelId ?? remembered ?? factory().meta.defaultModel
1553
+ };
1554
+ }, [providerRegistry, initialState]);
1555
+ const buildAgent = useCallback((session, key) => {
1556
+ const factory = providerRegistry[key];
1557
+ if (!factory) throw new Error(`No provider registered for key "${key}"`);
1558
+ const agent = createAgent({
1559
+ ...preset,
1560
+ provider: factory(),
1561
+ session
1562
+ });
1563
+ agent.hooks.hook("stream:thinking", ({ delta }) => stream.queueStreamDelta("thinking", delta));
1564
+ agent.hooks.hook("stream:text", ({ delta }) => stream.queueStreamDelta("markdown", delta));
1565
+ agent.hooks.hook("tool:before", ({ name, input }) => {
1566
+ stream.appendImmediate({
1567
+ kind: "tool",
1568
+ text: toolCallPreview(name, input)
1569
+ });
1570
+ });
1571
+ agent.hooks.hook("tool:after", ({ result }) => {
1572
+ stream.appendImmediate({
1573
+ kind: "tool-result",
1574
+ text: toolResultText(result)
1575
+ });
1576
+ });
1577
+ agent.hooks.hook("mcp:tool:after", ({ result }) => {
1578
+ stream.appendImmediate({
1579
+ kind: "tool-result",
1580
+ text: toolResultText(result)
1581
+ });
1582
+ });
1583
+ agent.hooks.hook("turn:after", ({ usage }) => {
1584
+ if (usage) setLastInputTokens(turnContextSize(usage));
1585
+ stream.flushAndUpdate(finalizeStreamingMarkdown);
1586
+ });
1587
+ agent.hooks.hook("spawn:before", ({ id, task, depth }) => {
1588
+ const taskPreview = task.length > 80 ? `${task.slice(0, 80)}…` : task;
1589
+ stream.appendImmediate({
1590
+ kind: "spawn-start",
1591
+ text: taskPreview,
1592
+ childId: id,
1593
+ depth: depth ?? 1
1594
+ });
1595
+ });
1596
+ agent.hooks.hook("spawn:complete", ({ id, depth, status, stats }) => {
1597
+ const tag = status === "aborted" ? "aborted" : status === "error" ? "error" : "done";
1598
+ stream.appendImmediate({
1599
+ kind: "spawn-end",
1600
+ text: `${tag} · ${stats.totalIn} in / ${stats.totalOut} out`,
1601
+ childId: id,
1602
+ depth: depth ?? 1
1603
+ });
1604
+ });
1605
+ agent.hooks.hook("spawn:error", ({ id, depth, error }) => {
1606
+ stream.appendImmediate({
1607
+ kind: "error",
1608
+ text: `[${id}] ${error.message}`,
1609
+ childId: id,
1610
+ depth: depth ?? 1
1611
+ });
1612
+ });
1613
+ agent.hooks.hook("child:stream:thinking", ({ delta, childId, depth }) => {
1614
+ stream.queueStreamDelta("thinking", delta, {
1615
+ childId,
1616
+ depth
1617
+ });
1618
+ });
1619
+ agent.hooks.hook("child:stream:text", ({ delta, childId, depth }) => {
1620
+ stream.queueStreamDelta("markdown", delta, {
1621
+ childId,
1622
+ depth
1623
+ });
1624
+ });
1625
+ agent.hooks.hook("child:tool:before", ({ name, input, childId, depth }) => {
1626
+ stream.appendImmediate({
1627
+ kind: "tool",
1628
+ text: toolCallPreview(name, input),
1629
+ childId,
1630
+ depth
1631
+ });
1632
+ });
1633
+ agent.hooks.hook("child:tool:after", ({ result, childId, depth }) => {
1634
+ stream.appendImmediate({
1635
+ kind: "tool-result",
1636
+ text: toolResultText(result),
1637
+ childId,
1638
+ depth
1639
+ });
1640
+ });
1641
+ agent.hooks.hook("child:stream:end", ({ childId }) => {
1642
+ stream.flushAndUpdate((prev) => finalizeStreamingMarkdownForOwner(prev, childId));
1643
+ });
1644
+ return agent;
1645
+ }, [
1646
+ providerRegistry,
1647
+ preset,
1648
+ stream
1649
+ ]);
1650
+ const refreshSessions = useCallback(async () => {
1651
+ const list = await listSessionMeta(store);
1652
+ setSessions(list);
1653
+ return list;
1654
+ }, [store]);
1655
+ const teardown = useCallback(async () => {
1656
+ stream.reset();
1657
+ await agentRef.current?.destroy().catch((err) => debugLog("agent.destroy failed", err));
1658
+ agentRef.current = null;
1659
+ sessionRef.current = null;
1660
+ }, [stream]);
1661
+ const activateSession = useCallback(async (id, key) => {
1662
+ await teardown();
1663
+ const session = (id ? await loadSession(store, id) : null) ?? await createSession({
1664
+ store,
1665
+ ...id ? { id } : {}
1666
+ });
1667
+ sessionRef.current = session;
1668
+ agentRef.current = buildAgent(session, key);
1669
+ setEvents(eventsFromTurns(session.turns));
1670
+ setLastInputTokens(lastContextSizeFromTurns(session.turns));
1671
+ setCurrentSession({
1672
+ id: session.id,
1673
+ title: titleFromTurns(session.turns) ?? "untitled",
1674
+ turnCount: session.turns.length,
1675
+ updatedAt: Date.now()
1676
+ });
1677
+ setScreen("chat");
1678
+ stateStore.save({
1679
+ ...stateStore.load(),
1680
+ lastProvider: key,
1681
+ lastSessionId: session.id
1682
+ });
1683
+ }, [
1684
+ teardown,
1685
+ buildAgent,
1686
+ store,
1687
+ stateStore
1688
+ ]);
1689
+ useEffect(() => {
1690
+ if (!resumeProvider) return;
1691
+ let cancelled = false;
1692
+ (async () => {
1693
+ if (lastResumedSessionId) {
1694
+ const data = await store.load(lastResumedSessionId);
1695
+ if (cancelled) return;
1696
+ if (data) {
1697
+ await activateSession(lastResumedSessionId, resumeProvider.key);
1698
+ return;
1699
+ }
1700
+ }
1701
+ const list = await refreshSessions();
1702
+ if (cancelled) return;
1703
+ if (list.length === 0) await activateSession(null, resumeProvider.key);
1704
+ else setScreen("sessions");
1705
+ })();
1706
+ return () => {
1707
+ cancelled = true;
1708
+ };
1709
+ }, [
1710
+ activateSession,
1711
+ refreshSessions,
1712
+ resumeProvider,
1713
+ lastResumedSessionId,
1714
+ store
1715
+ ]);
1716
+ const onPickProvider = useCallback(async (p) => {
1717
+ const next = makePicked(p);
1718
+ if (!next) return;
1719
+ setPicked(next);
1720
+ stateStore.save({
1721
+ ...stateStore.load(),
1722
+ lastProvider: p.key
1723
+ });
1724
+ if ((await refreshSessions()).length === 0) await activateSession(null, p.key);
1725
+ else setScreen("sessions");
1726
+ }, [
1727
+ refreshSessions,
1728
+ activateSession,
1729
+ makePicked,
1730
+ stateStore
1731
+ ]);
1732
+ const onCreateSession = useCallback(async () => {
1733
+ if (picked) await activateSession(null, picked.provider.key);
1734
+ }, [picked, activateSession]);
1735
+ const onSwitchSession = useCallback(async (id) => {
1736
+ if (picked) await activateSession(id, picked.provider.key);
1737
+ }, [picked, activateSession]);
1738
+ const onOpenSessions = useCallback(async () => {
1739
+ await refreshSessions();
1740
+ setScreen("sessions");
1741
+ }, [refreshSessions]);
1742
+ const onAbort = useCallback(() => {
1743
+ agentRef.current?.abort();
1744
+ }, []);
1745
+ const onPickModel = useCallback((modelId) => {
1746
+ setPicked((prev) => {
1747
+ if (!prev) return prev;
1748
+ const prior = stateStore.load();
1749
+ stateStore.save({
1750
+ ...prior,
1751
+ lastModelByProvider: {
1752
+ ...prior.lastModelByProvider,
1753
+ [prev.provider.key]: modelId
1754
+ }
1755
+ });
1756
+ return {
1757
+ ...prev,
1758
+ model: modelId
1759
+ };
1760
+ });
1761
+ modal.close();
1762
+ }, [modal, stateStore]);
1763
+ const onSubmitPrompt = useCallback(async (prompt) => {
1764
+ const agent = agentRef.current;
1765
+ const session = sessionRef.current;
1766
+ if (!agent || !session || !picked || !prompt.trim()) return;
1767
+ if (events.length > 0) stream.appendImmediate({
1768
+ kind: "separator",
1769
+ text: ""
1770
+ });
1771
+ stream.appendImmediate({
1772
+ kind: "info",
1773
+ text: `❯ ${prompt}`
1774
+ });
1775
+ setBusy(true);
1776
+ try {
1777
+ await agent.run({
1778
+ model: picked.model,
1779
+ prompt
1780
+ });
1781
+ await session.save().catch((err) => debugLog("session.save failed", err));
1782
+ setCurrentSession((prev) => prev ? {
1783
+ ...prev,
1784
+ title: titleFromTurns(session.turns) ?? prev.title,
1785
+ turnCount: session.turns.length,
1786
+ updatedAt: Date.now()
1787
+ } : prev);
1788
+ } catch (err) {
1789
+ stream.appendImmediate({
1790
+ kind: "error",
1791
+ text: err instanceof Error ? err.message : String(err)
1792
+ });
1793
+ } finally {
1794
+ stream.flushAndUpdate(finalizeStreamingMarkdown);
1795
+ setBusy(false);
1796
+ }
1797
+ }, [
1798
+ picked,
1799
+ events.length,
1800
+ stream
1801
+ ]);
1802
+ useKeyboard((key) => {
1803
+ if (modal.isOpen) return;
1804
+ if (key.ctrl && key.name === "," && screen !== "auth") {
1805
+ modal.open(/* @__PURE__ */ jsx(SettingsModal, {}));
1806
+ return;
1807
+ }
1808
+ if (key.ctrl && key.name === "m" && screen === "chat" && picked && !busy) {
1809
+ modal.open(/* @__PURE__ */ jsx(ModelPickerModal, {
1810
+ models: modelsFor(picked.provider.key),
1811
+ currentModelId: picked.model,
1812
+ onPick: onPickModel
1813
+ }));
1814
+ return;
1815
+ }
1816
+ if (key.name !== "escape") return;
1817
+ if (busy) return onAbort();
1818
+ if (screen === "chat") return onOpenSessions();
1819
+ if (screen === "sessions") {
1820
+ if (currentSession) setScreen("chat");
1821
+ else renderer.destroy();
1822
+ return;
1823
+ }
1824
+ renderer.destroy();
1825
+ });
1826
+ const hints = useMemo(() => buildHints(screen, busy, currentSession), [
1827
+ screen,
1828
+ busy,
1829
+ currentSession
1830
+ ]);
1831
+ const contextUsage = useMemo(() => {
1832
+ if (screen !== "chat" || !picked) return null;
1833
+ const max = modelsFor(picked.provider.key).find((m) => m.id === picked.model)?.contextWindow ?? getContextWindow(picked.provider.key, picked.model);
1834
+ return max ? {
1835
+ used: lastInputTokens,
1836
+ max
1837
+ } : null;
1838
+ }, [
1839
+ screen,
1840
+ picked,
1841
+ lastInputTokens,
1842
+ modelsFor
1843
+ ]);
1844
+ useEffect(() => () => {
1845
+ teardown();
1846
+ }, [teardown]);
1847
+ return /* @__PURE__ */ jsxs("box", {
1848
+ style: {
1849
+ flexDirection: "column",
1850
+ flexGrow: 1
1851
+ },
1852
+ children: [/* @__PURE__ */ jsxs("box", {
1853
+ style: {
1854
+ flexDirection: "column",
1855
+ flexGrow: 1,
1856
+ paddingLeft: 1,
1857
+ paddingRight: 1
1858
+ },
1859
+ children: [
1860
+ screen === "auth" && /* @__PURE__ */ jsx(AuthScreen, { onPick: onPickProvider }),
1861
+ screen === "sessions" && /* @__PURE__ */ jsx(SessionsScreen, {
1862
+ sessions,
1863
+ currentId: currentSession?.id ?? null,
1864
+ onPick: onSwitchSession,
1865
+ onCreate: onCreateSession
1866
+ }),
1867
+ screen === "chat" && /* @__PURE__ */ jsx(ChatScreen, {
1868
+ events,
1869
+ busy,
1870
+ settings,
1871
+ onSubmit: onSubmitPrompt,
1872
+ session: currentSession
1873
+ })
1874
+ ]
1875
+ }), /* @__PURE__ */ jsx(Footer, {
1876
+ hints,
1877
+ picked,
1878
+ context: contextUsage
1879
+ })]
1880
+ });
1881
+ }
1882
+ function buildHints(screen, busy, currentSession) {
1883
+ if (busy) return [{
1884
+ key: "esc",
1885
+ label: "abort"
1886
+ }];
1887
+ if (screen === "auth") return [
1888
+ {
1889
+ key: "↑↓",
1890
+ label: "navigate"
1891
+ },
1892
+ {
1893
+ key: "↵",
1894
+ label: "select"
1895
+ },
1896
+ {
1897
+ key: "esc",
1898
+ label: "exit"
1899
+ }
1900
+ ];
1901
+ if (screen === "sessions") return [
1902
+ {
1903
+ key: "↑↓",
1904
+ label: "navigate"
1905
+ },
1906
+ {
1907
+ key: "↵",
1908
+ label: "open"
1909
+ },
1910
+ {
1911
+ key: "ctrl+,",
1912
+ label: "settings"
1913
+ },
1914
+ {
1915
+ key: "esc",
1916
+ label: currentSession ? "back" : "exit"
1917
+ }
1918
+ ];
1919
+ return [
1920
+ {
1921
+ key: "↵",
1922
+ label: "send"
1923
+ },
1924
+ {
1925
+ key: "ctrl+m",
1926
+ label: "model"
1927
+ },
1928
+ {
1929
+ key: "ctrl+,",
1930
+ label: "settings"
1931
+ },
1932
+ {
1933
+ key: "esc",
1934
+ label: "sessions"
1935
+ }
1936
+ ];
1937
+ }
1938
+ //#endregion
1939
+ //#region src/tui/index.tsx
1940
+ /**
1941
+ * Tracks whether `runTui` has been invoked in this process. `createCliRenderer`
1942
+ * installs signal handlers + hijacks stdin; calling it a second time produces
1943
+ * undefined behavior. We bail loudly instead.
1944
+ */
1945
+ let runTuiInvoked = false;
1946
+ /**
1947
+ * Boot a full chat TUI with sensible defaults. **Does not return** under
1948
+ * normal use — it terminates the host process via `process.exit(0)` once
1949
+ * the user dismisses the renderer (Ctrl+C / Esc on the auth screen / a
1950
+ * non-zero exit code is mapped via the `catch` path below).
1951
+ *
1952
+ * **One-shot:** this function may only be invoked once per process. The
1953
+ * underlying OpenTUI renderer wires up global terminal state on init.
1954
+ *
1955
+ * **Why it exits the process:** after the renderer's `destroy()` restores
1956
+ * the terminal, React's reconciler and OpenTUI's internal listeners can
1957
+ * keep the Node/Bun event loop open indefinitely — the script appears to
1958
+ * hang in `bun run`, and under `bun --watch run` the watcher waits forever
1959
+ * for the child to exit. Forcing a clean exit here is the contract that
1960
+ * makes `runTui` a true one-shot launcher.
1961
+ *
1962
+ * Hosts that need post-renderer cleanup should mount `<App config={...} />`
1963
+ * against their own `createCliRenderer()` instead of calling `runTui()`.
1964
+ *
1965
+ * Env-var overrides (handy when launching from CI or restricted shells):
1966
+ * - `ZIDANE_STORAGE_DIR` — sets `storageDir`
1967
+ * - `ZIDANE_PREFIX` — sets `prefix`
1968
+ *
1969
+ * Hosts building on top of `zidane/tui` typically want their own env vars
1970
+ * (e.g. `MYAPP_STORAGE_DIR`); read them in your launch script and forward
1971
+ * to `runTui({ storageDir, prefix })`.
1972
+ *
1973
+ * ```ts
1974
+ * import { runTui } from 'zidane/tui'
1975
+ * import { createRemoteStore } from 'zidane/session' // for the `store` option
1976
+ *
1977
+ * await runTui() // ~/.zidane/sessions.db + state.json
1978
+ * await runTui({ prefix: '.myapp' }) // ~/.myapp/...
1979
+ * await runTui({ storageDir: '/data', prefix: 'myapp' })
1980
+ * await runTui({ providers: { custom: () => myProvider() } })
1981
+ * await runTui({ store: createRemoteStore({ url: '…' }) })
1982
+ * await runTui({ models: { anthropic: [{ id: 'claude-foo', contextWindow: 200_000 }] } })
1983
+ * ```
1984
+ */
1985
+ async function runTui(options = {}) {
1986
+ if (runTuiInvoked) throw new Error("runTui() can only be invoked once per process. Compose `<App config={resolveConfig(...)} />` against your own renderer if you need to run multiple TUIs in the same lifetime.");
1987
+ runTuiInvoked = true;
1988
+ await init();
1989
+ const config = resolveConfig(options);
1990
+ let done = () => {};
1991
+ const exited = new Promise((resolve) => {
1992
+ done = resolve;
1993
+ });
1994
+ createRoot(await createCliRenderer({
1995
+ exitOnCtrlC: true,
1996
+ onDestroy: () => done()
1997
+ })).render(/* @__PURE__ */ jsx(App, { config }));
1998
+ await exited;
1999
+ process.exit(0);
2000
+ }
2001
+ //#endregion
2002
+ export { App, AuthScreen, COLOR, ChatScreen, ConfigProvider, DEFAULT_SETTINGS, FACTORIES, Footer, MD_STYLE, Modal, ModalRoot, ModelPickerModal, PI_PROVIDER_ID, SELECT_THEME, SessionsScreen, SettingsModal, SettingsProvider, Spinner, Transcript, ageString, createStateStore, createTuiStore, detectAuth, envKeyFor, eventsFromTurns, finalizeStreamingMarkdown, finalizeStreamingMarkdownForOwner, fmtTokens, getContextWindow, lastContextSizeFromTurns, listSessionMeta, loadState, marginTopFor, onInputSubmit, resolveConfig, runTui, saveState, shortId, titleFromTurns, toolCallPreview, toolResultText, turnContextSize, useConfig, useModal, useModalAwareFocus, useSettings, useStreamBuffer };
2003
+
2004
+ //# sourceMappingURL=tui.js.map