zidane 4.1.4 → 4.1.6

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 (39) hide show
  1. package/README.md +3 -3
  2. package/dist/{agent-BoV5Twdl.d.ts → agent-BAoqUvwA.d.ts} +27 -1
  3. package/dist/{agent-BoV5Twdl.d.ts.map → agent-BAoqUvwA.d.ts.map} +1 -1
  4. package/dist/{index-28otmfLX.d.ts → index-B8-yNSsk.d.ts} +2 -2
  5. package/dist/index-B8-yNSsk.d.ts.map +1 -0
  6. package/dist/{index-DPsd0qwm.d.ts → index-CqpNqjDy.d.ts} +2 -2
  7. package/dist/{index-DPsd0qwm.d.ts.map → index-CqpNqjDy.d.ts.map} +1 -1
  8. package/dist/index.d.ts +3 -3
  9. package/dist/index.js +4 -4
  10. package/dist/mcp.d.ts +1 -1
  11. package/dist/{presets-Cs7_CsMk.js → presets-BzkJDW1K.js} +3 -3
  12. package/dist/presets-BzkJDW1K.js.map +1 -0
  13. package/dist/presets.d.ts +1 -1
  14. package/dist/presets.js +1 -1
  15. package/dist/{providers-CX-R-Oy-.js → providers-CCDvIXGJ.js} +26 -5
  16. package/dist/providers-CCDvIXGJ.js.map +1 -0
  17. package/dist/providers.d.ts +1 -1
  18. package/dist/providers.js +1 -1
  19. package/dist/session/sqlite.d.ts +1 -1
  20. package/dist/session.d.ts +1 -1
  21. package/dist/skills.d.ts +2 -2
  22. package/dist/{stats-DoKUtF5T.js → stats-BT9l57RS.js} +34 -2
  23. package/dist/stats-BT9l57RS.js.map +1 -0
  24. package/dist/{tools-DpeWKzP1.js → tools-C8kDot0H.js} +73 -23
  25. package/dist/tools-C8kDot0H.js.map +1 -0
  26. package/dist/tools.d.ts +2 -2
  27. package/dist/tools.js +1 -1
  28. package/dist/tui.d.ts +423 -80
  29. package/dist/tui.d.ts.map +1 -1
  30. package/dist/tui.js +1604 -250
  31. package/dist/tui.js.map +1 -1
  32. package/dist/types.d.ts +2 -2
  33. package/dist/types.js +1 -1
  34. package/package.json +1 -1
  35. package/dist/index-28otmfLX.d.ts.map +0 -1
  36. package/dist/presets-Cs7_CsMk.js.map +0 -1
  37. package/dist/providers-CX-R-Oy-.js.map +0 -1
  38. package/dist/stats-DoKUtF5T.js.map +0 -1
  39. package/dist/tools-DpeWKzP1.js.map +0 -1
package/dist/tui.js CHANGED
@@ -1,12 +1,15 @@
1
- import { d as createAgent } from "./tools-DpeWKzP1.js";
1
+ import { d as createAgent } from "./tools-C8kDot0H.js";
2
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";
3
+ import { n as formatTokenUsage } from "./stats-BT9l57RS.js";
4
+ import { r as basic_default } from "./presets-BzkJDW1K.js";
5
+ import { i as anthropic, n as openai, r as cerebras, t as openrouter } from "./providers-CCDvIXGJ.js";
5
6
  import { n as loadSession, t as createSession } from "./session-Cn68UASv.js";
6
7
  import { createSqliteStore } from "./session/sqlite.js";
8
+ import { spawn } from "node:child_process";
7
9
  import { dirname, resolve } from "node:path";
8
10
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
11
  import { homedir } from "node:os";
12
+ import { anthropicOAuthProvider, openaiCodexOAuthProvider } from "@mariozechner/pi-ai/oauth";
10
13
  import { getModel, getModels } from "@mariozechner/pi-ai";
11
14
  import { RGBA, SyntaxStyle, createCliRenderer, defaultTextareaKeyBindings } from "@opentui/core";
12
15
  import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
@@ -115,6 +118,27 @@ const MD_STYLE = SyntaxStyle.fromStyles({
115
118
  });
116
119
  //#endregion
117
120
  //#region src/tui/components.tsx
121
+ init().catch(() => {});
122
+ /**
123
+ * Heal incomplete streaming markdown, surviving any md4x failure.
124
+ *
125
+ * `md4x/wasm` can throw "WASM not initialized" in two cases we've seen:
126
+ * 1. A host mounted `<App>` directly without calling `runTui()`/`init()`.
127
+ * 2. A `bun build --compile` binary where the inlined `md4x.wasm` default
128
+ * export resolved to `undefined` — `init()` silently calls
129
+ * `_setInstance(undefined)` and every subsequent `heal()` throws.
130
+ *
131
+ * In both cases we'd rather render the raw streaming text (OpenTUI's
132
+ * `<markdown streaming>` tolerates unclosed delimiters) than crash the
133
+ * transcript.
134
+ */
135
+ function safeHeal(text) {
136
+ try {
137
+ return heal(text);
138
+ } catch {
139
+ return text;
140
+ }
141
+ }
118
142
  /**
119
143
  * Memoized so a flush that mutates only the trailing event doesn't force the
120
144
  * entire transcript to re-render. Each event holds a stable reference until
@@ -123,9 +147,17 @@ const MD_STYLE = SyntaxStyle.fromStyles({
123
147
  * The outer wrapper handles top-margin per kind (and per neighbor) so spacing
124
148
  * is the single source of truth for inter-event breathing room.
125
149
  */
