zidane 4.1.5 → 4.1.7

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 (45) hide show
  1. package/dist/chat.d.ts +305 -0
  2. package/dist/chat.d.ts.map +1 -0
  3. package/dist/chat.js +2 -0
  4. package/dist/contexts.d.ts +1 -1
  5. package/dist/{index-BfSdALzk.d.ts → index-BB4kuRh3.d.ts} +1 -1
  6. package/dist/{index-BfSdALzk.d.ts.map → index-BB4kuRh3.d.ts.map} +1 -1
  7. package/dist/{index-28otmfLX.d.ts → index-DRoG_udt.d.ts} +42 -42
  8. package/dist/index-DRoG_udt.d.ts.map +1 -0
  9. package/dist/{index-DPsd0qwm.d.ts → index-Ds5YpvfZ.d.ts} +3 -3
  10. package/dist/{index-DPsd0qwm.d.ts.map → index-Ds5YpvfZ.d.ts.map} +1 -1
  11. package/dist/{agent-BoV5Twdl.d.ts → index-bgh-k8Mv.d.ts} +1989 -1963
  12. package/dist/index-bgh-k8Mv.d.ts.map +1 -0
  13. package/dist/index.d.ts +4 -4
  14. package/dist/index.js +3 -3
  15. package/dist/mcp.d.ts +1 -1
  16. package/dist/{presets-Cs7_CsMk.js → presets-BzkJDW1K.js} +3 -3
  17. package/dist/presets-BzkJDW1K.js.map +1 -0
  18. package/dist/presets.d.ts +1 -1
  19. package/dist/presets.js +1 -1
  20. package/dist/providers.d.ts +1 -1
  21. package/dist/session/sqlite.d.ts +1 -1
  22. package/dist/session.d.ts +1 -1
  23. package/dist/skills.d.ts +2 -2
  24. package/dist/{stats-DoKUtF5T.js → stats-BT9l57RS.js} +34 -2
  25. package/dist/stats-BT9l57RS.js.map +1 -0
  26. package/dist/theme-BlXO6yHe.d.ts +503 -0
  27. package/dist/theme-BlXO6yHe.d.ts.map +1 -0
  28. package/dist/theme-context-MungM3SY.js +1713 -0
  29. package/dist/theme-context-MungM3SY.js.map +1 -0
  30. package/dist/{tools-DpeWKzP1.js → tools-C8kDot0H.js} +73 -23
  31. package/dist/tools-C8kDot0H.js.map +1 -0
  32. package/dist/tools.d.ts +2 -2
  33. package/dist/tools.js +1 -1
  34. package/dist/tui.d.ts +51 -499
  35. package/dist/tui.d.ts.map +1 -1
  36. package/dist/tui.js +687 -931
  37. package/dist/tui.js.map +1 -1
  38. package/dist/types.d.ts +3 -3
  39. package/dist/types.js +1 -1
  40. package/package.json +6 -3
  41. package/dist/agent-BoV5Twdl.d.ts.map +0 -1
  42. package/dist/index-28otmfLX.d.ts.map +0 -1
  43. package/dist/presets-Cs7_CsMk.js.map +0 -1
  44. package/dist/stats-DoKUtF5T.js.map +0 -1
  45. package/dist/tools-DpeWKzP1.js.map +0 -1
package/dist/tui.js CHANGED
@@ -1,120 +1,43 @@
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-CCDvIXGJ.js";
1
+ import { d as createAgent } from "./tools-C8kDot0H.js";
2
+ import { n as formatTokenUsage } from "./stats-BT9l57RS.js";
5
3
  import { n as loadSession, t as createSession } from "./session-Cn68UASv.js";
6
- import { createSqliteStore } from "./session/sqlite.js";
7
- import { spawn } from "node:child_process";
8
- import { dirname, resolve } from "node:path";
9
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
- import { homedir } from "node:os";
11
- import { anthropicOAuthProvider, openaiCodexOAuthProvider } from "@mariozechner/pi-ai/oauth";
12
- import { getModel, getModels } from "@mariozechner/pi-ai";
13
- import { RGBA, SyntaxStyle, createCliRenderer, defaultTextareaKeyBindings } from "@opentui/core";
14
- import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
15
- import { heal, init } from "md4x/wasm";
4
+ import { $ as toolCallPreview, A as isOnSafelist, B as shortId, E as useSafeModeQueue, H as useConfig, I as runOAuthLogin, J as listSessionMeta, K as eventsFromTurns, L as supportsOAuth, O as addToSafelist, P as suggestSafelistEntry, Q as titleFromTurns, R as ageString, T as useSafeModeActions, U as resolveConfig, V as ConfigProvider, Z as stripSpawnTokensLine, c as finalizeStreamingMarkdownForOwner, d as DEFAULT_SETTINGS, et as toolResultText, f as SETTINGS_CHOICES, h as useSettings, i as useSurfaces, k as getSafelist, l as turnContextSize, m as SettingsProvider, n as useColors, o as useTheme, p as SETTINGS_TOGGLES, pt as getContextWindow, q as lastContextSizeFromTurns, r as useSelectStyle, s as finalizeStreamingMarkdown, st as setProviderCredential, t as ThemeProvider, tt as detectAuth, u as useStreamBuffer, v as resolveTheme, w as SafeModeProvider, z as fmtTokens } from "./theme-context-MungM3SY.js";
16
5
  import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
17
6
  import { jsx, jsxs } from "@opentui/react/jsx-runtime";
