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/chat.d.ts +305 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +2 -0
- package/dist/contexts.d.ts +1 -1
- package/dist/{index-BfSdALzk.d.ts → index-BB4kuRh3.d.ts} +1 -1
- package/dist/{index-BfSdALzk.d.ts.map → index-BB4kuRh3.d.ts.map} +1 -1
- package/dist/{index-B8-yNSsk.d.ts → index-DRoG_udt.d.ts} +42 -42
- package/dist/index-DRoG_udt.d.ts.map +1 -0
- package/dist/{index-CqpNqjDy.d.ts → index-Ds5YpvfZ.d.ts} +3 -3
- package/dist/{index-CqpNqjDy.d.ts.map → index-Ds5YpvfZ.d.ts.map} +1 -1
- package/dist/{agent-BAoqUvwA.d.ts → index-bgh-k8Mv.d.ts} +1992 -1992
- package/dist/index-bgh-k8Mv.d.ts.map +1 -0
- package/dist/index.d.ts +4 -4
- package/dist/mcp.d.ts +1 -1
- package/dist/presets.d.ts +1 -1
- package/dist/providers.d.ts +1 -1
- package/dist/session/sqlite.d.ts +1 -1
- package/dist/session.d.ts +1 -1
- package/dist/skills.d.ts +2 -2
- package/dist/theme-BlXO6yHe.d.ts +503 -0
- package/dist/theme-BlXO6yHe.d.ts.map +1 -0
- package/dist/theme-context-MungM3SY.js +1713 -0
- package/dist/theme-context-MungM3SY.js.map +1 -0
- package/dist/tools.d.ts +2 -2
- package/dist/tui.d.ts +34 -636
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +302 -1261
- package/dist/tui.js.map +1 -1
- package/dist/types.d.ts +3 -3
- package/package.json +6 -3
- package/dist/agent-BAoqUvwA.d.ts.map +0 -1
- package/dist/index-B8-yNSsk.d.ts.map +0 -1
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 {
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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:
|
|
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.
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
*
|
|
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
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
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
|
-
*
|
|
623
|
-
*
|
|
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:
|
|
628
|
-
syntaxStyle:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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 [
|
|
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.
|
|
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
|
|
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: [
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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]
|
|
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,
|
|
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
|