126
- const EventLine = memo(({ event, previous }) => /* @__PURE__ */ jsx("box", {
127
- style: { marginTop: marginTopFor(event, previous) },
128
- children: /* @__PURE__ */ jsx(EventLineImpl, { event })
150
+ const EventLine = memo(({ event, previous, depthOffset = 0 }) => /* @__PURE__ */ jsx("box", {
151
+ style: {
152
+ marginTop: marginTopFor(event, previous),
153
+ alignSelf: "stretch",
154
+ flexShrink: 0,
155
+ flexDirection: "column"
156
+ },
157
+ children: /* @__PURE__ */ jsx(EventLineImpl, {
158
+ event,
159
+ depthOffset
160
+ })
129
161
  }));
130
162
  /**
131
163
  * `@opentui/react` extends `React.JSX.IntrinsicElements`, so `onSubmit` on `<input>`
@@ -254,8 +286,8 @@ function Spinner({ label }) {
254
286
  });
255
287
  }
256
288
  function Transcript({ events, settings }) {
257
- const visible = events.filter((e) => isVisible(e, settings));
258
- if (visible.length === 0) return /* @__PURE__ */ jsx(EmptyState$1, {});
289
+ const items = useMemo(() => partitionTranscript(events, settings), [events, settings]);
290
+ if (items.length === 0) return /* @__PURE__ */ jsx(EmptyState$1, {});
259
291
  return /* @__PURE__ */ jsx("scrollbox", {
260
292
  focusable: false,
261
293
  style: {
@@ -265,13 +297,33 @@ function Transcript({ events, settings }) {
265
297
  },
266
298
  stickyScroll: true,
267
299
  stickyStart: "bottom",
268
- children: visible.map((evt, i) => /* @__PURE__ */ jsx(EventLine, {
269
- event: evt,
270
- previous: visible[i - 1]
300
+ children: items.map((item, i) => item.kind === "event" ? /* @__PURE__ */ jsx(EventLine, {
301
+ event: item.event,
302
+ previous: item.previous
303
+ }, i) : /* @__PURE__ */ jsx(SubagentBlock, {
304
+ events: item.events,
305
+ previous: item.previous
271
306
  }, i))
272
307
  });
273
308
  }
309
+ /**
310
+ * Per-event visibility — filters honor user toggles and the
311
+ * `hideSubagentOutput` setting. When subagent output is hidden:
312
+ * - Child-agent events are filtered down to the `spawn-start` /
313
+ * `spawn-end` markers so the user still sees "🌱 working… 🌳 done".
314
+ * - The parent's `tool-result` for `spawn` is hidden too. Its body
315
+ * duplicates `spawn-end`'s stats line *and* the parent's next markdown
316
+ * turn ("Here's what the sub-agent found: …"). Showing it again
317
+ * produced an extra `┃ [sub-agent child-1] Completed …` block that
318
+ * the user just wanted gone.
319
+ *
320
+ * Exported so the visibility matrix can be unit-tested without rendering.
321
+ */
274
322
  function isVisible(event, settings) {
323
+ if (settings.hideSubagentOutput) {
324
+ if (isChild(event)) return event.kind === "spawn-start" || event.kind === "spawn-end";
325
+ if (event.kind === "tool-result" && event.tool === "spawn") return false;
326
+ }
275
327
  switch (event.kind) {
276
328
  case "thinking": return settings.showThinking;
277
329
  case "tool": return settings.showToolCalls;
@@ -279,6 +331,91 @@ function isVisible(event, settings) {
279
331
  default: return true;
280
332
  }
281
333
  }
334
+ /**
335
+ * Walk the visible-event list once and group consecutive child events
336
+ * (`depth > 0`) into runs so we can wrap each run in a single bordered
337
+ * subagent box.
338
+ *
339
+ * When `hideSubagentOutput` is on, `isVisible` already filters most child
340
+ * events out; the surviving `spawn-start` / `spawn-end` markers render as
341
+ * plain entries (no boxing) — there's nothing meaningful to box.
342
+ */
343
+ function partitionTranscript(events, settings) {
344
+ const visible = events.filter((e) => isVisible(e, settings));
345
+ if (visible.length === 0) return [];
346
+ if (settings.hideSubagentOutput) return visible.map((event, i) => ({
347
+ kind: "event",
348
+ event,
349
+ previous: visible[i - 1]
350
+ }));
351
+ const items = [];
352
+ let run = [];
353
+ let runPrevious;
354
+ const flush = () => {
355
+ if (run.length > 0) {
356
+ items.push({
357
+ kind: "child-run",
358
+ events: run,
359
+ previous: runPrevious
360
+ });
361
+ run = [];
362
+ runPrevious = void 0;
363
+ }
364
+ };
365
+ for (let i = 0; i < visible.length; i++) {
366
+ const event = visible[i];
367
+ if (isChild(event)) {
368
+ if (run.length === 0) runPrevious = visible[i - 1];
369
+ run.push(event);
370
+ } else {
371
+ flush();
372
+ items.push({
373
+ kind: "event",
374
+ event,
375
+ previous: visible[i - 1]
376
+ });
377
+ }
378
+ }
379
+ flush();
380
+ return items;
381
+ }
382
+ /**
383
+ * Bordered container for one run of subagent events. The box's border +
384
+ * left padding give the visual "this is a subagent" affordance, so events
385
+ * inside render with `depthOffset: 1` — a direct child of the parent
386
+ * (depth 1) sits flush against the box's inner padding rather than being
387
+ * indented twice. Grandchildren (depth ≥ 2) still indent further, so
388
+ * nested subagents remain visually distinct.
389
+ */
390
+ function SubagentBlock({ events, previous }) {
391
+ const childIds = useMemo(() => {
392
+ const set = /* @__PURE__ */ new Set();
393
+ for (const e of events) if (e.childId) set.add(e.childId);
394
+ return Array.from(set);
395
+ }, [events]);
396
+ const title = childIds.length === 0 ? " subagent " : childIds.length === 1 ? ` ${childIds[0]} ` : ` subagents · ${childIds.join(", ")} `;
397
+ const marginTop = previous ? 1 : 0;
398
+ return /* @__PURE__ */ jsx("box", {
399
+ title,
400
+ style: {
401
+ border: true,
402
+ borderColor: COLOR.mute,
403
+ paddingLeft: 1,
404
+ paddingRight: 1,
405
+ paddingTop: 0,
406
+ paddingBottom: 0,
407
+ marginTop,
408
+ flexDirection: "column",
409
+ flexShrink: 0,
410
+ alignSelf: "stretch"
411
+ },
412
+ children: events.map((evt, i) => /* @__PURE__ */ jsx(EventLine, {
413
+ event: evt,
414
+ previous: events[i - 1],
415
+ depthOffset: 1
416
+ }, i))
417
+ });
418
+ }
282
419
  function EmptyState$1() {
283
420
  return /* @__PURE__ */ jsx("box", {
284
421
  style: {
@@ -301,6 +438,23 @@ function isChild(event) {
301
438
  return (event.depth ?? 0) > 0;
302
439
  }
303
440
  /**
441
+ * Shared row geometry for every transcript event.
442
+ *
443
+ * `alignSelf: 'stretch'` + `flexShrink: 0` together pin each row to the
444
+ * scrollbox's content width and prevent flex re-negotiation when neighboring
445
+ * rows grow or shrink (streaming markdown, late-arriving tool results, etc.).
446
+ * Without this, Yoga is free to re-compute widths every render and the
447
+ * visible text appears to "wiggle" between columns as the stream advances.
448
+ */
449
+ function rowStyle(paddingLeft) {
450
+ return {
451
+ paddingLeft,
452
+ flexDirection: "column",
453
+ flexShrink: 0,
454
+ alignSelf: "stretch"
455
+ };
456
+ }
457
+ /**
304
458
  * Default top-margin per kind. Spacing intent:
305
459
  * - `info` / `markdown` / `tool` / `error` / `spawn-start` open a new block
306
460
  * so they each get one row of breathing room above.
@@ -325,33 +479,44 @@ const TOOL_KINDS = new Set(["tool", "tool-result"]);
325
479
  /**
326
480
  * Resolve the top margin for an event given the one rendered just before it.
327
481
  *
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.
482
+ * Context-aware rules:
483
+ *
484
+ * - A `tool` / `tool-result` event right after another `tool` / `tool-result`
485
+ * collapses to a tight list call→result pairs and back-to-back calls
486
+ * read as one logical block.
487
+ * - A parent-level event (`depth === 0`) right after a subagent event
488
+ * (`depth > 0`) collapses too. The subagent's `🌳` end marker (and, in
489
+ * show mode, the subagent box's bottom border) already provides the
490
+ * separation; adding the event's default `marginTop` on top would
491
+ * produce the visible "line jump" between a subagent's outcome and the
492
+ * parent's follow-up. Either form of marker is enough — we don't want
493
+ * both.
332
494
  *
333
495
  * Exported so the spacing matrix can be unit-tested without rendering.
334
496
  */
335
497
  function marginTopFor(event, previous) {
336
498
  if (TOOL_KINDS.has(event.kind) && previous && TOOL_KINDS.has(previous.kind)) return 0;
499
+ const eventDepth = event.depth ?? 0;
500
+ const previousDepth = previous?.depth ?? 0;
501
+ if (eventDepth === 0 && previousDepth > 0) return 0;
337
502
  return MARGIN_TOP[event.kind] ?? 0;
338
503
  }
339
- function EventLineImpl({ event }) {
504
+ function EventLineImpl({ event, depthOffset = 0 }) {
340
505
  const safeText = event.text === "" ? " " : event.text;
341
- const paddingLeft = indentFor(event.depth);
506
+ const row = rowStyle(indentFor(Math.max(0, (event.depth ?? 0) - depthOffset)));
342
507
  const child = isChild(event);
343
508
  switch (event.kind) {
344
509
  case "separator": return /* @__PURE__ */ jsx("text", { children: " " });
345
510
  case "info": return /* @__PURE__ */ jsx(UserPromptBlock, { text: safeText });
346
511
  case "thinking": return /* @__PURE__ */ jsx("box", {
347
- style: { paddingLeft },
512
+ style: row,
348
513
  children: /* @__PURE__ */ jsx("text", {
349
514
  fg: COLOR.dim,
350
515
  children: safeText
351
516
  })
352
517
  });
353
518
  case "tool": return /* @__PURE__ */ jsx("box", {
354
- style: { paddingLeft },
519
+ style: row,
355
520
  children: /* @__PURE__ */ jsxs("text", {
356
521
  fg: child ? COLOR.dim : COLOR.model,
357
522
  children: [/* @__PURE__ */ jsx("span", {
@@ -362,10 +527,10 @@ function EventLineImpl({ event }) {
362
527
  });
363
528
  case "tool-result": return /* @__PURE__ */ jsx(ToolResultBlock, {
364
529
  text: event.text,
365
- indent: paddingLeft
530
+ indent: row.paddingLeft
366
531
  });
367
532
  case "error": return /* @__PURE__ */ jsx("box", {
368
- style: { paddingLeft },
533
+ style: row,
369
534
  children: /* @__PURE__ */ jsxs("text", {
370
535
  fg: COLOR.error,
371
536
  children: [/* @__PURE__ */ jsx("span", {
@@ -375,7 +540,7 @@ function EventLineImpl({ event }) {
375
540
  })
376
541
  });
377
542
  case "markdown": return /* @__PURE__ */ jsx("box", {
378
- style: { paddingLeft },
543
+ style: row,
379
544
  children: /* @__PURE__ */ jsx(MarkdownBlock, {
380
545
  text: event.text,
381
546
  streaming: event.streaming ?? false,
@@ -383,7 +548,7 @@ function EventLineImpl({ event }) {
383
548
  })
384
549
  });
385
550
  case "spawn-start": return /* @__PURE__ */ jsx("box", {
386
- style: { paddingLeft },
551
+ style: row,
387
552
  children: /* @__PURE__ */ jsxs("text", {
388
553
  fg: COLOR.dim,
389
554
  children: [
@@ -403,13 +568,13 @@ function EventLineImpl({ event }) {
403
568
  })
404
569
  });
405
570
  case "spawn-end": return /* @__PURE__ */ jsx("box", {
406
- style: { paddingLeft },
571
+ style: row,
407
572
  children: /* @__PURE__ */ jsxs("text", {
408
573
  fg: COLOR.dim,
409
574
  children: [
410
575
  /* @__PURE__ */ jsx("span", {
411
576
  fg: COLOR.accent,
412
- children: " "
577
+ children: "🌳 "
413
578
  }),
414
579
  /* @__PURE__ */ jsx("span", {
415
580
  fg: COLOR.dim,
@@ -445,13 +610,27 @@ function UserPromptBlock({ text }) {
445
610
  * `md4x.heal()` so unclosed delimiters (bold, italic, code, link, table) render
446
611
  * as if already complete. OpenTUI's `streaming` prop keeps its parser from
447
612
  * committing to the final layout for the trailing block.
613
+ *
614
+ * `internalBlockMode: "top-level"` is the load-bearing knob for streaming
615
+ * stability: in the default `"coalesced"` mode, OpenTUI fuses adjacent
616
+ * top-level markdown blocks into one render block, so when a token streams
617
+ * in the *entire* coalesced block re-flows and earlier paragraphs visibly
618
+ * jump. With `"top-level"`, each top-level block (paragraph, heading, list,
619
+ * code fence) is its own render block — they finalize as soon as the parser
620
+ * moves past them, leaving only the trailing block reflowable.
621
+ *
622
+ * `alignSelf: 'stretch'` pins the markdown to the parent box's content
623
+ * width so its wrap column doesn't drift between renders.
448
624
  */
449
625
  function MarkdownBlock({ text, streaming, dim }) {
450
626
  return /* @__PURE__ */ jsx("markdown", {
451
- content: useMemo(() => streaming ? heal(text) : text, [text, streaming]),
627
+ content: useMemo(() => streaming ? safeHeal(text) : text, [text, streaming]),
452
628
  syntaxStyle: MD_STYLE,
453
629
  streaming,
454
- fg: dim ? COLOR.dim : void 0
630
+ internalBlockMode: "top-level",
631
+ fg: dim ? COLOR.dim : void 0,
632
+ alignSelf: "stretch",
633
+ flexShrink: 0
455
634
  });
456
635
  }
457
636
  const TOOL_RESULT_MAX_LINES = 6;
@@ -480,107 +659,281 @@ function ToolResultBlock({ text, indent }) {
480
659
  });
481
660
  }
482
661
  //#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"
662
+ //#region src/tui/providers.ts
663
+ /** Convenience accessor — returns `credentialFileKey ?? key`. */
664
+ function credKeyOf(desc) {
665
+ return desc.credentialFileKey ?? desc.key;
666
+ }
667
+ /** Convenience accessor — returns `piProviderId ?? key`. */
668
+ function piIdOf(desc) {
669
+ return desc.piProviderId ?? desc.key;
670
+ }
671
+ const anthropicDescriptor = {
672
+ key: "anthropic",
673
+ label: "Anthropic",
674
+ factory: anthropic,
675
+ defaultModel: "claude-opus-4-7",
676
+ envKey: "ANTHROPIC_API_KEY",
677
+ apiKeyPlaceholder: "sk-ant-…",
678
+ oauthProvider: anthropicOAuthProvider,
679
+ oauthHint: "Claude Pro/Max subscription"
680
+ };
681
+ const openaiDescriptor = {
682
+ key: "openai",
683
+ label: "OpenAI Codex",
684
+ factory: openai,
685
+ defaultModel: "gpt-5.4",
686
+ envKey: "OPENAI_CODEX_API_KEY",
687
+ credentialFileKey: "openai-codex",
688
+ piProviderId: "openai-codex",
689
+ apiKeyPlaceholder: "sk-… or eyJ… (Codex)",
690
+ oauthProvider: openaiCodexOAuthProvider
691
+ };
692
+ const openrouterDescriptor = {
693
+ key: "openrouter",
694
+ label: "OpenRouter",
695
+ factory: openrouter,
696
+ defaultModel: "anthropic/claude-sonnet-4-6",
697
+ envKey: "OPENROUTER_API_KEY",
698
+ apiKeyPlaceholder: "sk-or-…"
489
699
  };
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"
700
+ const cerebrasDescriptor = {
701
+ key: "cerebras",
702
+ label: "Cerebras",
703
+ factory: cerebras,
704
+ defaultModel: "zai-glm-4.7",
705
+ envKey: "CEREBRAS_API_KEY",
706
+ apiKeyPlaceholder: "csk-…"
494
707
  };
495
- const LABELS = {
496
- anthropic: "Anthropic",
497
- openai: "OpenAI Codex",
498
- openrouter: "OpenRouter",
499
- cerebras: "Cerebras"
708
+ /**
709
+ * Default provider registry. Passed verbatim when `runTui` is invoked without
710
+ * an explicit `providers` option. Hosts that want to override per-provider
711
+ * metadata can spread this and replace specific entries:
712
+ *
713
+ * ```ts
714
+ * runTui({ providers: { ...BUILTIN_PROVIDERS, anthropic: myOwnAnthropicDescriptor } })
715
+ * ```
716
+ */
717
+ const BUILTIN_PROVIDERS = {
718
+ anthropic: anthropicDescriptor,
719
+ openai: openaiDescriptor,
720
+ openrouter: openrouterDescriptor,
721
+ cerebras: cerebrasDescriptor
500
722
  };
501
- function envKeyFor(key) {
502
- return ENV_KEYS[key];
723
+ /**
724
+ * Resolve the model list for a given provider. Honors `descriptor.models`
725
+ * when set; otherwise queries pi-ai via `descriptor.piProviderId`. Returns
726
+ * `[]` for descriptors with no known mapping (custom providers without a
727
+ * model list) — callers should hide the model picker in that case.
728
+ */
729
+ function modelsForDescriptor(descriptor) {
730
+ if (descriptor.models) return descriptor.models;
731
+ try {
732
+ return getModels(piIdOf(descriptor));
733
+ } catch {
734
+ return [];
735
+ }
736
+ }
737
+ /**
738
+ * Look up the model's max context window via the descriptor's model source.
739
+ * Returns `null` when the model isn't known (custom slugs, providers without
740
+ * a registry); callers should hide the context indicator in that case.
741
+ */
742
+ function getContextWindow(descriptor, modelId) {
743
+ if (descriptor.models) return descriptor.models.find((m) => m.id === modelId)?.contextWindow ?? null;
744
+ try {
745
+ return getModel(piIdOf(descriptor), modelId)?.contextWindow ?? null;
746
+ } catch {
747
+ return null;
748
+ }
503
749
  }
750
+ //#endregion
751
+ //#region src/tui/credentials.ts
752
+ /** POSIX mode for the credentials file. Ignored on Windows. */
753
+ const FILE_MODE = 384;
504
754
  /**
505
- * Detect available auth across the providers the harness ships with.
755
+ * Resolve the credentials file path given the resolved TUI data directory
756
+ * (typically `~/.zidane`, i.e. `config.paths.dir`).
506
757
  *
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)
758
+ * Matches the convention used elsewhere in the TUI (sessions.db, state.json)
759
+ * so a single `ZIDANE_STORAGE_DIR` override moves the entire data root.
760
+ */
761
+ function credentialsPath(dataDir) {
762
+ return resolve(dataDir, "credentials.json");
763
+ }
764
+ /**
765
+ * Read credentials from disk.
510
766
  *
511
- * Pure read never refreshes or rewrites the credentials file.
767
+ * Returns `{}` when the file is missing or corrupt (last-ditch tolerance
768
+ * a hand-edit gone wrong shouldn't lock the user out of re-authing). On first
769
+ * call with no file present, attempts a migration from `cwd/.credentials.json`
770
+ * (the legacy location used by `bun run auth`).
512
771
  */
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
- });
772
+ function readCredentials(dataDir) {
773
+ const path = credentialsPath(dataDir);
774
+ if (!existsSync(path)) {
775
+ const migrated = migrateLegacyFile(path);
776
+ if (migrated) return migrated;
777
+ return {};
778
+ }
779
+ try {
780
+ const raw = readFileSync(path, "utf-8");
781
+ const parsed = JSON.parse(raw);
782
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
783
+ return parsed;
784
+ } catch {
785
+ return {};
786
+ }
787
+ }
788
+ /** Read a single provider's credential (translating via the descriptor). */
789
+ function readProviderCredential(dataDir, descriptor) {
790
+ return readCredentials(dataDir)[credKeyOf(descriptor)];
545
791
  }
546
- //#endregion
547
- //#region src/tui/providers.ts
548
792
  /**
549
- * Construct a fresh provider instance for a given key.
793
+ * Write credentials atomically (write-then-rename) with mode 0o600.
550
794
  *
551
- * Providers are cheap to buildcredentials 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.
795
+ * Atomic on the same filesystemreaders either see the previous file or the
796
+ * new one, never a half-written intermediate. Creates the parent dir if needed
797
+ * (first launch on a fresh machine: `~/.zidane/` may not exist yet).
554
798
  */
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
- };
799
+ function writeCredentials(dataDir, creds) {
800
+ const path = credentialsPath(dataDir);
801
+ mkdirSync(dirname(path), { recursive: true });
802
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
803
+ writeFileSync(tmp, `${JSON.stringify(creds, null, 2)}\n`, { mode: FILE_MODE });
804
+ renameSync(tmp, path);
805
+ }
806
+ function setProviderCredential(dataDir, descriptor, cred) {
807
+ const all = readCredentials(dataDir);
808
+ all[credKeyOf(descriptor)] = cred;
809
+ writeCredentials(dataDir, all);
810
+ }
811
+ function removeProviderCredential(dataDir, descriptor) {
812
+ const all = readCredentials(dataDir);
813
+ const fileKey = credKeyOf(descriptor);
814
+ if (!(fileKey in all)) return;
815
+ delete all[fileKey];
816
+ writeCredentials(dataDir, all);
817
+ }
568
818
  /**
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.
819
+ * Inject API-key credentials into `process.env` so the harness providers pick
820
+ * them up via their existing env-var resolution. Called once at TUI launch
821
+ * after the credentials file has been resolved. OAuth credentials are NOT
822
+ * injected — those reach providers via `ZIDANE_CREDENTIALS_PATH` + the file
823
+ * reader in `src/providers/oauth.ts`.
824
+ *
825
+ * Does not overwrite env vars that are already set — explicit user-provided
826
+ * env values win over stored API keys.
827
+ *
828
+ * Descriptors without an `envKey` (OAuth-only providers, custom providers
829
+ * that bypass env-var resolution) are skipped silently.
572
830
  */
573
- function getContextWindow(key, modelId) {
831
+ function applyApiKeyEnv(dataDir, registry) {
832
+ const creds = readCredentials(dataDir);
833
+ for (const descriptor of Object.values(registry)) {
834
+ if (!descriptor.envKey || process.env[descriptor.envKey]) continue;
835
+ const cred = creds[credKeyOf(descriptor)];
836
+ if (cred?.kind === "apikey" && cred.value) process.env[descriptor.envKey] = cred.value;
837
+ }
838
+ }
839
+ /**
840
+ * `bun run auth` (pre-TUI) wrote `cwd/.credentials.json` with an entry per
841
+ * provider mapping directly to an OAuthCredentials payload, e.g.:
842
+ *
843
+ * {
844
+ * "anthropic": { "access": "...", "refresh": "...", "expires": 123 },
845
+ * "openai-codex": { "access": "...", "refresh": "...", "expires": 123, "accountId": "..." }
846
+ * }
847
+ *
848
+ * We don't delete the legacy file — it might still be used by a host that
849
+ * imports the harness directly. We just copy its contents into the new
850
+ * location under the kind-tagged shape so the TUI picks them up.
851
+ *
852
+ * Migration is provider-agnostic: any top-level entry with an `access` field
853
+ * is preserved verbatim (extras included), under the same key. The TUI's
854
+ * detection then looks them up via the matching descriptor's `credentialFileKey`.
855
+ *
856
+ * Returns the migrated credentials when the migration ran, or `null` when
857
+ * there's no legacy file to migrate.
858
+ */
859
+ function migrateLegacyFile(targetPath) {
860
+ const legacyPath = resolve(process.cwd(), ".credentials.json");
861
+ if (!existsSync(legacyPath)) return null;
862
+ let legacy;
574
863
  try {
575
- const providerId = PI_PROVIDER_ID[key];
576
- return getModel(providerId, modelId)?.contextWindow ?? null;
864
+ legacy = JSON.parse(readFileSync(legacyPath, "utf-8"));
577
865
  } catch {
578
866
  return null;
579
867
  }
868
+ if (!legacy || typeof legacy !== "object" || Array.isArray(legacy)) return null;
869
+ const migrated = {};
870
+ for (const [fileKey, value] of Object.entries(legacy)) {
871
+ if (!isOAuthLegacy(value)) continue;
872
+ const { access, refresh, expires, ...extras } = value;
873
+ migrated[fileKey] = {
874
+ kind: "oauth",
875
+ access,
876
+ ...typeof refresh === "string" ? { refresh } : {},
877
+ ...typeof expires === "number" ? { expires } : {},
878
+ ...extras
879
+ };
880
+ }
881
+ if (Object.keys(migrated).length === 0) return null;
882
+ mkdirSync(dirname(targetPath), { recursive: true });
883
+ const tmp = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
884
+ writeFileSync(tmp, `${JSON.stringify(migrated, null, 2)}\n`, { mode: FILE_MODE });
885
+ renameSync(tmp, targetPath);
886
+ return migrated;
887
+ }
888
+ function isOAuthLegacy(value) {
889
+ return typeof value === "object" && value !== null && "access" in value && typeof value.access === "string";
890
+ }
891
+ //#endregion
892
+ //#region src/tui/auth.ts
893
+ /**
894
+ * Detect available auth for every registered provider.
895
+ *
896
+ * Resolution order per provider (a method appears in `methods` for each
897
+ * layer that has a credential — the agent itself resolves them in the same
898
+ * order via its provider factories):
899
+ *
900
+ * 1. `kind: 'apikey'` from `credentials.json` (injected into env at TUI launch)
901
+ * 2. explicit env var (descriptor's `envKey`)
902
+ * 3. `kind: 'oauth'` from `credentials.json` (or legacy `cwd/.credentials.json`)
903
+ *
904
+ * Pure read — never refreshes or rewrites the credentials file.
905
+ */
906
+ function detectAuth(dataDir, registry, env = process.env) {
907
+ const creds = readCredentials(dataDir);
908
+ return Object.values(registry).map((descriptor) => {
909
+ const methods = [];
910
+ const fileEntry = creds[credKeyOf(descriptor)];
911
+ if (fileEntry?.kind === "apikey" && fileEntry.value) methods.push({
912
+ source: "apikey",
913
+ detail: "credentials.json"
914
+ });
915
+ if (descriptor.envKey && env[descriptor.envKey]) methods.push({
916
+ source: "env",
917
+ detail: descriptor.envKey
918
+ });
919
+ if (fileEntry?.kind === "oauth" && fileEntry.access) {
920
+ const detail = typeof fileEntry.expires === "number" ? `oauth · expires ${new Date(fileEntry.expires).toLocaleString()}` : "oauth · credentials.json";
921
+ methods.push({
922
+ source: "oauth",
923
+ detail
924
+ });
925
+ }
926
+ return {
927
+ key: descriptor.key,
928
+ label: descriptor.label,
929
+ available: methods.length > 0,
930
+ methods
931
+ };
932
+ });
580
933
  }
581
934
  //#endregion
582
935
  //#region src/tui/store.ts
583
- function ensureDir(path) {
936
+ function ensureDir$1(path) {
584
937
  const dir = dirname(path);
585
938
  if (existsSync(dir)) return;
586
939
  try {
@@ -591,7 +944,7 @@ function ensureDir(path) {
591
944
  }
592
945
  }
593
946
  function createTuiStore(dbPath) {
594
- ensureDir(dbPath);
947
+ ensureDir$1(dbPath);
595
948
  return createSqliteStore({ path: dbPath });
596
949
  }
597
950
  function createStateStore(path) {
@@ -609,7 +962,7 @@ function loadState(path) {
609
962
  return {};
610
963
  }
611
964
  function saveState(path, state) {
612
- ensureDir(path);
965
+ ensureDir$1(path);
613
966
  const tmp = `${path}.${process.pid}.tmp`;
614
967
  writeFileSync(tmp, JSON.stringify(state, null, 2));
615
968
  renameSync(tmp, path);
@@ -643,43 +996,122 @@ function titleFromTurns(turns) {
643
996
  return null;
644
997
  }
645
998
  /**
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.
999
+ * Replay persisted turns as a viewable transcript. Mirrors the event shape
1000
+ * produced live by the agent hooks so loaded and streaming history render
1001
+ * identically — including subagent ancestry when `runs` is supplied.
1002
+ *
1003
+ * Subagent reconstruction:
1004
+ * - Every turn carries a `runId`. We look that up in `runs` to get the
1005
+ * run's `depth` and tag the resulting events with `{ depth, childId }`
1006
+ * — the same shape the live `child:*` bubble hooks produce.
1007
+ * - We synthesize `spawn-start` / `spawn-end` markers at each child-run
1008
+ * boundary so the transcript reads the same as a live run did
1009
+ * (`🌱 [run-id] task` … child events … `🌳 [run-id] done · tokens`).
1010
+ * - For child runs (`depth > 0`), the user-role "task" text is suppressed
1011
+ * because `spawn-start` already shows it.
648
1012
  *
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.
1013
+ * Without `runs` (legacy callers / tests), the function falls back to the
1014
+ * old behavior: depth-0 events with no subagent grouping.
651
1015
  */
652
- function eventsFromTurns(turns) {
1016
+ function eventsFromTurns(turns, runs = []) {
1017
+ const runById = /* @__PURE__ */ new Map();
1018
+ for (const run of runs) runById.set(run.id, run);
1019
+ const childLabelByRunId = /* @__PURE__ */ new Map();
1020
+ runs.filter((r) => (r.depth ?? 0) > 0).slice().sort((a, b) => a.startedAt - b.startedAt).forEach((r, i) => childLabelByRunId.set(r.id, `child-${i + 1}`));
1021
+ const labelFor = (runId) => childLabelByRunId.get(runId) ?? runId;
1022
+ const toolByCallId = /* @__PURE__ */ new Map();
1023
+ for (const turn of turns) {
1024
+ if (turn.role !== "assistant") continue;
1025
+ for (const block of turn.content) if (block.type === "tool_call") toolByCallId.set(block.id, block.name);
1026
+ }
653
1027
  const events = [];
1028
+ let lastRunId;
1029
+ let lastDepth = 0;
1030
+ const closeRun = (runId, depth) => {
1031
+ if (!runId || depth <= 0) return;
1032
+ const run = runById.get(runId);
1033
+ if (!run) return;
1034
+ const tag = run.status === "aborted" || run.status === "error" ? run.status : "done";
1035
+ const usage = formatTokenUsage({
1036
+ totalIn: run.tokensIn ?? run.totalUsage?.input ?? 0,
1037
+ totalOut: run.tokensOut ?? run.totalUsage?.output ?? 0,
1038
+ totalCacheRead: run.totalUsage?.cacheRead ?? 0,
1039
+ totalCacheCreation: run.totalUsage?.cacheCreation ?? 0
1040
+ });
1041
+ events.push({
1042
+ kind: "spawn-end",
1043
+ text: `${tag} ${usage}`,
1044
+ childId: labelFor(runId),
1045
+ depth
1046
+ });
1047
+ };
1048
+ const openRun = (runId, depth) => {
1049
+ if (depth <= 0) return;
1050
+ const run = runById.get(runId);
1051
+ if (!run) return;
1052
+ const taskPreview = run.prompt.length > 80 ? `${run.prompt.slice(0, 80)}…` : run.prompt;
1053
+ events.push({
1054
+ kind: "spawn-start",
1055
+ text: taskPreview,
1056
+ childId: labelFor(runId),
1057
+ depth
1058
+ });
1059
+ };
654
1060
  for (let i = 0; i < turns.length; i++) {
655
1061
  const turn = turns[i];
656
- if (i > 0) events.push({
1062
+ const depth = (turn.runId ? runById.get(turn.runId) : void 0)?.depth ?? 0;
1063
+ const tag = depth > 0 && turn.runId ? {
1064
+ childId: labelFor(turn.runId),
1065
+ depth
1066
+ } : void 0;
1067
+ if (turn.runId !== lastRunId) {
1068
+ closeRun(lastRunId, lastDepth);
1069
+ if (depth === 0 && lastDepth === 0 && i > 0) events.push({
1070
+ kind: "separator",
1071
+ text: ""
1072
+ });
1073
+ if (turn.runId) openRun(turn.runId, depth);
1074
+ lastRunId = turn.runId;
1075
+ lastDepth = depth;
1076
+ } else if (i > 0 && depth === 0) events.push({
657
1077
  kind: "separator",
658
1078
  text: ""
659
1079
  });
660
1080
  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
- });
1081
+ for (const block of turn.content) if (block.type === "text" && block.text.trim()) {
1082
+ if (depth === 0) events.push({
1083
+ kind: "info",
1084
+ text: `❯ ${block.text}`
1085
+ });
1086
+ } else if (block.type === "tool_result") {
1087
+ const tool = toolByCallId.get(block.callId);
1088
+ const raw = toolResultText(block.output);
1089
+ const text = tool === "spawn" ? stripSpawnTokensLine(raw) : raw;
1090
+ events.push({
1091
+ kind: "tool-result",
1092
+ text,
1093
+ ...tool ? { tool } : {},
1094
+ ...tag
1095
+ });
1096
+ }
669
1097
  continue;
670
1098
  }
671
1099
  if (turn.role === "assistant") {
672
1100
  for (const block of turn.content) if (block.type === "text" && block.text.trim()) events.push({
673
1101
  kind: "markdown",
674
1102
  text: block.text,
675
- streaming: false
1103
+ streaming: false,
1104
+ ...tag
676
1105
  });
677
1106
  else if (block.type === "tool_call") events.push({
678
1107
  kind: "tool",
679
- text: toolCallPreview(block.name, block.input)
1108
+ text: toolCallPreview(block.name, block.input),
1109
+ tool: block.name,
1110
+ ...tag
680
1111
  });
681
1112
  }
682
1113
  }
1114
+ closeRun(lastRunId, lastDepth);
683
1115
  return events;
684
1116
  }
685
1117
  /** Shared formatter for the `↳ name(args)` line shown on tool calls. */
@@ -691,6 +1123,22 @@ function toolCallPreview(name, input) {
691
1123
  function toolResultText(output) {
692
1124
  return typeof output === "string" ? output : toolResultToText(output);
693
1125
  }
1126
+ /**
1127
+ * Strip the `Tokens: …` line from a spawn tool-result. The spawn-end marker
1128
+ * displayed right above already shows the same stats; keeping the line in the
1129
+ * rendered tool-result body just produces a visible duplicate (and, on
1130
+ * reloaded pre-fix sessions, an *inconsistent* duplicate — the persisted line
1131
+ * uses the old `13 in / 4075 out` shape while the freshly synthesized
1132
+ * spawn-end uses the cache-aware `in 92615 (cache 92602) / 4075 out` shape).
1133
+ *
1134
+ * Display-only: the persisted tool_result content is untouched, so the LLM
1135
+ * still sees the full string in its context window. Anchored to start-of-line
1136
+ * and matches both `Tokens: 13 in / 4075 out` (legacy) and `Tokens: in 13 …`
1137
+ * (post-`formatTokenUsage`) shapes.
1138
+ */
1139
+ function stripSpawnTokensLine(text) {
1140
+ return text.replace(/^Tokens:[^\n]*\n?/m, "");
1141
+ }
694
1142
  /** Effective context size of the most recent assistant turn — drives the footer indicator. */
695
1143
  function lastContextSizeFromTurns(turns) {
696
1144
  for (let i = turns.length - 1; i >= 0; i--) {
@@ -714,13 +1162,12 @@ function resolveConfig(options = {}) {
714
1162
  const store = options.store ?? createTuiStore(paths.db);
715
1163
  const stateStore = createStateStore(paths.state);
716
1164
  const initialState = stateStore.load();
717
- const providers = {
718
- ...FACTORIES,
719
- ...options.providers ?? {}
720
- };
1165
+ const providers = options.providers ?? BUILTIN_PROVIDERS;
721
1166
  const preset = options.preset ?? basic_default;
722
- const modelsFor = makeModelsResolver(options.models);
723
- const resumeProvider = resolveResumeProvider(initialState, providers);
1167
+ process.env.ZIDANE_CREDENTIALS_PATH = credentialsPath(dir);
1168
+ applyApiKeyEnv(dir, providers);
1169
+ const modelsFor = makeModelsResolver(providers);
1170
+ const resumeProvider = resolveResumeProvider(initialState, providers, dir);
724
1171
  const initialPicked = resumeProvider ? pickInitial(resumeProvider, providers, initialState) : null;
725
1172
  return {
726
1173
  prefix,
@@ -737,31 +1184,32 @@ function resolveConfig(options = {}) {
737
1184
  initialPicked
738
1185
  };
739
1186
  }
740
- function makeModelsResolver(custom) {
1187
+ function makeModelsResolver(registry) {
741
1188
  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
- }
1189
+ const descriptor = registry[key];
1190
+ return descriptor ? modelsForDescriptor(descriptor) : [];
750
1191
  };
751
1192
  }
752
- function resolveResumeProvider(state, providers) {
1193
+ function resolveResumeProvider(state, providers, storageDir) {
753
1194
  if (!state.lastProvider) return null;
754
1195
  if (!providers[state.lastProvider]) return null;
755
- return detectAuth().find((p) => p.key === state.lastProvider && p.available) ?? null;
1196
+ return detectAuth(storageDir, providers).find((p) => p.key === state.lastProvider && p.available) ?? null;
756
1197
  }
757
1198
  function pickInitial(auth, providers, state) {
758
- const factory = providers[auth.key];
759
- if (!factory) return null;
760
- const provider = factory();
761
- return {
1199
+ const descriptor = providers[auth.key];
1200
+ if (!descriptor) return null;
1201
+ const model = state.lastModelByProvider?.[auth.key] ?? descriptor.defaultModel ?? safeFactoryDefault(descriptor);
1202
+ return model ? {
762
1203
  provider: auth,
763
- model: state.lastModelByProvider?.[auth.key] ?? provider.meta.defaultModel
764
- };
1204
+ model
1205
+ } : null;
1206
+ }
1207
+ function safeFactoryDefault(descriptor) {
1208
+ try {
1209
+ return descriptor.factory().meta.defaultModel;
1210
+ } catch {
1211
+ return;
1212
+ }
765
1213
  }
766
1214
  const ConfigContext = createContext(null);
767
1215
  function ConfigProvider({ config, children }) {
@@ -868,9 +1316,10 @@ function Modal({ title, onClose, children, maxWidth = 92, minWidth = 44, horizon
868
1316
  /** Cap the visible scroll window so a 30-model list doesn't push the modal off-screen. */
869
1317
  const VISIBLE_ROW_CAP = 12;
870
1318
  /**
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.
1319
+ * Modal that lists the available models for the current provider and lets
1320
+ * the user pick one. Options come from the active `ProviderDescriptor`
1321
+ * either its declared `models` list or, when absent, pi-ai's built-in
1322
+ * registry looked up via `piProviderId`.
874
1323
  *
875
1324
  * Each row shows: `● selected · name (ctx N · reasoning · vision)`.
876
1325
  */
@@ -933,9 +1382,21 @@ function EmptyState() {
933
1382
  children: [/* @__PURE__ */ jsx("text", {
934
1383
  fg: COLOR.dim,
935
1384
  children: "No models available for this provider."
936
- }), /* @__PURE__ */ jsx("text", {
1385
+ }), /* @__PURE__ */ jsxs("text", {
937
1386
  fg: COLOR.mute,
938
- children: "Pass a `models` registry to `runTui()` to populate this list."
1387
+ children: [
1388
+ "Set",
1389
+ /* @__PURE__ */ jsx("span", {
1390
+ fg: COLOR.model,
1391
+ children: " models "
1392
+ }),
1393
+ "on the provider descriptor (or a",
1394
+ /* @__PURE__ */ jsx("span", {
1395
+ fg: COLOR.model,
1396
+ children: " piProviderId "
1397
+ }),
1398
+ "that pi-ai recognizes) to populate this list."
1399
+ ]
939
1400
  })]
940
1401
  });
941
1402
  }
@@ -947,35 +1408,361 @@ function describeModel(m) {
947
1408
  return parts.join(" · ");
948
1409
  }
949
1410
  //#endregion
1411
+ //#region src/tui/safe-mode.ts
1412
+ /**
1413
+ * Safe-mode storage + matching for the TUI.
1414
+ *
1415
+ * Lives at `<dataDir>/projects.json` (default `~/.zidane/projects.json`). Each
1416
+ * top-level key is an absolute project directory; the value carries that
1417
+ * project's persisted tool-call `safelist`.
1418
+ *
1419
+ * ```json
1420
+ * {
1421
+ * "/Users/me/proj-a": { "safelist": ["read_file", "shell:git:*"] }
1422
+ * }
1423
+ * ```
1424
+ *
1425
+ * Two granularities for safelist entries:
1426
+ * - **bare tool name** — `"read_file"` matches every `read_file` call.
1427
+ * - **tool + first-arg token + wildcard** — `"shell:git:*"` matches `shell`
1428
+ * calls whose primary string argument starts with the token `git`
1429
+ * (followed by whitespace or end-of-string). Modelled on Claude Code's
1430
+ * `Bash(git:*)` syntax.
1431
+ *
1432
+ * A short list of read-only tools is **implicitly safe** without being
1433
+ * persisted — see {@link IMPLICITLY_SAFE_TOOLS}.
1434
+ */
1435
+ /** Resolve `projects.json`'s on-disk path given the TUI data directory. */
1436
+ function projectsFilePath(dataDir) {
1437
+ return resolve(dataDir, "projects.json");
1438
+ }
1439
+ function readProjects(dataDir) {
1440
+ const path = projectsFilePath(dataDir);
1441
+ if (!existsSync(path)) return {};
1442
+ try {
1443
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
1444
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
1445
+ } catch {}
1446
+ return {};
1447
+ }
1448
+ function ensureDir(path) {
1449
+ const dir = dirname(path);
1450
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1451
+ }
1452
+ /** Atomic write — tmp + rename so a crash never leaves a half-file. */
1453
+ function writeProjects(dataDir, file) {
1454
+ const path = projectsFilePath(dataDir);
1455
+ ensureDir(path);
1456
+ const tmp = `${path}.${process.pid}.tmp`;
1457
+ writeFileSync(tmp, JSON.stringify(file, null, 2));
1458
+ renameSync(tmp, path);
1459
+ }
1460
+ /**
1461
+ * Append `entry` to the safelist for `projectDir`, dedup-aware. Returns the
1462
+ * updated entry list (post-write) so callers can render it without re-reading.
1463
+ */
1464
+ function addToSafelist(dataDir, projectDir, entry) {
1465
+ const file = readProjects(dataDir);
1466
+ const existing = file[projectDir]?.safelist ?? [];
1467
+ if (existing.includes(entry)) return existing;
1468
+ const next = [...existing, entry];
1469
+ file[projectDir] = {
1470
+ ...file[projectDir],
1471
+ safelist: next
1472
+ };
1473
+ writeProjects(dataDir, file);
1474
+ return next;
1475
+ }
1476
+ /** Read the safelist for one project. Returns `[]` for unknown projects. */
1477
+ function getSafelist(dataDir, projectDir) {
1478
+ return readProjects(dataDir)[projectDir]?.safelist ?? [];
1479
+ }
1480
+ /**
1481
+ * Tools that always pass without prompting — pure file/dir reads with no
1482
+ * side effects. Users who want to gate them must disable safe-mode entirely
1483
+ * (or fork this list in their own embedding).
1484
+ */
1485
+ const IMPLICITLY_SAFE_TOOLS = [
1486
+ "read_file",
1487
+ "list_files",
1488
+ "glob",
1489
+ "grep"
1490
+ ];
1491
+ /** Common input keys carrying the "primary argument" we scope safelists on. */
1492
+ const PRIMARY_ARG_KEYS = [
1493
+ "command",
1494
+ "path",
1495
+ "pattern",
1496
+ "query"
1497
+ ];
1498
+ function primaryArgValue(input) {
1499
+ for (const key of PRIMARY_ARG_KEYS) {
1500
+ const v = input[key];
1501
+ if (typeof v === "string" && v.length > 0) return v;
1502
+ }
1503
+ return "";
1504
+ }
1505
+ /** Extract the first whitespace-delimited token of the primary arg. */
1506
+ function primaryArgToken(input) {
1507
+ return primaryArgValue(input).split(/\s+/)[0] ?? "";
1508
+ }
1509
+ /**
1510
+ * Shell metacharacters that turn a single command into a compound: pipes,
1511
+ * sequencing, redirects, substitutions, line breaks, subshells. A `shell:git:*`
1512
+ * entry is meant to greenlight "any git invocation" — without this guard,
1513
+ * `git status && rm -rf /` would tokenize to `git` and pass the safelist
1514
+ * unchallenged. Reject any command that's not a single program call.
1515
+ *
1516
+ * The regex is intentionally generous: false positives (e.g. `echo "hi & bye"`)
1517
+ * just prompt the user again, which is the safe failure mode.
1518
+ */
1519
+ const SHELL_COMPOUND_RE = /[;&|<>`$\n\r()]/;
1520
+ function isCompoundShellCommand(command) {
1521
+ return SHELL_COMPOUND_RE.test(command);
1522
+ }
1523
+ /**
1524
+ * Test whether a `{ tool, input }` pair is covered by one safelist entry.
1525
+ *
1526
+ * Supported entry shapes:
1527
+ * - `"<tool>"` — broad match on tool name. For `shell` this still requires
1528
+ * a single-program command (compound forms always prompt).
1529
+ * - `"<tool>:<token>:*"` — match when the primary arg's first token equals
1530
+ * `<token>`. For `shell`, also requires the command to be free of
1531
+ * metacharacters (`;`, `&&`, `||`, `|`, `$(`, backticks, `>`, `<`,
1532
+ * newlines, subshells) — otherwise a `shell:git:*` entry would silently
1533
+ * greenlight `git status && rm -rf /`.
1534
+ *
1535
+ * Entries that don't fit either shape are ignored (forward-compat for future
1536
+ * pattern syntax — readers shouldn't choke on entries written by a newer
1537
+ * version of the TUI).
1538
+ */
1539
+ function matchesSafelistEntry(entry, tool, input) {
1540
+ if (tool === "shell") {
1541
+ if (isCompoundShellCommand(typeof input.command === "string" ? input.command : "")) return false;
1542
+ }
1543
+ if (entry === tool) return true;
1544
+ const sep = entry.indexOf(":");
1545
+ if (sep <= 0) return false;
1546
+ if (entry.slice(0, sep) !== tool) return false;
1547
+ const scope = entry.slice(sep + 1);
1548
+ if (scope.endsWith(":*")) return primaryArgToken(input) === scope.slice(0, -2);
1549
+ return false;
1550
+ }
1551
+ /** True when a call matches ANY entry in the project's safelist (or is implicitly safe). */
1552
+ function isOnSafelist(entries, tool, input) {
1553
+ if (IMPLICITLY_SAFE_TOOLS.includes(tool)) return true;
1554
+ return entries.some((e) => matchesSafelistEntry(e, tool, input));
1555
+ }
1556
+ /**
1557
+ * Suggest the safelist entry to write when the user picks "accept and
1558
+ * remember" for a `{ tool, input }`. Heuristic:
1559
+ *
1560
+ * - `shell` → scope by first command token (`shell:git:*`).
1561
+ * - anything else → bare tool name (broad).
1562
+ *
1563
+ * Returning a string ensures the UI always has a concrete entry to display
1564
+ * as the button label.
1565
+ */
1566
+ function suggestSafelistEntry(tool, input) {
1567
+ if (tool === "shell") {
1568
+ const token = primaryArgToken(input);
1569
+ if (token) return `${tool}:${token}:*`;
1570
+ }
1571
+ return tool;
1572
+ }
1573
+ //#endregion
1574
+ //#region src/tui/safe-mode-context.tsx
1575
+ const SafeModeQueueContext = createContext([]);
1576
+ const SafeModeActionsContext = createContext(null);
1577
+ let approvalIdCounter = 0;
1578
+ function nextApprovalId() {
1579
+ approvalIdCounter += 1;
1580
+ return `approval-${approvalIdCounter}`;
1581
+ }
1582
+ /**
1583
+ * Owns the queue + actions. Splits the value across two contexts so a queue
1584
+ * change doesn't invalidate every callback memo that closes over the actions.
1585
+ */
1586
+ function SafeModeProvider({ children }) {
1587
+ const [queue, setQueue] = useState([]);
1588
+ const requestApproval = useCallback((tool, input) => new Promise((resolve) => {
1589
+ setQueue((prev) => [...prev, {
1590
+ id: nextApprovalId(),
1591
+ tool,
1592
+ input,
1593
+ resolve
1594
+ }]);
1595
+ }), []);
1596
+ const resolveHead = useCallback((decision) => {
1597
+ setQueue((prev) => {
1598
+ const [head, ...rest] = prev;
1599
+ if (head) head.resolve(decision);
1600
+ return rest;
1601
+ });
1602
+ }, []);
1603
+ const denyAll = useCallback(() => {
1604
+ setQueue((prev) => {
1605
+ for (const p of prev) p.resolve("deny");
1606
+ return [];
1607
+ });
1608
+ }, []);
1609
+ const actionsRef = useRef(null);
1610
+ if (!actionsRef.current) actionsRef.current = {
1611
+ requestApproval,
1612
+ resolveHead,
1613
+ denyAll
1614
+ };
1615
+ return /* @__PURE__ */ jsx(SafeModeActionsContext.Provider, {
1616
+ value: actionsRef.current,
1617
+ children: /* @__PURE__ */ jsx(SafeModeQueueContext.Provider, {
1618
+ value: queue,
1619
+ children
1620
+ })
1621
+ });
1622
+ }
1623
+ function useSafeModeQueue() {
1624
+ return useContext(SafeModeQueueContext);
1625
+ }
1626
+ function useSafeModeActions() {
1627
+ const ctx = useContext(SafeModeActionsContext);
1628
+ if (!ctx) throw new Error("useSafeModeActions must be used inside <SafeModeProvider>");
1629
+ return ctx;
1630
+ }
1631
+ //#endregion
1632
+ //#region src/tui/oauth.ts
1633
+ function supportsOAuth(descriptor) {
1634
+ return descriptor.oauthProvider !== void 0;
1635
+ }
1636
+ /**
1637
+ * Run the OAuth login flow for a provider.
1638
+ *
1639
+ * Returns the OAuth credentials on success; caller persists them via
1640
+ * `setProviderCredential(dataDir, descriptor, { kind: 'oauth', ...credentials })`.
1641
+ * Throws when the descriptor has no `oauthProvider` configured.
1642
+ */
1643
+ async function runOAuthLogin(descriptor, options) {
1644
+ if (!descriptor.oauthProvider) throw new Error(`OAuth not supported for ${descriptor.label} (${descriptor.key}) — use an API key instead.`);
1645
+ const callbacks = {
1646
+ onAuth: (info) => {
1647
+ options.onUrl(info.url, info.instructions);
1648
+ tryOpenBrowser(info.url);
1649
+ },
1650
+ onPrompt: async () => {
1651
+ if (!options.onCodeRequest) throw new Error("OAuth flow requires manual code input but no handler is wired.");
1652
+ return options.onCodeRequest();
1653
+ },
1654
+ onProgress: options.onProgress,
1655
+ signal: options.signal
1656
+ };
1657
+ return descriptor.oauthProvider.login(callbacks);
1658
+ }
1659
+ /**
1660
+ * Best-effort cross-platform browser open. macOS uses `open`, Linux uses
1661
+ * `xdg-open`, Windows uses `start`. Failures are swallowed — the callback
1662
+ * server is already listening, and the URL is displayed in the TUI for
1663
+ * manual click.
1664
+ *
1665
+ * Uses `spawn` (not `exec`) so the URL is passed as an argv element rather
1666
+ * than interpolated into a shell command — no need to think about quoting
1667
+ * URLs that contain `&`, `?`, `"` or other shell metacharacters.
1668
+ */
1669
+ function tryOpenBrowser(url) {
1670
+ const [cmd, ...args] = (() => {
1671
+ if (process.platform === "darwin") return ["open", url];
1672
+ if (process.platform === "win32") return [
1673
+ "cmd",
1674
+ "/c",
1675
+ "start",
1676
+ "",
1677
+ url
1678
+ ];
1679
+ return ["xdg-open", url];
1680
+ })();
1681
+ try {
1682
+ const child = spawn(cmd, args, {
1683
+ stdio: "ignore",
1684
+ detached: true
1685
+ });
1686
+ child.on("error", () => {});
1687
+ child.unref();
1688
+ } catch {}
1689
+ }
1690
+ //#endregion
950
1691
  //#region src/tui/screens.tsx
951
1692
  /**
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.
1693
+ * Build a key-binding set for the prompt textarea / API-key input. Strips the
1694
+ * default `return` action and reinstalls it with our preferred meaning, so the
1695
+ * binding wins regardless of modifier state. Pass `allowShiftReturnNewline`
1696
+ * to enable `shift+enter` → newline (multi-line input).
955
1697
  */
956
- const TEXTAREA_BINDINGS = [
957
- ...defaultTextareaKeyBindings.filter((b) => b.name !== "return"),
958
- {
1698
+ function makeSubmitBindings(allowShiftReturnNewline) {
1699
+ const base = defaultTextareaKeyBindings.filter((b) => b.name !== "return");
1700
+ return allowShiftReturnNewline ? [
1701
+ ...base,
1702
+ {
1703
+ name: "return",
1704
+ action: "submit"
1705
+ },
1706
+ {
1707
+ name: "return",
1708
+ shift: true,
1709
+ action: "newline"
1710
+ }
1711
+ ] : [...base, {
959
1712
  name: "return",
960
1713
  action: "submit"
961
- },
962
- {
963
- name: "return",
964
- shift: true,
965
- action: "newline"
966
- }
967
- ];
1714
+ }];
1715
+ }
1716
+ const TEXTAREA_BINDINGS = makeSubmitBindings(true);
1717
+ const API_KEY_INPUT_BINDINGS = makeSubmitBindings(false);
1718
+ /**
1719
+ * Look up a `{ key }` item by the value of a `<select>` option. Used by every
1720
+ * screen that mixes keyed-entry rows with sentinel "+ new" / "← back" rows —
1721
+ * sentinel handling stays explicit at the call site, this helper just trims
1722
+ * the boilerplate `.find(i => i.key === ...)` typing.
1723
+ */
1724
+ function findByKey(items, value) {
1725
+ return typeof value === "string" ? items.find((i) => i.key === value) : void 0;
1726
+ }
1727
+ /**
1728
+ * Sentinel value used by the picker's "+ add / re-configure" option. Lives
1729
+ * outside the provider key namespace (key strings are at least one char, no
1730
+ * leading `__`) so we can't collide with a real registry entry.
1731
+ */
1732
+ const WIZARD_OPTION_VALUE = "__wizard__";
968
1733
  function AuthScreen({ onPick }) {
969
- const { providers: registry } = useConfig();
1734
+ const config = useConfig();
1735
+ const { providers: registry } = config;
970
1736
  const focused = useModalAwareFocus();
971
- const providers = useMemo(() => detectAuth().filter((p) => p.key in registry), [registry]);
1737
+ const [providers, setProviders] = useState([]);
1738
+ const refresh = useCallback(() => setProviders(detectAuth(config.paths.dir, registry)), [config.paths.dir, registry]);
1739
+ useEffect(() => {
1740
+ refresh();
1741
+ }, [refresh]);
1742
+ const [forceWizard, setForceWizard] = useState(false);
972
1743
  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) => ({
1744
+ const onWizardDone = useCallback(() => {
1745
+ setForceWizard(false);
1746
+ refresh();
1747
+ }, [refresh]);
1748
+ if (available.length === 0 || forceWizard) {
1749
+ const canCancel = forceWizard && available.length > 0;
1750
+ return /* @__PURE__ */ jsx(SetupWizard, {
1751
+ registry,
1752
+ dataDir: config.paths.dir,
1753
+ onConfigured: onWizardDone,
1754
+ onCancel: canCancel ? () => setForceWizard(false) : void 0
1755
+ });
1756
+ }
1757
+ const options = [...available.map((p) => ({
975
1758
  name: p.label,
976
1759
  description: p.methods.map((m) => m.detail).join(" · "),
977
1760
  value: p.key
978
- }));
1761
+ })), {
1762
+ name: "+ add or re-configure a provider",
1763
+ description: "launch the setup wizard",
1764
+ value: WIZARD_OPTION_VALUE
1765
+ }];
979
1766
  return /* @__PURE__ */ jsx("box", {
980
1767
  title: " pick a provider ",
981
1768
  style: {
@@ -992,48 +1779,318 @@ function AuthScreen({ onPick }) {
992
1779
  wrapSelection: true,
993
1780
  onSelect: (_idx, option) => {
994
1781
  if (!option) return;
995
- const provider = available.find((p) => p.key === option.value);
1782
+ if (option.value === WIZARD_OPTION_VALUE) {
1783
+ setForceWizard(true);
1784
+ return;
1785
+ }
1786
+ const provider = findByKey(available, option.value);
996
1787
  if (provider) onPick(provider);
997
1788
  },
998
1789
  style: { flexGrow: 1 }
999
1790
  })
1000
1791
  });
1001
1792
  }
1002
- function NoAuthScreen({ providers }) {
1793
+ function SetupWizard({ registry, dataDir, onConfigured, onCancel }) {
1794
+ const [step, setStep] = useState({ kind: "pick-provider" });
1795
+ const [error, setError] = useState(null);
1796
+ const descriptors = useMemo(() => Object.values(registry), [registry]);
1797
+ const onPickProvider = useCallback((descriptor) => {
1798
+ setError(null);
1799
+ setStep({
1800
+ kind: "pick-method",
1801
+ descriptor
1802
+ });
1803
+ }, []);
1804
+ const onPickMethod = useCallback((descriptor, method) => {
1805
+ setError(null);
1806
+ if (method === "apikey") setStep({
1807
+ kind: "enter-apikey",
1808
+ descriptor
1809
+ });
1810
+ else setStep({
1811
+ kind: "oauth-running",
1812
+ descriptor
1813
+ });
1814
+ }, []);
1815
+ const onApiKeySubmit = useCallback((descriptor, value) => {
1816
+ const trimmed = value.trim();
1817
+ if (!trimmed) {
1818
+ setError("API key cannot be empty.");
1819
+ return;
1820
+ }
1821
+ try {
1822
+ setProviderCredential(dataDir, descriptor, {
1823
+ kind: "apikey",
1824
+ value: trimmed
1825
+ });
1826
+ if (descriptor.envKey) process.env[descriptor.envKey] = trimmed;
1827
+ onConfigured();
1828
+ } catch (err) {
1829
+ setError(err instanceof Error ? err.message : String(err));
1830
+ }
1831
+ }, [dataDir, onConfigured]);
1832
+ if (descriptors.length === 0) return /* @__PURE__ */ jsx(EmptyRegistryNotice, {});
1833
+ if (step.kind === "pick-provider") return /* @__PURE__ */ jsx(PickProviderStep, {
1834
+ descriptors,
1835
+ error,
1836
+ onPick: onPickProvider,
1837
+ onCancel
1838
+ });
1839
+ if (step.kind === "pick-method") return /* @__PURE__ */ jsx(PickMethodStep, {
1840
+ descriptor: step.descriptor,
1841
+ error,
1842
+ onPick: onPickMethod
1843
+ });
1844
+ if (step.kind === "enter-apikey") return /* @__PURE__ */ jsx(EnterApiKeyStep, {
1845
+ descriptor: step.descriptor,
1846
+ error,
1847
+ onSubmit: onApiKeySubmit
1848
+ });
1849
+ return /* @__PURE__ */ jsx(OAuthRunningStep, {
1850
+ descriptor: step.descriptor,
1851
+ dataDir,
1852
+ onSuccess: onConfigured,
1853
+ onError: (msg) => {
1854
+ setError(msg);
1855
+ setStep({
1856
+ kind: "pick-method",
1857
+ descriptor: step.descriptor
1858
+ });
1859
+ }
1860
+ });
1861
+ }
1862
+ /**
1863
+ * Shared wrapper for every wizard step — same border + padding + flex layout
1864
+ * with a customizable title and accent color. Footnote slot at the bottom for
1865
+ * an error banner.
1866
+ */
1867
+ function WizardPanel({ title, accent = COLOR.border, error, children }) {
1003
1868
  return /* @__PURE__ */ jsxs("box", {
1004
- title: " no authentication detected ",
1869
+ title,
1005
1870
  style: {
1006
1871
  border: true,
1007
- borderColor: COLOR.error,
1872
+ borderColor: accent,
1008
1873
  padding: 1,
1009
- flexDirection: "column",
1010
1874
  gap: 1,
1875
+ flexDirection: "column",
1011
1876
  flexGrow: 1
1012
1877
  },
1878
+ children: [children, error && /* @__PURE__ */ jsx("text", {
1879
+ fg: COLOR.error,
1880
+ children: error
1881
+ })]
1882
+ });
1883
+ }
1884
+ /** "esc to exit" footer hint shared by every wizard step that doesn't offer a "← back" affordance. */
1885
+ function WizardEscHint() {
1886
+ return /* @__PURE__ */ jsx("text", {
1887
+ fg: COLOR.dim,
1888
+ children: "esc to exit"
1889
+ });
1890
+ }
1891
+ function EmptyRegistryNotice() {
1892
+ return /* @__PURE__ */ jsxs(WizardPanel, {
1893
+ title: " no providers configured ",
1894
+ accent: COLOR.error,
1895
+ children: [/* @__PURE__ */ jsx("text", {
1896
+ fg: COLOR.error,
1897
+ children: "This TUI has no providers registered."
1898
+ }), /* @__PURE__ */ jsxs("text", {
1899
+ fg: COLOR.dim,
1900
+ children: [
1901
+ "Pass providers via",
1902
+ /* @__PURE__ */ jsx("span", {
1903
+ fg: COLOR.model,
1904
+ children: " runTui({ providers }) "
1905
+ }),
1906
+ "or use the built-ins via",
1907
+ /* @__PURE__ */ jsx("span", {
1908
+ fg: COLOR.model,
1909
+ children: " BUILTIN_PROVIDERS "
1910
+ }),
1911
+ "."
1912
+ ]
1913
+ })]
1914
+ });
1915
+ }
1916
+ /** Sentinel option value used for the wizard's "← back to picker" entry. */
1917
+ const WIZARD_BACK_VALUE = "__back__";
1918
+ function PickProviderStep({ descriptors, error, onPick, onCancel }) {
1919
+ const focused = useModalAwareFocus();
1920
+ const options = [...descriptors.map((d) => {
1921
+ const methods = supportsOAuth(d) ? ["API key", "OAuth"] : ["API key"];
1922
+ return {
1923
+ name: d.label,
1924
+ description: methods.join(" · "),
1925
+ value: d.key
1926
+ };
1927
+ }), ...onCancel ? [{
1928
+ name: "← back",
1929
+ description: "return to the provider list",
1930
+ value: WIZARD_BACK_VALUE
1931
+ }] : []];
1932
+ return /* @__PURE__ */ jsxs(WizardPanel, {
1933
+ title: onCancel ? " add or re-configure a provider " : " welcome to zidane · pick a provider ",
1934
+ error,
1935
+ children: [!onCancel && /* @__PURE__ */ jsxs("text", {
1936
+ fg: COLOR.dim,
1937
+ children: [
1938
+ "No provider credentials yet. Pick a provider to configure — keys are stored in",
1939
+ /* @__PURE__ */ jsx("span", {
1940
+ fg: COLOR.model,
1941
+ children: " ~/.zidane/credentials.json "
1942
+ }),
1943
+ "(owner-only)."
1944
+ ]
1945
+ }), /* @__PURE__ */ jsx("select", {
1946
+ ...SELECT_THEME,
1947
+ focused,
1948
+ options,
1949
+ wrapSelection: true,
1950
+ onSelect: (_idx, option) => {
1951
+ if (!option) return;
1952
+ if (option.value === WIZARD_BACK_VALUE) {
1953
+ onCancel?.();
1954
+ return;
1955
+ }
1956
+ const descriptor = findByKey(descriptors, option.value);
1957
+ if (descriptor) onPick(descriptor);
1958
+ },
1959
+ style: { flexGrow: 1 }
1960
+ })]
1961
+ });
1962
+ }
1963
+ function PickMethodStep({ descriptor, error, onPick }) {
1964
+ const focused = useModalAwareFocus();
1965
+ const options = useMemo(() => {
1966
+ const items = [{
1967
+ name: "API key",
1968
+ description: `paste your ${descriptor.label} API key`,
1969
+ value: "apikey"
1970
+ }];
1971
+ if (supportsOAuth(descriptor)) {
1972
+ const hint = descriptor.oauthHint ? ` (${descriptor.oauthHint})` : "";
1973
+ items.push({
1974
+ name: "OAuth",
1975
+ description: `browser-based sign-in${hint}`,
1976
+ value: "oauth"
1977
+ });
1978
+ }
1979
+ return items;
1980
+ }, [descriptor]);
1981
+ return /* @__PURE__ */ jsxs(WizardPanel, {
1982
+ title: ` configure ${descriptor.label} — pick auth method `,
1983
+ error,
1984
+ children: [/* @__PURE__ */ jsx(WizardEscHint, {}), /* @__PURE__ */ jsx("select", {
1985
+ ...SELECT_THEME,
1986
+ focused,
1987
+ options,
1988
+ wrapSelection: true,
1989
+ onSelect: (_idx, option) => {
1990
+ if (option) onPick(descriptor, option.value);
1991
+ },
1992
+ style: { flexGrow: 1 }
1993
+ })]
1994
+ });
1995
+ }
1996
+ function EnterApiKeyStep({ descriptor, error, onSubmit }) {
1997
+ const focused = useModalAwareFocus();
1998
+ const inputRef = useRef(null);
1999
+ const submit = useCallback(() => {
2000
+ onSubmit(descriptor, inputRef.current?.value ?? "");
2001
+ }, [descriptor, onSubmit]);
2002
+ return /* @__PURE__ */ jsxs(WizardPanel, {
2003
+ title: ` configure ${descriptor.label} — paste API key `,
2004
+ error,
2005
+ children: [/* @__PURE__ */ jsxs("text", {
2006
+ fg: COLOR.dim,
2007
+ children: [
2008
+ "Paste your",
2009
+ ` ${descriptor.label} `,
2010
+ "API key and press",
2011
+ /* @__PURE__ */ jsx("span", {
2012
+ fg: COLOR.model,
2013
+ children: " enter "
2014
+ }),
2015
+ "to save. Esc to exit."
2016
+ ]
2017
+ }), /* @__PURE__ */ jsx("box", {
2018
+ style: {
2019
+ border: true,
2020
+ borderColor: COLOR.borderActive,
2021
+ paddingLeft: 1,
2022
+ paddingRight: 1,
2023
+ height: 3
2024
+ },
2025
+ children: /* @__PURE__ */ jsx("input", {
2026
+ ref: inputRef,
2027
+ focused,
2028
+ keyBindings: API_KEY_INPUT_BINDINGS,
2029
+ placeholder: descriptor.apiKeyPlaceholder ?? "API key…",
2030
+ onSubmit: submit,
2031
+ style: { flexGrow: 1 }
2032
+ })
2033
+ })]
2034
+ });
2035
+ }
2036
+ function OAuthRunningStep({ descriptor, dataDir, onSuccess, onError }) {
2037
+ const [url, setUrl] = useState(null);
2038
+ const [status, setStatus] = useState("starting browser…");
2039
+ useEffect(() => {
2040
+ const ac = new AbortController();
2041
+ let cancelled = false;
2042
+ (async () => {
2043
+ try {
2044
+ const creds = await runOAuthLogin(descriptor, {
2045
+ onUrl: (loginUrl) => {
2046
+ if (cancelled) return;
2047
+ setUrl(loginUrl);
2048
+ setStatus("waiting for browser callback…");
2049
+ },
2050
+ onProgress: (message) => {
2051
+ if (!cancelled) setStatus(message);
2052
+ },
2053
+ signal: ac.signal
2054
+ });
2055
+ if (cancelled) return;
2056
+ setProviderCredential(dataDir, descriptor, {
2057
+ kind: "oauth",
2058
+ ...creds
2059
+ });
2060
+ onSuccess();
2061
+ } catch (err) {
2062
+ if (cancelled) return;
2063
+ onError(err instanceof Error ? err.message : String(err));
2064
+ }
2065
+ })();
2066
+ return () => {
2067
+ cancelled = true;
2068
+ ac.abort();
2069
+ };
2070
+ }, [
2071
+ descriptor,
2072
+ dataDir,
2073
+ onSuccess,
2074
+ onError
2075
+ ]);
2076
+ return /* @__PURE__ */ jsxs(WizardPanel, {
2077
+ title: ` configure ${descriptor.label} — OAuth `,
1013
2078
  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))
2079
+ /* @__PURE__ */ jsx(WizardEscHint, {}),
2080
+ /* @__PURE__ */ jsx(Spinner, { label: status }),
2081
+ url && /* @__PURE__ */ jsxs("box", {
2082
+ style: {
2083
+ flexDirection: "column",
2084
+ gap: 0
2085
+ },
2086
+ children: [/* @__PURE__ */ jsx("text", {
2087
+ fg: COLOR.dim,
2088
+ children: "If the browser didn't open, visit:"
2089
+ }), /* @__PURE__ */ jsx("text", {
2090
+ fg: COLOR.model,
2091
+ children: url
2092
+ })]
2093
+ })
1037
2094
  ]
1038
2095
  });
1039
2096
  }
@@ -1074,7 +2131,7 @@ function SessionsScreen({ sessions, currentId, onPick, onCreate }) {
1074
2131
  onSelect: (_idx, option) => {
1075
2132
  if (!option) return;
1076
2133
  if (option.value === NEW_VALUE) onCreate();
1077
- else onPick(option.value);
2134
+ else if (typeof option.value === "string") onPick(option.value);
1078
2135
  },
1079
2136
  style: { flexGrow: 1 }
1080
2137
  })
@@ -1083,11 +2140,11 @@ function SessionsScreen({ sessions, currentId, onPick, onCreate }) {
1083
2140
  /** Visible content lines: 1 minimum, 5 maximum (textarea scrolls past 5). */
1084
2141
  const MIN_CONTENT_LINES = 1;
1085
2142
  const MAX_CONTENT_LINES = 5;
1086
- function ChatScreen({ events, busy, settings, onSubmit, session }) {
2143
+ function ChatScreen({ events, busy, settings, onSubmit, session, pending, onApproval }) {
1087
2144
  const title = useMemo(() => {
1088
2145
  if (!session) return " untitled ";
1089
2146
  const turns = `${session.turnCount} turn${session.turnCount === 1 ? "" : "s"}`;
1090
- return ` ${session.title} · #${shortId(session.id)} · ${turns} `;
2147
+ return ` ${session.title} · #${shortId(session.id)} · ${turns} `;
1091
2148
  }, [session]);
1092
2149
  const userPrompts = useMemo(() => events.filter((e) => e.kind === "info").map((e) => e.text.replace(/^❯ /, "")), [events]);
1093
2150
  return /* @__PURE__ */ jsxs("box", {
@@ -1107,12 +2164,130 @@ function ChatScreen({ events, busy, settings, onSubmit, session }) {
1107
2164
  events,
1108
2165
  settings
1109
2166
  })
1110
- }), busy ? /* @__PURE__ */ jsx(BusyBlock, {}) : /* @__PURE__ */ jsx(PromptBlock, {
2167
+ }), pending ? /* @__PURE__ */ jsx(ApprovalBlock, {
2168
+ request: pending,
2169
+ onPick: onApproval
2170
+ }) : busy ? /* @__PURE__ */ jsx(BusyBlock, {}) : /* @__PURE__ */ jsx(PromptBlock, {
1111
2171
  userPrompts,
1112
2172
  onSubmit
1113
2173
  })]
1114
2174
  });
1115
2175
  }
2176
+ /** Max chars per scalar argument in the approval preview. */
2177
+ const APPROVAL_ARG_MAX = 80;
2178
+ /**
2179
+ * Render `{ path: 'x.ts', contents: 'long string' }` as
2180
+ * `path: "x.ts", contents: "long string…"` — readable, per-key, truncated
2181
+ * per value rather than dumping `JSON.stringify(input)` (which produces an
2182
+ * illegible 50KB blob for `write_file` etc.).
2183
+ */
2184
+ function formatApprovalArgs(input) {
2185
+ const parts = [];
2186
+ for (const [key, raw] of Object.entries(input)) {
2187
+ let value;
2188
+ if (typeof raw === "string") {
2189
+ const escaped = raw.replace(/\n/g, "\\n");
2190
+ value = escaped.length > APPROVAL_ARG_MAX ? `"${escaped.slice(0, APPROVAL_ARG_MAX)}…"` : `"${escaped}"`;
2191
+ } else {
2192
+ const json = JSON.stringify(raw);
2193
+ value = json.length > APPROVAL_ARG_MAX ? `${json.slice(0, APPROVAL_ARG_MAX)}…` : json;
2194
+ }
2195
+ parts.push(`${key}: ${value}`);
2196
+ }
2197
+ return parts.join(", ");
2198
+ }
2199
+ /**
2200
+ * Inline approval picker — replaces the chat input while a tool call is
2201
+ * pending. Three options:
2202
+ * - **accept once** — let this call execute, don't persist anything.
2203
+ * - **accept + remember** — execute + add a `projects.json` entry so the
2204
+ * same shape doesn't prompt again in this directory.
2205
+ * - **deny** — refuse the call. The model gets `Blocked: …` and adapts.
2206
+ *
2207
+ * Esc aborts the whole run via the parent keyboard handler; per-call
2208
+ * accept/deny only happens through the select below.
2209
+ *
2210
+ * Layout is fully pinned so the picker never overlaps with itself or the
2211
+ * transcript above:
2212
+ *
2213
+ * - Outer `<box>` has an explicit `height`. The slot below the transcript
2214
+ * adapts (the chat container is column-flex), so we control exactly how
2215
+ * many rows we occupy.
2216
+ * - Summary row is a `<box height: 1, overflow: hidden>` wrapping a
2217
+ * `<text wrapMode="none">` — a 500-char tool-call preview can never
2218
+ * wrap to row 2 and push the select off-screen.
2219
+ * - `<select showDescription={false}>` keeps each option to exactly one
2220
+ * row. Hints live in the `name` string after a `·` separator. Without
2221
+ * this, the default `showDescription: true` makes every option take 2
2222
+ * rows, and a `height: options.length` select would overdraw into the
2223
+ * summary above (the original bug).
2224
+ */
2225
+ function ApprovalBlock({ request, onPick }) {
2226
+ const focused = useModalAwareFocus();
2227
+ const summary = useMemo(() => `${request.tool}(${formatApprovalArgs(request.input)})`, [request.tool, request.input]);
2228
+ const options = useMemo(() => {
2229
+ return [
2230
+ {
2231
+ name: "accept once · allow this call only",
2232
+ description: "",
2233
+ value: "accept-once"
2234
+ },
2235
+ {
2236
+ name: `accept + remember · add "${suggestSafelistEntry(request.tool, request.input)}" to projects.json`,
2237
+ description: "",
2238
+ value: "accept-safelist"
2239
+ },
2240
+ {
2241
+ name: "deny · refuse — the model will see Blocked",
2242
+ description: "",
2243
+ value: "deny"
2244
+ }
2245
+ ];
2246
+ }, [request.tool, request.input]);
2247
+ const height = 3 + options.length;
2248
+ return /* @__PURE__ */ jsxs("box", {
2249
+ title: " approve tool call · esc to abort run ",
2250
+ style: {
2251
+ border: true,
2252
+ borderColor: COLOR.warn,
2253
+ paddingLeft: 1,
2254
+ paddingRight: 1,
2255
+ paddingTop: 0,
2256
+ paddingBottom: 0,
2257
+ height,
2258
+ flexDirection: "column",
2259
+ flexShrink: 0
2260
+ },
2261
+ children: [/* @__PURE__ */ jsx("box", {
2262
+ style: {
2263
+ height: 1,
2264
+ overflow: "hidden",
2265
+ flexShrink: 0
2266
+ },
2267
+ children: /* @__PURE__ */ jsxs("text", {
2268
+ fg: COLOR.model,
2269
+ wrapMode: "none",
2270
+ children: [/* @__PURE__ */ jsx("span", {
2271
+ fg: COLOR.warn,
2272
+ children: "↳ "
2273
+ }), summary]
2274
+ })
2275
+ }), /* @__PURE__ */ jsx("select", {
2276
+ ...SELECT_THEME,
2277
+ focused,
2278
+ options,
2279
+ showDescription: false,
2280
+ wrapSelection: true,
2281
+ onSelect: (_idx, option) => {
2282
+ if (option) onPick(option.value);
2283
+ },
2284
+ style: {
2285
+ height: options.length,
2286
+ flexShrink: 0
2287
+ }
2288
+ })]
2289
+ });
2290
+ }
1116
2291
  function BusyBlock() {
1117
2292
  return /* @__PURE__ */ jsx("box", {
1118
2293
  style: {
@@ -1210,7 +2385,9 @@ function PromptBlock({ userPrompts, onSubmit }) {
1210
2385
  const DEFAULT_SETTINGS = {
1211
2386
  showThinking: true,
1212
2387
  showToolCalls: true,
1213
- showToolResults: true
2388
+ showToolResults: true,
2389
+ safeMode: true,
2390
+ hideSubagentOutput: true
1214
2391
  };
1215
2392
  const SettingsContext = createContext(null);
1216
2393
  function SettingsProvider({ initial, onChange, children }) {
@@ -1239,40 +2416,88 @@ function useSettings() {
1239
2416
  if (!ctx) throw new Error("useSettings must be used inside <SettingsProvider>");
1240
2417
  return ctx;
1241
2418
  }
1242
- const ROWS = [
2419
+ const TOGGLES = [
2420
+ {
2421
+ kind: "toggle",
2422
+ key: "safeMode",
2423
+ label: "Safe mode",
2424
+ description: "prompt before each tool call (unless safelisted)"
2425
+ },
2426
+ {
2427
+ kind: "toggle",
2428
+ key: "hideSubagentOutput",
2429
+ label: "Hide subagent output",
2430
+ description: "collapse subagent runs to start/done markers"
2431
+ },
1243
2432
  {
2433
+ kind: "toggle",
1244
2434
  key: "showThinking",
1245
2435
  label: "Thinking blocks",
1246
2436
  description: "agent reasoning shown inline"
1247
2437
  },
1248
2438
  {
2439
+ kind: "toggle",
1249
2440
  key: "showToolCalls",
1250
2441
  label: "Tool calls",
1251
2442
  description: "the ↳ name(args) lines"
1252
2443
  },
1253
2444
  {
2445
+ kind: "toggle",
1254
2446
  key: "showToolResults",
1255
2447
  label: "Tool outputs",
1256
2448
  description: "the ┃ result blocks under tool calls"
1257
2449
  }
1258
2450
  ];
1259
- function SettingsModal() {
2451
+ function SettingsModal({ actions } = {}) {
1260
2452
  const { settings, toggle } = useSettings();
1261
- const [cursor, setCursor] = useState(0);
2453
+ const [cursor, setCursorRaw] = useState(0);
2454
+ const items = useMemo(() => {
2455
+ const actionItems = [];
2456
+ if (actions?.onReauth) actionItems.push({
2457
+ kind: "action",
2458
+ id: "reauth",
2459
+ label: "Authentication",
2460
+ description: "switch provider, add another, or re-authenticate",
2461
+ onPick: actions.onReauth
2462
+ });
2463
+ return [...TOGGLES, ...actionItems];
2464
+ }, [actions]);
2465
+ const safeCursor = Math.min(cursor, items.length - 1);
2466
+ const setCursor = useCallback((update) => setCursorRaw((prev) => Math.min(Math.max(0, update(prev)), items.length - 1)), [items.length]);
1262
2467
  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);
2468
+ if (key.name === "up" || key.ctrl && key.name === "p") setCursor((c) => c - 1);
2469
+ else if (key.name === "down" || key.ctrl && key.name === "n") setCursor((c) => c + 1);
2470
+ else if (key.name === "return" || key.name === "space") {
2471
+ const item = items[safeCursor];
2472
+ if (!item) return;
2473
+ if (item.kind === "toggle") toggle(item.key);
2474
+ else item.onPick();
2475
+ }
1266
2476
  });
2477
+ const firstActionIndex = items.findIndex((i) => i.kind === "action");
1267
2478
  return /* @__PURE__ */ jsxs(Modal, {
1268
2479
  title: "settings",
1269
2480
  children: [/* @__PURE__ */ jsx("box", {
1270
2481
  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))
2482
+ children: items.map((item, i) => /* @__PURE__ */ jsxs("box", {
2483
+ style: { flexDirection: "column" },
2484
+ children: [i === firstActionIndex && i > 0 && /* @__PURE__ */ jsx("box", { style: {
2485
+ border: ["top"],
2486
+ borderColor: COLOR.mute,
2487
+ height: 1,
2488
+ marginTop: 1,
2489
+ marginBottom: 1
2490
+ } }), item.kind === "toggle" ? /* @__PURE__ */ jsx(ToggleRow, {
2491
+ label: item.label,
2492
+ description: item.description,
2493
+ enabled: settings[item.key],
2494
+ focused: i === safeCursor
2495
+ }) : /* @__PURE__ */ jsx(ActionRow, {
2496
+ label: item.label,
2497
+ description: item.description,
2498
+ focused: i === safeCursor
2499
+ })]
2500
+ }, item.kind === "toggle" ? item.key : item.id))
1276
2501
  }), /* @__PURE__ */ jsxs("text", {
1277
2502
  fg: COLOR.mute,
1278
2503
  children: [
@@ -1285,7 +2510,7 @@ function SettingsModal() {
1285
2510
  fg: COLOR.warn,
1286
2511
  children: "↵"
1287
2512
  }),
1288
- " toggle · ",
2513
+ firstActionIndex >= 0 ? " toggle/select · " : " toggle · ",
1289
2514
  /* @__PURE__ */ jsx("span", {
1290
2515
  fg: COLOR.warn,
1291
2516
  children: "esc"
@@ -1296,14 +2521,13 @@ function SettingsModal() {
1296
2521
  });
1297
2522
  }
1298
2523
  /**
1299
- * A single setting row — `▶` marker · checkbox · label · description.
2524
+ * Toggle row — `▶` marker · checkbox · label · description.
1300
2525
  *
1301
2526
  * Rendered as one `<text>` so OpenTUI's word-wrap handles narrow terminals
1302
2527
  * 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.
2528
+ * the trailing description wraps under the label without breaking the row.
1305
2529
  */
1306
- function SettingRowView({ row, enabled, focused }) {
2530
+ function ToggleRow({ label, description, enabled, focused }) {
1307
2531
  return /* @__PURE__ */ jsxs("text", {
1308
2532
  fg: focused ? COLOR.brand : COLOR.dim,
1309
2533
  children: [
@@ -1317,11 +2541,42 @@ function SettingRowView({ row, enabled, focused }) {
1317
2541
  }),
1318
2542
  /* @__PURE__ */ jsx("span", {
1319
2543
  fg: focused ? COLOR.brand : COLOR.dim,
1320
- children: row.label
2544
+ children: label
2545
+ }),
2546
+ /* @__PURE__ */ jsx("span", {
2547
+ fg: COLOR.mute,
2548
+ children: ` ${description}`
2549
+ })
2550
+ ]
2551
+ });
2552
+ }
2553
+ /**
2554
+ * Action row — cursor marker · label · description · (focus-only) trailing arrow.
2555
+ *
2556
+ * The label sits in the same column as a toggle row's `[✓]` checkbox (right
2557
+ * after the 2-col cursor slot). The trailing `›` only renders when focused
2558
+ * so it reads as a "this row runs" affordance, not a static decoration on
2559
+ * every action.
2560
+ */
2561
+ function ActionRow({ label, description, focused }) {
2562
+ return /* @__PURE__ */ jsxs("text", {
2563
+ fg: focused ? COLOR.brand : COLOR.dim,
2564
+ children: [
2565
+ /* @__PURE__ */ jsx("span", {
2566
+ fg: focused ? COLOR.brand : COLOR.mute,
2567
+ children: focused ? "▶ " : " "
2568
+ }),
2569
+ /* @__PURE__ */ jsx("span", {
2570
+ fg: focused ? COLOR.brand : COLOR.accent,
2571
+ children: label
1321
2572
  }),
1322
2573
  /* @__PURE__ */ jsx("span", {
1323
2574
  fg: COLOR.mute,
1324
- children: ` ${row.description}`
2575
+ children: ` ${description}`
2576
+ }),
2577
+ focused && /* @__PURE__ */ jsx("span", {
2578
+ fg: COLOR.brand,
2579
+ children: " ›"
1325
2580
  })
1326
2581
  ]
1327
2582
  });
@@ -1518,7 +2773,7 @@ function App({ config }) {
1518
2773
  ...config.stateStore.load(),
1519
2774
  settings
1520
2775
  }), [config.stateStore]),
1521
- children: /* @__PURE__ */ jsx(ModalRoot, { children: /* @__PURE__ */ jsx(AppShell, {}) })
2776
+ children: /* @__PURE__ */ jsx(SafeModeProvider, { children: /* @__PURE__ */ jsx(ModalRoot, { children: /* @__PURE__ */ jsx(AppShell, {}) }) })
1522
2777
  })
1523
2778
  });
1524
2779
  }
@@ -1527,8 +2782,54 @@ function AppShell() {
1527
2782
  const modal = useModal();
1528
2783
  const config = useConfig();
1529
2784
  const { settings } = useSettings();
2785
+ const queue = useSafeModeQueue();
2786
+ const { requestApproval, resolveHead, denyAll } = useSafeModeActions();
1530
2787
  const { providers: providerRegistry, preset, store, stateStore, modelsFor, resumeProvider, initialPicked, initialState } = config;
1531
2788
  const lastResumedSessionId = initialState.lastSessionId;
2789
+ const dataDir = config.paths.dir;
2790
+ const safeModeEnabledRef = useRef(settings.safeMode);
2791
+ useEffect(() => {
2792
+ safeModeEnabledRef.current = settings.safeMode;
2793
+ }, [settings.safeMode]);
2794
+ const [projectDir] = useState(() => process.cwd());
2795
+ const safelistRef = useRef(null);
2796
+ const readSafelist = useCallback(() => {
2797
+ if (safelistRef.current === null) safelistRef.current = getSafelist(dataDir, projectDir);
2798
+ return safelistRef.current;
2799
+ }, [dataDir, projectDir]);
2800
+ useEffect(() => {
2801
+ safelistRef.current = null;
2802
+ }, [dataDir, projectDir]);
2803
+ /**
2804
+ * Single source of truth for "should this call execute?". Returns true to
2805
+ * let the call through, false to refuse it. Handles three short-circuits:
2806
+ *
2807
+ * - Safe-mode globally off → always allow.
2808
+ * - Call covered by the project safelist or the implicit read-only set
2809
+ * → always allow without prompting.
2810
+ * - Otherwise → prompt the user and act on their decision (including
2811
+ * persisting a new safelist entry on "accept + safelist").
2812
+ *
2813
+ * Wired into the parent agent via `tool:gate` / `mcp:tool:gate`, and to
2814
+ * every subagent (transitively, for free) via `child:tool:gate` /
2815
+ * `child:mcp:tool:gate` — see the bubble in `src/tools/spawn.ts`.
2816
+ */
2817
+ const gateDecision = useCallback(async (tool, input) => {
2818
+ if (!safeModeEnabledRef.current) return true;
2819
+ if (isOnSafelist(readSafelist(), tool, input)) return true;
2820
+ const decision = await requestApproval(tool, input);
2821
+ if (decision === "deny") return false;
2822
+ if (decision === "accept-safelist") {
2823
+ addToSafelist(dataDir, projectDir, suggestSafelistEntry(tool, input));
2824
+ safelistRef.current = null;
2825
+ }
2826
+ return true;
2827
+ }, [
2828
+ dataDir,
2829
+ projectDir,
2830
+ requestApproval,
2831
+ readSafelist
2832
+ ]);
1532
2833
  const [screen, setScreen] = useState(() => {
1533
2834
  if (!resumeProvider) return "auth";
1534
2835
  return lastResumedSessionId ? "chat" : "sessions";
@@ -1544,40 +2845,56 @@ function AppShell() {
1544
2845
  const sessionRef = useRef(null);
1545
2846
  const stream = useStreamBuffer(setEvents);
1546
2847
  const makePicked = useCallback((provider, modelId) => {
1547
- const factory = providerRegistry[provider.key];
1548
- if (!factory) return null;
2848
+ const descriptor = providerRegistry[provider.key];
2849
+ if (!descriptor) return null;
1549
2850
  const remembered = initialState.lastModelByProvider?.[provider.key];
1550
2851
  return {
1551
2852
  provider,
1552
- model: modelId ?? remembered ?? factory().meta.defaultModel
2853
+ model: modelId ?? remembered ?? descriptor.defaultModel ?? descriptor.factory().meta.defaultModel
1553
2854
  };
1554
2855
  }, [providerRegistry, initialState]);
1555
2856
  const buildAgent = useCallback((session, key) => {
1556
- const factory = providerRegistry[key];
1557
- if (!factory) throw new Error(`No provider registered for key "${key}"`);
2857
+ const descriptor = providerRegistry[key];
2858
+ if (!descriptor) throw new Error(`No provider registered for key "${key}"`);
1558
2859
  const agent = createAgent({
1559
2860
  ...preset,
1560
- provider: factory(),
2861
+ provider: descriptor.factory(),
1561
2862
  session
1562
2863
  });
2864
+ const applyGate = async (name, input, ctx) => {
2865
+ if (ctx.block) return;
2866
+ if (!await gateDecision(name, input)) {
2867
+ ctx.block = true;
2868
+ ctx.reason = "User denied this tool call";
2869
+ }
2870
+ };
2871
+ agent.hooks.hook("tool:gate", (ctx) => applyGate(ctx.name, ctx.input, ctx));
2872
+ agent.hooks.hook("child:tool:gate", (ctx) => applyGate(ctx.name, ctx.input, ctx));
2873
+ agent.hooks.hook("mcp:tool:gate", (ctx) => applyGate(ctx.displayName, ctx.input, ctx));
2874
+ agent.hooks.hook("child:mcp:tool:gate", (ctx) => applyGate(ctx.displayName, ctx.input, ctx));
1563
2875
  agent.hooks.hook("stream:thinking", ({ delta }) => stream.queueStreamDelta("thinking", delta));
1564
2876
  agent.hooks.hook("stream:text", ({ delta }) => stream.queueStreamDelta("markdown", delta));
1565
2877
  agent.hooks.hook("tool:before", ({ name, input }) => {
1566
2878
  stream.appendImmediate({
1567
2879
  kind: "tool",
1568
- text: toolCallPreview(name, input)
2880
+ text: toolCallPreview(name, input),
2881
+ tool: name
1569
2882
  });
1570
2883
  });
1571
- agent.hooks.hook("tool:after", ({ result }) => {
2884
+ agent.hooks.hook("tool:after", ({ name, result }) => {
2885
+ const raw = toolResultText(result);
2886
+ const text = name === "spawn" ? stripSpawnTokensLine(raw) : raw;
1572
2887
  stream.appendImmediate({
1573
2888
  kind: "tool-result",
1574
- text: toolResultText(result)
2889
+ text,
2890
+ tool: name
1575
2891
  });
1576
2892
  });
1577
- agent.hooks.hook("mcp:tool:after", ({ result }) => {
2893
+ agent.hooks.hook("mcp:tool:after", ({ displayName, result }) => {
1578
2894
  stream.appendImmediate({
1579
2895
  kind: "tool-result",
1580
- text: toolResultText(result)
2896
+ text: toolResultText(result),
2897
+ tool: displayName
1581
2898
  });
1582
2899
  });
1583
2900
  agent.hooks.hook("turn:after", ({ usage }) => {
@@ -1597,7 +2914,7 @@ function AppShell() {
1597
2914
  const tag = status === "aborted" ? "aborted" : status === "error" ? "error" : "done";
1598
2915
  stream.appendImmediate({
1599
2916
  kind: "spawn-end",
1600
- text: `${tag} · ${stats.totalIn} in / ${stats.totalOut} out`,
2917
+ text: `${tag} ${formatTokenUsage(stats)}`,
1601
2918
  childId: id,
1602
2919
  depth: depth ?? 1
1603
2920
  });
@@ -1626,14 +2943,16 @@ function AppShell() {
1626
2943
  stream.appendImmediate({
1627
2944
  kind: "tool",
1628
2945
  text: toolCallPreview(name, input),
2946
+ tool: name,
1629
2947
  childId,
1630
2948
  depth
1631
2949
  });
1632
2950
  });
1633
- agent.hooks.hook("child:tool:after", ({ result, childId, depth }) => {
2951
+ agent.hooks.hook("child:tool:after", ({ name, result, childId, depth }) => {
1634
2952
  stream.appendImmediate({
1635
2953
  kind: "tool-result",
1636
2954
  text: toolResultText(result),
2955
+ tool: name,
1637
2956
  childId,
1638
2957
  depth
1639
2958
  });
@@ -1645,7 +2964,8 @@ function AppShell() {
1645
2964
  }, [
1646
2965
  providerRegistry,
1647
2966
  preset,
1648
- stream
2967
+ stream,
2968
+ gateDecision
1649
2969
  ]);
1650
2970
  const refreshSessions = useCallback(async () => {
1651
2971
  const list = await listSessionMeta(store);
@@ -1666,7 +2986,7 @@ function AppShell() {
1666
2986
  });
1667
2987
  sessionRef.current = session;
1668
2988
  agentRef.current = buildAgent(session, key);
1669
- setEvents(eventsFromTurns(session.turns));
2989
+ setEvents(eventsFromTurns(session.turns, session.runs));
1670
2990
  setLastInputTokens(lastContextSizeFromTurns(session.turns));
1671
2991
  setCurrentSession({
1672
2992
  id: session.id,
@@ -1740,8 +3060,9 @@ function AppShell() {
1740
3060
  setScreen("sessions");
1741
3061
  }, [refreshSessions]);
1742
3062
  const onAbort = useCallback(() => {
3063
+ denyAll();
1743
3064
  agentRef.current?.abort();
1744
- }, []);
3065
+ }, [denyAll]);
1745
3066
  const onPickModel = useCallback((modelId) => {
1746
3067
  setPicked((prev) => {
1747
3068
  if (!prev) return prev;
@@ -1799,10 +3120,15 @@ function AppShell() {
1799
3120
  events.length,
1800
3121
  stream
1801
3122
  ]);
3123
+ const onReauth = useCallback(() => {
3124
+ modal.close();
3125
+ setScreen("auth");
3126
+ }, [modal]);
3127
+ const pendingApproval = queue[0] ?? null;
1802
3128
  useKeyboard((key) => {
1803
3129
  if (modal.isOpen) return;
1804
3130
  if (key.ctrl && key.name === "," && screen !== "auth") {
1805
- modal.open(/* @__PURE__ */ jsx(SettingsModal, {}));
3131
+ modal.open(/* @__PURE__ */ jsx(SettingsModal, { actions: { onReauth } }));
1806
3132
  return;
1807
3133
  }
1808
3134
  if (key.ctrl && key.name === "m" && screen === "chat" && picked && !busy) {
@@ -1814,23 +3140,30 @@ function AppShell() {
1814
3140
  return;
1815
3141
  }
1816
3142
  if (key.name !== "escape") return;
1817
- if (busy) return onAbort();
3143
+ if (busy || pendingApproval) return onAbort();
1818
3144
  if (screen === "chat") return onOpenSessions();
1819
3145
  if (screen === "sessions") {
1820
3146
  if (currentSession) setScreen("chat");
1821
3147
  else renderer.destroy();
1822
3148
  return;
1823
3149
  }
3150
+ if (picked) {
3151
+ setScreen(currentSession ? "chat" : "sessions");
3152
+ return;
3153
+ }
1824
3154
  renderer.destroy();
1825
3155
  });
1826
- const hints = useMemo(() => buildHints(screen, busy, currentSession), [
3156
+ const hints = useMemo(() => buildHints(screen, busy, !!pendingApproval, currentSession), [
1827
3157
  screen,
1828
3158
  busy,
3159
+ pendingApproval,
1829
3160
  currentSession
1830
3161
  ]);
1831
3162
  const contextUsage = useMemo(() => {
1832
3163
  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);
3164
+ const descriptor = providerRegistry[picked.provider.key];
3165
+ if (!descriptor) return null;
3166
+ const max = getContextWindow(descriptor, picked.model);
1834
3167
  return max ? {
1835
3168
  used: lastInputTokens,
1836
3169
  max
@@ -1839,7 +3172,7 @@ function AppShell() {
1839
3172
  screen,
1840
3173
  picked,
1841
3174
  lastInputTokens,
1842
- modelsFor
3175
+ providerRegistry
1843
3176
  ]);
1844
3177
  useEffect(() => () => {
1845
3178
  teardown();
@@ -1869,7 +3202,9 @@ function AppShell() {
1869
3202
  busy,
1870
3203
  settings,
1871
3204
  onSubmit: onSubmitPrompt,
1872
- session: currentSession
3205
+ session: currentSession,
3206
+ pending: pendingApproval,
3207
+ onApproval: resolveHead
1873
3208
  })
1874
3209
  ]
1875
3210
  }), /* @__PURE__ */ jsx(Footer, {
@@ -1879,7 +3214,21 @@ function AppShell() {
1879
3214
  })]
1880
3215
  });
1881
3216
  }
1882
- function buildHints(screen, busy, currentSession) {
3217
+ function buildHints(screen, busy, pending, currentSession) {
3218
+ if (pending) return [
3219
+ {
3220
+ key: "↑↓",
3221
+ label: "navigate"
3222
+ },
3223
+ {
3224
+ key: "↵",
3225
+ label: "select"
3226
+ },
3227
+ {
3228
+ key: "esc",
3229
+ label: "abort run"
3230
+ }
3231
+ ];
1883
3232
  if (busy) return [{
1884
3233
  key: "esc",
1885
3234
  label: "abort"
@@ -1971,21 +3320,26 @@ let runTuiInvoked = false;
1971
3320
  * to `runTui({ storageDir, prefix })`.
1972
3321
  *
1973
3322
  * ```ts
1974
- * import { runTui } from 'zidane/tui'
3323
+ * import { BUILTIN_PROVIDERS, runTui } from 'zidane/tui'
1975
3324
  * import { createRemoteStore } from 'zidane/session' // for the `store` option
1976
3325
  *
1977
3326
  * await runTui() // ~/.zidane/sessions.db + state.json
1978
3327
  * await runTui({ prefix: '.myapp' }) // ~/.myapp/...
1979
3328
  * await runTui({ storageDir: '/data', prefix: 'myapp' })
1980
- * await runTui({ providers: { custom: () => myProvider() } })
3329
+ * await runTui({ providers: { ...BUILTIN_PROVIDERS, mine: myDescriptor } })
1981
3330
  * await runTui({ store: createRemoteStore({ url: '…' }) })
1982
- * await runTui({ models: { anthropic: [{ id: 'claude-foo', contextWindow: 200_000 }] } })
1983
3331
  * ```
1984
3332
  */
1985
3333
  async function runTui(options = {}) {
1986
3334
  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
3335
  runTuiInvoked = true;
1988
3336
  await init();
3337
+ try {
3338
+ heal("");
3339
+ } catch (err) {
3340
+ const cause = err instanceof Error ? err.message : String(err);
3341
+ process.stderr.write(`[zidane/tui] md4x WASM probe failed: ${cause}\n`);
3342
+ }
1989
3343
  const config = resolveConfig(options);
1990
3344
  let done = () => {};
1991
3345
  const exited = new Promise((resolve) => {
@@ -1999,6 +3353,6 @@ async function runTui(options = {}) {
1999
3353
  process.exit(0);
2000
3354
  }
2001
3355
  //#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 };
3356
+ export { App, AuthScreen, BUILTIN_PROVIDERS, COLOR, ChatScreen, ConfigProvider, DEFAULT_SETTINGS, Footer, IMPLICITLY_SAFE_TOOLS, MD_STYLE, Modal, ModalRoot, ModelPickerModal, SELECT_THEME, SafeModeProvider, SessionsScreen, SettingsModal, SettingsProvider, Spinner, Transcript, addToSafelist, ageString, anthropicDescriptor, applyApiKeyEnv, cerebrasDescriptor, createStateStore, createTuiStore, credKeyOf, credentialsPath, detectAuth, eventsFromTurns, finalizeStreamingMarkdown, finalizeStreamingMarkdownForOwner, fmtTokens, getContextWindow, getSafelist, isOnSafelist, lastContextSizeFromTurns, listSessionMeta, loadState, marginTopFor, matchesSafelistEntry, modelsForDescriptor, onInputSubmit, openaiDescriptor, openrouterDescriptor, piIdOf, projectsFilePath, readCredentials, readProjects, readProviderCredential, removeProviderCredential, resolveConfig, runOAuthLogin, runTui, saveState, setProviderCredential, shortId, suggestSafelistEntry, supportsOAuth, titleFromTurns, toolCallPreview, toolResultText, turnContextSize, useConfig, useModal, useModalAwareFocus, useSafeModeActions, useSafeModeQueue, useSettings, useStreamBuffer, writeCredentials, writeProjects };
2003
3357
 
2004
3358
  //# sourceMappingURL=tui.js.map