18
- //#region src/tui/format.ts
19
- /** Compact token formatter 12_415 "12.4k", 1_234_567 → "1.23M". */
20
- function fmtTokens(n) {
21
- if (n < 1e3) return String(n);
22
- if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 2 : 1)}k`;
23
- return `${(n / 1e6).toFixed(2)}M`;
24
- }
25
- /** Compact relative-time formatter — "just now / 5m / 3h / 2d". */
26
- function ageString(ts, now = Date.now()) {
27
- const m = Math.floor((now - ts) / 6e4);
28
- if (m < 1) return "just now";
29
- if (m < 60) return `${m}m ago`;
30
- const h = Math.floor(m / 60);
31
- if (h < 24) return `${h}h ago`;
32
- return `${Math.floor(h / 24)}d ago`;
33
- }
34
- /** Six-char short form of a session id for headers and lists. */
35
- function shortId(id) {
36
- return id.replace(/-/g, "").slice(0, 6);
37
- }
38
- //#endregion
7
+ import { RGBA, SyntaxStyle, addDefaultParsers, createCliRenderer, defaultTextareaKeyBindings, getTreeSitterClient } from "@opentui/core";
8
+ import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
39
9
  //#region src/tui/theme.ts
40
10
  /**
41
- * Shared color palette. Kept as plain hex strings so it can be consumed by
42
- * OpenTUI props that accept `string | RGBA`. The names describe role, not
43
- * literal hue, so the theme can be swapped without touching call sites.
11
+ * Convert the renderer-agnostic `Theme.syntax` map (hex strings + plain
12
+ * booleans) into an OpenTUI `SyntaxStyle`. Used both for the markdown
13
+ * structural captures (`markup.heading`, `markup.bold`, …) and the
14
+ * embedded Tree-sitter language tokens (`keyword`, `string`, `function`,
15
+ * …) — OpenTUI's `<markdown>` re-uses the same `SyntaxStyle` for the
16
+ * fenced-code renderable, so one table drives both surfaces.
44
17
  */
45
- const COLOR = {
46
- brand: "#FFCC00",
47
- accent: "#00FF88",
48
- model: "#88CCFF",
49
- warn: "#FFAA66",
50
- error: "#FF6666",
51
- dim: "#888888",
52
- mute: "#555555",
53
- border: "#333333",
54
- borderActive: "#555555"
55
- };
56
- /**
57
- * Shared select styling — keeps the highlight bar from filling with a
58
- * different background than the surrounding box. The `▶` marker and the
59
- * brand-colored selected text carry the focus affordance.
60
- */
61
- const SELECT_THEME = {
62
- backgroundColor: "transparent",
63
- focusedBackgroundColor: "transparent",
64
- selectedBackgroundColor: "transparent",
65
- selectedTextColor: COLOR.brand,
66
- textColor: COLOR.dim,
67
- descriptionColor: COLOR.mute,
68
- selectedDescriptionColor: COLOR.dim
69
- };
18
+ function buildMdStyle(theme) {
19
+ const styles = {};
20
+ for (const [token, style] of Object.entries(theme.syntax)) {
21
+ const out = {};
22
+ if (style.fg) out.fg = RGBA.fromHex(style.fg);
23
+ if (style.bg) out.bg = RGBA.fromHex(style.bg);
24
+ if (style.bold) out.bold = true;
25
+ if (style.italic) out.italic = true;
26
+ if (style.underline) out.underline = true;
27
+ if (style.dim) out.dim = true;
28
+ styles[token] = out;
29
+ }
30
+ return SyntaxStyle.fromStyles(styles);
31
+ }
70
32
  /**
71
- * Theme for markdown token highlighting. Token names map to Tree-sitter highlight
72
- * captures emitted by OpenTUI's markdown parser; the `default` entry is the
73
- * fallback for unstyled text.
33
+ * Active markdown / syntax-highlighting style, memoized per theme. Reading
34
+ * this in a component subscribes it to theme changes a `Settings.theme`
35
+ * flip immediately re-renders the affected `<markdown>` instances.
74
36
  */
75
- const MD_STYLE = SyntaxStyle.fromStyles({
76
- "default": { fg: RGBA.fromHex("#E6EDF3") },
77
- "markup.heading": {
78
- fg: RGBA.fromHex(COLOR.brand),
79
- bold: true
80
- },
81
- "markup.heading.1": {
82
- fg: RGBA.fromHex(COLOR.brand),
83
- bold: true
84
- },
85
- "markup.heading.2": {
86
- fg: RGBA.fromHex("#FFD84D"),
87
- bold: true
88
- },
89
- "markup.heading.3": {
90
- fg: RGBA.fromHex("#FFE680"),
91
- bold: true
92
- },
93
- "markup.bold": {
94
- fg: RGBA.fromHex("#FFFFFF"),
95
- bold: true
96
- },
97
- "markup.italic": {
98
- fg: RGBA.fromHex("#E6EDF3"),
99
- italic: true
100
- },
101
- "markup.link": {
102
- fg: RGBA.fromHex(COLOR.model),
103
- underline: true
104
- },
105
- "markup.link.url": {
106
- fg: RGBA.fromHex(COLOR.model),
107
- underline: true
108
- },
109
- "markup.list": { fg: RGBA.fromHex(COLOR.warn) },
110
- "markup.raw": { fg: RGBA.fromHex("#A5D6FF") },
111
- "markup.raw.block": { fg: RGBA.fromHex("#A5D6FF") },
112
- "markup.quote": {
113
- fg: RGBA.fromHex(COLOR.dim),
114
- italic: true
115
- },
116
- "punctuation": { fg: RGBA.fromHex(COLOR.mute) }
117
- });
37
+ function useMdStyle() {
38
+ const theme = useTheme();
39
+ return useMemo(() => buildMdStyle(theme), [theme]);
40
+ }
118
41
  //#endregion
119
42
  //#region src/tui/components.tsx
120
43
  /**
@@ -125,9 +48,17 @@ const MD_STYLE = SyntaxStyle.fromStyles({
125
48
  * The outer wrapper handles top-margin per kind (and per neighbor) so spacing
126
49
  * is the single source of truth for inter-event breathing room.
127
50
  */
128
- const EventLine = memo(({ event, previous }) => /* @__PURE__ */ jsx("box", {
129
- style: { marginTop: marginTopFor(event, previous) },
130
- children: /* @__PURE__ */ jsx(EventLineImpl, { event })
51
+ const EventLine = memo(({ event, previous, depthOffset = 0 }) => /* @__PURE__ */ jsx("box", {
52
+ style: {
53
+ marginTop: marginTopFor(event, previous),
54
+ alignSelf: "stretch",
55
+ flexShrink: 0,
56
+ flexDirection: "column"
57
+ },
58
+ children: /* @__PURE__ */ jsx(EventLineImpl, {
59
+ event,
60
+ depthOffset
61
+ })
131
62
  }));
132
63
  /**
133
64
  * `@opentui/react` extends `React.JSX.IntrinsicElements`, so `onSubmit` on `<input>`
@@ -139,6 +70,7 @@ function onInputSubmit(handler) {
139
70
  return handler;
140
71
  }
141
72
  function Footer({ hints, picked, context }) {
73
+ const COLOR = useColors();
142
74
  return /* @__PURE__ */ jsxs("box", {
143
75
  style: {
144
76
  flexDirection: "row",
@@ -171,6 +103,7 @@ function Footer({ hints, picked, context }) {
171
103
  });
172
104
  }
173
105
  function ProviderBadge({ picked }) {
106
+ const COLOR = useColors();
174
107
  const source = picked.provider.methods[0].source;
175
108
  return /* @__PURE__ */ jsxs("text", {
176
109
  fg: COLOR.dim,
@@ -203,6 +136,7 @@ function ProviderBadge({ picked }) {
203
136
  });
204
137
  }
205
138
  function ContextIndicator({ context }) {
139
+ const COLOR = useColors();
206
140
  const ratio = context.max > 0 ? context.used / context.max : 0;
207
141
  const pct = Math.round(ratio * 100);
208
142
  const color = ratio >= .85 ? COLOR.error : ratio >= .6 ? COLOR.warn : COLOR.dim;
@@ -243,6 +177,7 @@ const SPINNER_FRAMES = [
243
177
  const SPINNER_INTERVAL_MS = 80;
244
178
  function Spinner({ label }) {
245
179
  const [frame, setFrame] = useState(0);
180
+ const COLOR = useColors();
246
181
  useEffect(() => {
247
182
  const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), SPINNER_INTERVAL_MS);
248
183
  return () => clearInterval(id);
@@ -256,8 +191,8 @@ function Spinner({ label }) {
256
191
  });
257
192
  }
258
193
  function Transcript({ events, settings }) {
259
- const visible = events.filter((e) => isVisible(e, settings));
260
- if (visible.length === 0) return /* @__PURE__ */ jsx(EmptyState$1, {});
194
+ const items = useMemo(() => partitionTranscript(events, settings), [events, settings]);
195
+ if (items.length === 0) return /* @__PURE__ */ jsx(EmptyState$1, {});
261
196
  return /* @__PURE__ */ jsx("scrollbox", {
262
197
  focusable: false,
263
198
  style: {
@@ -267,13 +202,33 @@ function Transcript({ events, settings }) {
267
202
  },
268
203
  stickyScroll: true,
269
204
  stickyStart: "bottom",
270
- children: visible.map((evt, i) => /* @__PURE__ */ jsx(EventLine, {
271
- event: evt,
272
- previous: visible[i - 1]
205
+ children: items.map((item, i) => item.kind === "event" ? /* @__PURE__ */ jsx(EventLine, {
206
+ event: item.event,
207
+ previous: item.previous
208
+ }, i) : /* @__PURE__ */ jsx(SubagentBlock, {
209
+ events: item.events,
210
+ previous: item.previous
273
211
  }, i))
274
212
  });
275
213
  }
214
+ /**
215
+ * Per-event visibility — filters honor user toggles and the
216
+ * `hideSubagentOutput` setting. When subagent output is hidden:
217
+ * - Child-agent events are filtered down to the `spawn-start` /
218
+ * `spawn-end` markers so the user still sees "🌱 working… 🌳 done".
219
+ * - The parent's `tool-result` for `spawn` is hidden too. Its body
220
+ * duplicates `spawn-end`'s stats line *and* the parent's next markdown
221
+ * turn ("Here's what the sub-agent found: …"). Showing it again
222
+ * produced an extra `┃ [sub-agent child-1] Completed …` block that
223
+ * the user just wanted gone.
224
+ *
225
+ * Exported so the visibility matrix can be unit-tested without rendering.
226
+ */
276
227
  function isVisible(event, settings) {
228
+ if (settings.hideSubagentOutput) {
229
+ if (isChild(event)) return event.kind === "spawn-start" || event.kind === "spawn-end";
230
+ if (event.kind === "tool-result" && event.tool === "spawn") return false;
231
+ }
277
232
  switch (event.kind) {
278
233
  case "thinking": return settings.showThinking;
279
234
  case "tool": return settings.showToolCalls;
@@ -281,6 +236,92 @@ function isVisible(event, settings) {
281
236
  default: return true;
282
237
  }
283
238
  }
239
+ /**
240
+ * Walk the visible-event list once and group consecutive child events
241
+ * (`depth > 0`) into runs so we can wrap each run in a single bordered
242
+ * subagent box.
243
+ *
244
+ * When `hideSubagentOutput` is on, `isVisible` already filters most child
245
+ * events out; the surviving `spawn-start` / `spawn-end` markers render as
246
+ * plain entries (no boxing) — there's nothing meaningful to box.
247
+ */
248
+ function partitionTranscript(events, settings) {
249
+ const visible = events.filter((e) => isVisible(e, settings));
250
+ if (visible.length === 0) return [];
251
+ if (settings.hideSubagentOutput) return visible.map((event, i) => ({
252
+ kind: "event",
253
+ event,
254
+ previous: visible[i - 1]
255
+ }));
256
+ const items = [];
257
+ let run = [];
258
+ let runPrevious;
259
+ const flush = () => {
260
+ if (run.length > 0) {
261
+ items.push({
262
+ kind: "child-run",
263
+ events: run,
264
+ previous: runPrevious
265
+ });
266
+ run = [];
267
+ runPrevious = void 0;
268
+ }
269
+ };
270
+ for (let i = 0; i < visible.length; i++) {
271
+ const event = visible[i];
272
+ if (isChild(event)) {
273
+ if (run.length === 0) runPrevious = visible[i - 1];
274
+ run.push(event);
275
+ } else {
276
+ flush();
277
+ items.push({
278
+ kind: "event",
279
+ event,
280
+ previous: visible[i - 1]
281
+ });
282
+ }
283
+ }
284
+ flush();
285
+ return items;
286
+ }
287
+ /**
288
+ * Bordered container for one run of subagent events. The box's border +
289
+ * left padding give the visual "this is a subagent" affordance, so events
290
+ * inside render with `depthOffset: 1` — a direct child of the parent
291
+ * (depth 1) sits flush against the box's inner padding rather than being
292
+ * indented twice. Grandchildren (depth ≥ 2) still indent further, so
293
+ * nested subagents remain visually distinct.
294
+ */
295
+ function SubagentBlock({ events, previous }) {
296
+ const COLOR = useColors();
297
+ const childIds = useMemo(() => {
298
+ const set = /* @__PURE__ */ new Set();
299
+ for (const e of events) if (e.childId) set.add(e.childId);
300
+ return Array.from(set);
301
+ }, [events]);
302
+ const title = childIds.length === 0 ? " subagent " : childIds.length === 1 ? ` ${childIds[0]} ` : ` subagents · ${childIds.join(", ")} `;
303
+ const marginTop = previous ? 1 : 0;
304
+ return /* @__PURE__ */ jsx("box", {
305
+ title,
306
+ style: {
307
+ border: true,
308
+ borderColor: COLOR.mute,
309
+ paddingLeft: 1,
310
+ paddingRight: 1,
311
+ paddingTop: 0,
312
+ paddingBottom: 0,
313
+ marginTop,
314
+ flexDirection: "column",
315
+ flexShrink: 0,
316
+ alignSelf: "stretch"
317
+ },
318
+ children: events.map((evt, i) => /* @__PURE__ */ jsx(EventLine, {
319
+ event: evt,
320
+ previous: events[i - 1],
321
+ depthOffset: 1
322
+ }, i))
323
+ });
324
+ }
284
325
  function EmptyState$1() {
285
326
  return /* @__PURE__ */ jsx("box", {
286
327
  style: {
@@ -289,7 +330,7 @@ function EmptyState$1() {
289
330
  justifyContent: "center"
290
331
  },
291
332
  children: /* @__PURE__ */ jsx("text", {
292
- fg: COLOR.mute,
333
+ fg: useColors().mute,
293
334
  children: "no messages yet — type below to start"
294
335
  })
295
336
  });
@@ -303,6 +344,23 @@ function isChild(event) {
303
344
  return (event.depth ?? 0) > 0;
304
345
  }
305
346
  /**
347
+ * Shared row geometry for every transcript event.
348
+ *
349
+ * `alignSelf: 'stretch'` + `flexShrink: 0` together pin each row to the
350
+ * scrollbox's content width and prevent flex re-negotiation when neighboring
351
+ * rows grow or shrink (streaming markdown, late-arriving tool results, etc.).
352
+ * Without this, Yoga is free to re-compute widths every render and the
353
+ * visible text appears to "wiggle" between columns as the stream advances.
354
+ */
355
+ function rowStyle(paddingLeft) {
356
+ return {
357
+ paddingLeft,
358
+ flexDirection: "column",
359
+ flexShrink: 0,
360
+ alignSelf: "stretch"
361
+ };
362
+ }
363
+ /**
306
364
  * Default top-margin per kind. Spacing intent:
307
365
  * - `info` / `markdown` / `tool` / `error` / `spawn-start` open a new block
308
366
  * so they each get one row of breathing room above.
@@ -327,33 +385,45 @@ const TOOL_KINDS = new Set(["tool", "tool-result"]);
327
385
  /**
328
386
  * Resolve the top margin for an event given the one rendered just before it.
329
387
  *
330
- * The only context-aware rule today: a tool/tool-result event that follows
331
- * another tool/tool-result event collapses its margin to zero, so a chain of
332
- * tool calls reads as a tight list whether the user has hidden tool outputs
333
- * or not, and whether the agent emits back-to-back calls or call→result pairs.
388
+ * Context-aware rules:
389
+ *
390
+ * - A `tool` / `tool-result` event right after another `tool` / `tool-result`
391
+ * collapses to a tight list call→result pairs and back-to-back calls
392
+ * read as one logical block.
393
+ * - A parent-level event (`depth === 0`) right after a subagent event
394
+ * (`depth > 0`) collapses too. The subagent's `🌳` end marker (and, in
395
+ * show mode, the subagent box's bottom border) already provides the
396
+ * separation; adding the event's default `marginTop` on top would
397
+ * produce the visible "line jump" between a subagent's outcome and the
398
+ * parent's follow-up. Either form of marker is enough — we don't want
399
+ * both.
334
400
  *
335
401
  * Exported so the spacing matrix can be unit-tested without rendering.
336
402
  */
337
403
  function marginTopFor(event, previous) {
338
404
  if (TOOL_KINDS.has(event.kind) && previous && TOOL_KINDS.has(previous.kind)) return 0;
405
+ const eventDepth = event.depth ?? 0;
406
+ const previousDepth = previous?.depth ?? 0;
407
+ if (eventDepth === 0 && previousDepth > 0) return 0;
339
408
  return MARGIN_TOP[event.kind] ?? 0;
340
409
  }
341
- function EventLineImpl({ event }) {
410
+ function EventLineImpl({ event, depthOffset = 0 }) {
411
+ const COLOR = useColors();
342
412
  const safeText = event.text === "" ? " " : event.text;
343
- const paddingLeft = indentFor(event.depth);
413
+ const row = rowStyle(indentFor(Math.max(0, (event.depth ?? 0) - depthOffset)));
344
414
  const child = isChild(event);
345
415
  switch (event.kind) {
346
416
  case "separator": return /* @__PURE__ */ jsx("text", { children: " " });
347
417
  case "info": return /* @__PURE__ */ jsx(UserPromptBlock, { text: safeText });
348
418
  case "thinking": return /* @__PURE__ */ jsx("box", {
349
- style: { paddingLeft },
419
+ style: row,
350
420
  children: /* @__PURE__ */ jsx("text", {
351
421
  fg: COLOR.dim,
352
422
  children: safeText
353
423
  })
354
424
  });
355
425
  case "tool": return /* @__PURE__ */ jsx("box", {
356
- style: { paddingLeft },
426
+ style: row,
357
427
  children: /* @__PURE__ */ jsxs("text", {
358
428
  fg: child ? COLOR.dim : COLOR.model,
359
429
  children: [/* @__PURE__ */ jsx("span", {
@@ -364,10 +434,10 @@ function EventLineImpl({ event }) {
364
434
  });
365
435
  case "tool-result": return /* @__PURE__ */ jsx(ToolResultBlock, {
366
436
  text: event.text,
367
- indent: paddingLeft
437
+ indent: row.paddingLeft
368
438
  });
369
439
  case "error": return /* @__PURE__ */ jsx("box", {
370
- style: { paddingLeft },
440
+ style: row,
371
441
  children: /* @__PURE__ */ jsxs("text", {
372
442
  fg: COLOR.error,
373
443
  children: [/* @__PURE__ */ jsx("span", {
@@ -377,7 +447,7 @@ function EventLineImpl({ event }) {
377
447
  })
378
448
  });
379
449
  case "markdown": return /* @__PURE__ */ jsx("box", {
380
- style: { paddingLeft },
450
+ style: row,
381
451
  children: /* @__PURE__ */ jsx(MarkdownBlock, {
382
452
  text: event.text,
383
453
  streaming: event.streaming ?? false,
@@ -385,7 +455,7 @@ function EventLineImpl({ event }) {
385
455
  })
386
456
  });
387
457
  case "spawn-start": return /* @__PURE__ */ jsx("box", {
388
- style: { paddingLeft },
458
+ style: row,
389
459
  children: /* @__PURE__ */ jsxs("text", {
390
460
  fg: COLOR.dim,
391
461
  children: [
@@ -405,13 +475,13 @@ function EventLineImpl({ event }) {
405
475
  })
406
476
  });
407
477
  case "spawn-end": return /* @__PURE__ */ jsx("box", {
408
- style: { paddingLeft },
478
+ style: row,
409
479
  children: /* @__PURE__ */ jsxs("text", {
410
480
  fg: COLOR.dim,
411
481
  children: [
412
482
  /* @__PURE__ */ jsx("span", {
413
483
  fg: COLOR.accent,
414
- children: " "
484
+ children: "🌳 "
415
485
  }),
416
486
  /* @__PURE__ */ jsx("span", {
417
487
  fg: COLOR.dim,
@@ -429,6 +499,7 @@ function EventLineImpl({ event }) {
429
499
  }
430
500
  /** User prompt — bordered to rhyme with the prompt input box below. */
431
501
  function UserPromptBlock({ text }) {
502
+ const COLOR = useColors();
432
503
  return /* @__PURE__ */ jsx("box", {
433
504
  style: {
434
505
  border: true,
@@ -443,21 +514,43 @@ function UserPromptBlock({ text }) {
443
514
  });
444
515
  }
445
516
  /**
446
- * Markdown block. While `streaming` is true, content is passed through
447
- * `md4x.heal()` so unclosed delimiters (bold, italic, code, link, table) render
448
- * as if already complete. OpenTUI's `streaming` prop keeps its parser from
449
- * committing to the final layout for the trailing block.
517
+ * Markdown block. Renders either live-streaming markdown (with `streaming`
518
+ * on, while deltas are still appending) or finalized markdown (after
519
+ * `turn:after`, or every entry on a reloaded transcript).
520
+ *
521
+ * `internalBlockMode` is the load-bearing knob for layout: the OpenTUI
522
+ * default (`"coalesced"`) fuses adjacent top-level blocks into one render
523
+ * block, which is the right tradeoff for finalized markdown — fewer flex
524
+ * children, fewer layout passes, the parser already knows the final shape.
525
+ * During streaming, that same coalescing makes earlier paragraphs visually
526
+ * re-flow on every token, so we switch to `"top-level"` (each block its
527
+ * own renderable, only the trailing one is unstable).
528
+ *
529
+ * `internalBlockMode` is set only at construction by OpenTUI — there's no
530
+ * setter — so a `<MarkdownBlock>` keeps whichever mode it was born with.
531
+ * Live blocks start `streaming=true` → top-level; reloaded blocks start
532
+ * `streaming=false` → coalesced. Each variant stays optimal for its
533
+ * lifecycle.
534
+ *
535
+ * Note: we don't pre-process unclosed delimiters. OpenTUI's markdown
536
+ * parser already renders partial input reasonably during streaming (the
537
+ * trailing block reflows as tokens close), and the simplicity is worth
538
+ * accepting a brief literal `**` before the closer arrives. Persisted
539
+ * reloads come from completed assistant turns whose markdown is closed.
450
540
  */
451
541
  function MarkdownBlock({ text, streaming, dim }) {
542
+ const COLOR = useColors();
452
543
  return /* @__PURE__ */ jsx("markdown", {
453
- content: useMemo(() => streaming ? heal(text) : text, [text, streaming]),
454
- syntaxStyle: MD_STYLE,
544
+ content: text,
545
+ syntaxStyle: useMdStyle(),
455
546
  streaming,
547
+ internalBlockMode: streaming ? "top-level" : "coalesced",
456
548
  fg: dim ? COLOR.dim : void 0
457
549
  });
458
550
  }
459
551
  const TOOL_RESULT_MAX_LINES = 6;
460
552
  function ToolResultBlock({ text, indent }) {
553
+ const COLOR = useColors();
461
554
  const lines = text.split("\n");
462
555
  const visible = lines.slice(0, TOOL_RESULT_MAX_LINES);
463
556
  const omitted = Math.max(0, lines.length - TOOL_RESULT_MAX_LINES);
@@ -482,476 +575,6 @@ function ToolResultBlock({ text, indent }) {
482
575
  });
483
576
  }
484
577
  //#endregion
485
- //#region src/tui/providers.ts
486
- /** Convenience accessor — returns `credentialFileKey ?? key`. */
487
- function credKeyOf(desc) {
488
- return desc.credentialFileKey ?? desc.key;
489
- }
490
- /** Convenience accessor — returns `piProviderId ?? key`. */
491
- function piIdOf(desc) {
492
- return desc.piProviderId ?? desc.key;
493
- }
494
- const anthropicDescriptor = {
495
- key: "anthropic",
496
- label: "Anthropic",
497
- factory: anthropic,
498
- defaultModel: "claude-opus-4-7",
499
- envKey: "ANTHROPIC_API_KEY",
500
- apiKeyPlaceholder: "sk-ant-…",
501
- oauthProvider: anthropicOAuthProvider,
502
- oauthHint: "Claude Pro/Max subscription"
503
- };
504
- const openaiDescriptor = {
505
- key: "openai",
506
- label: "OpenAI Codex",
507
- factory: openai,
508
- defaultModel: "gpt-5.4",
509
- envKey: "OPENAI_CODEX_API_KEY",
510
- credentialFileKey: "openai-codex",
511
- piProviderId: "openai-codex",
512
- apiKeyPlaceholder: "sk-… or eyJ… (Codex)",
513
- oauthProvider: openaiCodexOAuthProvider
514
- };
515
- const openrouterDescriptor = {
516
- key: "openrouter",
517
- label: "OpenRouter",
518
- factory: openrouter,
519
- defaultModel: "anthropic/claude-sonnet-4-6",
520
- envKey: "OPENROUTER_API_KEY",
521
- apiKeyPlaceholder: "sk-or-…"
522
- };
523
- const cerebrasDescriptor = {
524
- key: "cerebras",
525
- label: "Cerebras",
526
- factory: cerebras,
527
- defaultModel: "zai-glm-4.7",
528
- envKey: "CEREBRAS_API_KEY",
529
- apiKeyPlaceholder: "csk-…"
530
- };
531
- /**
532
- * Default provider registry. Passed verbatim when `runTui` is invoked without
533
- * an explicit `providers` option. Hosts that want to override per-provider
534
- * metadata can spread this and replace specific entries:
535
- *
536
- * ```ts
537
- * runTui({ providers: { ...BUILTIN_PROVIDERS, anthropic: myOwnAnthropicDescriptor } })
538
- * ```
539
- */
540
- const BUILTIN_PROVIDERS = {
541
- anthropic: anthropicDescriptor,
542
- openai: openaiDescriptor,
543
- openrouter: openrouterDescriptor,
544
- cerebras: cerebrasDescriptor
545
- };
546
- /**
547
- * Resolve the model list for a given provider. Honors `descriptor.models`
548
- * when set; otherwise queries pi-ai via `descriptor.piProviderId`. Returns
549
- * `[]` for descriptors with no known mapping (custom providers without a
550
- * model list) — callers should hide the model picker in that case.
551
- */
552
- function modelsForDescriptor(descriptor) {
553
- if (descriptor.models) return descriptor.models;
554
- try {
555
- return getModels(piIdOf(descriptor));
556
- } catch {
557
- return [];
558
- }
559
- }
560
- /**
561
- * Look up the model's max context window via the descriptor's model source.
562
- * Returns `null` when the model isn't known (custom slugs, providers without
563
- * a registry); callers should hide the context indicator in that case.
564
- */
565
- function getContextWindow(descriptor, modelId) {
566
- if (descriptor.models) return descriptor.models.find((m) => m.id === modelId)?.contextWindow ?? null;
567
- try {
568
- return getModel(piIdOf(descriptor), modelId)?.contextWindow ?? null;
569
- } catch {
570
- return null;
571
- }
572
- }
573
- //#endregion
574
- //#region src/tui/credentials.ts
575
- /** POSIX mode for the credentials file. Ignored on Windows. */
576
- const FILE_MODE = 384;
577
- /**
578
- * Resolve the credentials file path given the resolved TUI data directory
579
- * (typically `~/.zidane`, i.e. `config.paths.dir`).
580
- *
581
- * Matches the convention used elsewhere in the TUI (sessions.db, state.json)
582
- * so a single `ZIDANE_STORAGE_DIR` override moves the entire data root.
583
- */
584
- function credentialsPath(dataDir) {
585
- return resolve(dataDir, "credentials.json");
586
- }
587
- /**
588
- * Read credentials from disk.
589
- *
590
- * Returns `{}` when the file is missing or corrupt (last-ditch tolerance —
591
- * a hand-edit gone wrong shouldn't lock the user out of re-authing). On first
592
- * call with no file present, attempts a migration from `cwd/.credentials.json`
593
- * (the legacy location used by `bun run auth`).
594
- */
595
- function readCredentials(dataDir) {
596
- const path = credentialsPath(dataDir);
597
- if (!existsSync(path)) {
598
- const migrated = migrateLegacyFile(path);
599
- if (migrated) return migrated;
600
- return {};
601
- }
602
- try {
603
- const raw = readFileSync(path, "utf-8");
604
- const parsed = JSON.parse(raw);
605
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
606
- return parsed;
607
- } catch {
608
- return {};
609
- }
610
- }
611
- /** Read a single provider's credential (translating via the descriptor). */
612
- function readProviderCredential(dataDir, descriptor) {
613
- return readCredentials(dataDir)[credKeyOf(descriptor)];
614
- }
615
- /**
616
- * Write credentials atomically (write-then-rename) with mode 0o600.
617
- *
618
- * Atomic on the same filesystem — readers either see the previous file or the
619
- * new one, never a half-written intermediate. Creates the parent dir if needed
620
- * (first launch on a fresh machine: `~/.zidane/` may not exist yet).
621
- */
622
- function writeCredentials(dataDir, creds) {
623
- const path = credentialsPath(dataDir);
624
- mkdirSync(dirname(path), { recursive: true });
625
- const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
626
- writeFileSync(tmp, `${JSON.stringify(creds, null, 2)}\n`, { mode: FILE_MODE });
627
- renameSync(tmp, path);
628
- }
629
- function setProviderCredential(dataDir, descriptor, cred) {
630
- const all = readCredentials(dataDir);
631
- all[credKeyOf(descriptor)] = cred;
632
- writeCredentials(dataDir, all);
633
- }
634
- function removeProviderCredential(dataDir, descriptor) {
635
- const all = readCredentials(dataDir);
636
- const fileKey = credKeyOf(descriptor);
637
- if (!(fileKey in all)) return;
638
- delete all[fileKey];
639
- writeCredentials(dataDir, all);
640
- }
641
- /**
642
- * Inject API-key credentials into `process.env` so the harness providers pick
643
- * them up via their existing env-var resolution. Called once at TUI launch
644
- * after the credentials file has been resolved. OAuth credentials are NOT
645
- * injected — those reach providers via `ZIDANE_CREDENTIALS_PATH` + the file
646
- * reader in `src/providers/oauth.ts`.
647
- *
648
- * Does not overwrite env vars that are already set — explicit user-provided
649
- * env values win over stored API keys.
650
- *
651
- * Descriptors without an `envKey` (OAuth-only providers, custom providers
652
- * that bypass env-var resolution) are skipped silently.
653
- */
654
- function applyApiKeyEnv(dataDir, registry) {
655
- const creds = readCredentials(dataDir);
656
- for (const descriptor of Object.values(registry)) {
657
- if (!descriptor.envKey || process.env[descriptor.envKey]) continue;
658
- const cred = creds[credKeyOf(descriptor)];
659
- if (cred?.kind === "apikey" && cred.value) process.env[descriptor.envKey] = cred.value;
660
- }
661
- }
662
- /**
663
- * `bun run auth` (pre-TUI) wrote `cwd/.credentials.json` with an entry per
664
- * provider mapping directly to an OAuthCredentials payload, e.g.:
665
- *
666
- * {
667
- * "anthropic": { "access": "...", "refresh": "...", "expires": 123 },
668
- * "openai-codex": { "access": "...", "refresh": "...", "expires": 123, "accountId": "..." }
669
- * }
670
- *
671
- * We don't delete the legacy file — it might still be used by a host that
672
- * imports the harness directly. We just copy its contents into the new
673
- * location under the kind-tagged shape so the TUI picks them up.
674
- *
675
- * Migration is provider-agnostic: any top-level entry with an `access` field
676
- * is preserved verbatim (extras included), under the same key. The TUI's
677
- * detection then looks them up via the matching descriptor's `credentialFileKey`.
678
- *
679
- * Returns the migrated credentials when the migration ran, or `null` when
680
- * there's no legacy file to migrate.
681
- */
682
- function migrateLegacyFile(targetPath) {
683
- const legacyPath = resolve(process.cwd(), ".credentials.json");
684
- if (!existsSync(legacyPath)) return null;
685
- let legacy;
686
- try {
687
- legacy = JSON.parse(readFileSync(legacyPath, "utf-8"));
688
- } catch {
689
- return null;
690
- }
691
- if (!legacy || typeof legacy !== "object" || Array.isArray(legacy)) return null;
692
- const migrated = {};
693
- for (const [fileKey, value] of Object.entries(legacy)) {
694
- if (!isOAuthLegacy(value)) continue;
695
- const { access, refresh, expires, ...extras } = value;
696
- migrated[fileKey] = {
697
- kind: "oauth",
698
- access,
699
- ...typeof refresh === "string" ? { refresh } : {},
700
- ...typeof expires === "number" ? { expires } : {},
701
- ...extras
702
- };
703
- }
704
- if (Object.keys(migrated).length === 0) return null;
705
- mkdirSync(dirname(targetPath), { recursive: true });
706
- const tmp = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
707
- writeFileSync(tmp, `${JSON.stringify(migrated, null, 2)}\n`, { mode: FILE_MODE });
708
- renameSync(tmp, targetPath);
709
- return migrated;
710
- }
711
- function isOAuthLegacy(value) {
712
- return typeof value === "object" && value !== null && "access" in value && typeof value.access === "string";
713
- }
714
- //#endregion
715
- //#region src/tui/auth.ts
716
- /**
717
- * Detect available auth for every registered provider.
718
- *
719
- * Resolution order per provider (a method appears in `methods` for each
720
- * layer that has a credential — the agent itself resolves them in the same
721
- * order via its provider factories):
722
- *
723
- * 1. `kind: 'apikey'` from `credentials.json` (injected into env at TUI launch)
724
- * 2. explicit env var (descriptor's `envKey`)
725
- * 3. `kind: 'oauth'` from `credentials.json` (or legacy `cwd/.credentials.json`)
726
- *
727
- * Pure read — never refreshes or rewrites the credentials file.
728
- */
729
- function detectAuth(dataDir, registry, env = process.env) {
730
- const creds = readCredentials(dataDir);
731
- return Object.values(registry).map((descriptor) => {
732
- const methods = [];
733
- const fileEntry = creds[credKeyOf(descriptor)];
734
- if (fileEntry?.kind === "apikey" && fileEntry.value) methods.push({
735
- source: "apikey",
736
- detail: "credentials.json"
737
- });
738
- if (descriptor.envKey && env[descriptor.envKey]) methods.push({
739
- source: "env",
740
- detail: descriptor.envKey
741
- });
742
- if (fileEntry?.kind === "oauth" && fileEntry.access) {
743
- const detail = typeof fileEntry.expires === "number" ? `oauth · expires ${new Date(fileEntry.expires).toLocaleString()}` : "oauth · credentials.json";
744
- methods.push({
745
- source: "oauth",
746
- detail
747
- });
748
- }
749
- return {
750
- key: descriptor.key,
751
- label: descriptor.label,
752
- available: methods.length > 0,
753
- methods
754
- };
755
- });
756
- }
757
- //#endregion
758
- //#region src/tui/store.ts
759
- function ensureDir(path) {
760
- const dir = dirname(path);
761
- if (existsSync(dir)) return;
762
- try {
763
- mkdirSync(dir, { recursive: true });
764
- } catch (err) {
765
- const message = err instanceof Error ? err.message : String(err);
766
- 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}`);
767
- }
768
- }
769
- function createTuiStore(dbPath) {
770
- ensureDir(dbPath);
771
- return createSqliteStore({ path: dbPath });
772
- }
773
- function createStateStore(path) {
774
- return {
775
- load: () => loadState(path),
776
- save: (state) => saveState(path, state)
777
- };
778
- }
779
- function loadState(path) {
780
- if (!existsSync(path)) return {};
781
- try {
782
- const parsed = JSON.parse(readFileSync(path, "utf-8"));
783
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
784
- } catch {}
785
- return {};
786
- }
787
- function saveState(path, state) {
788
- ensureDir(path);
789
- const tmp = `${path}.${process.pid}.tmp`;
790
- writeFileSync(tmp, JSON.stringify(state, null, 2));
791
- renameSync(tmp, path);
792
- }
793
- /**
794
- * Load every session and project it to the compact `SessionMeta` shape used by
795
- * the picker. Sorted by recency via the underlying store's `list()` contract
796
- * (sqlite store returns by `updated_at DESC`).
797
- */
798
- async function listSessionMeta(store) {
799
- const ids = await store.list();
800
- return (await Promise.all(ids.map(async (id) => {
801
- const data = await store.load(id);
802
- if (!data) return null;
803
- return {
804
- id,
805
- title: titleFromTurns(data.turns) ?? "untitled",
806
- turnCount: data.turns.length,
807
- updatedAt: data.updatedAt
808
- };
809
- }))).filter((m) => m !== null);
810
- }
811
- /** Derive a short title from the first user message — returns null when empty. */
812
- function titleFromTurns(turns) {
813
- const first = turns.find((t) => t.role === "user");
814
- if (!first) return null;
815
- for (const block of first.content) if (block.type === "text" && block.text.trim()) {
816
- const oneLine = block.text.replace(/\s+/g, " ").trim();
817
- return oneLine.length > 60 ? `${oneLine.slice(0, 60)}…` : oneLine;
818
- }
819
- return null;
820
- }
821
- /**
822
- * Replay persisted turns as a viewable transcript. Mirrors the event shape produced
823
- * live by the agent hooks so loaded and streaming history render identically.
824
- *
825
- * Skips `tool_result` blocks (they're not user-visible by default), and inserts a
826
- * `separator` event between turn groups so the eye can parse turn boundaries.
827
- */
828
- function eventsFromTurns(turns) {
829
- const events = [];
830
- for (let i = 0; i < turns.length; i++) {
831
- const turn = turns[i];
832
- if (i > 0) events.push({
833
- kind: "separator",
834
- text: ""
835
- });
836
- if (turn.role === "user") {
837
- for (const block of turn.content) if (block.type === "text" && block.text.trim()) events.push({
838
- kind: "info",
839
- text: `❯ ${block.text}`
840
- });
841
- else if (block.type === "tool_result") events.push({
842
- kind: "tool-result",
843
- text: toolResultText(block.output)
844
- });
845
- continue;
846
- }
847
- if (turn.role === "assistant") {
848
- for (const block of turn.content) if (block.type === "text" && block.text.trim()) events.push({
849
- kind: "markdown",
850
- text: block.text,
851
- streaming: false
852
- });
853
- else if (block.type === "tool_call") events.push({
854
- kind: "tool",
855
- text: toolCallPreview(block.name, block.input)
856
- });
857
- }
858
- }
859
- return events;
860
- }
861
- /** Shared formatter for the `↳ name(args)` line shown on tool calls. */
862
- function toolCallPreview(name, input) {
863
- const args = JSON.stringify(input);
864
- return args && args !== "{}" ? `${name}(${args})` : name;
865
- }
866
- /** Render tool output as plain text, whether it's a string or structured content. */
867
- function toolResultText(output) {
868
- return typeof output === "string" ? output : toolResultToText(output);
869
- }
870
- /** Effective context size of the most recent assistant turn — drives the footer indicator. */
871
- function lastContextSizeFromTurns(turns) {
872
- for (let i = turns.length - 1; i >= 0; i--) {
873
- const turn = turns[i];
874
- if (turn.role === "assistant" && turn.usage) return (turn.usage.input ?? 0) + (turn.usage.cacheRead ?? 0) + (turn.usage.cacheCreation ?? 0);
875
- }
876
- return 0;
877
- }
878
- //#endregion
879
- //#region src/tui/config.tsx
880
- /** Resolve user options into a fully-bound runtime config. Pure aside from disk reads. */
881
- function resolveConfig(options = {}) {
882
- const prefix = options.prefix ?? process.env.ZIDANE_PREFIX ?? ".zidane";
883
- const storageDir = options.storageDir ?? process.env.ZIDANE_STORAGE_DIR ?? homedir();
884
- const dir = resolve(storageDir, prefix);
885
- const paths = {
886
- dir,
887
- db: resolve(dir, "sessions.db"),
888
- state: resolve(dir, "state.json")
889
- };
890
- const store = options.store ?? createTuiStore(paths.db);
891
- const stateStore = createStateStore(paths.state);
892
- const initialState = stateStore.load();
893
- const providers = options.providers ?? BUILTIN_PROVIDERS;
894
- const preset = options.preset ?? basic_default;
895
- process.env.ZIDANE_CREDENTIALS_PATH = credentialsPath(dir);
896
- applyApiKeyEnv(dir, providers);
897
- const modelsFor = makeModelsResolver(providers);
898
- const resumeProvider = resolveResumeProvider(initialState, providers, dir);
899
- const initialPicked = resumeProvider ? pickInitial(resumeProvider, providers, initialState) : null;
900
- return {
901
- prefix,
902
- storageDir,
903
- paths,
904
- providers,
905
- preset,
906
- store,
907
- stateStore,
908
- modelsFor,
909
- initialState,
910
- initialSettings: initialState.settings ?? {},
911
- resumeProvider,
912
- initialPicked
913
- };
914
- }
915
- function makeModelsResolver(registry) {
916
- return (key) => {
917
- const descriptor = registry[key];
918
- return descriptor ? modelsForDescriptor(descriptor) : [];
919
- };
920
- }
921
- function resolveResumeProvider(state, providers, storageDir) {
922
- if (!state.lastProvider) return null;
923
- if (!providers[state.lastProvider]) return null;
924
- return detectAuth(storageDir, providers).find((p) => p.key === state.lastProvider && p.available) ?? null;
925
- }
926
- function pickInitial(auth, providers, state) {
927
- const descriptor = providers[auth.key];
928
- if (!descriptor) return null;
929
- const model = state.lastModelByProvider?.[auth.key] ?? descriptor.defaultModel ?? safeFactoryDefault(descriptor);
930
- return model ? {
931
- provider: auth,
932
- model
933
- } : null;
934
- }
935
- function safeFactoryDefault(descriptor) {
936
- try {
937
- return descriptor.factory().meta.defaultModel;
938
- } catch {
939
- return;
940
- }
941
- }
942
- const ConfigContext = createContext(null);
943
- function ConfigProvider({ config, children }) {
944
- return /* @__PURE__ */ jsx(ConfigContext.Provider, {
945
- value: config,
946
- children
947
- });
948
- }
949
- function useConfig() {
950
- const ctx = useContext(ConfigContext);
951
- if (!ctx) throw new Error("useConfig must be used inside <ConfigProvider>");
952
- return ctx;
953
- }
954
- //#endregion
955
578
  //#region src/tui/modal.tsx
