zidane 4.1.6 → 4.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tui.js CHANGED
@@ -1,144 +1,45 @@
1
1
  import { d as createAgent } from "./tools-C8kDot0H.js";
2
- import { n as toolResultToText } from "./types-Bx_F8jet.js";
3
2
  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";
6
3
  import { n as loadSession, t as createSession } from "./session-Cn68UASv.js";
7
- import { createSqliteStore } from "./session/sqlite.js";
8
- import { spawn } from "node:child_process";
9
- import { dirname, resolve } from "node:path";
10
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
11
- import { homedir } from "node:os";
12
- import { anthropicOAuthProvider, openaiCodexOAuthProvider } from "@mariozechner/pi-ai/oauth";
13
- import { getModel, getModels } from "@mariozechner/pi-ai";
14
- import { RGBA, SyntaxStyle, createCliRenderer, defaultTextareaKeyBindings } from "@opentui/core";
15
- import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
16
- import { heal, init } from "md4x/wasm";
4
+ import { $ as toolCallPreview, A as isOnSafelist, B as shortId, E as useSafeModeQueue, H as useConfig, I as runOAuthLogin, J as listSessionMeta, K as eventsFromTurns, L as supportsOAuth, O as addToSafelist, P as suggestSafelistEntry, Q as titleFromTurns, R as ageString, T as useSafeModeActions, U as resolveConfig, V as ConfigProvider, Z as stripSpawnTokensLine, c as finalizeStreamingMarkdownForOwner, d as DEFAULT_SETTINGS, et as toolResultText, f as SETTINGS_CHOICES, h as useSettings, i as useSurfaces, k as getSafelist, l as turnContextSize, m as SettingsProvider, n as useColors, o as useTheme, p as SETTINGS_TOGGLES, pt as getContextWindow, q as lastContextSizeFromTurns, r as useSelectStyle, s as finalizeStreamingMarkdown, st as setProviderCredential, t as ThemeProvider, tt as detectAuth, u as useStreamBuffer, v as resolveTheme, w as SafeModeProvider, z as fmtTokens } from "./theme-context-MungM3SY.js";
17
5
  import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
18
6
  import { jsx, jsxs } from "@opentui/react/jsx-runtime";
