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