956
579
  const ModalContext = createContext(null);
957
580
  function ModalRoot({ children }) {
@@ -1017,6 +640,8 @@ function useModalAwareFocus(preferred = true) {
1017
640
  function Modal({ title, onClose, children, maxWidth = 92, minWidth = 44, horizontalMargin = 4 }) {
1018
641
  const ctx = useContext(ModalContext);
1019
642
  const dismiss = onClose ?? ctx?.close;
643
+ const COLOR = useColors();
644
+ const SURFACE = useSurfaces();
1020
645
  useKeyboard((key) => {
1021
646
  if (key.name === "escape") dismiss?.();
1022
647
  });
@@ -1027,7 +652,7 @@ function Modal({ title, onClose, children, maxWidth = 92, minWidth = 44, horizon
1027
652
  style: {
1028
653
  border: true,
1029
654
  borderColor: COLOR.borderActive,
1030
- backgroundColor: "#101010",
655
+ backgroundColor: SURFACE.modal,
1031
656
  paddingTop: 1,
1032
657
  paddingBottom: 1,
1033
658
  paddingLeft: 2,
@@ -1052,6 +677,8 @@ const VISIBLE_ROW_CAP = 12;
1052
677
  * Each row shows: `● selected · name (ctx N · reasoning · vision)`.
1053
678
  */
1054
679
  function ModelPickerModal({ models, currentModelId, onPick }) {
680
+ const COLOR = useColors();
681
+ const SELECT_THEME = useSelectStyle();
1055
682
  const initialIndex = useMemo(() => models.findIndex((m) => m.id === currentModelId), [models, currentModelId]);
1056
683
  const options = useMemo(() => models.map((m) => ({
1057
684
  name: `${m.id === currentModelId ? "● " : " "}${m.name ?? m.id}`,
@@ -1105,6 +732,7 @@ function ModelPickerModal({ models, currentModelId, onPick }) {
1105
732
  });
1106
733
  }
1107
734
  function EmptyState() {
735
+ const COLOR = useColors();
1108
736
  return /* @__PURE__ */ jsxs(Modal, {
1109
737
  title: "select model",
1110
738
  children: [/* @__PURE__ */ jsx("text", {
@@ -1136,65 +764,6 @@ function describeModel(m) {
1136
764
  return parts.join(" · ");
1137
765
  }
1138
766
  //#endregion
1139
- //#region src/tui/oauth.ts
1140
- function supportsOAuth(descriptor) {
1141
- return descriptor.oauthProvider !== void 0;
1142
- }
1143
- /**
1144
- * Run the OAuth login flow for a provider.
1145
- *
1146
- * Returns the OAuth credentials on success; caller persists them via
1147
- * `setProviderCredential(dataDir, descriptor, { kind: 'oauth', ...credentials })`.
1148
- * Throws when the descriptor has no `oauthProvider` configured.
1149
- */
1150
- async function runOAuthLogin(descriptor, options) {
1151
- if (!descriptor.oauthProvider) throw new Error(`OAuth not supported for ${descriptor.label} (${descriptor.key}) — use an API key instead.`);
1152
- const callbacks = {
1153
- onAuth: (info) => {
1154
- options.onUrl(info.url, info.instructions);
1155
- tryOpenBrowser(info.url);
1156
- },
1157
- onPrompt: async () => {
1158
- if (!options.onCodeRequest) throw new Error("OAuth flow requires manual code input but no handler is wired.");
1159
- return options.onCodeRequest();
1160
- },
1161
- onProgress: options.onProgress,
1162
- signal: options.signal
1163
- };
1164
- return descriptor.oauthProvider.login(callbacks);
1165
- }
1166
- /**
1167
- * Best-effort cross-platform browser open. macOS uses `open`, Linux uses
1168
- * `xdg-open`, Windows uses `start`. Failures are swallowed — the callback
1169
- * server is already listening, and the URL is displayed in the TUI for
1170
- * manual click.
1171
- *
1172
- * Uses `spawn` (not `exec`) so the URL is passed as an argv element rather
1173
- * than interpolated into a shell command — no need to think about quoting
1174
- * URLs that contain `&`, `?`, `"` or other shell metacharacters.
1175
- */
1176
- function tryOpenBrowser(url) {
1177
- const [cmd, ...args] = (() => {
1178
- if (process.platform === "darwin") return ["open", url];
1179
- if (process.platform === "win32") return [
1180
- "cmd",
1181
- "/c",
1182
- "start",
1183
- "",
1184
- url
1185
- ];
1186
- return ["xdg-open", url];
1187
- })();
1188
- try {
1189
- const child = spawn(cmd, args, {
1190
- stdio: "ignore",
1191
- detached: true
1192
- });
1193
- child.on("error", () => {});
1194
- child.unref();
1195
- } catch {}
1196
- }
1197
- //#endregion
1198
767
  //#region src/tui/screens.tsx
1199
768
  /**
1200
769
  * Build a key-binding set for the prompt textarea / API-key input. Strips the
@@ -1241,6 +810,8 @@ function AuthScreen({ onPick }) {
1241
810
  const config = useConfig();
1242
811
  const { providers: registry } = config;
1243
812
  const focused = useModalAwareFocus();
813
+ const COLOR = useColors();
814
+ const SELECT_THEME = useSelectStyle();
1244
815
  const [providers, setProviders] = useState([]);
1245
816
  const refresh = useCallback(() => setProviders(detectAuth(config.paths.dir, registry)), [config.paths.dir, registry]);
1246
817
  useEffect(() => {
@@ -1371,12 +942,13 @@ function SetupWizard({ registry, dataDir, onConfigured, onCancel }) {
1371
942
  * with a customizable title and accent color. Footnote slot at the bottom for
1372
943
  * an error banner.
1373
944
  */
1374
- function WizardPanel({ title, accent = COLOR.border, error, children }) {
945
+ function WizardPanel({ title, accent, error, children }) {
946
+ const COLOR = useColors();
1375
947
  return /* @__PURE__ */ jsxs("box", {
1376
948
  title,
1377
949
  style: {
1378
950
  border: true,
1379
- borderColor: accent,
951
+ borderColor: accent ?? COLOR.border,
1380
952
  padding: 1,
1381
953
  gap: 1,
1382
954
  flexDirection: "column",
@@ -1391,11 +963,12 @@ function WizardPanel({ title, accent = COLOR.border, error, children }) {
1391
963
  /** "esc to exit" footer hint shared by every wizard step that doesn't offer a "← back" affordance. */
1392
964
  function WizardEscHint() {
1393
965
  return /* @__PURE__ */ jsx("text", {
1394
- fg: COLOR.dim,
966
+ fg: useColors().dim,
1395
967
  children: "esc to exit"
1396
968
  });
1397
969
  }
1398
970
  function EmptyRegistryNotice() {
971
+ const COLOR = useColors();
1399
972
  return /* @__PURE__ */ jsxs(WizardPanel, {
1400
973
  title: " no providers configured ",
1401
974
  accent: COLOR.error,
@@ -1424,6 +997,8 @@ function EmptyRegistryNotice() {
1424
997
  const WIZARD_BACK_VALUE = "__back__";
1425
998
  function PickProviderStep({ descriptors, error, onPick, onCancel }) {
1426
999
  const focused = useModalAwareFocus();
1000
+ const COLOR = useColors();
1001
+ const SELECT_THEME = useSelectStyle();
1427
1002
  const options = [...descriptors.map((d) => {
1428
1003
  const methods = supportsOAuth(d) ? ["API key", "OAuth"] : ["API key"];
1429
1004
  return {
@@ -1469,6 +1044,7 @@ function PickProviderStep({ descriptors, error, onPick, onCancel }) {
1469
1044
  }
1470
1045
  function PickMethodStep({ descriptor, error, onPick }) {
1471
1046
  const focused = useModalAwareFocus();
1047
+ const SELECT_THEME = useSelectStyle();
1472
1048
  const options = useMemo(() => {
1473
1049
  const items = [{
1474
1050
  name: "API key",
@@ -1503,6 +1079,7 @@ function PickMethodStep({ descriptor, error, onPick }) {
1503
1079
  function EnterApiKeyStep({ descriptor, error, onSubmit }) {
1504
1080
  const focused = useModalAwareFocus();
1505
1081
  const inputRef = useRef(null);
1082
+ const COLOR = useColors();
1506
1083
  const submit = useCallback(() => {
1507
1084
  onSubmit(descriptor, inputRef.current?.value ?? "");
1508
1085
  }, [descriptor, onSubmit]);
@@ -1543,6 +1120,7 @@ function EnterApiKeyStep({ descriptor, error, onSubmit }) {
1543
1120
  function OAuthRunningStep({ descriptor, dataDir, onSuccess, onError }) {
1544
1121
  const [url, setUrl] = useState(null);
1545
1122
  const [status, setStatus] = useState("starting browser…");
1123
+ const COLOR = useColors();
1546
1124
  useEffect(() => {
1547
1125
  const ac = new AbortController();
1548
1126
  let cancelled = false;
@@ -1604,6 +1182,8 @@ function OAuthRunningStep({ descriptor, dataDir, onSuccess, onError }) {
1604
1182
  const NEW_VALUE = "__new__";
1605
1183
  function SessionsScreen({ sessions, currentId, onPick, onCreate }) {
1606
1184
  const focused = useModalAwareFocus();
1185
+ const COLOR = useColors();
1186
+ const SELECT_THEME = useSelectStyle();
1607
1187
  const options = useMemo(() => {
1608
1188
  const items = [{
1609
1189
  name: "+ new session",
@@ -1647,7 +1227,8 @@ function SessionsScreen({ sessions, currentId, onPick, onCreate }) {
1647
1227
  /** Visible content lines: 1 minimum, 5 maximum (textarea scrolls past 5). */
1648
1228
  const MIN_CONTENT_LINES = 1;
1649
1229
  const MAX_CONTENT_LINES = 5;
1650
- function ChatScreen({ events, busy, settings, onSubmit, session }) {
1230
+ function ChatScreen({ events, busy, settings, onSubmit, session, pending, onApproval }) {
1231
+ const COLOR = useColors();
1651
1232
  const title = useMemo(() => {
1652
1233
  if (!session) return " untitled ";
1653
1234
  const turns = `${session.turnCount} turn${session.turnCount === 1 ? "" : "s"}`;
@@ -1671,17 +1252,137 @@ function ChatScreen({ events, busy, settings, onSubmit, session }) {
1671
1252
  events,
1672
1253
  settings
1673
1254
  })
1674
- }), busy ? /* @__PURE__ */ jsx(BusyBlock, {}) : /* @__PURE__ */ jsx(PromptBlock, {
1255
+ }), pending ? /* @__PURE__ */ jsx(ApprovalBlock, {
1256
+ request: pending,
1257
+ onPick: onApproval
1258
+ }) : busy ? /* @__PURE__ */ jsx(BusyBlock, {}) : /* @__PURE__ */ jsx(PromptBlock, {
1675
1259
  userPrompts,
1676
1260
  onSubmit
1677
1261
  })]
1678
1262
  });
1679
1263
  }
1264
+ /** Max chars per scalar argument in the approval preview. */
1265
+ const APPROVAL_ARG_MAX = 80;
1266
+ /**
1267
+ * Render `{ path: 'x.ts', contents: 'long string' }` as
1268
+ * `path: "x.ts", contents: "long string…"` — readable, per-key, truncated
1269
+ * per value rather than dumping `JSON.stringify(input)` (which produces an
1270
+ * illegible 50KB blob for `write_file` etc.).
1271
+ */
1272
+ function formatApprovalArgs(input) {
1273
+ const parts = [];
1274
+ for (const [key, raw] of Object.entries(input)) {
1275
+ let value;
1276
+ if (typeof raw === "string") {
1277
+ const escaped = raw.replace(/\n/g, "\\n");
1278
+ value = escaped.length > APPROVAL_ARG_MAX ? `"${escaped.slice(0, APPROVAL_ARG_MAX)}…"` : `"${escaped}"`;
1279
+ } else {
1280
+ const json = JSON.stringify(raw);
1281
+ value = json.length > APPROVAL_ARG_MAX ? `${json.slice(0, APPROVAL_ARG_MAX)}…` : json;
1282
+ }
1283
+ parts.push(`${key}: ${value}`);
1284
+ }
1285
+ return parts.join(", ");
1286
+ }
1287
+ /**
1288
+ * Inline approval picker — replaces the chat input while a tool call is
1289
+ * pending. Three options:
1290
+ * - **accept once** — let this call execute, don't persist anything.
1291
+ * - **accept + remember** — execute + add a `projects.json` entry so the
1292
+ * same shape doesn't prompt again in this directory.
1293
+ * - **deny** — refuse the call. The model gets `Blocked: …` and adapts.
1294
+ *
1295
+ * Esc aborts the whole run via the parent keyboard handler; per-call
1296
+ * accept/deny only happens through the select below.
1297
+ *
1298
+ * Layout is fully pinned so the picker never overlaps with itself or the
1299
+ * transcript above:
1300
+ *
1301
+ * - Outer `<box>` has an explicit `height`. The slot below the transcript
1302
+ * adapts (the chat container is column-flex), so we control exactly how
1303
+ * many rows we occupy.
1304
+ * - Summary row is a `<box height: 1, overflow: hidden>` wrapping a
1305
+ * `<text wrapMode="none">` — a 500-char tool-call preview can never
1306
+ * wrap to row 2 and push the select off-screen.
1307
+ * - `<select showDescription={false}>` keeps each option to exactly one
1308
+ * row. Hints live in the `name` string after a `·` separator. Without
1309
+ * this, the default `showDescription: true` makes every option take 2
1310
+ * rows, and a `height: options.length` select would overdraw into the
1311
+ * summary above (the original bug).
1312
+ */
1313
+ function ApprovalBlock({ request, onPick }) {
1314
+ const focused = useModalAwareFocus();
1315
+ const COLOR = useColors();
1316
+ const SELECT_THEME = useSelectStyle();
1317
+ const summary = useMemo(() => `${request.tool}(${formatApprovalArgs(request.input)})`, [request.tool, request.input]);
1318
+ const options = useMemo(() => {
1319
+ return [
1320
+ {
1321
+ name: "accept once · allow this call only",
1322
+ description: "",
1323
+ value: "accept-once"
1324
+ },
1325
+ {
1326
+ name: `accept + remember · add "${suggestSafelistEntry(request.tool, request.input)}" to projects.json`,
1327
+ description: "",
1328
+ value: "accept-safelist"
1329
+ },
1330
+ {
1331
+ name: "deny · refuse — the model will see Blocked",
1332
+ description: "",
1333
+ value: "deny"
1334
+ }
1335
+ ];
1336
+ }, [request.tool, request.input]);
1337
+ const height = 3 + options.length;
1338
+ return /* @__PURE__ */ jsxs("box", {
1339
+ title: " approve tool call · esc to abort run ",
1340
+ style: {
1341
+ border: true,
1342
+ borderColor: COLOR.warn,
1343
+ paddingLeft: 1,
1344
+ paddingRight: 1,
1345
+ paddingTop: 0,
1346
+ paddingBottom: 0,
1347
+ height,
1348
+ flexDirection: "column",
1349
+ flexShrink: 0
1350
+ },
1351
+ children: [/* @__PURE__ */ jsx("box", {
1352
+ style: {
1353
+ height: 1,
1354
+ overflow: "hidden",
1355
+ flexShrink: 0
1356
+ },
1357
+ children: /* @__PURE__ */ jsxs("text", {
1358
+ fg: COLOR.model,
1359
+ wrapMode: "none",
1360
+ children: [/* @__PURE__ */ jsx("span", {
1361
+ fg: COLOR.warn,
1362
+ children: "↳ "
1363
+ }), summary]
1364
+ })
1365
+ }), /* @__PURE__ */ jsx("select", {
1366
+ ...SELECT_THEME,
1367
+ focused,
1368
+ options,
1369
+ showDescription: false,
1370
+ wrapSelection: true,
1371
+ onSelect: (_idx, option) => {
1372
+ if (option) onPick(option.value);
1373
+ },
1374
+ style: {
1375
+ height: options.length,
1376
+ flexShrink: 0
1377
+ }
1378
+ })]
1379
+ });
1380
+ }
1680
1381
  function BusyBlock() {
1681
1382
  return /* @__PURE__ */ jsx("box", {
1682
1383
  style: {
1683
1384
  border: true,
1684
- borderColor: COLOR.warn,
1385
+ borderColor: useColors().warn,
1685
1386
  paddingLeft: 1,
1686
1387
  paddingRight: 1,
1687
1388
  height: 3
@@ -1691,6 +1392,7 @@ function BusyBlock() {
1691
1392
  }
1692
1393
  function PromptBlock({ userPrompts, onSubmit }) {
1693
1394
  const focused = useModalAwareFocus();
1395
+ const COLOR = useColors();
1694
1396
  const textareaRef = useRef(null);
1695
1397
  /** Auto-grow: visible content rows the textarea currently occupies (clamped 1..5). */
1696
1398
  const [contentLines, setContentLines] = useState(MIN_CONTENT_LINES);
@@ -1770,63 +1472,20 @@ function PromptBlock({ userPrompts, onSubmit }) {
1770
1472
  });
1771
1473
  }
1772
1474
  //#endregion
1773
- //#region src/tui/settings.tsx
1774
- const DEFAULT_SETTINGS = {
1775
- showThinking: true,
1776
- showToolCalls: true,
1777
- showToolResults: true
1778
- };
1779
- const SettingsContext = createContext(null);
1780
- function SettingsProvider({ initial, onChange, children }) {
1781
- const [settings, setSettings] = useState(initial);
1782
- const toggle = useCallback((key) => {
1783
- setSettings((prev) => {
1784
- const next = {
1785
- ...prev,
1786
- [key]: !prev[key]
1787
- };
1788
- onChange?.(next);
1789
- return next;
1790
- });
1791
- }, [onChange]);
1792
- const value = useMemo(() => ({
1793
- settings,
1794
- toggle
1795
- }), [settings, toggle]);
1796
- return /* @__PURE__ */ jsx(SettingsContext.Provider, {
1797
- value,
1798
- children
1799
- });
1800
- }
1801
- function useSettings() {
1802
- const ctx = useContext(SettingsContext);
1803
- if (!ctx) throw new Error("useSettings must be used inside <SettingsProvider>");
1804
- return ctx;
1805
- }
1806
- const TOGGLES = [
1807
- {
1808
- kind: "toggle",
1809
- key: "showThinking",
1810
- label: "Thinking blocks",
1811
- description: "agent reasoning shown inline"
1812
- },
1813
- {
1814
- kind: "toggle",
1815
- key: "showToolCalls",
1816
- label: "Tool calls",
1817
- description: "the ↳ name(args) lines"
1818
- },
1819
- {
1820
- kind: "toggle",
1821
- key: "showToolResults",
1822
- label: "Tool outputs",
1823
- description: "the ┃ result blocks under tool calls"
1824
- }
1825
- ];
1475
+ //#region src/tui/settings-modal.tsx
1826
1476
  function SettingsModal({ actions } = {}) {
1827
- const { settings, toggle } = useSettings();
1477
+ const { settings, toggle, setSetting } = useSettings();
1828
1478
  const [cursor, setCursorRaw] = useState(0);
1479
+ const COLOR = useColors();
1829
1480
  const items = useMemo(() => {
1481
+ const toggleItems = SETTINGS_TOGGLES.map((t) => ({
1482
+ kind: "toggle",
1483
+ ...t
1484
+ }));
1485
+ const choiceItems = SETTINGS_CHOICES.map((c) => ({
1486
+ kind: "choice",
1487
+ ...c
1488
+ }));
1830
1489
  const actionItems = [];
1831
1490
  if (actions?.onReauth) actionItems.push({
1832
1491
  kind: "action",
@@ -1835,7 +1494,11 @@ function SettingsModal({ actions } = {}) {
1835
1494
  description: "switch provider, add another, or re-authenticate",
1836
1495
  onPick: actions.onReauth
1837
1496
  });
1838
- return [...TOGGLES, ...actionItems];
1497
+ return [
1498
+ ...toggleItems,
1499
+ ...choiceItems,
1500
+ ...actionItems
1501
+ ];
1839
1502
  }, [actions]);
1840
1503
  const safeCursor = Math.min(cursor, items.length - 1);
1841
1504
  const setCursor = useCallback((update) => setCursorRaw((prev) => Math.min(Math.max(0, update(prev)), items.length - 1)), [items.length]);
@@ -1846,33 +1509,49 @@ function SettingsModal({ actions } = {}) {
1846
1509
  const item = items[safeCursor];
1847
1510
  if (!item) return;
1848
1511
  if (item.kind === "toggle") toggle(item.key);
1849
- else item.onPick();
1512
+ else if (item.kind === "choice") {
1513
+ const current = settings[item.key];
1514
+ const idx = item.options.findIndex((o) => o.value === current);
1515
+ const next = item.options[(idx + 1) % item.options.length];
1516
+ if (next) setSetting(item.key, next.value);
1517
+ } else item.onPick();
1850
1518
  }
1851
1519
  });
1852
- const firstActionIndex = items.findIndex((i) => i.kind === "action");
1520
+ const firstNonToggleIndex = items.findIndex((i) => i.kind !== "toggle");
1853
1521
  return /* @__PURE__ */ jsxs(Modal, {
1854
1522
  title: "settings",
1855
1523
  children: [/* @__PURE__ */ jsx("box", {
1856
1524
  style: { flexDirection: "column" },
1857
1525
  children: items.map((item, i) => /* @__PURE__ */ jsxs("box", {
1858
1526
  style: { flexDirection: "column" },
1859
- children: [i === firstActionIndex && i > 0 && /* @__PURE__ */ jsx("box", { style: {
1860
- border: ["top"],
1861
- borderColor: COLOR.mute,
1862
- height: 1,
1863
- marginTop: 1,
1864
- marginBottom: 1
1865
- } }), item.kind === "toggle" ? /* @__PURE__ */ jsx(ToggleRow, {
1866
- label: item.label,
1867
- description: item.description,
1868
- enabled: settings[item.key],
1869
- focused: i === safeCursor
1870
- }) : /* @__PURE__ */ jsx(ActionRow, {
1871
- label: item.label,
1872
- description: item.description,
1873
- focused: i === safeCursor
1874
- })]
1875
- }, item.kind === "toggle" ? item.key : item.id))
1527
+ children: [
1528
+ i === firstNonToggleIndex && i > 0 && /* @__PURE__ */ jsx("box", { style: {
1529
+ border: ["top"],
1530
+ borderColor: COLOR.mute,
1531
+ height: 1,
1532
+ marginTop: 1,
1533
+ marginBottom: 1
1534
+ } }),
1535
+ item.kind === "toggle" && /* @__PURE__ */ jsx(ToggleRow, {
1536
+ label: item.label,
1537
+ description: item.description,
1538
+ enabled: settings[item.key],
1539
+ focused: i === safeCursor
1540
+ }),
1541
+ item.kind === "choice" && /* @__PURE__ */ jsx(ChoiceRow, {
1542
+ label: item.label,
1543
+ description: item.description,
1544
+ value: item.options.find((o) => o.value === settings[item.key])?.label ?? String(settings[item.key]),
1545
+ cyclable: item.options.length > 1,
1546
+ focused: i === safeCursor
1547
+ }),
1548
+ item.kind === "action" && /* @__PURE__ */ jsx(ActionRow, {
1549
+ label: item.label,
1550
+ description: item.description,
1551
+ focused: i === safeCursor
1552
+ })
1553
+ ]
1554
+ }, item.kind === "action" ? item.id : item.key))
1876
1555
  }), /* @__PURE__ */ jsxs("text", {
1877
1556
  fg: COLOR.mute,
1878
1557
  children: [
@@ -1885,7 +1564,7 @@ function SettingsModal({ actions } = {}) {
1885
1564
  fg: COLOR.warn,
1886
1565
  children: "↵"
1887
1566
  }),
1888
- firstActionIndex >= 0 ? " toggle/select · " : " toggle · ",
1567
+ " toggle/cycle/select · ",
1889
1568
  /* @__PURE__ */ jsx("span", {
1890
1569
  fg: COLOR.warn,
1891
1570
  children: "esc"
@@ -1903,6 +1582,7 @@ function SettingsModal({ actions } = {}) {
1903
1582
  * the trailing description wraps under the label without breaking the row.
1904
1583
  */
1905
1584
  function ToggleRow({ label, description, enabled, focused }) {
1585
+ const COLOR = useColors();
1906
1586
  return /* @__PURE__ */ jsxs("text", {
1907
1587
  fg: focused ? COLOR.brand : COLOR.dim,
1908
1588
  children: [
@@ -1926,6 +1606,46 @@ function ToggleRow({ label, description, enabled, focused }) {
1926
1606
  });
1927
1607
  }
1928
1608
  /**
1609
+ * Choice row — `▶` marker · label · `:` · current value · description.
1610
+ *
1611
+ * Cycles through `options` on enter/space. When only one option is
1612
+ * available (`cyclable=false`) the row still renders with the current
1613
+ * value but the enter handler is a no-op — we surface this via the absence
1614
+ * of the trailing `›` affordance so it visually reads as informational.
1615
+ */
1616
+ function ChoiceRow({ label, description, value, cyclable, focused }) {
1617
+ const COLOR = useColors();
1618
+ return /* @__PURE__ */ jsxs("text", {
1619
+ fg: focused ? COLOR.brand : COLOR.dim,
1620
+ children: [
1621
+ /* @__PURE__ */ jsx("span", {
1622
+ fg: focused ? COLOR.brand : COLOR.mute,
1623
+ children: focused ? "▶ " : " "
1624
+ }),
1625
+ /* @__PURE__ */ jsx("span", {
1626
+ fg: focused ? COLOR.brand : COLOR.dim,
1627
+ children: label
1628
+ }),
1629
+ /* @__PURE__ */ jsx("span", {
1630
+ fg: COLOR.mute,
1631
+ children: ": "
1632
+ }),
1633
+ /* @__PURE__ */ jsx("span", {
1634
+ fg: focused ? COLOR.brand : COLOR.accent,
1635
+ children: value
1636
+ }),
1637
+ /* @__PURE__ */ jsx("span", {
1638
+ fg: COLOR.mute,
1639
+ children: ` ${description}`
1640
+ }),
1641
+ focused && cyclable && /* @__PURE__ */ jsx("span", {
1642
+ fg: COLOR.brand,
1643
+ children: " ↻"
1644
+ })
1645
+ ]
1646
+ });
1647
+ }
1648
+ /**
1929
1649
  * Action row — cursor marker · label · description · (focus-only) trailing arrow.
1930
1650
  *
1931
1651
  * The label sits in the same column as a toggle row's `[✓]` checkbox (right
@@ -1934,6 +1654,7 @@ function ToggleRow({ label, description, enabled, focused }) {
1934
1654
  * every action.
1935
1655
  */
1936
1656
  function ActionRow({ label, description, focused }) {
1657
+ const COLOR = useColors();
1937
1658
  return /* @__PURE__ */ jsxs("text", {
1938
1659
  fg: focused ? COLOR.brand : COLOR.dim,
1939
1660
  children: [
@@ -1957,170 +1678,6 @@ function ActionRow({ label, description, focused }) {
1957
1678
  });
1958
1679
  }
1959
1680
  //#endregion
1960
- //#region src/tui/streaming.ts
1961
- /** Target one flush per ~33ms (one frame at the default renderer targetFps=30). */
1962
- const FLUSH_INTERVAL_MS = 33;
1963
- const PARENT_OWNER = "parent";
1964
- function emptyBucket(owner, depth) {
1965
- return {
1966
- markdown: "",
1967
- thinking: "",
1968
- owner,
1969
- depth
1970
- };
1971
- }
1972
- function applyBucket(prev, bucket) {
1973
- let result = prev;
1974
- if (bucket.thinking) result = appendThinkingLines(result, bucket.thinking, bucket.owner, bucket.depth);
1975
- if (bucket.markdown) result = appendMarkdownDelta(result, bucket.markdown, bucket.owner, bucket.depth);
1976
- return result;
1977
- }
1978
- function appendMarkdownDelta(prev, delta, owner, depth) {
1979
- const last = prev[prev.length - 1];
1980
- if (last && last.kind === "markdown" && last.streaming && ownerOf(last) === owner) {
1981
- const next = prev.slice(0, -1);
1982
- next.push({
1983
- ...last,
1984
- text: last.text + delta
1985
- });
1986
- return next;
1987
- }
1988
- return [...prev, eventWithOwner({
1989
- kind: "markdown",
1990
- text: delta,
1991
- streaming: true
1992
- }, owner, depth)];
1993
- }
1994
- function appendThinkingLines(prev, delta, owner, depth) {
1995
- const lines = delta.split("\n");
1996
- const result = [...prev];
1997
- const last = result[result.length - 1];
1998
- if (last && last.kind === "thinking" && ownerOf(last) === owner) result[result.length - 1] = {
1999
- ...last,
2000
- text: last.text + lines[0]
2001
- };
2002
- else if (lines[0] || lines.length > 1) result.push(eventWithOwner({
2003
- kind: "thinking",
2004
- text: lines[0]
2005
- }, owner, depth));
2006
- for (let i = 1; i < lines.length; i++) result.push(eventWithOwner({
2007
- kind: "thinking",
2008
- text: lines[i]
2009
- }, owner, depth));
2010
- return result;
2011
- }
2012
- function ownerOf(evt) {
2013
- return evt.childId ?? PARENT_OWNER;
2014
- }
2015
- function eventWithOwner(evt, owner, depth) {
2016
- if (owner === PARENT_OWNER) return evt;
2017
- return {
2018
- ...evt,
2019
- childId: owner,
2020
- depth
2021
- };
2022
- }
2023
- /** Flip any trailing streaming markdown blocks (any owner) to finalized. */
2024
- function finalizeStreamingMarkdown(events) {
2025
- let changed = false;
2026
- const next = events.map((e) => {
2027
- if (e.kind === "markdown" && e.streaming) {
2028
- changed = true;
2029
- return {
2030
- ...e,
2031
- streaming: false
2032
- };
2033
- }
2034
- return e;
2035
- });
2036
- return changed ? next : events;
2037
- }
2038
- /** Flip the trailing streaming markdown block for one specific owner. */
2039
- function finalizeStreamingMarkdownForOwner(events, owner) {
2040
- for (let i = events.length - 1; i >= 0; i--) {
2041
- const e = events[i];
2042
- if (e.kind !== "markdown") continue;
2043
- if (!e.streaming) continue;
2044
- if (ownerOf(e) !== owner) continue;
2045
- const next = events.slice();
2046
- next[i] = {
2047
- ...e,
2048
- streaming: false
2049
- };
2050
- return next;
2051
- }
2052
- return events;
2053
- }
2054
- /**
2055
- * Effective context size for a single turn.
2056
- *
2057
- * `usage.input` is misleading on its own when prompt caching is active: providers
2058
- * (Anthropic, OpenRouter→Anthropic, Gemini) report `input` as the *new uncached*
2059
- * tokens only — the cached prefix shows up in `cacheRead`, and newly-cached
2060
- * tokens in `cacheCreation`. The model still saw all three buckets, so the real
2061
- * context-window utilization is their sum.
2062
- *
2063
- * Non-caching providers leave `cacheRead`/`cacheCreation` undefined, so this
2064
- * collapses to plain `input` for them.
2065
- */
2066
- function turnContextSize(usage) {
2067
- if (!usage) return 0;
2068
- return (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheCreation ?? 0);
2069
- }
2070
- function useStreamBuffer(setEvents) {
2071
- const bucketsRef = useRef(/* @__PURE__ */ new Map());
2072
- const flushTimerRef = useRef(null);
2073
- const drainPendingInto = useCallback((updater) => {
2074
- if (flushTimerRef.current) {
2075
- clearTimeout(flushTimerRef.current);
2076
- flushTimerRef.current = null;
2077
- }
2078
- const buckets = Array.from(bucketsRef.current.values());
2079
- bucketsRef.current.clear();
2080
- if (!buckets.some((b) => b.markdown.length > 0 || b.thinking.length > 0) && !updater) return;
2081
- setEvents((prev) => {
2082
- let merged = prev;
2083
- for (const bucket of buckets) merged = applyBucket(merged, bucket);
2084
- return updater ? updater(merged) : merged;
2085
- });
2086
- }, [setEvents]);
2087
- const flush = useCallback(() => drainPendingInto(), [drainPendingInto]);
2088
- const flushAndUpdate = useCallback((update) => drainPendingInto(update), [drainPendingInto]);
2089
- const appendImmediate = useCallback((evt) => drainPendingInto((events) => [...events, evt]), [drainPendingInto]);
2090
- const queueStreamDelta = useCallback((kind, delta, source) => {
2091
- if (!delta) return;
2092
- const owner = source?.childId ?? PARENT_OWNER;
2093
- const depth = source?.depth ?? 0;
2094
- let bucket = bucketsRef.current.get(owner);
2095
- if (!bucket) {
2096
- bucket = emptyBucket(owner, depth);
2097
- bucketsRef.current.set(owner, bucket);
2098
- }
2099
- bucket[kind] += delta;
2100
- if (!flushTimerRef.current) flushTimerRef.current = setTimeout(flush, FLUSH_INTERVAL_MS);
2101
- }, [flush]);
2102
- const reset = useCallback(() => {
2103
- if (flushTimerRef.current) {
2104
- clearTimeout(flushTimerRef.current);
2105
- flushTimerRef.current = null;
2106
- }
2107
- bucketsRef.current.clear();
2108
- }, []);
2109
- return useMemo(() => ({
2110
- queueStreamDelta,
2111
- appendImmediate,
2112
- flushAndUpdate,
2113
- flush,
2114
- reset
2115
- }), [
2116
- queueStreamDelta,
2117
- appendImmediate,
2118
- flushAndUpdate,
2119
- flush,
2120
- reset
2121
- ]);
2122
- }
2123
- //#endregion
2124
1681
  //#region src/tui/app.tsx
2125
1682
  /**
2126
1683
  * Surface failures that are normally silenced (teardown / save) when the
@@ -2148,17 +1705,79 @@ function App({ config }) {
2148
1705
  ...config.stateStore.load(),
2149
1706
  settings
2150
1707
  }), [config.stateStore]),
2151
- children: /* @__PURE__ */ jsx(ModalRoot, { children: /* @__PURE__ */ jsx(AppShell, {}) })
1708
+ children: /* @__PURE__ */ jsx(ThemedShell, {})
2152
1709
  })
2153
1710
  });
2154
1711
  }
1712
+ /**
1713
+ * Reads `settings.theme` from the surrounding `SettingsProvider`, resolves
1714
+ * it to a `Theme`, and mounts everything else underneath a `ThemeProvider`.
1715
+ * Split out so `App` doesn't need to call `useSettings` (which would force
1716
+ * it to live inside its own provider — invalid).
1717
+ *
1718
+ * `resolveTheme` falls back to `DEFAULT_THEME` on unknown ids, so an
1719
+ * out-of-date `state.json` (theme renamed / removed) never breaks rendering.
1720
+ */
1721
+ function ThemedShell() {
1722
+ const { settings } = useSettings();
1723
+ return /* @__PURE__ */ jsx(ThemeProvider, {
1724
+ theme: useMemo(() => resolveTheme(settings.theme), [settings.theme]),
1725
+ children: /* @__PURE__ */ jsx(SafeModeProvider, { children: /* @__PURE__ */ jsx(ModalRoot, { children: /* @__PURE__ */ jsx(AppShell, {}) }) })
1726
+ });
1727
+ }
2155
1728
  function AppShell() {
2156
1729
  const renderer = useRenderer();
2157
1730
  const modal = useModal();
2158
1731
  const config = useConfig();
2159
1732
  const { settings } = useSettings();
1733
+ const queue = useSafeModeQueue();
1734
+ const { requestApproval, resolveHead, denyAll } = useSafeModeActions();
2160
1735
  const { providers: providerRegistry, preset, store, stateStore, modelsFor, resumeProvider, initialPicked, initialState } = config;
2161
1736
  const lastResumedSessionId = initialState.lastSessionId;
1737
+ const dataDir = config.paths.dir;
1738
+ const safeModeEnabledRef = useRef(settings.safeMode);
1739
+ useEffect(() => {
1740
+ safeModeEnabledRef.current = settings.safeMode;
1741
+ }, [settings.safeMode]);
1742
+ const [projectDir] = useState(() => process.cwd());
1743
+ const safelistRef = useRef(null);
1744
+ const readSafelist = useCallback(() => {
1745
+ if (safelistRef.current === null) safelistRef.current = getSafelist(dataDir, projectDir);
1746
+ return safelistRef.current;
1747
+ }, [dataDir, projectDir]);
1748
+ useEffect(() => {
1749
+ safelistRef.current = null;
1750
+ }, [dataDir, projectDir]);
1751
+ /**
1752
+ * Single source of truth for "should this call execute?". Returns true to
1753
+ * let the call through, false to refuse it. Handles three short-circuits:
1754
+ *
1755
+ * - Safe-mode globally off → always allow.
1756
+ * - Call covered by the project safelist or the implicit read-only set
1757
+ * → always allow without prompting.
1758
+ * - Otherwise → prompt the user and act on their decision (including
1759
+ * persisting a new safelist entry on "accept + safelist").
1760
+ *
1761
+ * Wired into the parent agent via `tool:gate` / `mcp:tool:gate`, and to
1762
+ * every subagent (transitively, for free) via `child:tool:gate` /
1763
+ * `child:mcp:tool:gate` — see the bubble in `src/tools/spawn.ts`.
1764
+ */
1765
+ const gateDecision = useCallback(async (tool, input) => {
1766
+ if (!safeModeEnabledRef.current) return true;
1767
+ if (isOnSafelist(readSafelist(), tool, input)) return true;
1768
+ const decision = await requestApproval(tool, input);
1769
+ if (decision === "deny") return false;
1770
+ if (decision === "accept-safelist") {
1771
+ addToSafelist(dataDir, projectDir, suggestSafelistEntry(tool, input));
1772
+ safelistRef.current = null;
1773
+ }
1774
+ return true;
1775
+ }, [
1776
+ dataDir,
1777
+ projectDir,
1778
+ requestApproval,
1779
+ readSafelist
1780
+ ]);
2162
1781
  const [screen, setScreen] = useState(() => {
2163
1782
  if (!resumeProvider) return "auth";
2164
1783
  return lastResumedSessionId ? "chat" : "sessions";
@@ -2190,24 +1809,40 @@ function AppShell() {
2190
1809
  provider: descriptor.factory(),
2191
1810
  session
2192
1811
  });
1812
+ const applyGate = async (name, input, ctx) => {
1813
+ if (ctx.block) return;
1814
+ if (!await gateDecision(name, input)) {
1815
+ ctx.block = true;
1816
+ ctx.reason = "User denied this tool call";
1817
+ }
1818
+ };
1819
+ agent.hooks.hook("tool:gate", (ctx) => applyGate(ctx.name, ctx.input, ctx));
1820
+ agent.hooks.hook("child:tool:gate", (ctx) => applyGate(ctx.name, ctx.input, ctx));
1821
+ agent.hooks.hook("mcp:tool:gate", (ctx) => applyGate(ctx.displayName, ctx.input, ctx));
1822
+ agent.hooks.hook("child:mcp:tool:gate", (ctx) => applyGate(ctx.displayName, ctx.input, ctx));
2193
1823
  agent.hooks.hook("stream:thinking", ({ delta }) => stream.queueStreamDelta("thinking", delta));
2194
1824
  agent.hooks.hook("stream:text", ({ delta }) => stream.queueStreamDelta("markdown", delta));
2195
1825
  agent.hooks.hook("tool:before", ({ name, input }) => {
2196
1826
  stream.appendImmediate({
2197
1827
  kind: "tool",
2198
- text: toolCallPreview(name, input)
1828
+ text: toolCallPreview(name, input),
1829
+ tool: name
2199
1830
  });
2200
1831
  });
2201
- agent.hooks.hook("tool:after", ({ result }) => {
1832
+ agent.hooks.hook("tool:after", ({ name, result }) => {
1833
+ const raw = toolResultText(result);
1834
+ const text = name === "spawn" ? stripSpawnTokensLine(raw) : raw;
2202
1835
  stream.appendImmediate({
2203
1836
  kind: "tool-result",
2204
- text: toolResultText(result)
1837
+ text,
1838
+ tool: name
2205
1839
  });
2206
1840
  });
2207
- agent.hooks.hook("mcp:tool:after", ({ result }) => {
1841
+ agent.hooks.hook("mcp:tool:after", ({ displayName, result }) => {
2208
1842
  stream.appendImmediate({
2209
1843
  kind: "tool-result",
2210
- text: toolResultText(result)
1844
+ text: toolResultText(result),
1845
+ tool: displayName
2211
1846
  });
2212
1847
  });
2213
1848
  agent.hooks.hook("turn:after", ({ usage }) => {
@@ -2227,7 +1862,7 @@ function AppShell() {
2227
1862
  const tag = status === "aborted" ? "aborted" : status === "error" ? "error" : "done";
2228
1863
  stream.appendImmediate({
2229
1864
  kind: "spawn-end",
2230
- text: `${tag} · ${stats.totalIn} in / ${stats.totalOut} out`,
1865
+ text: `${tag} ${formatTokenUsage(stats)}`,
2231
1866
  childId: id,
2232
1867
  depth: depth ?? 1
2233
1868
  });
@@ -2256,14 +1891,16 @@ function AppShell() {
2256
1891
  stream.appendImmediate({
2257
1892
  kind: "tool",
2258
1893
  text: toolCallPreview(name, input),
1894
+ tool: name,
2259
1895
  childId,
2260
1896
  depth
2261
1897
  });
2262
1898
  });
2263
- agent.hooks.hook("child:tool:after", ({ result, childId, depth }) => {
1899
+ agent.hooks.hook("child:tool:after", ({ name, result, childId, depth }) => {
2264
1900
  stream.appendImmediate({
2265
1901
  kind: "tool-result",
2266
1902
  text: toolResultText(result),
1903
+ tool: name,
2267
1904
  childId,
2268
1905
  depth
2269
1906
  });
@@ -2275,7 +1912,8 @@ function AppShell() {
2275
1912
  }, [
2276
1913
  providerRegistry,
2277
1914
  preset,
2278
- stream
1915
+ stream,
1916
+ gateDecision
2279
1917
  ]);
2280
1918
  const refreshSessions = useCallback(async () => {
2281
1919
  const list = await listSessionMeta(store);
@@ -2296,7 +1934,7 @@ function AppShell() {
2296
1934
  });
2297
1935
  sessionRef.current = session;
2298
1936
  agentRef.current = buildAgent(session, key);
2299
- setEvents(eventsFromTurns(session.turns));
1937
+ setEvents(eventsFromTurns(session.turns, session.runs));
2300
1938
  setLastInputTokens(lastContextSizeFromTurns(session.turns));
2301
1939
  setCurrentSession({
2302
1940
  id: session.id,
@@ -2370,8 +2008,9 @@ function AppShell() {
2370
2008
  setScreen("sessions");
2371
2009
  }, [refreshSessions]);
2372
2010
  const onAbort = useCallback(() => {
2011
+ denyAll();
2373
2012
  agentRef.current?.abort();
2374
- }, []);
2013
+ }, [denyAll]);
2375
2014
  const onPickModel = useCallback((modelId) => {
2376
2015
  setPicked((prev) => {
2377
2016
  if (!prev) return prev;
@@ -2433,6 +2072,7 @@ function AppShell() {
2433
2072
  modal.close();
2434
2073
  setScreen("auth");
2435
2074
  }, [modal]);
2075
+ const pendingApproval = queue[0] ?? null;
2436
2076
  useKeyboard((key) => {
2437
2077
  if (modal.isOpen) return;
2438
2078
  if (key.ctrl && key.name === "," && screen !== "auth") {
@@ -2448,7 +2088,7 @@ function AppShell() {
2448
2088
  return;
2449
2089
  }
2450
2090
  if (key.name !== "escape") return;
2451
- if (busy) return onAbort();
2091
+ if (busy || pendingApproval) return onAbort();
2452
2092
  if (screen === "chat") return onOpenSessions();
2453
2093
  if (screen === "sessions") {
2454
2094
  if (currentSession) setScreen("chat");
@@ -2461,9 +2101,10 @@ function AppShell() {
2461
2101
  }
2462
2102
  renderer.destroy();
2463
2103
  });
2464
- const hints = useMemo(() => buildHints(screen, busy, currentSession), [
2104
+ const hints = useMemo(() => buildHints(screen, busy, !!pendingApproval, currentSession), [
2465
2105
  screen,
2466
2106
  busy,
2107
+ pendingApproval,
2467
2108
  currentSession
2468
2109
  ]);
2469
2110
  const contextUsage = useMemo(() => {
@@ -2509,7 +2150,9 @@ function AppShell() {
2509
2150
  busy,
2510
2151
  settings,
2511
2152
  onSubmit: onSubmitPrompt,
2512
- session: currentSession
2153
+ session: currentSession,
2154
+ pending: pendingApproval,
2155
+ onApproval: resolveHead
2513
2156
  })
2514
2157
  ]
2515
2158
  }), /* @__PURE__ */ jsx(Footer, {
@@ -2519,7 +2162,21 @@ function AppShell() {
2519
2162
  })]
2520
2163
  });
2521
2164
  }
2522
- function buildHints(screen, busy, currentSession) {
2165
+ function buildHints(screen, busy, pending, currentSession) {
2166
+ if (pending) return [
2167
+ {
2168
+ key: "↑↓",
2169
+ label: "navigate"
2170
+ },
2171
+ {
2172
+ key: "↵",
2173
+ label: "select"
2174
+ },
2175
+ {
2176
+ key: "esc",
2177
+ label: "abort run"
2178
+ }
2179
+ ];
2523
2180
  if (busy) return [{
2524
2181
  key: "esc",
2525
2182
  label: "abort"
@@ -2576,6 +2233,101 @@ function buildHints(screen, busy, currentSession) {
2576
2233
  ];
2577
2234
  }
2578
2235
  //#endregion
2236
+ //#region src/tui/tree-sitter.ts
2237
+ /**
2238
+ * Register Tree-sitter parsers for the languages we'd like highlighted
2239
+ * inside fenced markdown code blocks.
2240
+ *
2241
+ * OpenTUI ships JS/TS/Markdown/Zig out of the box. Anything else needs a
2242
+ * Tree-sitter `.wasm` grammar + a `highlights.scm` capture query. We fetch
2243
+ * both from the upstream Tree-sitter grammar repos; OpenTUI's worker
2244
+ * caches them under its data path (`~/.local/share/opentui/...` by
2245
+ * default) so the download is a one-shot cost per language per machine.
2246
+ *
2247
+ * `aliases` lets a single grammar handle multiple fence info-strings — e.g.
2248
+ * `bash` also matches ` ```sh ` and ` ```shell `. The model picks fences
2249
+ * inconsistently across providers; aliases save us from missing highlights
2250
+ * on synonyms.
2251
+ *
2252
+ * Runtime caveats:
2253
+ * - **First use** of a language triggers an HTTPS download. Subsequent
2254
+ * uses (same machine, same data path) are instant.
2255
+ * - **Compiled binaries** (`bun --compile`) still work — the data path
2256
+ * is a writable OS dir, not the bunfs. Air-gapped deployments would
2257
+ * need to either pre-populate the cache or migrate to local-file
2258
+ * vendoring via `with { type: 'file' }` imports (see
2259
+ * https://opentui.com/docs/reference/tree-sitter/#use-local-files).
2260
+ * - If a download fails (offline / firewall), the language renders as
2261
+ * plain `markup.raw.block` — no crash, just no syntax color.
2262
+ *
2263
+ * Versions are pinned in the WASM URLs so a grammar repo's `master`
2264
+ * landing a breaking change can't silently affect us.
2265
+ */
2266
+ const EXTRA_PARSERS = [
2267
+ {
2268
+ filetype: "python",
2269
+ aliases: ["py"],
2270
+ wasm: "https://github.com/tree-sitter/tree-sitter-python/releases/download/v0.23.6/tree-sitter-python.wasm",
2271
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-python/v0.23.6/queries/highlights.scm"] }
2272
+ },
2273
+ {
2274
+ filetype: "bash",
2275
+ aliases: [
2276
+ "sh",
2277
+ "shell",
2278
+ "zsh"
2279
+ ],
2280
+ wasm: "https://github.com/tree-sitter/tree-sitter-bash/releases/download/v0.23.3/tree-sitter-bash.wasm",
2281
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-bash/v0.23.3/queries/highlights.scm"] }
2282
+ },
2283
+ {
2284
+ filetype: "json",
2285
+ wasm: "https://github.com/tree-sitter/tree-sitter-json/releases/download/v0.24.8/tree-sitter-json.wasm",
2286
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-json/v0.24.8/queries/highlights.scm"] }
2287
+ },
2288
+ {
2289
+ filetype: "rust",
2290
+ aliases: ["rs"],
2291
+ wasm: "https://github.com/tree-sitter/tree-sitter-rust/releases/download/v0.23.2/tree-sitter-rust.wasm",
2292
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-rust/v0.23.2/queries/highlights.scm"] }
2293
+ },
2294
+ {
2295
+ filetype: "go",
2296
+ aliases: ["golang"],
2297
+ wasm: "https://github.com/tree-sitter/tree-sitter-go/releases/download/v0.23.4/tree-sitter-go.wasm",
2298
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-go/v0.23.4/queries/highlights.scm"] }
2299
+ },
2300
+ {
2301
+ filetype: "yaml",
2302
+ aliases: ["yml"],
2303
+ wasm: "https://github.com/tree-sitter-grammars/tree-sitter-yaml/releases/download/v0.7.0/tree-sitter-yaml.wasm",
2304
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-yaml/v0.7.0/queries/highlights.scm"] }
2305
+ },
2306
+ {
2307
+ filetype: "html",
2308
+ aliases: ["htm"],
2309
+ wasm: "https://github.com/tree-sitter/tree-sitter-html/releases/download/v0.23.2/tree-sitter-html.wasm",
2310
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-html/v0.23.2/queries/highlights.scm"] }
2311
+ },
2312
+ {
2313
+ filetype: "css",
2314
+ wasm: "https://github.com/tree-sitter/tree-sitter-css/releases/download/v0.23.2/tree-sitter-css.wasm",
2315
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-css/v0.23.2/queries/highlights.scm"] }
2316
+ }
2317
+ ];
2318
+ let registered = false;
2319
+ /**
2320
+ * Register the extra Tree-sitter parsers + start the worker. Idempotent —
2321
+ * subsequent calls are no-ops. Safe to invoke from `runTui()` and from
2322
+ * composition hosts that mount `<App>` directly.
2323
+ */
2324
+ async function setupTreeSitter() {
2325
+ if (registered) return;
2326
+ registered = true;
2327
+ addDefaultParsers(EXTRA_PARSERS);
2328
+ await getTreeSitterClient().initialize();
2329
+ }
2330
+ //#endregion
2579
2331
  //#region src/tui/index.tsx
2580
2332
  /**
2581
2333
  * Tracks whether `runTui` has been invoked in this process. `createCliRenderer`
@@ -2611,7 +2363,8 @@ let runTuiInvoked = false;
2611
2363
  * to `runTui({ storageDir, prefix })`.
2612
2364
  *
2613
2365
  * ```ts
2614
- * import { BUILTIN_PROVIDERS, runTui } from 'zidane/tui'
2366
+ * import { BUILTIN_PROVIDERS } from 'zidane/chat'
2367
+ * import { runTui } from 'zidane/tui'
2615
2368
  * import { createRemoteStore } from 'zidane/session' // for the `store` option
2616
2369
  *
2617
2370
  * await runTui() // ~/.zidane/sessions.db + state.json
@@ -2624,7 +2377,10 @@ let runTuiInvoked = false;
2624
2377
  async function runTui(options = {}) {
2625
2378
  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.");
2626
2379
  runTuiInvoked = true;
2627
- await init();
2380
+ await setupTreeSitter().catch((err) => {
2381
+ const cause = err instanceof Error ? err.message : String(err);
2382
+ process.stderr.write(`[zidane/tui] tree-sitter setup failed: ${cause}\n`);
2383
+ });
2628
2384
  const config = resolveConfig(options);
2629
2385
  let done = () => {};
2630
2386
  const exited = new Promise((resolve) => {
@@ -2638,6 +2394,6 @@ async function runTui(options = {}) {
2638
2394
  process.exit(0);
2639
2395
  }
2640
2396
  //#endregion
2641
- export { App, AuthScreen, BUILTIN_PROVIDERS, COLOR, ChatScreen, ConfigProvider, DEFAULT_SETTINGS, Footer, MD_STYLE, Modal, ModalRoot, ModelPickerModal, SELECT_THEME, SessionsScreen, SettingsModal, SettingsProvider, Spinner, Transcript, ageString, anthropicDescriptor, applyApiKeyEnv, cerebrasDescriptor, createStateStore, createTuiStore, credKeyOf, credentialsPath, detectAuth, eventsFromTurns, finalizeStreamingMarkdown, finalizeStreamingMarkdownForOwner, fmtTokens, getContextWindow, lastContextSizeFromTurns, listSessionMeta, loadState, marginTopFor, modelsForDescriptor, onInputSubmit, openaiDescriptor, openrouterDescriptor, piIdOf, readCredentials, readProviderCredential, removeProviderCredential, resolveConfig, runOAuthLogin, runTui, saveState, setProviderCredential, shortId, supportsOAuth, titleFromTurns, toolCallPreview, toolResultText, turnContextSize, useConfig, useModal, useModalAwareFocus, useSettings, useStreamBuffer, writeCredentials };
2397
+ export { App, AuthScreen, ChatScreen, Footer, Modal, ModalRoot, ModelPickerModal, SessionsScreen, SettingsModal, Spinner, Transcript, buildMdStyle, isVisible, marginTopFor, onInputSubmit, runTui, useMdStyle, useModal, useModalAwareFocus };
2642
2398
 
2643
2399
  //# sourceMappingURL=tui.js.map