19
- //#region src/tui/format.ts
20
- /** Compact token formatter 12_415 "12.4k", 1_234_567 → "1.23M". */
21
- function fmtTokens(n) {
22
- if (n < 1e3) return String(n);
23
- if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 2 : 1)}k`;
24
- return `${(n / 1e6).toFixed(2)}M`;
25
- }
26
- /** Compact relative-time formatter — "just now / 5m / 3h / 2d". */
27
- function ageString(ts, now = Date.now()) {
28
- const m = Math.floor((now - ts) / 6e4);
29
- if (m < 1) return "just now";
30
- if (m < 60) return `${m}m ago`;
31
- const h = Math.floor(m / 60);
32
- if (h < 24) return `${h}h ago`;
33
- return `${Math.floor(h / 24)}d ago`;
34
- }
35
- /** Six-char short form of a session id for headers and lists. */
36
- function shortId(id) {
37
- return id.replace(/-/g, "").slice(0, 6);
38
- }
39
- //#endregion
7
+ import { RGBA, SyntaxStyle, addDefaultParsers, createCliRenderer, defaultTextareaKeyBindings, getTreeSitterClient } from "@opentui/core";
8
+ import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
40
9
  //#region src/tui/theme.ts
41
10
  /**
42
- * Shared color palette. Kept as plain hex strings so it can be consumed by
43
- * OpenTUI props that accept `string | RGBA`. The names describe role, not
44
- * literal hue, so the theme can be swapped without touching call sites.
11
+ * Convert the renderer-agnostic `Theme.syntax` map (hex strings + plain
12
+ * booleans) into an OpenTUI `SyntaxStyle`. Used both for the markdown
13
+ * structural captures (`markup.heading`, `markup.bold`, …) and the
14
+ * embedded Tree-sitter language tokens (`keyword`, `string`, `function`,
15
+ * …) — OpenTUI's `<markdown>` re-uses the same `SyntaxStyle` for the
16
+ * fenced-code renderable, so one table drives both surfaces.
45
17
  */
46
- const COLOR = {
47
- brand: "#FFCC00",
48
- accent: "#00FF88",
49
- model: "#88CCFF",
50
- warn: "#FFAA66",
51
- error: "#FF6666",
52
- dim: "#888888",
53
- mute: "#555555",
54
- border: "#333333",
55
- borderActive: "#555555"
56
- };
57
- /**
58
- * Shared select styling — keeps the highlight bar from filling with a
59
- * different background than the surrounding box. The `▶` marker and the
60
- * brand-colored selected text carry the focus affordance.
61
- */
62
- const SELECT_THEME = {
63
- backgroundColor: "transparent",
64
- focusedBackgroundColor: "transparent",
65
- selectedBackgroundColor: "transparent",
66
- selectedTextColor: COLOR.brand,
67
- textColor: COLOR.dim,
68
- descriptionColor: COLOR.mute,
69
- selectedDescriptionColor: COLOR.dim
70
- };
18
+ function buildMdStyle(theme) {
19
+ const styles = {};
20
+ for (const [token, style] of Object.entries(theme.syntax)) {
21
+ const out = {};
22
+ if (style.fg) out.fg = RGBA.fromHex(style.fg);
23
+ if (style.bg) out.bg = RGBA.fromHex(style.bg);
24
+ if (style.bold) out.bold = true;
25
+ if (style.italic) out.italic = true;
26
+ if (style.underline) out.underline = true;
27
+ if (style.dim) out.dim = true;
28
+ styles[token] = out;
29
+ }
30
+ return SyntaxStyle.fromStyles(styles);
31
+ }
71
32
  /**
72
- * Theme for markdown token highlighting. Token names map to Tree-sitter highlight
73
- * captures emitted by OpenTUI's markdown parser; the `default` entry is the
74
- * fallback for unstyled text.
33
+ * Active markdown / syntax-highlighting style, memoized per theme. Reading
34
+ * this in a component subscribes it to theme changes a `Settings.theme`
35
+ * flip immediately re-renders the affected `<markdown>` instances.
75
36
  */
76
- const MD_STYLE = SyntaxStyle.fromStyles({
77
- "default": { fg: RGBA.fromHex("#E6EDF3") },
78
- "markup.heading": {
79
- fg: RGBA.fromHex(COLOR.brand),
80
- bold: true
81
- },
82
- "markup.heading.1": {
83
- fg: RGBA.fromHex(COLOR.brand),
84
- bold: true
85
- },
86
- "markup.heading.2": {
87
- fg: RGBA.fromHex("#FFD84D"),
88
- bold: true
89
- },
90
- "markup.heading.3": {
91
- fg: RGBA.fromHex("#FFE680"),
92
- bold: true
93
- },
94
- "markup.bold": {
95
- fg: RGBA.fromHex("#FFFFFF"),
96
- bold: true
97
- },
98
- "markup.italic": {
99
- fg: RGBA.fromHex("#E6EDF3"),
100
- italic: true
101
- },
102
- "markup.link": {
103
- fg: RGBA.fromHex(COLOR.model),
104
- underline: true
105
- },
106
- "markup.link.url": {
107
- fg: RGBA.fromHex(COLOR.model),
108
- underline: true
109
- },
110
- "markup.list": { fg: RGBA.fromHex(COLOR.warn) },
111
- "markup.raw": { fg: RGBA.fromHex("#A5D6FF") },
112
- "markup.raw.block": { fg: RGBA.fromHex("#A5D6FF") },
113
- "markup.quote": {
114
- fg: RGBA.fromHex(COLOR.dim),
115
- italic: true
116
- },
117
- "punctuation": { fg: RGBA.fromHex(COLOR.mute) }
118
- });
37
+ function useMdStyle() {
38
+ const theme = useTheme();
39
+ return useMemo(() => buildMdStyle(theme), [theme]);
40
+ }
119
41
  //#endregion
120
42
  //#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
- }
142
43
  /**
143
44
  * Memoized so a flush that mutates only the trailing event doesn't force the
144
45
  * entire transcript to re-render. Each event holds a stable reference until
@@ -169,6 +70,7 @@ function onInputSubmit(handler) {
169
70
  return handler;
170
71
  }
171
72
  function Footer({ hints, picked, context }) {
73
+ const COLOR = useColors();
172
74
  return /* @__PURE__ */ jsxs("box", {
173
75
  style: {
174
76
  flexDirection: "row",
@@ -201,6 +103,7 @@ function Footer({ hints, picked, context }) {
201
103
  });
202
104
  }
203
105
  function ProviderBadge({ picked }) {
106
+ const COLOR = useColors();
204
107
  const source = picked.provider.methods[0].source;
205
108
  return /* @__PURE__ */ jsxs("text", {
206
109
  fg: COLOR.dim,
@@ -233,6 +136,7 @@ function ProviderBadge({ picked }) {
233
136
  });
234
137
  }
235
138
  function ContextIndicator({ context }) {
139
+ const COLOR = useColors();
236
140
  const ratio = context.max > 0 ? context.used / context.max : 0;
237
141
  const pct = Math.round(ratio * 100);
238
142
  const color = ratio >= .85 ? COLOR.error : ratio >= .6 ? COLOR.warn : COLOR.dim;
@@ -273,6 +177,7 @@ const SPINNER_FRAMES = [
273
177
  const SPINNER_INTERVAL_MS = 80;
274
178
  function Spinner({ label }) {
275
179
  const [frame, setFrame] = useState(0);
180
+ const COLOR = useColors();
276
181
  useEffect(() => {
277
182
  const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), SPINNER_INTERVAL_MS);
278
183
  return () => clearInterval(id);
@@ -388,6 +293,7 @@ function partitionTranscript(events, settings) {
388
293
  * nested subagents remain visually distinct.
389
294
  */
390
295
  function SubagentBlock({ events, previous }) {
296
+ const COLOR = useColors();
391
297
  const childIds = useMemo(() => {
392
298
  const set = /* @__PURE__ */ new Set();
393
299
  for (const e of events) if (e.childId) set.add(e.childId);
@@ -424,7 +330,7 @@ function EmptyState$1() {
424
330
  justifyContent: "center"
425
331
  },
426
332
  children: /* @__PURE__ */ jsx("text", {
427
- fg: COLOR.mute,
333
+ fg: useColors().mute,
428
334
  children: "no messages yet — type below to start"
429
335
  })
430
336
  });
@@ -502,6 +408,7 @@ function marginTopFor(event, previous) {
502
408
  return MARGIN_TOP[event.kind] ?? 0;
503
409
  }
504
410
  function EventLineImpl({ event, depthOffset = 0 }) {
411
+ const COLOR = useColors();
505
412
  const safeText = event.text === "" ? " " : event.text;
506
413
  const row = rowStyle(indentFor(Math.max(0, (event.depth ?? 0) - depthOffset)));
507
414
  const child = isChild(event);
@@ -592,6 +499,7 @@ function EventLineImpl({ event, depthOffset = 0 }) {
592
499
  }
593
500
  /** User prompt — bordered to rhyme with the prompt input box below. */
594
501
  function UserPromptBlock({ text }) {
502
+ const COLOR = useColors();
595
503
  return /* @__PURE__ */ jsx("box", {
596
504
  style: {
597
505
  border: true,
@@ -606,35 +514,43 @@ function UserPromptBlock({ text }) {
606
514
  });
607
515
  }
608
516
  /**
609
- * Markdown block. While `streaming` is true, content is passed through
610
- * `md4x.heal()` so unclosed delimiters (bold, italic, code, link, table) render
611
- * as if already complete. OpenTUI's `streaming` prop keeps its parser from
612
- * committing to the final layout for the trailing block.
517
+ * Markdown block. Renders either live-streaming markdown (with `streaming`
518
+ * on, while deltas are still appending) or finalized markdown (after
519
+ * `turn:after`, or every entry on a reloaded transcript).
520
+ *
521
+ * `internalBlockMode` is the load-bearing knob for layout: the OpenTUI
522
+ * default (`"coalesced"`) fuses adjacent top-level blocks into one render
523
+ * block, which is the right tradeoff for finalized markdown — fewer flex
524
+ * children, fewer layout passes, the parser already knows the final shape.
525
+ * During streaming, that same coalescing makes earlier paragraphs visually
526
+ * re-flow on every token, so we switch to `"top-level"` (each block its
527
+ * own renderable, only the trailing one is unstable).
613
528
  *
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.
529
+ * `internalBlockMode` is set only at construction by OpenTUI — there's no
530
+ * setter so a `<MarkdownBlock>` keeps whichever mode it was born with.
531
+ * Live blocks start `streaming=true` top-level; reloaded blocks start
532
+ * `streaming=false` coalesced. Each variant stays optimal for its
533
+ * lifecycle.
621
534
  *
622
- * `alignSelf: 'stretch'` pins the markdown to the parent box's content
623
- * width so its wrap column doesn't drift between renders.
535
+ * Note: we don't pre-process unclosed delimiters. OpenTUI's markdown
536
+ * parser already renders partial input reasonably during streaming (the
537
+ * trailing block reflows as tokens close), and the simplicity is worth
538
+ * accepting a brief literal `**` before the closer arrives. Persisted
539
+ * reloads come from completed assistant turns whose markdown is closed.
624
540
  */
625
541
  function MarkdownBlock({ text, streaming, dim }) {
542
+ const COLOR = useColors();
626
543
  return /* @__PURE__ */ jsx("markdown", {
627
- content: useMemo(() => streaming ? safeHeal(text) : text, [text, streaming]),
628
- syntaxStyle: MD_STYLE,
544
+ content: text,
545
+ syntaxStyle: useMdStyle(),
629
546
  streaming,
630
- internalBlockMode: "top-level",
631
- fg: dim ? COLOR.dim : void 0,
632
- alignSelf: "stretch",
633
- flexShrink: 0
547
+ internalBlockMode: streaming ? "top-level" : "coalesced",
548
+ fg: dim ? COLOR.dim : void 0
634
549
  });
635
550
  }
636
551
  const TOOL_RESULT_MAX_LINES = 6;
637
552
  function ToolResultBlock({ text, indent }) {
553
+ const COLOR = useColors();
638
554
  const lines = text.split("\n");
639
555
  const visible = lines.slice(0, TOOL_RESULT_MAX_LINES);
640
556
  const omitted = Math.max(0, lines.length - TOOL_RESULT_MAX_LINES);
@@ -659,571 +575,6 @@ function ToolResultBlock({ text, indent }) {
659
575
  });
660
576
  }
661
577
  //#endregion
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-…"
699
- };
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-…"
707
- };
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
722
- };
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
- }
749
- }
750
- //#endregion
751
- //#region src/tui/credentials.ts
752
- /** POSIX mode for the credentials file. Ignored on Windows. */
753
- const FILE_MODE = 384;
754
- /**
755
- * Resolve the credentials file path given the resolved TUI data directory
756
- * (typically `~/.zidane`, i.e. `config.paths.dir`).
757
- *
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.
766
- *
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`).
771
- */
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)];
791
- }
792
- /**
793
- * Write credentials atomically (write-then-rename) with mode 0o600.
794
- *
795
- * Atomic on the same filesystem — readers 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).
798
- */
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
- }
818
- /**
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.
830
- */
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;
863
- try {
864
- legacy = JSON.parse(readFileSync(legacyPath, "utf-8"));
865
- } catch {
866
- return null;
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
- });
933
- }
934
- //#endregion
935
- //#region src/tui/store.ts
936
- function ensureDir$1(path) {
937
- const dir = dirname(path);
938
- if (existsSync(dir)) return;
939
- try {
940
- mkdirSync(dir, { recursive: true });
941
- } catch (err) {
942
- const message = err instanceof Error ? err.message : String(err);
943
- throw new Error(`Could not create TUI storage directory at "${dir}". Override the location via \`runTui({ storageDir, prefix })\` or the \`ZIDANE_STORAGE_DIR\` env var. Original error: ${message}`);
944
- }
945
- }
946
- function createTuiStore(dbPath) {
947
- ensureDir$1(dbPath);
948
- return createSqliteStore({ path: dbPath });
949
- }
950
- function createStateStore(path) {
951
- return {
952
- load: () => loadState(path),
953
- save: (state) => saveState(path, state)
954
- };
955
- }
956
- function loadState(path) {
957
- if (!existsSync(path)) return {};
958
- try {
959
- const parsed = JSON.parse(readFileSync(path, "utf-8"));
960
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
961
- } catch {}
962
- return {};
963
- }
964
- function saveState(path, state) {
965
- ensureDir$1(path);
966
- const tmp = `${path}.${process.pid}.tmp`;
967
- writeFileSync(tmp, JSON.stringify(state, null, 2));
968
- renameSync(tmp, path);
969
- }
970
- /**
971
- * Load every session and project it to the compact `SessionMeta` shape used by
972
- * the picker. Sorted by recency via the underlying store's `list()` contract
973
- * (sqlite store returns by `updated_at DESC`).
974
- */
975
- async function listSessionMeta(store) {
976
- const ids = await store.list();
977
- return (await Promise.all(ids.map(async (id) => {
978
- const data = await store.load(id);
979
- if (!data) return null;
980
- return {
981
- id,
982
- title: titleFromTurns(data.turns) ?? "untitled",
983
- turnCount: data.turns.length,
984
- updatedAt: data.updatedAt
985
- };
986
- }))).filter((m) => m !== null);
987
- }
988
- /** Derive a short title from the first user message — returns null when empty. */
989
- function titleFromTurns(turns) {
990
- const first = turns.find((t) => t.role === "user");
991
- if (!first) return null;
992
- for (const block of first.content) if (block.type === "text" && block.text.trim()) {
993
- const oneLine = block.text.replace(/\s+/g, " ").trim();
994
- return oneLine.length > 60 ? `${oneLine.slice(0, 60)}…` : oneLine;
995
- }
996
- return null;
997
- }
998
- /**
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.
1012
- *
1013
- * Without `runs` (legacy callers / tests), the function falls back to the
1014
- * old behavior: depth-0 events with no subagent grouping.
1015
- */
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
- }
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
- };
1060
- for (let i = 0; i < turns.length; i++) {
1061
- const turn = turns[i];
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({
1077
- kind: "separator",
1078
- text: ""
1079
- });
1080
- if (turn.role === "user") {
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
- }
1097
- continue;
1098
- }
1099
- if (turn.role === "assistant") {
1100
- for (const block of turn.content) if (block.type === "text" && block.text.trim()) events.push({
1101
- kind: "markdown",
1102
- text: block.text,
1103
- streaming: false,
1104
- ...tag
1105
- });
1106
- else if (block.type === "tool_call") events.push({
1107
- kind: "tool",
1108
- text: toolCallPreview(block.name, block.input),
1109
- tool: block.name,
1110
- ...tag
1111
- });
1112
- }
1113
- }
1114
- closeRun(lastRunId, lastDepth);
1115
- return events;
1116
- }
1117
- /** Shared formatter for the `↳ name(args)` line shown on tool calls. */
1118
- function toolCallPreview(name, input) {
1119
- const args = JSON.stringify(input);
1120
- return args && args !== "{}" ? `${name}(${args})` : name;
1121
- }
1122
- /** Render tool output as plain text, whether it's a string or structured content. */
1123
- function toolResultText(output) {
1124
- return typeof output === "string" ? output : toolResultToText(output);
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
- }
1142
- /** Effective context size of the most recent assistant turn — drives the footer indicator. */
1143
- function lastContextSizeFromTurns(turns) {
1144
- for (let i = turns.length - 1; i >= 0; i--) {
1145
- const turn = turns[i];
1146
- if (turn.role === "assistant" && turn.usage) return (turn.usage.input ?? 0) + (turn.usage.cacheRead ?? 0) + (turn.usage.cacheCreation ?? 0);
1147
- }
1148
- return 0;
1149
- }
1150
- //#endregion
1151
- //#region src/tui/config.tsx
1152
- /** Resolve user options into a fully-bound runtime config. Pure aside from disk reads. */
1153
- function resolveConfig(options = {}) {
1154
- const prefix = options.prefix ?? process.env.ZIDANE_PREFIX ?? ".zidane";
1155
- const storageDir = options.storageDir ?? process.env.ZIDANE_STORAGE_DIR ?? homedir();
1156
- const dir = resolve(storageDir, prefix);
1157
- const paths = {
1158
- dir,
1159
- db: resolve(dir, "sessions.db"),
1160
- state: resolve(dir, "state.json")
1161
- };
1162
- const store = options.store ?? createTuiStore(paths.db);
1163
- const stateStore = createStateStore(paths.state);
1164
- const initialState = stateStore.load();
1165
- const providers = options.providers ?? BUILTIN_PROVIDERS;
1166
- const preset = options.preset ?? basic_default;
1167
- process.env.ZIDANE_CREDENTIALS_PATH = credentialsPath(dir);
1168
- applyApiKeyEnv(dir, providers);
1169
- const modelsFor = makeModelsResolver(providers);
1170
- const resumeProvider = resolveResumeProvider(initialState, providers, dir);
1171
- const initialPicked = resumeProvider ? pickInitial(resumeProvider, providers, initialState) : null;
1172
- return {
1173
- prefix,
1174
- storageDir,
1175
- paths,
1176
- providers,
1177
- preset,
1178
- store,
1179
- stateStore,
1180
- modelsFor,
1181
- initialState,
1182
- initialSettings: initialState.settings ?? {},
1183
- resumeProvider,
1184
- initialPicked
1185
- };
1186
- }
1187
- function makeModelsResolver(registry) {
1188
- return (key) => {
1189
- const descriptor = registry[key];
1190
- return descriptor ? modelsForDescriptor(descriptor) : [];
1191
- };
1192
- }
1193
- function resolveResumeProvider(state, providers, storageDir) {
1194
- if (!state.lastProvider) return null;
1195
- if (!providers[state.lastProvider]) return null;
1196
- return detectAuth(storageDir, providers).find((p) => p.key === state.lastProvider && p.available) ?? null;
1197
- }
1198
- function pickInitial(auth, providers, state) {
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 ? {
1203
- provider: auth,
1204
- model
1205
- } : null;
1206
- }
1207
- function safeFactoryDefault(descriptor) {
1208
- try {
1209
- return descriptor.factory().meta.defaultModel;
1210
- } catch {
1211
- return;
1212
- }
1213
- }
1214
- const ConfigContext = createContext(null);
1215
- function ConfigProvider({ config, children }) {
1216
- return /* @__PURE__ */ jsx(ConfigContext.Provider, {
1217
- value: config,
1218
- children
1219
- });
1220
- }
1221
- function useConfig() {
1222
- const ctx = useContext(ConfigContext);
1223
- if (!ctx) throw new Error("useConfig must be used inside <ConfigProvider>");
1224
- return ctx;
1225
- }
1226
- //#endregion
1227
578
  //#region src/tui/modal.tsx
1228
579
  const ModalContext = createContext(null);
1229
580
  function ModalRoot({ children }) {
@@ -1289,6 +640,8 @@ function useModalAwareFocus(preferred = true) {
1289
640
  function Modal({ title, onClose, children, maxWidth = 92, minWidth = 44, horizontalMargin = 4 }) {
1290
641
  const ctx = useContext(ModalContext);
1291
642
  const dismiss = onClose ?? ctx?.close;
643
+ const COLOR = useColors();
644
+ const SURFACE = useSurfaces();
1292
645
  useKeyboard((key) => {
1293
646
  if (key.name === "escape") dismiss?.();
1294
647
  });
@@ -1299,7 +652,7 @@ function Modal({ title, onClose, children, maxWidth = 92, minWidth = 44, horizon
1299
652
  style: {
1300
653
  border: true,
1301
654
  borderColor: COLOR.borderActive,
1302
- backgroundColor: "#101010",
655
+ backgroundColor: SURFACE.modal,
1303
656
  paddingTop: 1,
1304
657
  paddingBottom: 1,
1305
658
  paddingLeft: 2,
@@ -1324,6 +677,8 @@ const VISIBLE_ROW_CAP = 12;
1324
677
  * Each row shows: `● selected · name (ctx N · reasoning · vision)`.
1325
678
  */
1326
679
  function ModelPickerModal({ models, currentModelId, onPick }) {
680
+ const COLOR = useColors();
681
+ const SELECT_THEME = useSelectStyle();
1327
682
  const initialIndex = useMemo(() => models.findIndex((m) => m.id === currentModelId), [models, currentModelId]);
1328
683
  const options = useMemo(() => models.map((m) => ({
1329
684
  name: `${m.id === currentModelId ? "● " : " "}${m.name ?? m.id}`,
@@ -1377,6 +732,7 @@ function ModelPickerModal({ models, currentModelId, onPick }) {
1377
732
  });
1378
733
  }
1379
734
  function EmptyState() {
735
+ const COLOR = useColors();
1380
736
  return /* @__PURE__ */ jsxs(Modal, {
1381
737
  title: "select model",
1382
738
  children: [/* @__PURE__ */ jsx("text", {
@@ -1408,286 +764,6 @@ function describeModel(m) {
1408
764
  return parts.join(" · ");
1409
765
  }
1410
766
  //#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
1691
767
  //#region src/tui/screens.tsx
1692
768
  /**
1693
769
  * Build a key-binding set for the prompt textarea / API-key input. Strips the
@@ -1734,6 +810,8 @@ function AuthScreen({ onPick }) {
1734
810
  const config = useConfig();
1735
811
  const { providers: registry } = config;
1736
812
  const focused = useModalAwareFocus();
813
+ const COLOR = useColors();
814
+ const SELECT_THEME = useSelectStyle();
1737
815
  const [providers, setProviders] = useState([]);
1738
816
  const refresh = useCallback(() => setProviders(detectAuth(config.paths.dir, registry)), [config.paths.dir, registry]);
1739
817
  useEffect(() => {
@@ -1864,12 +942,13 @@ function SetupWizard({ registry, dataDir, onConfigured, onCancel }) {
1864
942
  * with a customizable title and accent color. Footnote slot at the bottom for
1865
943
  * an error banner.
1866
944
  */
1867
- function WizardPanel({ title, accent = COLOR.border, error, children }) {
945
+ function WizardPanel({ title, accent, error, children }) {
946
+ const COLOR = useColors();
1868
947
  return /* @__PURE__ */ jsxs("box", {
1869
948
  title,
1870
949
  style: {
1871
950
  border: true,
1872
- borderColor: accent,
951
+ borderColor: accent ?? COLOR.border,
1873
952
  padding: 1,
1874
953
  gap: 1,
1875
954
  flexDirection: "column",
@@ -1884,11 +963,12 @@ function WizardPanel({ title, accent = COLOR.border, error, children }) {
1884
963
  /** "esc to exit" footer hint shared by every wizard step that doesn't offer a "← back" affordance. */
1885
964
  function WizardEscHint() {
1886
965
  return /* @__PURE__ */ jsx("text", {
1887
- fg: COLOR.dim,
966
+ fg: useColors().dim,
1888
967
  children: "esc to exit"
1889
968
  });
1890
969
  }
1891
970
  function EmptyRegistryNotice() {
971
+ const COLOR = useColors();
1892
972
  return /* @__PURE__ */ jsxs(WizardPanel, {
1893
973
  title: " no providers configured ",
1894
974
  accent: COLOR.error,
@@ -1917,6 +997,8 @@ function EmptyRegistryNotice() {
1917
997
  const WIZARD_BACK_VALUE = "__back__";
1918
998
  function PickProviderStep({ descriptors, error, onPick, onCancel }) {
1919
999
  const focused = useModalAwareFocus();
1000
+ const COLOR = useColors();
1001
+ const SELECT_THEME = useSelectStyle();
1920
1002
  const options = [...descriptors.map((d) => {
1921
1003
  const methods = supportsOAuth(d) ? ["API key", "OAuth"] : ["API key"];
1922
1004
  return {
@@ -1962,6 +1044,7 @@ function PickProviderStep({ descriptors, error, onPick, onCancel }) {
1962
1044
  }
1963
1045
  function PickMethodStep({ descriptor, error, onPick }) {
1964
1046
  const focused = useModalAwareFocus();
1047
+ const SELECT_THEME = useSelectStyle();
1965
1048
  const options = useMemo(() => {
1966
1049
  const items = [{
1967
1050
  name: "API key",
@@ -1996,6 +1079,7 @@ function PickMethodStep({ descriptor, error, onPick }) {
1996
1079
  function EnterApiKeyStep({ descriptor, error, onSubmit }) {
1997
1080
  const focused = useModalAwareFocus();
1998
1081
  const inputRef = useRef(null);
1082
+ const COLOR = useColors();
1999
1083
  const submit = useCallback(() => {
2000
1084
  onSubmit(descriptor, inputRef.current?.value ?? "");
2001
1085
  }, [descriptor, onSubmit]);
@@ -2036,6 +1120,7 @@ function EnterApiKeyStep({ descriptor, error, onSubmit }) {
2036
1120
  function OAuthRunningStep({ descriptor, dataDir, onSuccess, onError }) {
2037
1121
  const [url, setUrl] = useState(null);
2038
1122
  const [status, setStatus] = useState("starting browser…");
1123
+ const COLOR = useColors();
2039
1124
  useEffect(() => {
2040
1125
  const ac = new AbortController();
2041
1126
  let cancelled = false;
@@ -2097,6 +1182,8 @@ function OAuthRunningStep({ descriptor, dataDir, onSuccess, onError }) {
2097
1182
  const NEW_VALUE = "__new__";
2098
1183
  function SessionsScreen({ sessions, currentId, onPick, onCreate }) {
2099
1184
  const focused = useModalAwareFocus();
1185
+ const COLOR = useColors();
1186
+ const SELECT_THEME = useSelectStyle();
2100
1187
  const options = useMemo(() => {
2101
1188
  const items = [{
2102
1189
  name: "+ new session",
@@ -2141,6 +1228,7 @@ function SessionsScreen({ sessions, currentId, onPick, onCreate }) {
2141
1228
  const MIN_CONTENT_LINES = 1;
2142
1229
  const MAX_CONTENT_LINES = 5;
2143
1230
  function ChatScreen({ events, busy, settings, onSubmit, session, pending, onApproval }) {
1231
+ const COLOR = useColors();
2144
1232
  const title = useMemo(() => {
2145
1233
  if (!session) return " untitled ";
2146
1234
  const turns = `${session.turnCount} turn${session.turnCount === 1 ? "" : "s"}`;
@@ -2224,6 +1312,8 @@ function formatApprovalArgs(input) {
2224
1312
  */
2225
1313
  function ApprovalBlock({ request, onPick }) {
2226
1314
  const focused = useModalAwareFocus();
1315
+ const COLOR = useColors();
1316
+ const SELECT_THEME = useSelectStyle();
2227
1317
  const summary = useMemo(() => `${request.tool}(${formatApprovalArgs(request.input)})`, [request.tool, request.input]);
2228
1318
  const options = useMemo(() => {
2229
1319
  return [
@@ -2292,7 +1382,7 @@ function BusyBlock() {
2292
1382
  return /* @__PURE__ */ jsx("box", {
2293
1383
  style: {
2294
1384
  border: true,
2295
- borderColor: COLOR.warn,
1385
+ borderColor: useColors().warn,
2296
1386
  paddingLeft: 1,
2297
1387
  paddingRight: 1,
2298
1388
  height: 3
@@ -2302,6 +1392,7 @@ function BusyBlock() {
2302
1392
  }
2303
1393
  function PromptBlock({ userPrompts, onSubmit }) {
2304
1394
  const focused = useModalAwareFocus();
1395
+ const COLOR = useColors();
2305
1396
  const textareaRef = useRef(null);
2306
1397
  /** Auto-grow: visible content rows the textarea currently occupies (clamped 1..5). */
2307
1398
  const [contentLines, setContentLines] = useState(MIN_CONTENT_LINES);
@@ -2381,77 +1472,20 @@ function PromptBlock({ userPrompts, onSubmit }) {
2381
1472
  });
2382
1473
  }
2383
1474
  //#endregion
2384
- //#region src/tui/settings.tsx
2385
- const DEFAULT_SETTINGS = {
2386
- showThinking: true,
2387
- showToolCalls: true,
2388
- showToolResults: true,
2389
- safeMode: true,
2390
- hideSubagentOutput: true
2391
- };
2392
- const SettingsContext = createContext(null);
2393
- function SettingsProvider({ initial, onChange, children }) {
2394
- const [settings, setSettings] = useState(initial);
2395
- const toggle = useCallback((key) => {
2396
- setSettings((prev) => {
2397
- const next = {
2398
- ...prev,
2399
- [key]: !prev[key]
2400
- };
2401
- onChange?.(next);
2402
- return next;
2403
- });
2404
- }, [onChange]);
2405
- const value = useMemo(() => ({
2406
- settings,
2407
- toggle
2408
- }), [settings, toggle]);
2409
- return /* @__PURE__ */ jsx(SettingsContext.Provider, {
2410
- value,
2411
- children
2412
- });
2413
- }
2414
- function useSettings() {
2415
- const ctx = useContext(SettingsContext);
2416
- if (!ctx) throw new Error("useSettings must be used inside <SettingsProvider>");
2417
- return ctx;
2418
- }
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
- },
2432
- {
2433
- kind: "toggle",
2434
- key: "showThinking",
2435
- label: "Thinking blocks",
2436
- description: "agent reasoning shown inline"
2437
- },
2438
- {
2439
- kind: "toggle",
2440
- key: "showToolCalls",
2441
- label: "Tool calls",
2442
- description: "the ↳ name(args) lines"
2443
- },
2444
- {
2445
- kind: "toggle",
2446
- key: "showToolResults",
2447
- label: "Tool outputs",
2448
- description: "the ┃ result blocks under tool calls"
2449
- }
2450
- ];
1475
+ //#region src/tui/settings-modal.tsx
2451
1476
  function SettingsModal({ actions } = {}) {
2452
- const { settings, toggle } = useSettings();
1477
+ const { settings, toggle, setSetting } = useSettings();
2453
1478
  const [cursor, setCursorRaw] = useState(0);
1479
+ const COLOR = useColors();
2454
1480
  const items = useMemo(() => {
1481
+ const toggleItems = SETTINGS_TOGGLES.map((t) => ({
1482
+ kind: "toggle",
1483
+ ...t
1484
+ }));
1485
+ const choiceItems = SETTINGS_CHOICES.map((c) => ({
1486
+ kind: "choice",
1487
+ ...c
1488
+ }));
2455
1489
  const actionItems = [];
2456
1490
  if (actions?.onReauth) actionItems.push({
2457
1491
  kind: "action",
@@ -2460,7 +1494,11 @@ function SettingsModal({ actions } = {}) {
2460
1494
  description: "switch provider, add another, or re-authenticate",
2461
1495
  onPick: actions.onReauth
2462
1496
  });
2463
- return [...TOGGLES, ...actionItems];
1497
+ return [
1498
+ ...toggleItems,
1499
+ ...choiceItems,
1500
+ ...actionItems
1501
+ ];
2464
1502
  }, [actions]);
2465
1503
  const safeCursor = Math.min(cursor, items.length - 1);
2466
1504
  const setCursor = useCallback((update) => setCursorRaw((prev) => Math.min(Math.max(0, update(prev)), items.length - 1)), [items.length]);
@@ -2471,33 +1509,49 @@ function SettingsModal({ actions } = {}) {
2471
1509
  const item = items[safeCursor];
2472
1510
  if (!item) return;
2473
1511
  if (item.kind === "toggle") toggle(item.key);
2474
- else item.onPick();
1512
+ else if (item.kind === "choice") {
1513
+ const current = settings[item.key];
1514
+ const idx = item.options.findIndex((o) => o.value === current);
1515
+ const next = item.options[(idx + 1) % item.options.length];
1516
+ if (next) setSetting(item.key, next.value);
1517
+ } else item.onPick();
2475
1518
  }
2476
1519
  });
2477
- const firstActionIndex = items.findIndex((i) => i.kind === "action");
1520
+ const firstNonToggleIndex = items.findIndex((i) => i.kind !== "toggle");
2478
1521
  return /* @__PURE__ */ jsxs(Modal, {
2479
1522
  title: "settings",
2480
1523
  children: [/* @__PURE__ */ jsx("box", {
2481
1524
  style: { flexDirection: "column" },
2482
1525
  children: items.map((item, i) => /* @__PURE__ */ jsxs("box", {
2483
1526
  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))
1527
+ children: [
1528
+ i === firstNonToggleIndex && i > 0 && /* @__PURE__ */ jsx("box", { style: {
1529
+ border: ["top"],
1530
+ borderColor: COLOR.mute,
1531
+ height: 1,
1532
+ marginTop: 1,
1533
+ marginBottom: 1
1534
+ } }),
1535
+ item.kind === "toggle" && /* @__PURE__ */ jsx(ToggleRow, {
1536
+ label: item.label,
1537
+ description: item.description,
1538
+ enabled: settings[item.key],
1539
+ focused: i === safeCursor
1540
+ }),
1541
+ item.kind === "choice" && /* @__PURE__ */ jsx(ChoiceRow, {
1542
+ label: item.label,
1543
+ description: item.description,
1544
+ value: item.options.find((o) => o.value === settings[item.key])?.label ?? String(settings[item.key]),
1545
+ cyclable: item.options.length > 1,
1546
+ focused: i === safeCursor
1547
+ }),
1548
+ item.kind === "action" && /* @__PURE__ */ jsx(ActionRow, {
1549
+ label: item.label,
1550
+ description: item.description,
1551
+ focused: i === safeCursor
1552
+ })
1553
+ ]
1554
+ }, item.kind === "action" ? item.id : item.key))
2501
1555
  }), /* @__PURE__ */ jsxs("text", {
2502
1556
  fg: COLOR.mute,
2503
1557
  children: [
@@ -2510,7 +1564,7 @@ function SettingsModal({ actions } = {}) {
2510
1564
  fg: COLOR.warn,
2511
1565
  children: "↵"
2512
1566
  }),
2513
- firstActionIndex >= 0 ? " toggle/select · " : " toggle · ",
1567
+ " toggle/cycle/select · ",
2514
1568
  /* @__PURE__ */ jsx("span", {
2515
1569
  fg: COLOR.warn,
2516
1570
  children: "esc"
@@ -2528,6 +1582,7 @@ function SettingsModal({ actions } = {}) {
2528
1582
  * the trailing description wraps under the label without breaking the row.
2529
1583
  */
2530
1584
  function ToggleRow({ label, description, enabled, focused }) {
1585
+ const COLOR = useColors();
2531
1586
  return /* @__PURE__ */ jsxs("text", {
2532
1587
  fg: focused ? COLOR.brand : COLOR.dim,
2533
1588
  children: [
@@ -2551,6 +1606,46 @@ function ToggleRow({ label, description, enabled, focused }) {
2551
1606
  });
2552
1607
  }
2553
1608
  /**
1609
+ * Choice row — `▶` marker · label · `:` · current value · description.
1610
+ *
1611
+ * Cycles through `options` on enter/space. When only one option is
1612
+ * available (`cyclable=false`) the row still renders with the current
1613
+ * value but the enter handler is a no-op — we surface this via the absence
1614
+ * of the trailing `›` affordance so it visually reads as informational.
1615
+ */
1616
+ function ChoiceRow({ label, description, value, cyclable, focused }) {
1617
+ const COLOR = useColors();
1618
+ return /* @__PURE__ */ jsxs("text", {
1619
+ fg: focused ? COLOR.brand : COLOR.dim,
1620
+ children: [
1621
+ /* @__PURE__ */ jsx("span", {
1622
+ fg: focused ? COLOR.brand : COLOR.mute,
1623
+ children: focused ? "▶ " : " "
1624
+ }),
1625
+ /* @__PURE__ */ jsx("span", {
1626
+ fg: focused ? COLOR.brand : COLOR.dim,
1627
+ children: label
1628
+ }),
1629
+ /* @__PURE__ */ jsx("span", {
1630
+ fg: COLOR.mute,
1631
+ children: ": "
1632
+ }),
1633
+ /* @__PURE__ */ jsx("span", {
1634
+ fg: focused ? COLOR.brand : COLOR.accent,
1635
+ children: value
1636
+ }),
1637
+ /* @__PURE__ */ jsx("span", {
1638
+ fg: COLOR.mute,
1639
+ children: ` ${description}`
1640
+ }),
1641
+ focused && cyclable && /* @__PURE__ */ jsx("span", {
1642
+ fg: COLOR.brand,
1643
+ children: " ↻"
1644
+ })
1645
+ ]
1646
+ });
1647
+ }
1648
+ /**
2554
1649
  * Action row — cursor marker · label · description · (focus-only) trailing arrow.
2555
1650
  *
2556
1651
  * The label sits in the same column as a toggle row's `[✓]` checkbox (right
@@ -2559,6 +1654,7 @@ function ToggleRow({ label, description, enabled, focused }) {
2559
1654
  * every action.
2560
1655
  */
2561
1656
  function ActionRow({ label, description, focused }) {
1657
+ const COLOR = useColors();
2562
1658
  return /* @__PURE__ */ jsxs("text", {
2563
1659
  fg: focused ? COLOR.brand : COLOR.dim,
2564
1660
  children: [
@@ -2582,170 +1678,6 @@ function ActionRow({ label, description, focused }) {
2582
1678
  });
2583
1679
  }
2584
1680
  //#endregion
2585
- //#region src/tui/streaming.ts
2586
- /** Target one flush per ~33ms (one frame at the default renderer targetFps=30). */
2587
- const FLUSH_INTERVAL_MS = 33;
2588
- const PARENT_OWNER = "parent";
2589
- function emptyBucket(owner, depth) {
2590
- return {
2591
- markdown: "",
2592
- thinking: "",
2593
- owner,
2594
- depth
2595
- };
2596
- }
2597
- function applyBucket(prev, bucket) {
2598
- let result = prev;
2599
- if (bucket.thinking) result = appendThinkingLines(result, bucket.thinking, bucket.owner, bucket.depth);
2600
- if (bucket.markdown) result = appendMarkdownDelta(result, bucket.markdown, bucket.owner, bucket.depth);
2601
- return result;
2602
- }
2603
- function appendMarkdownDelta(prev, delta, owner, depth) {
2604
- const last = prev[prev.length - 1];
2605
- if (last && last.kind === "markdown" && last.streaming && ownerOf(last) === owner) {
2606
- const next = prev.slice(0, -1);
2607
- next.push({
2608
- ...last,
2609
- text: last.text + delta
2610
- });
2611
- return next;
2612
- }
2613
- return [...prev, eventWithOwner({
2614
- kind: "markdown",
2615
- text: delta,
2616
- streaming: true
2617
- }, owner, depth)];
2618
- }
2619
- function appendThinkingLines(prev, delta, owner, depth) {
2620
- const lines = delta.split("\n");
2621
- const result = [...prev];
2622
- const last = result[result.length - 1];
2623
- if (last && last.kind === "thinking" && ownerOf(last) === owner) result[result.length - 1] = {
2624
- ...last,
2625
- text: last.text + lines[0]
2626
- };
2627
- else if (lines[0] || lines.length > 1) result.push(eventWithOwner({
2628
- kind: "thinking",
2629
- text: lines[0]
2630
- }, owner, depth));
2631
- for (let i = 1; i < lines.length; i++) result.push(eventWithOwner({
2632
- kind: "thinking",
2633
- text: lines[i]
2634
- }, owner, depth));
2635
- return result;
2636
- }
2637
- function ownerOf(evt) {
2638
- return evt.childId ?? PARENT_OWNER;
2639
- }
2640
- function eventWithOwner(evt, owner, depth) {
2641
- if (owner === PARENT_OWNER) return evt;
2642
- return {
2643
- ...evt,
2644
- childId: owner,
2645
- depth
2646
- };
2647
- }
2648
- /** Flip any trailing streaming markdown blocks (any owner) to finalized. */
2649
- function finalizeStreamingMarkdown(events) {
2650
- let changed = false;
2651
- const next = events.map((e) => {
2652
- if (e.kind === "markdown" && e.streaming) {
2653
- changed = true;
2654
- return {
2655
- ...e,
2656
- streaming: false
2657
- };
2658
- }
2659
- return e;
2660
- });
2661
- return changed ? next : events;
2662
- }
2663
- /** Flip the trailing streaming markdown block for one specific owner. */
2664
- function finalizeStreamingMarkdownForOwner(events, owner) {
2665
- for (let i = events.length - 1; i >= 0; i--) {
2666
- const e = events[i];
2667
- if (e.kind !== "markdown") continue;
2668
- if (!e.streaming) continue;
2669
- if (ownerOf(e) !== owner) continue;
2670
- const next = events.slice();
2671
- next[i] = {
2672
- ...e,
2673
- streaming: false
2674
- };
2675
- return next;
2676
- }
2677
- return events;
2678
- }
2679
- /**
2680
- * Effective context size for a single turn.
2681
- *
2682
- * `usage.input` is misleading on its own when prompt caching is active: providers
2683
- * (Anthropic, OpenRouter→Anthropic, Gemini) report `input` as the *new uncached*
2684
- * tokens only — the cached prefix shows up in `cacheRead`, and newly-cached
2685
- * tokens in `cacheCreation`. The model still saw all three buckets, so the real
2686
- * context-window utilization is their sum.
2687
- *
2688
- * Non-caching providers leave `cacheRead`/`cacheCreation` undefined, so this
2689
- * collapses to plain `input` for them.
2690
- */
2691
- function turnContextSize(usage) {
2692
- if (!usage) return 0;
2693
- return (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheCreation ?? 0);
2694
- }
2695
- function useStreamBuffer(setEvents) {
2696
- const bucketsRef = useRef(/* @__PURE__ */ new Map());
2697
- const flushTimerRef = useRef(null);
2698
- const drainPendingInto = useCallback((updater) => {
2699
- if (flushTimerRef.current) {
2700
- clearTimeout(flushTimerRef.current);
2701
- flushTimerRef.current = null;
2702
- }
2703
- const buckets = Array.from(bucketsRef.current.values());
2704
- bucketsRef.current.clear();
2705
- if (!buckets.some((b) => b.markdown.length > 0 || b.thinking.length > 0) && !updater) return;
2706
- setEvents((prev) => {
2707
- let merged = prev;
2708
- for (const bucket of buckets) merged = applyBucket(merged, bucket);
2709
- return updater ? updater(merged) : merged;
2710
- });
2711
- }, [setEvents]);
2712
- const flush = useCallback(() => drainPendingInto(), [drainPendingInto]);
2713
- const flushAndUpdate = useCallback((update) => drainPendingInto(update), [drainPendingInto]);
2714
- const appendImmediate = useCallback((evt) => drainPendingInto((events) => [...events, evt]), [drainPendingInto]);
2715
- const queueStreamDelta = useCallback((kind, delta, source) => {
2716
- if (!delta) return;
2717
- const owner = source?.childId ?? PARENT_OWNER;
2718
- const depth = source?.depth ?? 0;
2719
- let bucket = bucketsRef.current.get(owner);
2720
- if (!bucket) {
2721
- bucket = emptyBucket(owner, depth);
2722
- bucketsRef.current.set(owner, bucket);
2723
- }
2724
- bucket[kind] += delta;
2725
- if (!flushTimerRef.current) flushTimerRef.current = setTimeout(flush, FLUSH_INTERVAL_MS);
2726
- }, [flush]);
2727
- const reset = useCallback(() => {
2728
- if (flushTimerRef.current) {
2729
- clearTimeout(flushTimerRef.current);
2730
- flushTimerRef.current = null;
2731
- }
2732
- bucketsRef.current.clear();
2733
- }, []);
2734
- return useMemo(() => ({
2735
- queueStreamDelta,
2736
- appendImmediate,
2737
- flushAndUpdate,
2738
- flush,
2739
- reset
2740
- }), [
2741
- queueStreamDelta,
2742
- appendImmediate,
2743
- flushAndUpdate,
2744
- flush,
2745
- reset
2746
- ]);
2747
- }
2748
- //#endregion
2749
1681
  //#region src/tui/app.tsx
2750
1682
  /**
2751
1683
  * Surface failures that are normally silenced (teardown / save) when the
@@ -2773,10 +1705,26 @@ function App({ config }) {
2773
1705
  ...config.stateStore.load(),
2774
1706
  settings
2775
1707
  }), [config.stateStore]),
2776
- children: /* @__PURE__ */ jsx(SafeModeProvider, { children: /* @__PURE__ */ jsx(ModalRoot, { children: /* @__PURE__ */ jsx(AppShell, {}) }) })
1708
+ children: /* @__PURE__ */ jsx(ThemedShell, {})
2777
1709
  })
2778
1710
  });
2779
1711
  }
1712
+ /**
1713
+ * Reads `settings.theme` from the surrounding `SettingsProvider`, resolves
1714
+ * it to a `Theme`, and mounts everything else underneath a `ThemeProvider`.
1715
+ * Split out so `App` doesn't need to call `useSettings` (which would force
1716
+ * it to live inside its own provider — invalid).
1717
+ *
1718
+ * `resolveTheme` falls back to `DEFAULT_THEME` on unknown ids, so an
1719
+ * out-of-date `state.json` (theme renamed / removed) never breaks rendering.
1720
+ */
1721
+ function ThemedShell() {
1722
+ const { settings } = useSettings();
1723
+ return /* @__PURE__ */ jsx(ThemeProvider, {
1724
+ theme: useMemo(() => resolveTheme(settings.theme), [settings.theme]),
1725
+ children: /* @__PURE__ */ jsx(SafeModeProvider, { children: /* @__PURE__ */ jsx(ModalRoot, { children: /* @__PURE__ */ jsx(AppShell, {}) }) })
1726
+ });
1727
+ }
2780
1728
  function AppShell() {
2781
1729
  const renderer = useRenderer();
2782
1730
  const modal = useModal();
@@ -3285,6 +2233,101 @@ function buildHints(screen, busy, pending, currentSession) {
3285
2233
  ];
3286
2234
  }
3287
2235
  //#endregion
2236
+ //#region src/tui/tree-sitter.ts
2237
+ /**
2238
+ * Register Tree-sitter parsers for the languages we'd like highlighted
2239
+ * inside fenced markdown code blocks.
2240
+ *
2241
+ * OpenTUI ships JS/TS/Markdown/Zig out of the box. Anything else needs a
2242
+ * Tree-sitter `.wasm` grammar + a `highlights.scm` capture query. We fetch
2243
+ * both from the upstream Tree-sitter grammar repos; OpenTUI's worker
2244
+ * caches them under its data path (`~/.local/share/opentui/...` by
2245
+ * default) so the download is a one-shot cost per language per machine.
2246
+ *
2247
+ * `aliases` lets a single grammar handle multiple fence info-strings — e.g.
2248
+ * `bash` also matches ` ```sh ` and ` ```shell `. The model picks fences
2249
+ * inconsistently across providers; aliases save us from missing highlights
2250
+ * on synonyms.
2251
+ *
2252
+ * Runtime caveats:
2253
+ * - **First use** of a language triggers an HTTPS download. Subsequent
2254
+ * uses (same machine, same data path) are instant.
2255
+ * - **Compiled binaries** (`bun --compile`) still work — the data path
2256
+ * is a writable OS dir, not the bunfs. Air-gapped deployments would
2257
+ * need to either pre-populate the cache or migrate to local-file
2258
+ * vendoring via `with { type: 'file' }` imports (see
2259
+ * https://opentui.com/docs/reference/tree-sitter/#use-local-files).
2260
+ * - If a download fails (offline / firewall), the language renders as
2261
+ * plain `markup.raw.block` — no crash, just no syntax color.
2262
+ *
2263
+ * Versions are pinned in the WASM URLs so a grammar repo's `master`
2264
+ * landing a breaking change can't silently affect us.
2265
+ */
2266
+ const EXTRA_PARSERS = [
2267
+ {
2268
+ filetype: "python",
2269
+ aliases: ["py"],
2270
+ wasm: "https://github.com/tree-sitter/tree-sitter-python/releases/download/v0.23.6/tree-sitter-python.wasm",
2271
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-python/v0.23.6/queries/highlights.scm"] }
2272
+ },
2273
+ {
2274
+ filetype: "bash",
2275
+ aliases: [
2276
+ "sh",
2277
+ "shell",
2278
+ "zsh"
2279
+ ],
2280
+ wasm: "https://github.com/tree-sitter/tree-sitter-bash/releases/download/v0.23.3/tree-sitter-bash.wasm",
2281
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-bash/v0.23.3/queries/highlights.scm"] }
2282
+ },
2283
+ {
2284
+ filetype: "json",
2285
+ wasm: "https://github.com/tree-sitter/tree-sitter-json/releases/download/v0.24.8/tree-sitter-json.wasm",
2286
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-json/v0.24.8/queries/highlights.scm"] }
2287
+ },
2288
+ {
2289
+ filetype: "rust",
2290
+ aliases: ["rs"],
2291
+ wasm: "https://github.com/tree-sitter/tree-sitter-rust/releases/download/v0.23.2/tree-sitter-rust.wasm",
2292
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-rust/v0.23.2/queries/highlights.scm"] }
2293
+ },
2294
+ {
2295
+ filetype: "go",
2296
+ aliases: ["golang"],
2297
+ wasm: "https://github.com/tree-sitter/tree-sitter-go/releases/download/v0.23.4/tree-sitter-go.wasm",
2298
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-go/v0.23.4/queries/highlights.scm"] }
2299
+ },
2300
+ {
2301
+ filetype: "yaml",
2302
+ aliases: ["yml"],
2303
+ wasm: "https://github.com/tree-sitter-grammars/tree-sitter-yaml/releases/download/v0.7.0/tree-sitter-yaml.wasm",
2304
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-yaml/v0.7.0/queries/highlights.scm"] }
2305
+ },
2306
+ {
2307
+ filetype: "html",
2308
+ aliases: ["htm"],
2309
+ wasm: "https://github.com/tree-sitter/tree-sitter-html/releases/download/v0.23.2/tree-sitter-html.wasm",
2310
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-html/v0.23.2/queries/highlights.scm"] }
2311
+ },
2312
+ {
2313
+ filetype: "css",
2314
+ wasm: "https://github.com/tree-sitter/tree-sitter-css/releases/download/v0.23.2/tree-sitter-css.wasm",
2315
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-css/v0.23.2/queries/highlights.scm"] }
2316
+ }
2317
+ ];
2318
+ let registered = false;
2319
+ /**
2320
+ * Register the extra Tree-sitter parsers + start the worker. Idempotent —
2321
+ * subsequent calls are no-ops. Safe to invoke from `runTui()` and from
2322
+ * composition hosts that mount `<App>` directly.
2323
+ */
2324
+ async function setupTreeSitter() {
2325
+ if (registered) return;
2326
+ registered = true;
2327
+ addDefaultParsers(EXTRA_PARSERS);
2328
+ await getTreeSitterClient().initialize();
2329
+ }
2330
+ //#endregion
3288
2331
  //#region src/tui/index.tsx
3289
2332
  /**
3290
2333
  * Tracks whether `runTui` has been invoked in this process. `createCliRenderer`
@@ -3320,7 +2363,8 @@ let runTuiInvoked = false;
3320
2363
  * to `runTui({ storageDir, prefix })`.
3321
2364
  *
3322
2365
  * ```ts
3323
- * import { BUILTIN_PROVIDERS, runTui } from 'zidane/tui'
2366
+ * import { BUILTIN_PROVIDERS } from 'zidane/chat'
2367
+ * import { runTui } from 'zidane/tui'
3324
2368
  * import { createRemoteStore } from 'zidane/session' // for the `store` option
3325
2369
  *
3326
2370
  * await runTui() // ~/.zidane/sessions.db + state.json
@@ -3333,13 +2377,10 @@ let runTuiInvoked = false;
3333
2377
  async function runTui(options = {}) {
3334
2378
  if (runTuiInvoked) throw new Error("runTui() can only be invoked once per process. Compose `<App config={resolveConfig(...)} />` against your own renderer if you need to run multiple TUIs in the same lifetime.");
3335
2379
  runTuiInvoked = true;
3336
- await init();
3337
- try {
3338
- heal("");
3339
- } catch (err) {
2380
+ await setupTreeSitter().catch((err) => {
3340
2381
  const cause = err instanceof Error ? err.message : String(err);
3341
- process.stderr.write(`[zidane/tui] md4x WASM probe failed: ${cause}\n`);
3342
- }
2382
+ process.stderr.write(`[zidane/tui] tree-sitter setup failed: ${cause}\n`);
2383
+ });
3343
2384
  const config = resolveConfig(options);
3344
2385
  let done = () => {};
3345
2386
  const exited = new Promise((resolve) => {
@@ -3353,6 +2394,6 @@ async function runTui(options = {}) {
3353
2394
  process.exit(0);
3354
2395
  }
3355
2396
  //#endregion
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 };
2397
+ export { App, AuthScreen, ChatScreen, Footer, Modal, ModalRoot, ModelPickerModal, SessionsScreen, SettingsModal, Spinner, Transcript, buildMdStyle, isVisible, marginTopFor, onInputSubmit, runTui, useMdStyle, useModal, useModalAwareFocus };
3357
2398
 
3358
2399
  //# sourceMappingURL=tui.js.map