zidane 4.0.2 → 4.1.4
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/README.md +196 -614
- package/dist/agent-BoV5Twdl.d.ts +2347 -0
- package/dist/agent-BoV5Twdl.d.ts.map +1 -0
- package/dist/contexts-3Arvn7yR.js +321 -0
- package/dist/contexts-3Arvn7yR.js.map +1 -0
- package/dist/contexts.d.ts +2 -25
- package/dist/contexts.js +2 -10
- package/dist/errors-D1lhd6mX.js +118 -0
- package/dist/errors-D1lhd6mX.js.map +1 -0
- package/dist/index-28otmfLX.d.ts +400 -0
- package/dist/index-28otmfLX.d.ts.map +1 -0
- package/dist/index-BfSdALzk.d.ts +113 -0
- package/dist/index-BfSdALzk.d.ts.map +1 -0
- package/dist/index-DPsd0qwm.d.ts +254 -0
- package/dist/index-DPsd0qwm.d.ts.map +1 -0
- package/dist/index.d.ts +5 -95
- package/dist/index.js +141 -271
- package/dist/index.js.map +1 -0
- package/dist/interpolate-CukJwP2G.js +887 -0
- package/dist/interpolate-CukJwP2G.js.map +1 -0
- package/dist/mcp-8wClKY-3.js +771 -0
- package/dist/mcp-8wClKY-3.js.map +1 -0
- package/dist/mcp.d.ts +2 -4
- package/dist/mcp.js +2 -13
- package/dist/messages-z5Pq20p7.js +1020 -0
- package/dist/messages-z5Pq20p7.js.map +1 -0
- package/dist/presets-Cs7_CsMk.js +39 -0
- package/dist/presets-Cs7_CsMk.js.map +1 -0
- package/dist/presets.d.ts +2 -43
- package/dist/presets.js +2 -17
- package/dist/providers-CX-R-Oy-.js +969 -0
- package/dist/providers-CX-R-Oy-.js.map +1 -0
- package/dist/providers.d.ts +2 -4
- package/dist/providers.js +3 -23
- package/dist/session/sqlite.d.ts +7 -12
- package/dist/session/sqlite.d.ts.map +1 -0
- package/dist/session/sqlite.js +67 -79
- package/dist/session/sqlite.js.map +1 -0
- package/dist/session-Cn68UASv.js +440 -0
- package/dist/session-Cn68UASv.js.map +1 -0
- package/dist/session.d.ts +2 -4
- package/dist/session.js +3 -27
- package/dist/skills.d.ts +3 -322
- package/dist/skills.js +24 -47
- package/dist/skills.js.map +1 -0
- package/dist/stats-DoKUtF5T.js +58 -0
- package/dist/stats-DoKUtF5T.js.map +1 -0
- package/dist/tools-DpeWKzP1.js +3941 -0
- package/dist/tools-DpeWKzP1.js.map +1 -0
- package/dist/tools.d.ts +3 -95
- package/dist/tools.js +2 -40
- package/dist/tui.d.ts +533 -0
- package/dist/tui.d.ts.map +1 -0
- package/dist/tui.js +2004 -0
- package/dist/tui.js.map +1 -0
- package/dist/types-Bx_F8jet.js +39 -0
- package/dist/types-Bx_F8jet.js.map +1 -0
- package/dist/types.d.ts +4 -55
- package/dist/types.js +4 -28
- package/package.json +38 -4
- package/dist/agent-BAHrGtqu.d.ts +0 -2425
- package/dist/chunk-4ILGBQ23.js +0 -803
- package/dist/chunk-4LPBN547.js +0 -3540
- package/dist/chunk-64LLNY7F.js +0 -28
- package/dist/chunk-6STZTA4N.js +0 -830
- package/dist/chunk-7GQ7P6DM.js +0 -566
- package/dist/chunk-IC7FT4OD.js +0 -37
- package/dist/chunk-JCOB6IYO.js +0 -22
- package/dist/chunk-JH6IAAFA.js +0 -28
- package/dist/chunk-LNN5UTS2.js +0 -97
- package/dist/chunk-PMCQOMV4.js +0 -490
- package/dist/chunk-UD25QF3H.js +0 -304
- package/dist/chunk-W57VY6DJ.js +0 -834
- package/dist/sandbox-D7v6Wy62.d.ts +0 -28
- package/dist/skills-use-DwZrNmcw.d.ts +0 -80
- package/dist/types-Bai5rKpa.d.ts +0 -89
- package/dist/validation-Pm--dQEU.d.ts +0 -185
package/dist/tui.js
ADDED
|
@@ -0,0 +1,2004 @@
|
|
|
1
|
+
import { d as createAgent } from "./tools-DpeWKzP1.js";
|
|
2
|
+
import { n as toolResultToText } from "./types-Bx_F8jet.js";
|
|
3
|
+
import { r as basic_default } from "./presets-Cs7_CsMk.js";
|
|
4
|
+
import { i as anthropic, n as openai, r as cerebras, t as openrouter } from "./providers-CX-R-Oy-.js";
|
|
5
|
+
import { n as loadSession, t as createSession } from "./session-Cn68UASv.js";
|
|
6
|
+
import { createSqliteStore } from "./session/sqlite.js";
|
|
7
|
+
import { dirname, resolve } from "node:path";
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { getModel, getModels } from "@mariozechner/pi-ai";
|
|
11
|
+
import { RGBA, SyntaxStyle, createCliRenderer, defaultTextareaKeyBindings } from "@opentui/core";
|
|
12
|
+
import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
13
|
+
import { heal, init } from "md4x/wasm";
|
|
14
|
+
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
15
|
+
import { jsx, jsxs } from "@opentui/react/jsx-runtime";
|
|
16
|
+
//#region src/tui/format.ts
|
|
17
|
+
/** Compact token formatter — 12_415 → "12.4k", 1_234_567 → "1.23M". */
|
|
18
|
+
function fmtTokens(n) {
|
|
19
|
+
if (n < 1e3) return String(n);
|
|
20
|
+
if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 2 : 1)}k`;
|
|
21
|
+
return `${(n / 1e6).toFixed(2)}M`;
|
|
22
|
+
}
|
|
23
|
+
/** Compact relative-time formatter — "just now / 5m / 3h / 2d". */
|
|
24
|
+
function ageString(ts, now = Date.now()) {
|
|
25
|
+
const m = Math.floor((now - ts) / 6e4);
|
|
26
|
+
if (m < 1) return "just now";
|
|
27
|
+
if (m < 60) return `${m}m ago`;
|
|
28
|
+
const h = Math.floor(m / 60);
|
|
29
|
+
if (h < 24) return `${h}h ago`;
|
|
30
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
31
|
+
}
|
|
32
|
+
/** Six-char short form of a session id for headers and lists. */
|
|
33
|
+
function shortId(id) {
|
|
34
|
+
return id.replace(/-/g, "").slice(0, 6);
|
|
35
|
+
}
|
|
36
|
+
//#endregion
|
|
37
|
+
//#region src/tui/theme.ts
|
|
38
|
+
/**
|
|
39
|
+
* Shared color palette. Kept as plain hex strings so it can be consumed by
|
|
40
|
+
* OpenTUI props that accept `string | RGBA`. The names describe role, not
|
|
41
|
+
* literal hue, so the theme can be swapped without touching call sites.
|
|
42
|
+
*/
|
|
43
|
+
const COLOR = {
|
|
44
|
+
brand: "#FFCC00",
|
|
45
|
+
accent: "#00FF88",
|
|
46
|
+
model: "#88CCFF",
|
|
47
|
+
warn: "#FFAA66",
|
|
48
|
+
error: "#FF6666",
|
|
49
|
+
dim: "#888888",
|
|
50
|
+
mute: "#555555",
|
|
51
|
+
border: "#333333",
|
|
52
|
+
borderActive: "#555555"
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Shared select styling — keeps the highlight bar from filling with a
|
|
56
|
+
* different background than the surrounding box. The `▶` marker and the
|
|
57
|
+
* brand-colored selected text carry the focus affordance.
|
|
58
|
+
*/
|
|
59
|
+
const SELECT_THEME = {
|
|
60
|
+
backgroundColor: "transparent",
|
|
61
|
+
focusedBackgroundColor: "transparent",
|
|
62
|
+
selectedBackgroundColor: "transparent",
|
|
63
|
+
selectedTextColor: COLOR.brand,
|
|
64
|
+
textColor: COLOR.dim,
|
|
65
|
+
descriptionColor: COLOR.mute,
|
|
66
|
+
selectedDescriptionColor: COLOR.dim
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Theme for markdown token highlighting. Token names map to Tree-sitter highlight
|
|
70
|
+
* captures emitted by OpenTUI's markdown parser; the `default` entry is the
|
|
71
|
+
* fallback for unstyled text.
|
|
72
|
+
*/
|
|
73
|
+
const MD_STYLE = SyntaxStyle.fromStyles({
|
|
74
|
+
"default": { fg: RGBA.fromHex("#E6EDF3") },
|
|
75
|
+
"markup.heading": {
|
|
76
|
+
fg: RGBA.fromHex(COLOR.brand),
|
|
77
|
+
bold: true
|
|
78
|
+
},
|
|
79
|
+
"markup.heading.1": {
|
|
80
|
+
fg: RGBA.fromHex(COLOR.brand),
|
|
81
|
+
bold: true
|
|
82
|
+
},
|
|
83
|
+
"markup.heading.2": {
|
|
84
|
+
fg: RGBA.fromHex("#FFD84D"),
|
|
85
|
+
bold: true
|
|
86
|
+
},
|
|
87
|
+
"markup.heading.3": {
|
|
88
|
+
fg: RGBA.fromHex("#FFE680"),
|
|
89
|
+
bold: true
|
|
90
|
+
},
|
|
91
|
+
"markup.bold": {
|
|
92
|
+
fg: RGBA.fromHex("#FFFFFF"),
|
|
93
|
+
bold: true
|
|
94
|
+
},
|
|
95
|
+
"markup.italic": {
|
|
96
|
+
fg: RGBA.fromHex("#E6EDF3"),
|
|
97
|
+
italic: true
|
|
98
|
+
},
|
|
99
|
+
"markup.link": {
|
|
100
|
+
fg: RGBA.fromHex(COLOR.model),
|
|
101
|
+
underline: true
|
|
102
|
+
},
|
|
103
|
+
"markup.link.url": {
|
|
104
|
+
fg: RGBA.fromHex(COLOR.model),
|
|
105
|
+
underline: true
|
|
106
|
+
},
|
|
107
|
+
"markup.list": { fg: RGBA.fromHex(COLOR.warn) },
|
|
108
|
+
"markup.raw": { fg: RGBA.fromHex("#A5D6FF") },
|
|
109
|
+
"markup.raw.block": { fg: RGBA.fromHex("#A5D6FF") },
|
|
110
|
+
"markup.quote": {
|
|
111
|
+
fg: RGBA.fromHex(COLOR.dim),
|
|
112
|
+
italic: true
|
|
113
|
+
},
|
|
114
|
+
"punctuation": { fg: RGBA.fromHex(COLOR.mute) }
|
|
115
|
+
});
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/tui/components.tsx
|
|
118
|
+
/**
|
|
119
|
+
* Memoized so a flush that mutates only the trailing event doesn't force the
|
|
120
|
+
* entire transcript to re-render. Each event holds a stable reference until
|
|
121
|
+
* its content changes (we only ever recreate the streaming-markdown tail).
|
|
122
|
+
*
|
|
123
|
+
* The outer wrapper handles top-margin per kind (and per neighbor) so spacing
|
|
124
|
+
* is the single source of truth for inter-event breathing room.
|
|
125
|
+
*/
|
|
126
|
+
const EventLine = memo(({ event, previous }) => /* @__PURE__ */ jsx("box", {
|
|
127
|
+
style: { marginTop: marginTopFor(event, previous) },
|
|
128
|
+
children: /* @__PURE__ */ jsx(EventLineImpl, { event })
|
|
129
|
+
}));
|
|
130
|
+
/**
|
|
131
|
+
* `@opentui/react` extends `React.JSX.IntrinsicElements`, so `onSubmit` on `<input>`
|
|
132
|
+
* gets intersected with the DOM `SubmitEvent` shape and demands an unhelpful overload.
|
|
133
|
+
* The OpenTUI input runtime fires `(value: string) => void`; this helper isolates
|
|
134
|
+
* the cast so each call site stays readable.
|
|
135
|
+
*/
|
|
136
|
+
function onInputSubmit(handler) {
|
|
137
|
+
return handler;
|
|
138
|
+
}
|
|
139
|
+
function Footer({ hints, picked, context }) {
|
|
140
|
+
return /* @__PURE__ */ jsxs("box", {
|
|
141
|
+
style: {
|
|
142
|
+
flexDirection: "row",
|
|
143
|
+
height: 1,
|
|
144
|
+
paddingLeft: 1,
|
|
145
|
+
paddingRight: 1
|
|
146
|
+
},
|
|
147
|
+
children: [
|
|
148
|
+
/* @__PURE__ */ jsx("text", {
|
|
149
|
+
fg: COLOR.dim,
|
|
150
|
+
children: hints.map((h, i) => /* @__PURE__ */ jsxs("span", { children: [
|
|
151
|
+
i > 0 && /* @__PURE__ */ jsx("span", {
|
|
152
|
+
fg: COLOR.mute,
|
|
153
|
+
children: " · "
|
|
154
|
+
}),
|
|
155
|
+
/* @__PURE__ */ jsx("span", {
|
|
156
|
+
fg: COLOR.warn,
|
|
157
|
+
children: h.key
|
|
158
|
+
}),
|
|
159
|
+
/* @__PURE__ */ jsx("span", {
|
|
160
|
+
fg: COLOR.dim,
|
|
161
|
+
children: ` ${h.label}`
|
|
162
|
+
})
|
|
163
|
+
] }, i))
|
|
164
|
+
}),
|
|
165
|
+
picked && /* @__PURE__ */ jsx(ProviderBadge, { picked }),
|
|
166
|
+
/* @__PURE__ */ jsx("box", { style: { flexGrow: 1 } }),
|
|
167
|
+
context && /* @__PURE__ */ jsx(ContextIndicator, { context })
|
|
168
|
+
]
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
function ProviderBadge({ picked }) {
|
|
172
|
+
const source = picked.provider.methods[0].source;
|
|
173
|
+
return /* @__PURE__ */ jsxs("text", {
|
|
174
|
+
fg: COLOR.dim,
|
|
175
|
+
children: [
|
|
176
|
+
/* @__PURE__ */ jsx("span", {
|
|
177
|
+
fg: COLOR.mute,
|
|
178
|
+
children: " · "
|
|
179
|
+
}),
|
|
180
|
+
/* @__PURE__ */ jsx("span", {
|
|
181
|
+
fg: COLOR.accent,
|
|
182
|
+
children: picked.provider.label
|
|
183
|
+
}),
|
|
184
|
+
/* @__PURE__ */ jsx("span", {
|
|
185
|
+
fg: COLOR.mute,
|
|
186
|
+
children: " · "
|
|
187
|
+
}),
|
|
188
|
+
/* @__PURE__ */ jsx("span", {
|
|
189
|
+
fg: COLOR.model,
|
|
190
|
+
children: picked.model
|
|
191
|
+
}),
|
|
192
|
+
/* @__PURE__ */ jsx("span", {
|
|
193
|
+
fg: COLOR.mute,
|
|
194
|
+
children: " · "
|
|
195
|
+
}),
|
|
196
|
+
/* @__PURE__ */ jsx("span", {
|
|
197
|
+
fg: source === "oauth" ? COLOR.accent : COLOR.warn,
|
|
198
|
+
children: source
|
|
199
|
+
})
|
|
200
|
+
]
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
function ContextIndicator({ context }) {
|
|
204
|
+
const ratio = context.max > 0 ? context.used / context.max : 0;
|
|
205
|
+
const pct = Math.round(ratio * 100);
|
|
206
|
+
const color = ratio >= .85 ? COLOR.error : ratio >= .6 ? COLOR.warn : COLOR.dim;
|
|
207
|
+
return /* @__PURE__ */ jsxs("text", {
|
|
208
|
+
fg: COLOR.dim,
|
|
209
|
+
children: [
|
|
210
|
+
/* @__PURE__ */ jsx("span", {
|
|
211
|
+
fg: COLOR.mute,
|
|
212
|
+
children: "ctx "
|
|
213
|
+
}),
|
|
214
|
+
/* @__PURE__ */ jsx("span", {
|
|
215
|
+
fg: color,
|
|
216
|
+
children: fmtTokens(context.used)
|
|
217
|
+
}),
|
|
218
|
+
/* @__PURE__ */ jsx("span", {
|
|
219
|
+
fg: COLOR.mute,
|
|
220
|
+
children: ` / ${fmtTokens(context.max)} `
|
|
221
|
+
}),
|
|
222
|
+
/* @__PURE__ */ jsx("span", {
|
|
223
|
+
fg: color,
|
|
224
|
+
children: `(${pct}%)`
|
|
225
|
+
})
|
|
226
|
+
]
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const SPINNER_FRAMES = [
|
|
230
|
+
"⠋",
|
|
231
|
+
"⠙",
|
|
232
|
+
"⠹",
|
|
233
|
+
"⠸",
|
|
234
|
+
"⠼",
|
|
235
|
+
"⠴",
|
|
236
|
+
"⠦",
|
|
237
|
+
"⠧",
|
|
238
|
+
"⠇",
|
|
239
|
+
"⠏"
|
|
240
|
+
];
|
|
241
|
+
const SPINNER_INTERVAL_MS = 80;
|
|
242
|
+
function Spinner({ label }) {
|
|
243
|
+
const [frame, setFrame] = useState(0);
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), SPINNER_INTERVAL_MS);
|
|
246
|
+
return () => clearInterval(id);
|
|
247
|
+
}, []);
|
|
248
|
+
return /* @__PURE__ */ jsxs("text", {
|
|
249
|
+
fg: COLOR.warn,
|
|
250
|
+
children: [SPINNER_FRAMES[frame], /* @__PURE__ */ jsx("span", {
|
|
251
|
+
fg: COLOR.dim,
|
|
252
|
+
children: ` ${label}`
|
|
253
|
+
})]
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
function Transcript({ events, settings }) {
|
|
257
|
+
const visible = events.filter((e) => isVisible(e, settings));
|
|
258
|
+
if (visible.length === 0) return /* @__PURE__ */ jsx(EmptyState$1, {});
|
|
259
|
+
return /* @__PURE__ */ jsx("scrollbox", {
|
|
260
|
+
focusable: false,
|
|
261
|
+
style: {
|
|
262
|
+
flexGrow: 1,
|
|
263
|
+
paddingLeft: 1,
|
|
264
|
+
paddingRight: 1
|
|
265
|
+
},
|
|
266
|
+
stickyScroll: true,
|
|
267
|
+
stickyStart: "bottom",
|
|
268
|
+
children: visible.map((evt, i) => /* @__PURE__ */ jsx(EventLine, {
|
|
269
|
+
event: evt,
|
|
270
|
+
previous: visible[i - 1]
|
|
271
|
+
}, i))
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
function isVisible(event, settings) {
|
|
275
|
+
switch (event.kind) {
|
|
276
|
+
case "thinking": return settings.showThinking;
|
|
277
|
+
case "tool": return settings.showToolCalls;
|
|
278
|
+
case "tool-result": return settings.showToolResults;
|
|
279
|
+
default: return true;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function EmptyState$1() {
|
|
283
|
+
return /* @__PURE__ */ jsx("box", {
|
|
284
|
+
style: {
|
|
285
|
+
flexGrow: 1,
|
|
286
|
+
alignItems: "center",
|
|
287
|
+
justifyContent: "center"
|
|
288
|
+
},
|
|
289
|
+
children: /* @__PURE__ */ jsx("text", {
|
|
290
|
+
fg: COLOR.mute,
|
|
291
|
+
children: "no messages yet — type below to start"
|
|
292
|
+
})
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
/** Left-pad applied per depth level (in columns). */
|
|
296
|
+
const INDENT_PER_DEPTH = 2;
|
|
297
|
+
function indentFor(depth) {
|
|
298
|
+
return depth && depth > 0 ? depth * INDENT_PER_DEPTH : 0;
|
|
299
|
+
}
|
|
300
|
+
function isChild(event) {
|
|
301
|
+
return (event.depth ?? 0) > 0;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Default top-margin per kind. Spacing intent:
|
|
305
|
+
* - `info` / `markdown` / `tool` / `error` / `spawn-start` open a new block
|
|
306
|
+
* so they each get one row of breathing room above.
|
|
307
|
+
* - `thinking` / `tool-result` / `spawn-end` continue the previous block
|
|
308
|
+
* and stay flush.
|
|
309
|
+
*
|
|
310
|
+
* Context-aware overrides live in `marginTopFor` — e.g. consecutive tool
|
|
311
|
+
* round-trips collapse to a tight list regardless of whether outputs are shown.
|
|
312
|
+
*/
|
|
313
|
+
const MARGIN_TOP = {
|
|
314
|
+
"separator": 0,
|
|
315
|
+
"info": 1,
|
|
316
|
+
"thinking": 0,
|
|
317
|
+
"tool": 1,
|
|
318
|
+
"tool-result": 0,
|
|
319
|
+
"error": 1,
|
|
320
|
+
"markdown": 1,
|
|
321
|
+
"spawn-start": 1,
|
|
322
|
+
"spawn-end": 0
|
|
323
|
+
};
|
|
324
|
+
const TOOL_KINDS = new Set(["tool", "tool-result"]);
|
|
325
|
+
/**
|
|
326
|
+
* Resolve the top margin for an event given the one rendered just before it.
|
|
327
|
+
*
|
|
328
|
+
* The only context-aware rule today: a tool/tool-result event that follows
|
|
329
|
+
* another tool/tool-result event collapses its margin to zero, so a chain of
|
|
330
|
+
* tool calls reads as a tight list — whether the user has hidden tool outputs
|
|
331
|
+
* or not, and whether the agent emits back-to-back calls or call→result pairs.
|
|
332
|
+
*
|
|
333
|
+
* Exported so the spacing matrix can be unit-tested without rendering.
|
|
334
|
+
*/
|
|
335
|
+
function marginTopFor(event, previous) {
|
|
336
|
+
if (TOOL_KINDS.has(event.kind) && previous && TOOL_KINDS.has(previous.kind)) return 0;
|
|
337
|
+
return MARGIN_TOP[event.kind] ?? 0;
|
|
338
|
+
}
|
|
339
|
+
function EventLineImpl({ event }) {
|
|
340
|
+
const safeText = event.text === "" ? " " : event.text;
|
|
341
|
+
const paddingLeft = indentFor(event.depth);
|
|
342
|
+
const child = isChild(event);
|
|
343
|
+
switch (event.kind) {
|
|
344
|
+
case "separator": return /* @__PURE__ */ jsx("text", { children: " " });
|
|
345
|
+
case "info": return /* @__PURE__ */ jsx(UserPromptBlock, { text: safeText });
|
|
346
|
+
case "thinking": return /* @__PURE__ */ jsx("box", {
|
|
347
|
+
style: { paddingLeft },
|
|
348
|
+
children: /* @__PURE__ */ jsx("text", {
|
|
349
|
+
fg: COLOR.dim,
|
|
350
|
+
children: safeText
|
|
351
|
+
})
|
|
352
|
+
});
|
|
353
|
+
case "tool": return /* @__PURE__ */ jsx("box", {
|
|
354
|
+
style: { paddingLeft },
|
|
355
|
+
children: /* @__PURE__ */ jsxs("text", {
|
|
356
|
+
fg: child ? COLOR.dim : COLOR.model,
|
|
357
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
358
|
+
fg: COLOR.mute,
|
|
359
|
+
children: "↳ "
|
|
360
|
+
}), safeText]
|
|
361
|
+
})
|
|
362
|
+
});
|
|
363
|
+
case "tool-result": return /* @__PURE__ */ jsx(ToolResultBlock, {
|
|
364
|
+
text: event.text,
|
|
365
|
+
indent: paddingLeft
|
|
366
|
+
});
|
|
367
|
+
case "error": return /* @__PURE__ */ jsx("box", {
|
|
368
|
+
style: { paddingLeft },
|
|
369
|
+
children: /* @__PURE__ */ jsxs("text", {
|
|
370
|
+
fg: COLOR.error,
|
|
371
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
372
|
+
fg: COLOR.error,
|
|
373
|
+
children: "✗ "
|
|
374
|
+
}), safeText]
|
|
375
|
+
})
|
|
376
|
+
});
|
|
377
|
+
case "markdown": return /* @__PURE__ */ jsx("box", {
|
|
378
|
+
style: { paddingLeft },
|
|
379
|
+
children: /* @__PURE__ */ jsx(MarkdownBlock, {
|
|
380
|
+
text: event.text,
|
|
381
|
+
streaming: event.streaming ?? false,
|
|
382
|
+
dim: child
|
|
383
|
+
})
|
|
384
|
+
});
|
|
385
|
+
case "spawn-start": return /* @__PURE__ */ jsx("box", {
|
|
386
|
+
style: { paddingLeft },
|
|
387
|
+
children: /* @__PURE__ */ jsxs("text", {
|
|
388
|
+
fg: COLOR.dim,
|
|
389
|
+
children: [
|
|
390
|
+
/* @__PURE__ */ jsx("span", {
|
|
391
|
+
fg: COLOR.accent,
|
|
392
|
+
children: "🌱 "
|
|
393
|
+
}),
|
|
394
|
+
/* @__PURE__ */ jsx("span", {
|
|
395
|
+
fg: COLOR.dim,
|
|
396
|
+
children: `[${event.childId ?? "child"}] `
|
|
397
|
+
}),
|
|
398
|
+
/* @__PURE__ */ jsx("span", {
|
|
399
|
+
fg: COLOR.dim,
|
|
400
|
+
children: safeText
|
|
401
|
+
})
|
|
402
|
+
]
|
|
403
|
+
})
|
|
404
|
+
});
|
|
405
|
+
case "spawn-end": return /* @__PURE__ */ jsx("box", {
|
|
406
|
+
style: { paddingLeft },
|
|
407
|
+
children: /* @__PURE__ */ jsxs("text", {
|
|
408
|
+
fg: COLOR.dim,
|
|
409
|
+
children: [
|
|
410
|
+
/* @__PURE__ */ jsx("span", {
|
|
411
|
+
fg: COLOR.accent,
|
|
412
|
+
children: "✓ "
|
|
413
|
+
}),
|
|
414
|
+
/* @__PURE__ */ jsx("span", {
|
|
415
|
+
fg: COLOR.dim,
|
|
416
|
+
children: `[${event.childId ?? "child"}] `
|
|
417
|
+
}),
|
|
418
|
+
/* @__PURE__ */ jsx("span", {
|
|
419
|
+
fg: COLOR.mute,
|
|
420
|
+
children: safeText
|
|
421
|
+
})
|
|
422
|
+
]
|
|
423
|
+
})
|
|
424
|
+
});
|
|
425
|
+
default: return /* @__PURE__ */ jsx("text", { children: safeText });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/** User prompt — bordered to rhyme with the prompt input box below. */
|
|
429
|
+
function UserPromptBlock({ text }) {
|
|
430
|
+
return /* @__PURE__ */ jsx("box", {
|
|
431
|
+
style: {
|
|
432
|
+
border: true,
|
|
433
|
+
borderColor: COLOR.borderActive,
|
|
434
|
+
paddingLeft: 1,
|
|
435
|
+
paddingRight: 1
|
|
436
|
+
},
|
|
437
|
+
children: /* @__PURE__ */ jsx("text", {
|
|
438
|
+
fg: COLOR.brand,
|
|
439
|
+
children: text
|
|
440
|
+
})
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Markdown block. While `streaming` is true, content is passed through
|
|
445
|
+
* `md4x.heal()` so unclosed delimiters (bold, italic, code, link, table) render
|
|
446
|
+
* as if already complete. OpenTUI's `streaming` prop keeps its parser from
|
|
447
|
+
* committing to the final layout for the trailing block.
|
|
448
|
+
*/
|
|
449
|
+
function MarkdownBlock({ text, streaming, dim }) {
|
|
450
|
+
return /* @__PURE__ */ jsx("markdown", {
|
|
451
|
+
content: useMemo(() => streaming ? heal(text) : text, [text, streaming]),
|
|
452
|
+
syntaxStyle: MD_STYLE,
|
|
453
|
+
streaming,
|
|
454
|
+
fg: dim ? COLOR.dim : void 0
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
const TOOL_RESULT_MAX_LINES = 6;
|
|
458
|
+
function ToolResultBlock({ text, indent }) {
|
|
459
|
+
const lines = text.split("\n");
|
|
460
|
+
const visible = lines.slice(0, TOOL_RESULT_MAX_LINES);
|
|
461
|
+
const omitted = Math.max(0, lines.length - TOOL_RESULT_MAX_LINES);
|
|
462
|
+
return /* @__PURE__ */ jsxs("box", {
|
|
463
|
+
style: {
|
|
464
|
+
paddingLeft: indent,
|
|
465
|
+
flexDirection: "column"
|
|
466
|
+
},
|
|
467
|
+
children: [visible.map((line, i) => /* @__PURE__ */ jsxs("text", {
|
|
468
|
+
fg: COLOR.mute,
|
|
469
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
470
|
+
fg: COLOR.borderActive,
|
|
471
|
+
children: "┃ "
|
|
472
|
+
}), line || " "]
|
|
473
|
+
}, i)), omitted > 0 && /* @__PURE__ */ jsxs("text", {
|
|
474
|
+
fg: COLOR.mute,
|
|
475
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
476
|
+
fg: COLOR.borderActive,
|
|
477
|
+
children: "┃ "
|
|
478
|
+
}), `… ${omitted} more line${omitted === 1 ? "" : "s"}`]
|
|
479
|
+
})]
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
//#endregion
|
|
483
|
+
//#region src/tui/auth.ts
|
|
484
|
+
const ENV_KEYS = {
|
|
485
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
486
|
+
openai: "OPENAI_CODEX_API_KEY",
|
|
487
|
+
openrouter: "OPENROUTER_API_KEY",
|
|
488
|
+
cerebras: "CEREBRAS_API_KEY"
|
|
489
|
+
};
|
|
490
|
+
/** Maps a provider to the credentials.json key written by `bun run auth`. */
|
|
491
|
+
const OAUTH_KEYS = {
|
|
492
|
+
anthropic: "anthropic",
|
|
493
|
+
openai: "openai-codex"
|
|
494
|
+
};
|
|
495
|
+
const LABELS = {
|
|
496
|
+
anthropic: "Anthropic",
|
|
497
|
+
openai: "OpenAI Codex",
|
|
498
|
+
openrouter: "OpenRouter",
|
|
499
|
+
cerebras: "Cerebras"
|
|
500
|
+
};
|
|
501
|
+
function envKeyFor(key) {
|
|
502
|
+
return ENV_KEYS[key];
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Detect available auth across the providers the harness ships with.
|
|
506
|
+
*
|
|
507
|
+
* Mirrors the resolution order used by the providers at runtime:
|
|
508
|
+
* - explicit env var (highest)
|
|
509
|
+
* - OAuth credentials in `.credentials.json` (anthropic + openai-codex only)
|
|
510
|
+
*
|
|
511
|
+
* Pure read — never refreshes or rewrites the credentials file.
|
|
512
|
+
*/
|
|
513
|
+
function detectAuth(env = process.env) {
|
|
514
|
+
const credsPath = resolve(process.cwd(), ".credentials.json");
|
|
515
|
+
let creds = {};
|
|
516
|
+
if (existsSync(credsPath)) try {
|
|
517
|
+
const parsed = JSON.parse(readFileSync(credsPath, "utf-8"));
|
|
518
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) creds = parsed;
|
|
519
|
+
} catch {}
|
|
520
|
+
return Object.keys(LABELS).map((key) => {
|
|
521
|
+
const methods = [];
|
|
522
|
+
const envKey = ENV_KEYS[key];
|
|
523
|
+
if (env[envKey]) methods.push({
|
|
524
|
+
source: "env",
|
|
525
|
+
detail: envKey
|
|
526
|
+
});
|
|
527
|
+
const oauthKey = OAUTH_KEYS[key];
|
|
528
|
+
if (oauthKey) {
|
|
529
|
+
const entry = creds[oauthKey];
|
|
530
|
+
if (entry?.access && entry.refresh) {
|
|
531
|
+
const detail = entry.expires ? `oauth · expires ${new Date(entry.expires).toLocaleString()}` : "oauth · .credentials.json";
|
|
532
|
+
methods.push({
|
|
533
|
+
source: "oauth",
|
|
534
|
+
detail
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return {
|
|
539
|
+
key,
|
|
540
|
+
label: LABELS[key],
|
|
541
|
+
available: methods.length > 0,
|
|
542
|
+
methods
|
|
543
|
+
};
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
//#endregion
|
|
547
|
+
//#region src/tui/providers.ts
|
|
548
|
+
/**
|
|
549
|
+
* Construct a fresh provider instance for a given key.
|
|
550
|
+
*
|
|
551
|
+
* Providers are cheap to build — credentials are resolved lazily at first
|
|
552
|
+
* stream call — so we instantiate on demand rather than caching a singleton.
|
|
553
|
+
* This also avoids leaking state across session/provider switches.
|
|
554
|
+
*/
|
|
555
|
+
const FACTORIES = {
|
|
556
|
+
anthropic,
|
|
557
|
+
openai,
|
|
558
|
+
openrouter,
|
|
559
|
+
cerebras
|
|
560
|
+
};
|
|
561
|
+
/** zidane provider key → pi-ai provider id (some don't match 1:1). */
|
|
562
|
+
const PI_PROVIDER_ID = {
|
|
563
|
+
anthropic: "anthropic",
|
|
564
|
+
openai: "openai-codex",
|
|
565
|
+
openrouter: "openrouter",
|
|
566
|
+
cerebras: "cerebras"
|
|
567
|
+
};
|
|
568
|
+
/**
|
|
569
|
+
* Look up the model's max context window via pi-ai's model registry.
|
|
570
|
+
* Returns `null` when the model isn't known (e.g. a custom openrouter slug);
|
|
571
|
+
* callers should hide the context indicator in that case.
|
|
572
|
+
*/
|
|
573
|
+
function getContextWindow(key, modelId) {
|
|
574
|
+
try {
|
|
575
|
+
const providerId = PI_PROVIDER_ID[key];
|
|
576
|
+
return getModel(providerId, modelId)?.contextWindow ?? null;
|
|
577
|
+
} catch {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
//#endregion
|
|
582
|
+
//#region src/tui/store.ts
|
|
583
|
+
function ensureDir(path) {
|
|
584
|
+
const dir = dirname(path);
|
|
585
|
+
if (existsSync(dir)) return;
|
|
586
|
+
try {
|
|
587
|
+
mkdirSync(dir, { recursive: true });
|
|
588
|
+
} catch (err) {
|
|
589
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
590
|
+
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}`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
function createTuiStore(dbPath) {
|
|
594
|
+
ensureDir(dbPath);
|
|
595
|
+
return createSqliteStore({ path: dbPath });
|
|
596
|
+
}
|
|
597
|
+
function createStateStore(path) {
|
|
598
|
+
return {
|
|
599
|
+
load: () => loadState(path),
|
|
600
|
+
save: (state) => saveState(path, state)
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
function loadState(path) {
|
|
604
|
+
if (!existsSync(path)) return {};
|
|
605
|
+
try {
|
|
606
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
607
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
608
|
+
} catch {}
|
|
609
|
+
return {};
|
|
610
|
+
}
|
|
611
|
+
function saveState(path, state) {
|
|
612
|
+
ensureDir(path);
|
|
613
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
614
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
615
|
+
renameSync(tmp, path);
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Load every session and project it to the compact `SessionMeta` shape used by
|
|
619
|
+
* the picker. Sorted by recency via the underlying store's `list()` contract
|
|
620
|
+
* (sqlite store returns by `updated_at DESC`).
|
|
621
|
+
*/
|
|
622
|
+
async function listSessionMeta(store) {
|
|
623
|
+
const ids = await store.list();
|
|
624
|
+
return (await Promise.all(ids.map(async (id) => {
|
|
625
|
+
const data = await store.load(id);
|
|
626
|
+
if (!data) return null;
|
|
627
|
+
return {
|
|
628
|
+
id,
|
|
629
|
+
title: titleFromTurns(data.turns) ?? "untitled",
|
|
630
|
+
turnCount: data.turns.length,
|
|
631
|
+
updatedAt: data.updatedAt
|
|
632
|
+
};
|
|
633
|
+
}))).filter((m) => m !== null);
|
|
634
|
+
}
|
|
635
|
+
/** Derive a short title from the first user message — returns null when empty. */
|
|
636
|
+
function titleFromTurns(turns) {
|
|
637
|
+
const first = turns.find((t) => t.role === "user");
|
|
638
|
+
if (!first) return null;
|
|
639
|
+
for (const block of first.content) if (block.type === "text" && block.text.trim()) {
|
|
640
|
+
const oneLine = block.text.replace(/\s+/g, " ").trim();
|
|
641
|
+
return oneLine.length > 60 ? `${oneLine.slice(0, 60)}…` : oneLine;
|
|
642
|
+
}
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Replay persisted turns as a viewable transcript. Mirrors the event shape produced
|
|
647
|
+
* live by the agent hooks so loaded and streaming history render identically.
|
|
648
|
+
*
|
|
649
|
+
* Skips `tool_result` blocks (they're not user-visible by default), and inserts a
|
|
650
|
+
* `separator` event between turn groups so the eye can parse turn boundaries.
|
|
651
|
+
*/
|
|
652
|
+
function eventsFromTurns(turns) {
|
|
653
|
+
const events = [];
|
|
654
|
+
for (let i = 0; i < turns.length; i++) {
|
|
655
|
+
const turn = turns[i];
|
|
656
|
+
if (i > 0) events.push({
|
|
657
|
+
kind: "separator",
|
|
658
|
+
text: ""
|
|
659
|
+
});
|
|
660
|
+
if (turn.role === "user") {
|
|
661
|
+
for (const block of turn.content) if (block.type === "text" && block.text.trim()) events.push({
|
|
662
|
+
kind: "info",
|
|
663
|
+
text: `❯ ${block.text}`
|
|
664
|
+
});
|
|
665
|
+
else if (block.type === "tool_result") events.push({
|
|
666
|
+
kind: "tool-result",
|
|
667
|
+
text: toolResultText(block.output)
|
|
668
|
+
});
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (turn.role === "assistant") {
|
|
672
|
+
for (const block of turn.content) if (block.type === "text" && block.text.trim()) events.push({
|
|
673
|
+
kind: "markdown",
|
|
674
|
+
text: block.text,
|
|
675
|
+
streaming: false
|
|
676
|
+
});
|
|
677
|
+
else if (block.type === "tool_call") events.push({
|
|
678
|
+
kind: "tool",
|
|
679
|
+
text: toolCallPreview(block.name, block.input)
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return events;
|
|
684
|
+
}
|
|
685
|
+
/** Shared formatter for the `↳ name(args)` line shown on tool calls. */
|
|
686
|
+
function toolCallPreview(name, input) {
|
|
687
|
+
const args = JSON.stringify(input);
|
|
688
|
+
return args && args !== "{}" ? `${name}(${args})` : name;
|
|
689
|
+
}
|
|
690
|
+
/** Render tool output as plain text, whether it's a string or structured content. */
|
|
691
|
+
function toolResultText(output) {
|
|
692
|
+
return typeof output === "string" ? output : toolResultToText(output);
|
|
693
|
+
}
|
|
694
|
+
/** Effective context size of the most recent assistant turn — drives the footer indicator. */
|
|
695
|
+
function lastContextSizeFromTurns(turns) {
|
|
696
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
697
|
+
const turn = turns[i];
|
|
698
|
+
if (turn.role === "assistant" && turn.usage) return (turn.usage.input ?? 0) + (turn.usage.cacheRead ?? 0) + (turn.usage.cacheCreation ?? 0);
|
|
699
|
+
}
|
|
700
|
+
return 0;
|
|
701
|
+
}
|
|
702
|
+
//#endregion
|
|
703
|
+
//#region src/tui/config.tsx
|
|
704
|
+
/** Resolve user options into a fully-bound runtime config. Pure aside from disk reads. */
|
|
705
|
+
function resolveConfig(options = {}) {
|
|
706
|
+
const prefix = options.prefix ?? process.env.ZIDANE_PREFIX ?? ".zidane";
|
|
707
|
+
const storageDir = options.storageDir ?? process.env.ZIDANE_STORAGE_DIR ?? homedir();
|
|
708
|
+
const dir = resolve(storageDir, prefix);
|
|
709
|
+
const paths = {
|
|
710
|
+
dir,
|
|
711
|
+
db: resolve(dir, "sessions.db"),
|
|
712
|
+
state: resolve(dir, "state.json")
|
|
713
|
+
};
|
|
714
|
+
const store = options.store ?? createTuiStore(paths.db);
|
|
715
|
+
const stateStore = createStateStore(paths.state);
|
|
716
|
+
const initialState = stateStore.load();
|
|
717
|
+
const providers = {
|
|
718
|
+
...FACTORIES,
|
|
719
|
+
...options.providers ?? {}
|
|
720
|
+
};
|
|
721
|
+
const preset = options.preset ?? basic_default;
|
|
722
|
+
const modelsFor = makeModelsResolver(options.models);
|
|
723
|
+
const resumeProvider = resolveResumeProvider(initialState, providers);
|
|
724
|
+
const initialPicked = resumeProvider ? pickInitial(resumeProvider, providers, initialState) : null;
|
|
725
|
+
return {
|
|
726
|
+
prefix,
|
|
727
|
+
storageDir,
|
|
728
|
+
paths,
|
|
729
|
+
providers,
|
|
730
|
+
preset,
|
|
731
|
+
store,
|
|
732
|
+
stateStore,
|
|
733
|
+
modelsFor,
|
|
734
|
+
initialState,
|
|
735
|
+
initialSettings: initialState.settings ?? {},
|
|
736
|
+
resumeProvider,
|
|
737
|
+
initialPicked
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
function makeModelsResolver(custom) {
|
|
741
|
+
return (key) => {
|
|
742
|
+
const overridden = custom?.[key];
|
|
743
|
+
if (overridden) return overridden;
|
|
744
|
+
try {
|
|
745
|
+
const piId = PI_PROVIDER_ID[key];
|
|
746
|
+
return getModels(piId);
|
|
747
|
+
} catch {
|
|
748
|
+
return [];
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
function resolveResumeProvider(state, providers) {
|
|
753
|
+
if (!state.lastProvider) return null;
|
|
754
|
+
if (!providers[state.lastProvider]) return null;
|
|
755
|
+
return detectAuth().find((p) => p.key === state.lastProvider && p.available) ?? null;
|
|
756
|
+
}
|
|
757
|
+
function pickInitial(auth, providers, state) {
|
|
758
|
+
const factory = providers[auth.key];
|
|
759
|
+
if (!factory) return null;
|
|
760
|
+
const provider = factory();
|
|
761
|
+
return {
|
|
762
|
+
provider: auth,
|
|
763
|
+
model: state.lastModelByProvider?.[auth.key] ?? provider.meta.defaultModel
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
const ConfigContext = createContext(null);
|
|
767
|
+
function ConfigProvider({ config, children }) {
|
|
768
|
+
return /* @__PURE__ */ jsx(ConfigContext.Provider, {
|
|
769
|
+
value: config,
|
|
770
|
+
children
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
function useConfig() {
|
|
774
|
+
const ctx = useContext(ConfigContext);
|
|
775
|
+
if (!ctx) throw new Error("useConfig must be used inside <ConfigProvider>");
|
|
776
|
+
return ctx;
|
|
777
|
+
}
|
|
778
|
+
//#endregion
|
|
779
|
+
//#region src/tui/modal.tsx
|
|
780
|
+
const ModalContext = createContext(null);
|
|
781
|
+
function ModalRoot({ children }) {
|
|
782
|
+
const [active, setActive] = useState(null);
|
|
783
|
+
const api = useMemo(() => ({
|
|
784
|
+
open: (node) => setActive(node),
|
|
785
|
+
close: () => setActive(null),
|
|
786
|
+
get isOpen() {
|
|
787
|
+
return active !== null;
|
|
788
|
+
}
|
|
789
|
+
}), [active]);
|
|
790
|
+
return /* @__PURE__ */ jsxs(ModalContext.Provider, {
|
|
791
|
+
value: api,
|
|
792
|
+
children: [/* @__PURE__ */ jsx("box", {
|
|
793
|
+
style: {
|
|
794
|
+
flexDirection: "column",
|
|
795
|
+
flexGrow: 1
|
|
796
|
+
},
|
|
797
|
+
children
|
|
798
|
+
}), active && /* @__PURE__ */ jsx("box", {
|
|
799
|
+
style: {
|
|
800
|
+
position: "absolute",
|
|
801
|
+
top: 0,
|
|
802
|
+
left: 0,
|
|
803
|
+
right: 0,
|
|
804
|
+
bottom: 0,
|
|
805
|
+
alignItems: "center",
|
|
806
|
+
justifyContent: "center",
|
|
807
|
+
zIndex: 100
|
|
808
|
+
},
|
|
809
|
+
children: active
|
|
810
|
+
})]
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
function useModal() {
|
|
814
|
+
const ctx = useContext(ModalContext);
|
|
815
|
+
if (!ctx) throw new Error("useModal must be used inside <ModalRoot>");
|
|
816
|
+
return ctx;
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Focus computed against the modal layer.
|
|
820
|
+
*
|
|
821
|
+
* Pass a component's preferred focus state and this returns `false` whenever a
|
|
822
|
+
* modal is open — so focused inputs (textarea, selects) release their focus and
|
|
823
|
+
* stop intercepting keys behind the overlay. Pair with `focusable={false}` on
|
|
824
|
+
* "passive" focusables (scrollbox) so the renderer doesn't cycle focus into
|
|
825
|
+
* them when the primary input blurs.
|
|
826
|
+
*/
|
|
827
|
+
function useModalAwareFocus(preferred = true) {
|
|
828
|
+
const { isOpen } = useModal();
|
|
829
|
+
return preferred && !isOpen;
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Responsive modal — picks a width based on the live terminal size.
|
|
833
|
+
*
|
|
834
|
+
* - On a wide terminal, the modal grows to `maxWidth` so descriptions sit on
|
|
835
|
+
* one line and don't wrap.
|
|
836
|
+
* - On a narrow terminal, the modal shrinks down to `minWidth`, keeping a
|
|
837
|
+
* small horizontal margin from the screen edges. Text inside wraps naturally.
|
|
838
|
+
*
|
|
839
|
+
* Uses `useTerminalDimensions()` so it reflows on `SIGWINCH` without remount.
|
|
840
|
+
*/
|
|
841
|
+
function Modal({ title, onClose, children, maxWidth = 92, minWidth = 44, horizontalMargin = 4 }) {
|
|
842
|
+
const ctx = useContext(ModalContext);
|
|
843
|
+
const dismiss = onClose ?? ctx?.close;
|
|
844
|
+
useKeyboard((key) => {
|
|
845
|
+
if (key.name === "escape") dismiss?.();
|
|
846
|
+
});
|
|
847
|
+
const { width: termWidth } = useTerminalDimensions();
|
|
848
|
+
const width = Math.max(minWidth, Math.min(maxWidth, termWidth - horizontalMargin * 2));
|
|
849
|
+
return /* @__PURE__ */ jsx("box", {
|
|
850
|
+
title: title ? ` ${title} ` : void 0,
|
|
851
|
+
style: {
|
|
852
|
+
border: true,
|
|
853
|
+
borderColor: COLOR.borderActive,
|
|
854
|
+
backgroundColor: "#101010",
|
|
855
|
+
paddingTop: 1,
|
|
856
|
+
paddingBottom: 1,
|
|
857
|
+
paddingLeft: 2,
|
|
858
|
+
paddingRight: 2,
|
|
859
|
+
width,
|
|
860
|
+
flexDirection: "column",
|
|
861
|
+
gap: 1
|
|
862
|
+
},
|
|
863
|
+
children
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
//#endregion
|
|
867
|
+
//#region src/tui/model-picker.tsx
|
|
868
|
+
/** Cap the visible scroll window so a 30-model list doesn't push the modal off-screen. */
|
|
869
|
+
const VISIBLE_ROW_CAP = 12;
|
|
870
|
+
/**
|
|
871
|
+
* Modal that lists the available models for the current provider and lets the
|
|
872
|
+
* user pick one. Options come from `runTui({ models })` if supplied, otherwise
|
|
873
|
+
* from pi-ai's built-in registry.
|
|
874
|
+
*
|
|
875
|
+
* Each row shows: `● selected · name (ctx N · reasoning · vision)`.
|
|
876
|
+
*/
|
|
877
|
+
function ModelPickerModal({ models, currentModelId, onPick }) {
|
|
878
|
+
const initialIndex = useMemo(() => models.findIndex((m) => m.id === currentModelId), [models, currentModelId]);
|
|
879
|
+
const options = useMemo(() => models.map((m) => ({
|
|
880
|
+
name: `${m.id === currentModelId ? "● " : " "}${m.name ?? m.id}`,
|
|
881
|
+
description: describeModel(m),
|
|
882
|
+
value: m.id
|
|
883
|
+
})), [models, currentModelId]);
|
|
884
|
+
if (models.length === 0) return /* @__PURE__ */ jsx(EmptyState, {});
|
|
885
|
+
const visibleRows = Math.min(options.length, VISIBLE_ROW_CAP);
|
|
886
|
+
const currentMissing = initialIndex < 0;
|
|
887
|
+
const safeIndex = currentMissing ? 0 : initialIndex;
|
|
888
|
+
return /* @__PURE__ */ jsxs(Modal, {
|
|
889
|
+
title: "select model",
|
|
890
|
+
children: [
|
|
891
|
+
currentMissing && /* @__PURE__ */ jsx("text", {
|
|
892
|
+
fg: COLOR.warn,
|
|
893
|
+
children: `Current model "${currentModelId}" is not in this registry — pick one below to switch.`
|
|
894
|
+
}),
|
|
895
|
+
/* @__PURE__ */ jsx("select", {
|
|
896
|
+
...SELECT_THEME,
|
|
897
|
+
focused: true,
|
|
898
|
+
options,
|
|
899
|
+
wrapSelection: true,
|
|
900
|
+
selectedIndex: safeIndex,
|
|
901
|
+
showScrollIndicator: options.length > visibleRows,
|
|
902
|
+
style: { height: visibleRows },
|
|
903
|
+
onSelect: (_idx, option) => {
|
|
904
|
+
if (option) onPick(option.value);
|
|
905
|
+
}
|
|
906
|
+
}),
|
|
907
|
+
/* @__PURE__ */ jsxs("text", {
|
|
908
|
+
fg: COLOR.mute,
|
|
909
|
+
children: [
|
|
910
|
+
/* @__PURE__ */ jsx("span", {
|
|
911
|
+
fg: COLOR.warn,
|
|
912
|
+
children: "↑↓"
|
|
913
|
+
}),
|
|
914
|
+
" navigate · ",
|
|
915
|
+
/* @__PURE__ */ jsx("span", {
|
|
916
|
+
fg: COLOR.warn,
|
|
917
|
+
children: "↵"
|
|
918
|
+
}),
|
|
919
|
+
" select · ",
|
|
920
|
+
/* @__PURE__ */ jsx("span", {
|
|
921
|
+
fg: COLOR.warn,
|
|
922
|
+
children: "esc"
|
|
923
|
+
}),
|
|
924
|
+
" close"
|
|
925
|
+
]
|
|
926
|
+
})
|
|
927
|
+
]
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
function EmptyState() {
|
|
931
|
+
return /* @__PURE__ */ jsxs(Modal, {
|
|
932
|
+
title: "select model",
|
|
933
|
+
children: [/* @__PURE__ */ jsx("text", {
|
|
934
|
+
fg: COLOR.dim,
|
|
935
|
+
children: "No models available for this provider."
|
|
936
|
+
}), /* @__PURE__ */ jsx("text", {
|
|
937
|
+
fg: COLOR.mute,
|
|
938
|
+
children: "Pass a `models` registry to `runTui()` to populate this list."
|
|
939
|
+
})]
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
/** "ctx 200k · reasoning · vision" — compact per-model description. */
|
|
943
|
+
function describeModel(m) {
|
|
944
|
+
const parts = [`ctx ${fmtTokens(m.contextWindow)}`];
|
|
945
|
+
if (m.reasoning) parts.push("reasoning");
|
|
946
|
+
if (m.input?.includes("image")) parts.push("vision");
|
|
947
|
+
return parts.join(" · ");
|
|
948
|
+
}
|
|
949
|
+
//#endregion
|
|
950
|
+
//#region src/tui/screens.tsx
|
|
951
|
+
/**
|
|
952
|
+
* Textarea bindings: plain `enter` submits; `shift+enter` inserts a newline.
|
|
953
|
+
* All `return` defaults are stripped and replaced so the user's preferred
|
|
954
|
+
* binding wins regardless of modifier state.
|
|
955
|
+
*/
|
|
956
|
+
const TEXTAREA_BINDINGS = [
|
|
957
|
+
...defaultTextareaKeyBindings.filter((b) => b.name !== "return"),
|
|
958
|
+
{
|
|
959
|
+
name: "return",
|
|
960
|
+
action: "submit"
|
|
961
|
+
},
|
|
962
|
+
{
|
|
963
|
+
name: "return",
|
|
964
|
+
shift: true,
|
|
965
|
+
action: "newline"
|
|
966
|
+
}
|
|
967
|
+
];
|
|
968
|
+
function AuthScreen({ onPick }) {
|
|
969
|
+
const { providers: registry } = useConfig();
|
|
970
|
+
const focused = useModalAwareFocus();
|
|
971
|
+
const providers = useMemo(() => detectAuth().filter((p) => p.key in registry), [registry]);
|
|
972
|
+
const available = useMemo(() => providers.filter((p) => p.available), [providers]);
|
|
973
|
+
if (available.length === 0) return /* @__PURE__ */ jsx(NoAuthScreen, { providers });
|
|
974
|
+
const options = available.map((p) => ({
|
|
975
|
+
name: p.label,
|
|
976
|
+
description: p.methods.map((m) => m.detail).join(" · "),
|
|
977
|
+
value: p.key
|
|
978
|
+
}));
|
|
979
|
+
return /* @__PURE__ */ jsx("box", {
|
|
980
|
+
title: " pick a provider ",
|
|
981
|
+
style: {
|
|
982
|
+
border: true,
|
|
983
|
+
borderColor: COLOR.border,
|
|
984
|
+
padding: 1,
|
|
985
|
+
flexDirection: "column",
|
|
986
|
+
flexGrow: 1
|
|
987
|
+
},
|
|
988
|
+
children: /* @__PURE__ */ jsx("select", {
|
|
989
|
+
...SELECT_THEME,
|
|
990
|
+
focused,
|
|
991
|
+
options,
|
|
992
|
+
wrapSelection: true,
|
|
993
|
+
onSelect: (_idx, option) => {
|
|
994
|
+
if (!option) return;
|
|
995
|
+
const provider = available.find((p) => p.key === option.value);
|
|
996
|
+
if (provider) onPick(provider);
|
|
997
|
+
},
|
|
998
|
+
style: { flexGrow: 1 }
|
|
999
|
+
})
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
function NoAuthScreen({ providers }) {
|
|
1003
|
+
return /* @__PURE__ */ jsxs("box", {
|
|
1004
|
+
title: " no authentication detected ",
|
|
1005
|
+
style: {
|
|
1006
|
+
border: true,
|
|
1007
|
+
borderColor: COLOR.error,
|
|
1008
|
+
padding: 1,
|
|
1009
|
+
flexDirection: "column",
|
|
1010
|
+
gap: 1,
|
|
1011
|
+
flexGrow: 1
|
|
1012
|
+
},
|
|
1013
|
+
children: [
|
|
1014
|
+
/* @__PURE__ */ jsx("text", {
|
|
1015
|
+
fg: COLOR.error,
|
|
1016
|
+
children: "No provider credentials found."
|
|
1017
|
+
}),
|
|
1018
|
+
/* @__PURE__ */ jsx("text", {
|
|
1019
|
+
fg: COLOR.dim,
|
|
1020
|
+
children: "Set one of these env vars (or run `bun run auth` for OAuth):"
|
|
1021
|
+
}),
|
|
1022
|
+
providers.map((p) => /* @__PURE__ */ jsxs("text", {
|
|
1023
|
+
fg: COLOR.dim,
|
|
1024
|
+
children: [
|
|
1025
|
+
" · ",
|
|
1026
|
+
/* @__PURE__ */ jsx("span", {
|
|
1027
|
+
fg: COLOR.brand,
|
|
1028
|
+
children: p.label
|
|
1029
|
+
}),
|
|
1030
|
+
" → ",
|
|
1031
|
+
/* @__PURE__ */ jsx("span", {
|
|
1032
|
+
fg: COLOR.model,
|
|
1033
|
+
children: envKeyFor(p.key)
|
|
1034
|
+
})
|
|
1035
|
+
]
|
|
1036
|
+
}, p.key))
|
|
1037
|
+
]
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
const NEW_VALUE = "__new__";
|
|
1041
|
+
function SessionsScreen({ sessions, currentId, onPick, onCreate }) {
|
|
1042
|
+
const focused = useModalAwareFocus();
|
|
1043
|
+
const options = useMemo(() => {
|
|
1044
|
+
const items = [{
|
|
1045
|
+
name: "+ new session",
|
|
1046
|
+
description: "start fresh",
|
|
1047
|
+
value: NEW_VALUE
|
|
1048
|
+
}];
|
|
1049
|
+
for (const s of sessions) {
|
|
1050
|
+
const marker = s.id === currentId ? "● " : " ";
|
|
1051
|
+
const turnLabel = `${s.turnCount} turn${s.turnCount === 1 ? "" : "s"}`;
|
|
1052
|
+
items.push({
|
|
1053
|
+
name: `${marker}${s.title}`,
|
|
1054
|
+
description: `#${shortId(s.id)} · ${turnLabel} · ${ageString(s.updatedAt)}`,
|
|
1055
|
+
value: s.id
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
return items;
|
|
1059
|
+
}, [sessions, currentId]);
|
|
1060
|
+
return /* @__PURE__ */ jsx("box", {
|
|
1061
|
+
title: " sessions ",
|
|
1062
|
+
style: {
|
|
1063
|
+
border: true,
|
|
1064
|
+
borderColor: COLOR.border,
|
|
1065
|
+
padding: 1,
|
|
1066
|
+
flexDirection: "column",
|
|
1067
|
+
flexGrow: 1
|
|
1068
|
+
},
|
|
1069
|
+
children: /* @__PURE__ */ jsx("select", {
|
|
1070
|
+
...SELECT_THEME,
|
|
1071
|
+
focused,
|
|
1072
|
+
options,
|
|
1073
|
+
wrapSelection: true,
|
|
1074
|
+
onSelect: (_idx, option) => {
|
|
1075
|
+
if (!option) return;
|
|
1076
|
+
if (option.value === NEW_VALUE) onCreate();
|
|
1077
|
+
else onPick(option.value);
|
|
1078
|
+
},
|
|
1079
|
+
style: { flexGrow: 1 }
|
|
1080
|
+
})
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
/** Visible content lines: 1 minimum, 5 maximum (textarea scrolls past 5). */
|
|
1084
|
+
const MIN_CONTENT_LINES = 1;
|
|
1085
|
+
const MAX_CONTENT_LINES = 5;
|
|
1086
|
+
function ChatScreen({ events, busy, settings, onSubmit, session }) {
|
|
1087
|
+
const title = useMemo(() => {
|
|
1088
|
+
if (!session) return " untitled ";
|
|
1089
|
+
const turns = `${session.turnCount} turn${session.turnCount === 1 ? "" : "s"}`;
|
|
1090
|
+
return ` ${session.title} · #${shortId(session.id)} · ${turns} `;
|
|
1091
|
+
}, [session]);
|
|
1092
|
+
const userPrompts = useMemo(() => events.filter((e) => e.kind === "info").map((e) => e.text.replace(/^❯ /, "")), [events]);
|
|
1093
|
+
return /* @__PURE__ */ jsxs("box", {
|
|
1094
|
+
style: {
|
|
1095
|
+
flexDirection: "column",
|
|
1096
|
+
flexGrow: 1
|
|
1097
|
+
},
|
|
1098
|
+
children: [/* @__PURE__ */ jsx("box", {
|
|
1099
|
+
title,
|
|
1100
|
+
style: {
|
|
1101
|
+
border: true,
|
|
1102
|
+
borderColor: COLOR.border,
|
|
1103
|
+
flexGrow: 1,
|
|
1104
|
+
flexDirection: "column"
|
|
1105
|
+
},
|
|
1106
|
+
children: /* @__PURE__ */ jsx(Transcript, {
|
|
1107
|
+
events,
|
|
1108
|
+
settings
|
|
1109
|
+
})
|
|
1110
|
+
}), busy ? /* @__PURE__ */ jsx(BusyBlock, {}) : /* @__PURE__ */ jsx(PromptBlock, {
|
|
1111
|
+
userPrompts,
|
|
1112
|
+
onSubmit
|
|
1113
|
+
})]
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
function BusyBlock() {
|
|
1117
|
+
return /* @__PURE__ */ jsx("box", {
|
|
1118
|
+
style: {
|
|
1119
|
+
border: true,
|
|
1120
|
+
borderColor: COLOR.warn,
|
|
1121
|
+
paddingLeft: 1,
|
|
1122
|
+
paddingRight: 1,
|
|
1123
|
+
height: 3
|
|
1124
|
+
},
|
|
1125
|
+
children: /* @__PURE__ */ jsx(Spinner, { label: "streaming response — esc to abort" })
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
function PromptBlock({ userPrompts, onSubmit }) {
|
|
1129
|
+
const focused = useModalAwareFocus();
|
|
1130
|
+
const textareaRef = useRef(null);
|
|
1131
|
+
/** Auto-grow: visible content rows the textarea currently occupies (clamped 1..5). */
|
|
1132
|
+
const [contentLines, setContentLines] = useState(MIN_CONTENT_LINES);
|
|
1133
|
+
/**
|
|
1134
|
+
* History navigation state. `null` = not navigating (textarea owns its content).
|
|
1135
|
+
* Once the user enters history (up at top), we snapshot the draft and cycle.
|
|
1136
|
+
*/
|
|
1137
|
+
const historyRef = useRef(null);
|
|
1138
|
+
const syncLines = useCallback(() => {
|
|
1139
|
+
const lines = textareaRef.current?.lineCount ?? MIN_CONTENT_LINES;
|
|
1140
|
+
setContentLines(Math.max(MIN_CONTENT_LINES, lines));
|
|
1141
|
+
}, []);
|
|
1142
|
+
const submit = useCallback(() => {
|
|
1143
|
+
const value = textareaRef.current?.plainText ?? "";
|
|
1144
|
+
if (!value.trim()) return;
|
|
1145
|
+
onSubmit(value);
|
|
1146
|
+
textareaRef.current?.clear();
|
|
1147
|
+
historyRef.current = null;
|
|
1148
|
+
setContentLines(MIN_CONTENT_LINES);
|
|
1149
|
+
}, [onSubmit]);
|
|
1150
|
+
const cycleHistory = useCallback((direction) => {
|
|
1151
|
+
if (userPrompts.length === 0 || !textareaRef.current) return;
|
|
1152
|
+
if (historyRef.current === null) historyRef.current = {
|
|
1153
|
+
idx: userPrompts.length,
|
|
1154
|
+
draft: textareaRef.current.plainText
|
|
1155
|
+
};
|
|
1156
|
+
const nextIdx = historyRef.current.idx + direction;
|
|
1157
|
+
if (nextIdx < 0) return;
|
|
1158
|
+
if (nextIdx >= userPrompts.length) {
|
|
1159
|
+
textareaRef.current.setText(historyRef.current.draft);
|
|
1160
|
+
textareaRef.current.gotoBufferEnd();
|
|
1161
|
+
historyRef.current = null;
|
|
1162
|
+
} else {
|
|
1163
|
+
textareaRef.current.setText(userPrompts[nextIdx]);
|
|
1164
|
+
textareaRef.current.gotoBufferEnd();
|
|
1165
|
+
historyRef.current.idx = nextIdx;
|
|
1166
|
+
}
|
|
1167
|
+
syncLines();
|
|
1168
|
+
}, [userPrompts, syncLines]);
|
|
1169
|
+
/**
|
|
1170
|
+
* Up/Down at the buffer boundary cycles prompt history (fish/zsh pattern).
|
|
1171
|
+
* Mid-buffer up/down move the cursor normally — handled by the default
|
|
1172
|
+
* `move-up` / `move-down` actions in `TEXTAREA_BINDINGS`.
|
|
1173
|
+
*/
|
|
1174
|
+
const onKeyDown = useCallback((event) => {
|
|
1175
|
+
if (event.ctrl || event.shift || event.meta) return;
|
|
1176
|
+
if (event.name !== "up" && event.name !== "down") return;
|
|
1177
|
+
const buffer = textareaRef.current;
|
|
1178
|
+
if (!buffer) return;
|
|
1179
|
+
const cursorRow = buffer.logicalCursor.row;
|
|
1180
|
+
if (event.name === "up" && cursorRow === 0) cycleHistory(-1);
|
|
1181
|
+
else if (event.name === "down" && cursorRow === buffer.lineCount - 1) cycleHistory(1);
|
|
1182
|
+
}, [cycleHistory]);
|
|
1183
|
+
const boxHeight = Math.min(MAX_CONTENT_LINES, contentLines) + 2;
|
|
1184
|
+
return /* @__PURE__ */ jsx("box", {
|
|
1185
|
+
style: {
|
|
1186
|
+
border: true,
|
|
1187
|
+
borderColor: COLOR.borderActive,
|
|
1188
|
+
paddingLeft: 1,
|
|
1189
|
+
paddingRight: 1,
|
|
1190
|
+
height: boxHeight,
|
|
1191
|
+
flexDirection: "column"
|
|
1192
|
+
},
|
|
1193
|
+
children: /* @__PURE__ */ jsx("textarea", {
|
|
1194
|
+
ref: textareaRef,
|
|
1195
|
+
focused,
|
|
1196
|
+
keyBindings: TEXTAREA_BINDINGS,
|
|
1197
|
+
placeholder: "Ask zidane… (enter = send · shift+enter = newline · ↑↓ at edges = history)",
|
|
1198
|
+
style: {
|
|
1199
|
+
flexGrow: 1,
|
|
1200
|
+
height: "100%"
|
|
1201
|
+
},
|
|
1202
|
+
onSubmit: submit,
|
|
1203
|
+
onContentChange: syncLines,
|
|
1204
|
+
onKeyDown
|
|
1205
|
+
})
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
//#endregion
|
|
1209
|
+
//#region src/tui/settings.tsx
|
|
1210
|
+
const DEFAULT_SETTINGS = {
|
|
1211
|
+
showThinking: true,
|
|
1212
|
+
showToolCalls: true,
|
|
1213
|
+
showToolResults: true
|
|
1214
|
+
};
|
|
1215
|
+
const SettingsContext = createContext(null);
|
|
1216
|
+
function SettingsProvider({ initial, onChange, children }) {
|
|
1217
|
+
const [settings, setSettings] = useState(initial);
|
|
1218
|
+
const toggle = useCallback((key) => {
|
|
1219
|
+
setSettings((prev) => {
|
|
1220
|
+
const next = {
|
|
1221
|
+
...prev,
|
|
1222
|
+
[key]: !prev[key]
|
|
1223
|
+
};
|
|
1224
|
+
onChange?.(next);
|
|
1225
|
+
return next;
|
|
1226
|
+
});
|
|
1227
|
+
}, [onChange]);
|
|
1228
|
+
const value = useMemo(() => ({
|
|
1229
|
+
settings,
|
|
1230
|
+
toggle
|
|
1231
|
+
}), [settings, toggle]);
|
|
1232
|
+
return /* @__PURE__ */ jsx(SettingsContext.Provider, {
|
|
1233
|
+
value,
|
|
1234
|
+
children
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
function useSettings() {
|
|
1238
|
+
const ctx = useContext(SettingsContext);
|
|
1239
|
+
if (!ctx) throw new Error("useSettings must be used inside <SettingsProvider>");
|
|
1240
|
+
return ctx;
|
|
1241
|
+
}
|
|
1242
|
+
const ROWS = [
|
|
1243
|
+
{
|
|
1244
|
+
key: "showThinking",
|
|
1245
|
+
label: "Thinking blocks",
|
|
1246
|
+
description: "agent reasoning shown inline"
|
|
1247
|
+
},
|
|
1248
|
+
{
|
|
1249
|
+
key: "showToolCalls",
|
|
1250
|
+
label: "Tool calls",
|
|
1251
|
+
description: "the ↳ name(args) lines"
|
|
1252
|
+
},
|
|
1253
|
+
{
|
|
1254
|
+
key: "showToolResults",
|
|
1255
|
+
label: "Tool outputs",
|
|
1256
|
+
description: "the ┃ result blocks under tool calls"
|
|
1257
|
+
}
|
|
1258
|
+
];
|
|
1259
|
+
function SettingsModal() {
|
|
1260
|
+
const { settings, toggle } = useSettings();
|
|
1261
|
+
const [cursor, setCursor] = useState(0);
|
|
1262
|
+
useKeyboard((key) => {
|
|
1263
|
+
if (key.name === "up" || key.ctrl && key.name === "p") setCursor((c) => Math.max(0, c - 1));
|
|
1264
|
+
else if (key.name === "down" || key.ctrl && key.name === "n") setCursor((c) => Math.min(ROWS.length - 1, c + 1));
|
|
1265
|
+
else if (key.name === "return" || key.name === "space") toggle(ROWS[cursor].key);
|
|
1266
|
+
});
|
|
1267
|
+
return /* @__PURE__ */ jsxs(Modal, {
|
|
1268
|
+
title: "settings",
|
|
1269
|
+
children: [/* @__PURE__ */ jsx("box", {
|
|
1270
|
+
style: { flexDirection: "column" },
|
|
1271
|
+
children: ROWS.map((row, i) => /* @__PURE__ */ jsx(SettingRowView, {
|
|
1272
|
+
row,
|
|
1273
|
+
enabled: settings[row.key],
|
|
1274
|
+
focused: i === cursor
|
|
1275
|
+
}, row.key))
|
|
1276
|
+
}), /* @__PURE__ */ jsxs("text", {
|
|
1277
|
+
fg: COLOR.mute,
|
|
1278
|
+
children: [
|
|
1279
|
+
/* @__PURE__ */ jsx("span", {
|
|
1280
|
+
fg: COLOR.warn,
|
|
1281
|
+
children: "↑↓"
|
|
1282
|
+
}),
|
|
1283
|
+
" navigate · ",
|
|
1284
|
+
/* @__PURE__ */ jsx("span", {
|
|
1285
|
+
fg: COLOR.warn,
|
|
1286
|
+
children: "↵"
|
|
1287
|
+
}),
|
|
1288
|
+
" toggle · ",
|
|
1289
|
+
/* @__PURE__ */ jsx("span", {
|
|
1290
|
+
fg: COLOR.warn,
|
|
1291
|
+
children: "esc"
|
|
1292
|
+
}),
|
|
1293
|
+
" close"
|
|
1294
|
+
]
|
|
1295
|
+
})]
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* A single setting row — `▶` marker · checkbox · label · description.
|
|
1300
|
+
*
|
|
1301
|
+
* Rendered as one `<text>` so OpenTUI's word-wrap handles narrow terminals
|
|
1302
|
+
* automatically: on wide screens everything sits on one line; on narrow ones
|
|
1303
|
+
* the trailing description wraps under the label without breaking the row's
|
|
1304
|
+
* structure.
|
|
1305
|
+
*/
|
|
1306
|
+
function SettingRowView({ row, enabled, focused }) {
|
|
1307
|
+
return /* @__PURE__ */ jsxs("text", {
|
|
1308
|
+
fg: focused ? COLOR.brand : COLOR.dim,
|
|
1309
|
+
children: [
|
|
1310
|
+
/* @__PURE__ */ jsx("span", {
|
|
1311
|
+
fg: focused ? COLOR.brand : COLOR.mute,
|
|
1312
|
+
children: focused ? "▶ " : " "
|
|
1313
|
+
}),
|
|
1314
|
+
/* @__PURE__ */ jsx("span", {
|
|
1315
|
+
fg: enabled ? COLOR.accent : COLOR.mute,
|
|
1316
|
+
children: enabled ? "[✓] " : "[ ] "
|
|
1317
|
+
}),
|
|
1318
|
+
/* @__PURE__ */ jsx("span", {
|
|
1319
|
+
fg: focused ? COLOR.brand : COLOR.dim,
|
|
1320
|
+
children: row.label
|
|
1321
|
+
}),
|
|
1322
|
+
/* @__PURE__ */ jsx("span", {
|
|
1323
|
+
fg: COLOR.mute,
|
|
1324
|
+
children: ` ${row.description}`
|
|
1325
|
+
})
|
|
1326
|
+
]
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
//#endregion
|
|
1330
|
+
//#region src/tui/streaming.ts
|
|
1331
|
+
/** Target one flush per ~33ms (one frame at the default renderer targetFps=30). */
|
|
1332
|
+
const FLUSH_INTERVAL_MS = 33;
|
|
1333
|
+
const PARENT_OWNER = "parent";
|
|
1334
|
+
function emptyBucket(owner, depth) {
|
|
1335
|
+
return {
|
|
1336
|
+
markdown: "",
|
|
1337
|
+
thinking: "",
|
|
1338
|
+
owner,
|
|
1339
|
+
depth
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
function applyBucket(prev, bucket) {
|
|
1343
|
+
let result = prev;
|
|
1344
|
+
if (bucket.thinking) result = appendThinkingLines(result, bucket.thinking, bucket.owner, bucket.depth);
|
|
1345
|
+
if (bucket.markdown) result = appendMarkdownDelta(result, bucket.markdown, bucket.owner, bucket.depth);
|
|
1346
|
+
return result;
|
|
1347
|
+
}
|
|
1348
|
+
function appendMarkdownDelta(prev, delta, owner, depth) {
|
|
1349
|
+
const last = prev[prev.length - 1];
|
|
1350
|
+
if (last && last.kind === "markdown" && last.streaming && ownerOf(last) === owner) {
|
|
1351
|
+
const next = prev.slice(0, -1);
|
|
1352
|
+
next.push({
|
|
1353
|
+
...last,
|
|
1354
|
+
text: last.text + delta
|
|
1355
|
+
});
|
|
1356
|
+
return next;
|
|
1357
|
+
}
|
|
1358
|
+
return [...prev, eventWithOwner({
|
|
1359
|
+
kind: "markdown",
|
|
1360
|
+
text: delta,
|
|
1361
|
+
streaming: true
|
|
1362
|
+
}, owner, depth)];
|
|
1363
|
+
}
|
|
1364
|
+
function appendThinkingLines(prev, delta, owner, depth) {
|
|
1365
|
+
const lines = delta.split("\n");
|
|
1366
|
+
const result = [...prev];
|
|
1367
|
+
const last = result[result.length - 1];
|
|
1368
|
+
if (last && last.kind === "thinking" && ownerOf(last) === owner) result[result.length - 1] = {
|
|
1369
|
+
...last,
|
|
1370
|
+
text: last.text + lines[0]
|
|
1371
|
+
};
|
|
1372
|
+
else if (lines[0] || lines.length > 1) result.push(eventWithOwner({
|
|
1373
|
+
kind: "thinking",
|
|
1374
|
+
text: lines[0]
|
|
1375
|
+
}, owner, depth));
|
|
1376
|
+
for (let i = 1; i < lines.length; i++) result.push(eventWithOwner({
|
|
1377
|
+
kind: "thinking",
|
|
1378
|
+
text: lines[i]
|
|
1379
|
+
}, owner, depth));
|
|
1380
|
+
return result;
|
|
1381
|
+
}
|
|
1382
|
+
function ownerOf(evt) {
|
|
1383
|
+
return evt.childId ?? PARENT_OWNER;
|
|
1384
|
+
}
|
|
1385
|
+
function eventWithOwner(evt, owner, depth) {
|
|
1386
|
+
if (owner === PARENT_OWNER) return evt;
|
|
1387
|
+
return {
|
|
1388
|
+
...evt,
|
|
1389
|
+
childId: owner,
|
|
1390
|
+
depth
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
/** Flip any trailing streaming markdown blocks (any owner) to finalized. */
|
|
1394
|
+
function finalizeStreamingMarkdown(events) {
|
|
1395
|
+
let changed = false;
|
|
1396
|
+
const next = events.map((e) => {
|
|
1397
|
+
if (e.kind === "markdown" && e.streaming) {
|
|
1398
|
+
changed = true;
|
|
1399
|
+
return {
|
|
1400
|
+
...e,
|
|
1401
|
+
streaming: false
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
return e;
|
|
1405
|
+
});
|
|
1406
|
+
return changed ? next : events;
|
|
1407
|
+
}
|
|
1408
|
+
/** Flip the trailing streaming markdown block for one specific owner. */
|
|
1409
|
+
function finalizeStreamingMarkdownForOwner(events, owner) {
|
|
1410
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
1411
|
+
const e = events[i];
|
|
1412
|
+
if (e.kind !== "markdown") continue;
|
|
1413
|
+
if (!e.streaming) continue;
|
|
1414
|
+
if (ownerOf(e) !== owner) continue;
|
|
1415
|
+
const next = events.slice();
|
|
1416
|
+
next[i] = {
|
|
1417
|
+
...e,
|
|
1418
|
+
streaming: false
|
|
1419
|
+
};
|
|
1420
|
+
return next;
|
|
1421
|
+
}
|
|
1422
|
+
return events;
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* Effective context size for a single turn.
|
|
1426
|
+
*
|
|
1427
|
+
* `usage.input` is misleading on its own when prompt caching is active: providers
|
|
1428
|
+
* (Anthropic, OpenRouter→Anthropic, Gemini) report `input` as the *new uncached*
|
|
1429
|
+
* tokens only — the cached prefix shows up in `cacheRead`, and newly-cached
|
|
1430
|
+
* tokens in `cacheCreation`. The model still saw all three buckets, so the real
|
|
1431
|
+
* context-window utilization is their sum.
|
|
1432
|
+
*
|
|
1433
|
+
* Non-caching providers leave `cacheRead`/`cacheCreation` undefined, so this
|
|
1434
|
+
* collapses to plain `input` for them.
|
|
1435
|
+
*/
|
|
1436
|
+
function turnContextSize(usage) {
|
|
1437
|
+
if (!usage) return 0;
|
|
1438
|
+
return (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheCreation ?? 0);
|
|
1439
|
+
}
|
|
1440
|
+
function useStreamBuffer(setEvents) {
|
|
1441
|
+
const bucketsRef = useRef(/* @__PURE__ */ new Map());
|
|
1442
|
+
const flushTimerRef = useRef(null);
|
|
1443
|
+
const drainPendingInto = useCallback((updater) => {
|
|
1444
|
+
if (flushTimerRef.current) {
|
|
1445
|
+
clearTimeout(flushTimerRef.current);
|
|
1446
|
+
flushTimerRef.current = null;
|
|
1447
|
+
}
|
|
1448
|
+
const buckets = Array.from(bucketsRef.current.values());
|
|
1449
|
+
bucketsRef.current.clear();
|
|
1450
|
+
if (!buckets.some((b) => b.markdown.length > 0 || b.thinking.length > 0) && !updater) return;
|
|
1451
|
+
setEvents((prev) => {
|
|
1452
|
+
let merged = prev;
|
|
1453
|
+
for (const bucket of buckets) merged = applyBucket(merged, bucket);
|
|
1454
|
+
return updater ? updater(merged) : merged;
|
|
1455
|
+
});
|
|
1456
|
+
}, [setEvents]);
|
|
1457
|
+
const flush = useCallback(() => drainPendingInto(), [drainPendingInto]);
|
|
1458
|
+
const flushAndUpdate = useCallback((update) => drainPendingInto(update), [drainPendingInto]);
|
|
1459
|
+
const appendImmediate = useCallback((evt) => drainPendingInto((events) => [...events, evt]), [drainPendingInto]);
|
|
1460
|
+
const queueStreamDelta = useCallback((kind, delta, source) => {
|
|
1461
|
+
if (!delta) return;
|
|
1462
|
+
const owner = source?.childId ?? PARENT_OWNER;
|
|
1463
|
+
const depth = source?.depth ?? 0;
|
|
1464
|
+
let bucket = bucketsRef.current.get(owner);
|
|
1465
|
+
if (!bucket) {
|
|
1466
|
+
bucket = emptyBucket(owner, depth);
|
|
1467
|
+
bucketsRef.current.set(owner, bucket);
|
|
1468
|
+
}
|
|
1469
|
+
bucket[kind] += delta;
|
|
1470
|
+
if (!flushTimerRef.current) flushTimerRef.current = setTimeout(flush, FLUSH_INTERVAL_MS);
|
|
1471
|
+
}, [flush]);
|
|
1472
|
+
const reset = useCallback(() => {
|
|
1473
|
+
if (flushTimerRef.current) {
|
|
1474
|
+
clearTimeout(flushTimerRef.current);
|
|
1475
|
+
flushTimerRef.current = null;
|
|
1476
|
+
}
|
|
1477
|
+
bucketsRef.current.clear();
|
|
1478
|
+
}, []);
|
|
1479
|
+
return useMemo(() => ({
|
|
1480
|
+
queueStreamDelta,
|
|
1481
|
+
appendImmediate,
|
|
1482
|
+
flushAndUpdate,
|
|
1483
|
+
flush,
|
|
1484
|
+
reset
|
|
1485
|
+
}), [
|
|
1486
|
+
queueStreamDelta,
|
|
1487
|
+
appendImmediate,
|
|
1488
|
+
flushAndUpdate,
|
|
1489
|
+
flush,
|
|
1490
|
+
reset
|
|
1491
|
+
]);
|
|
1492
|
+
}
|
|
1493
|
+
//#endregion
|
|
1494
|
+
//#region src/tui/app.tsx
|
|
1495
|
+
/**
|
|
1496
|
+
* Surface failures that are normally silenced (teardown / save) when the
|
|
1497
|
+
* `ZIDANE_DEBUG` env var is set. Logging via `console.error` would otherwise
|
|
1498
|
+
* trigger OpenTUI's error console overlay and clutter the UI for end users.
|
|
1499
|
+
*/
|
|
1500
|
+
const debugLog = process.env.ZIDANE_DEBUG ? (label, err) => console.error(`[zidane/tui] ${label}:`, err) : () => {};
|
|
1501
|
+
/**
|
|
1502
|
+
* Top-level TUI component. Accepts a fully-resolved `ResolvedConfig` and wires
|
|
1503
|
+
* everything (settings, modal layer, screens, footer) underneath it.
|
|
1504
|
+
*
|
|
1505
|
+
* Hosts can either drive this via `runTui()` for the standard bootstrap or
|
|
1506
|
+
* mount `<App config={resolveConfig(...)} />` themselves inside a renderer
|
|
1507
|
+
* they already own.
|
|
1508
|
+
*/
|
|
1509
|
+
function App({ config }) {
|
|
1510
|
+
return /* @__PURE__ */ jsx(ConfigProvider, {
|
|
1511
|
+
config,
|
|
1512
|
+
children: /* @__PURE__ */ jsx(SettingsProvider, {
|
|
1513
|
+
initial: useMemo(() => ({
|
|
1514
|
+
...DEFAULT_SETTINGS,
|
|
1515
|
+
...config.initialSettings
|
|
1516
|
+
}), [config.initialSettings]),
|
|
1517
|
+
onChange: useCallback((settings) => config.stateStore.save({
|
|
1518
|
+
...config.stateStore.load(),
|
|
1519
|
+
settings
|
|
1520
|
+
}), [config.stateStore]),
|
|
1521
|
+
children: /* @__PURE__ */ jsx(ModalRoot, { children: /* @__PURE__ */ jsx(AppShell, {}) })
|
|
1522
|
+
})
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
function AppShell() {
|
|
1526
|
+
const renderer = useRenderer();
|
|
1527
|
+
const modal = useModal();
|
|
1528
|
+
const config = useConfig();
|
|
1529
|
+
const { settings } = useSettings();
|
|
1530
|
+
const { providers: providerRegistry, preset, store, stateStore, modelsFor, resumeProvider, initialPicked, initialState } = config;
|
|
1531
|
+
const lastResumedSessionId = initialState.lastSessionId;
|
|
1532
|
+
const [screen, setScreen] = useState(() => {
|
|
1533
|
+
if (!resumeProvider) return "auth";
|
|
1534
|
+
return lastResumedSessionId ? "chat" : "sessions";
|
|
1535
|
+
});
|
|
1536
|
+
const [picked, setPicked] = useState(() => initialPicked);
|
|
1537
|
+
const [sessions, setSessions] = useState([]);
|
|
1538
|
+
const [currentSession, setCurrentSession] = useState(null);
|
|
1539
|
+
const [events, setEvents] = useState([]);
|
|
1540
|
+
const [busy, setBusy] = useState(false);
|
|
1541
|
+
/** Token count from the most recent assistant turn (caching-aware). */
|
|
1542
|
+
const [lastInputTokens, setLastInputTokens] = useState(0);
|
|
1543
|
+
const agentRef = useRef(null);
|
|
1544
|
+
const sessionRef = useRef(null);
|
|
1545
|
+
const stream = useStreamBuffer(setEvents);
|
|
1546
|
+
const makePicked = useCallback((provider, modelId) => {
|
|
1547
|
+
const factory = providerRegistry[provider.key];
|
|
1548
|
+
if (!factory) return null;
|
|
1549
|
+
const remembered = initialState.lastModelByProvider?.[provider.key];
|
|
1550
|
+
return {
|
|
1551
|
+
provider,
|
|
1552
|
+
model: modelId ?? remembered ?? factory().meta.defaultModel
|
|
1553
|
+
};
|
|
1554
|
+
}, [providerRegistry, initialState]);
|
|
1555
|
+
const buildAgent = useCallback((session, key) => {
|
|
1556
|
+
const factory = providerRegistry[key];
|
|
1557
|
+
if (!factory) throw new Error(`No provider registered for key "${key}"`);
|
|
1558
|
+
const agent = createAgent({
|
|
1559
|
+
...preset,
|
|
1560
|
+
provider: factory(),
|
|
1561
|
+
session
|
|
1562
|
+
});
|
|
1563
|
+
agent.hooks.hook("stream:thinking", ({ delta }) => stream.queueStreamDelta("thinking", delta));
|
|
1564
|
+
agent.hooks.hook("stream:text", ({ delta }) => stream.queueStreamDelta("markdown", delta));
|
|
1565
|
+
agent.hooks.hook("tool:before", ({ name, input }) => {
|
|
1566
|
+
stream.appendImmediate({
|
|
1567
|
+
kind: "tool",
|
|
1568
|
+
text: toolCallPreview(name, input)
|
|
1569
|
+
});
|
|
1570
|
+
});
|
|
1571
|
+
agent.hooks.hook("tool:after", ({ result }) => {
|
|
1572
|
+
stream.appendImmediate({
|
|
1573
|
+
kind: "tool-result",
|
|
1574
|
+
text: toolResultText(result)
|
|
1575
|
+
});
|
|
1576
|
+
});
|
|
1577
|
+
agent.hooks.hook("mcp:tool:after", ({ result }) => {
|
|
1578
|
+
stream.appendImmediate({
|
|
1579
|
+
kind: "tool-result",
|
|
1580
|
+
text: toolResultText(result)
|
|
1581
|
+
});
|
|
1582
|
+
});
|
|
1583
|
+
agent.hooks.hook("turn:after", ({ usage }) => {
|
|
1584
|
+
if (usage) setLastInputTokens(turnContextSize(usage));
|
|
1585
|
+
stream.flushAndUpdate(finalizeStreamingMarkdown);
|
|
1586
|
+
});
|
|
1587
|
+
agent.hooks.hook("spawn:before", ({ id, task, depth }) => {
|
|
1588
|
+
const taskPreview = task.length > 80 ? `${task.slice(0, 80)}…` : task;
|
|
1589
|
+
stream.appendImmediate({
|
|
1590
|
+
kind: "spawn-start",
|
|
1591
|
+
text: taskPreview,
|
|
1592
|
+
childId: id,
|
|
1593
|
+
depth: depth ?? 1
|
|
1594
|
+
});
|
|
1595
|
+
});
|
|
1596
|
+
agent.hooks.hook("spawn:complete", ({ id, depth, status, stats }) => {
|
|
1597
|
+
const tag = status === "aborted" ? "aborted" : status === "error" ? "error" : "done";
|
|
1598
|
+
stream.appendImmediate({
|
|
1599
|
+
kind: "spawn-end",
|
|
1600
|
+
text: `${tag} · ${stats.totalIn} in / ${stats.totalOut} out`,
|
|
1601
|
+
childId: id,
|
|
1602
|
+
depth: depth ?? 1
|
|
1603
|
+
});
|
|
1604
|
+
});
|
|
1605
|
+
agent.hooks.hook("spawn:error", ({ id, depth, error }) => {
|
|
1606
|
+
stream.appendImmediate({
|
|
1607
|
+
kind: "error",
|
|
1608
|
+
text: `[${id}] ${error.message}`,
|
|
1609
|
+
childId: id,
|
|
1610
|
+
depth: depth ?? 1
|
|
1611
|
+
});
|
|
1612
|
+
});
|
|
1613
|
+
agent.hooks.hook("child:stream:thinking", ({ delta, childId, depth }) => {
|
|
1614
|
+
stream.queueStreamDelta("thinking", delta, {
|
|
1615
|
+
childId,
|
|
1616
|
+
depth
|
|
1617
|
+
});
|
|
1618
|
+
});
|
|
1619
|
+
agent.hooks.hook("child:stream:text", ({ delta, childId, depth }) => {
|
|
1620
|
+
stream.queueStreamDelta("markdown", delta, {
|
|
1621
|
+
childId,
|
|
1622
|
+
depth
|
|
1623
|
+
});
|
|
1624
|
+
});
|
|
1625
|
+
agent.hooks.hook("child:tool:before", ({ name, input, childId, depth }) => {
|
|
1626
|
+
stream.appendImmediate({
|
|
1627
|
+
kind: "tool",
|
|
1628
|
+
text: toolCallPreview(name, input),
|
|
1629
|
+
childId,
|
|
1630
|
+
depth
|
|
1631
|
+
});
|
|
1632
|
+
});
|
|
1633
|
+
agent.hooks.hook("child:tool:after", ({ result, childId, depth }) => {
|
|
1634
|
+
stream.appendImmediate({
|
|
1635
|
+
kind: "tool-result",
|
|
1636
|
+
text: toolResultText(result),
|
|
1637
|
+
childId,
|
|
1638
|
+
depth
|
|
1639
|
+
});
|
|
1640
|
+
});
|
|
1641
|
+
agent.hooks.hook("child:stream:end", ({ childId }) => {
|
|
1642
|
+
stream.flushAndUpdate((prev) => finalizeStreamingMarkdownForOwner(prev, childId));
|
|
1643
|
+
});
|
|
1644
|
+
return agent;
|
|
1645
|
+
}, [
|
|
1646
|
+
providerRegistry,
|
|
1647
|
+
preset,
|
|
1648
|
+
stream
|
|
1649
|
+
]);
|
|
1650
|
+
const refreshSessions = useCallback(async () => {
|
|
1651
|
+
const list = await listSessionMeta(store);
|
|
1652
|
+
setSessions(list);
|
|
1653
|
+
return list;
|
|
1654
|
+
}, [store]);
|
|
1655
|
+
const teardown = useCallback(async () => {
|
|
1656
|
+
stream.reset();
|
|
1657
|
+
await agentRef.current?.destroy().catch((err) => debugLog("agent.destroy failed", err));
|
|
1658
|
+
agentRef.current = null;
|
|
1659
|
+
sessionRef.current = null;
|
|
1660
|
+
}, [stream]);
|
|
1661
|
+
const activateSession = useCallback(async (id, key) => {
|
|
1662
|
+
await teardown();
|
|
1663
|
+
const session = (id ? await loadSession(store, id) : null) ?? await createSession({
|
|
1664
|
+
store,
|
|
1665
|
+
...id ? { id } : {}
|
|
1666
|
+
});
|
|
1667
|
+
sessionRef.current = session;
|
|
1668
|
+
agentRef.current = buildAgent(session, key);
|
|
1669
|
+
setEvents(eventsFromTurns(session.turns));
|
|
1670
|
+
setLastInputTokens(lastContextSizeFromTurns(session.turns));
|
|
1671
|
+
setCurrentSession({
|
|
1672
|
+
id: session.id,
|
|
1673
|
+
title: titleFromTurns(session.turns) ?? "untitled",
|
|
1674
|
+
turnCount: session.turns.length,
|
|
1675
|
+
updatedAt: Date.now()
|
|
1676
|
+
});
|
|
1677
|
+
setScreen("chat");
|
|
1678
|
+
stateStore.save({
|
|
1679
|
+
...stateStore.load(),
|
|
1680
|
+
lastProvider: key,
|
|
1681
|
+
lastSessionId: session.id
|
|
1682
|
+
});
|
|
1683
|
+
}, [
|
|
1684
|
+
teardown,
|
|
1685
|
+
buildAgent,
|
|
1686
|
+
store,
|
|
1687
|
+
stateStore
|
|
1688
|
+
]);
|
|
1689
|
+
useEffect(() => {
|
|
1690
|
+
if (!resumeProvider) return;
|
|
1691
|
+
let cancelled = false;
|
|
1692
|
+
(async () => {
|
|
1693
|
+
if (lastResumedSessionId) {
|
|
1694
|
+
const data = await store.load(lastResumedSessionId);
|
|
1695
|
+
if (cancelled) return;
|
|
1696
|
+
if (data) {
|
|
1697
|
+
await activateSession(lastResumedSessionId, resumeProvider.key);
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
const list = await refreshSessions();
|
|
1702
|
+
if (cancelled) return;
|
|
1703
|
+
if (list.length === 0) await activateSession(null, resumeProvider.key);
|
|
1704
|
+
else setScreen("sessions");
|
|
1705
|
+
})();
|
|
1706
|
+
return () => {
|
|
1707
|
+
cancelled = true;
|
|
1708
|
+
};
|
|
1709
|
+
}, [
|
|
1710
|
+
activateSession,
|
|
1711
|
+
refreshSessions,
|
|
1712
|
+
resumeProvider,
|
|
1713
|
+
lastResumedSessionId,
|
|
1714
|
+
store
|
|
1715
|
+
]);
|
|
1716
|
+
const onPickProvider = useCallback(async (p) => {
|
|
1717
|
+
const next = makePicked(p);
|
|
1718
|
+
if (!next) return;
|
|
1719
|
+
setPicked(next);
|
|
1720
|
+
stateStore.save({
|
|
1721
|
+
...stateStore.load(),
|
|
1722
|
+
lastProvider: p.key
|
|
1723
|
+
});
|
|
1724
|
+
if ((await refreshSessions()).length === 0) await activateSession(null, p.key);
|
|
1725
|
+
else setScreen("sessions");
|
|
1726
|
+
}, [
|
|
1727
|
+
refreshSessions,
|
|
1728
|
+
activateSession,
|
|
1729
|
+
makePicked,
|
|
1730
|
+
stateStore
|
|
1731
|
+
]);
|
|
1732
|
+
const onCreateSession = useCallback(async () => {
|
|
1733
|
+
if (picked) await activateSession(null, picked.provider.key);
|
|
1734
|
+
}, [picked, activateSession]);
|
|
1735
|
+
const onSwitchSession = useCallback(async (id) => {
|
|
1736
|
+
if (picked) await activateSession(id, picked.provider.key);
|
|
1737
|
+
}, [picked, activateSession]);
|
|
1738
|
+
const onOpenSessions = useCallback(async () => {
|
|
1739
|
+
await refreshSessions();
|
|
1740
|
+
setScreen("sessions");
|
|
1741
|
+
}, [refreshSessions]);
|
|
1742
|
+
const onAbort = useCallback(() => {
|
|
1743
|
+
agentRef.current?.abort();
|
|
1744
|
+
}, []);
|
|
1745
|
+
const onPickModel = useCallback((modelId) => {
|
|
1746
|
+
setPicked((prev) => {
|
|
1747
|
+
if (!prev) return prev;
|
|
1748
|
+
const prior = stateStore.load();
|
|
1749
|
+
stateStore.save({
|
|
1750
|
+
...prior,
|
|
1751
|
+
lastModelByProvider: {
|
|
1752
|
+
...prior.lastModelByProvider,
|
|
1753
|
+
[prev.provider.key]: modelId
|
|
1754
|
+
}
|
|
1755
|
+
});
|
|
1756
|
+
return {
|
|
1757
|
+
...prev,
|
|
1758
|
+
model: modelId
|
|
1759
|
+
};
|
|
1760
|
+
});
|
|
1761
|
+
modal.close();
|
|
1762
|
+
}, [modal, stateStore]);
|
|
1763
|
+
const onSubmitPrompt = useCallback(async (prompt) => {
|
|
1764
|
+
const agent = agentRef.current;
|
|
1765
|
+
const session = sessionRef.current;
|
|
1766
|
+
if (!agent || !session || !picked || !prompt.trim()) return;
|
|
1767
|
+
if (events.length > 0) stream.appendImmediate({
|
|
1768
|
+
kind: "separator",
|
|
1769
|
+
text: ""
|
|
1770
|
+
});
|
|
1771
|
+
stream.appendImmediate({
|
|
1772
|
+
kind: "info",
|
|
1773
|
+
text: `❯ ${prompt}`
|
|
1774
|
+
});
|
|
1775
|
+
setBusy(true);
|
|
1776
|
+
try {
|
|
1777
|
+
await agent.run({
|
|
1778
|
+
model: picked.model,
|
|
1779
|
+
prompt
|
|
1780
|
+
});
|
|
1781
|
+
await session.save().catch((err) => debugLog("session.save failed", err));
|
|
1782
|
+
setCurrentSession((prev) => prev ? {
|
|
1783
|
+
...prev,
|
|
1784
|
+
title: titleFromTurns(session.turns) ?? prev.title,
|
|
1785
|
+
turnCount: session.turns.length,
|
|
1786
|
+
updatedAt: Date.now()
|
|
1787
|
+
} : prev);
|
|
1788
|
+
} catch (err) {
|
|
1789
|
+
stream.appendImmediate({
|
|
1790
|
+
kind: "error",
|
|
1791
|
+
text: err instanceof Error ? err.message : String(err)
|
|
1792
|
+
});
|
|
1793
|
+
} finally {
|
|
1794
|
+
stream.flushAndUpdate(finalizeStreamingMarkdown);
|
|
1795
|
+
setBusy(false);
|
|
1796
|
+
}
|
|
1797
|
+
}, [
|
|
1798
|
+
picked,
|
|
1799
|
+
events.length,
|
|
1800
|
+
stream
|
|
1801
|
+
]);
|
|
1802
|
+
useKeyboard((key) => {
|
|
1803
|
+
if (modal.isOpen) return;
|
|
1804
|
+
if (key.ctrl && key.name === "," && screen !== "auth") {
|
|
1805
|
+
modal.open(/* @__PURE__ */ jsx(SettingsModal, {}));
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
if (key.ctrl && key.name === "m" && screen === "chat" && picked && !busy) {
|
|
1809
|
+
modal.open(/* @__PURE__ */ jsx(ModelPickerModal, {
|
|
1810
|
+
models: modelsFor(picked.provider.key),
|
|
1811
|
+
currentModelId: picked.model,
|
|
1812
|
+
onPick: onPickModel
|
|
1813
|
+
}));
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
if (key.name !== "escape") return;
|
|
1817
|
+
if (busy) return onAbort();
|
|
1818
|
+
if (screen === "chat") return onOpenSessions();
|
|
1819
|
+
if (screen === "sessions") {
|
|
1820
|
+
if (currentSession) setScreen("chat");
|
|
1821
|
+
else renderer.destroy();
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
renderer.destroy();
|
|
1825
|
+
});
|
|
1826
|
+
const hints = useMemo(() => buildHints(screen, busy, currentSession), [
|
|
1827
|
+
screen,
|
|
1828
|
+
busy,
|
|
1829
|
+
currentSession
|
|
1830
|
+
]);
|
|
1831
|
+
const contextUsage = useMemo(() => {
|
|
1832
|
+
if (screen !== "chat" || !picked) return null;
|
|
1833
|
+
const max = modelsFor(picked.provider.key).find((m) => m.id === picked.model)?.contextWindow ?? getContextWindow(picked.provider.key, picked.model);
|
|
1834
|
+
return max ? {
|
|
1835
|
+
used: lastInputTokens,
|
|
1836
|
+
max
|
|
1837
|
+
} : null;
|
|
1838
|
+
}, [
|
|
1839
|
+
screen,
|
|
1840
|
+
picked,
|
|
1841
|
+
lastInputTokens,
|
|
1842
|
+
modelsFor
|
|
1843
|
+
]);
|
|
1844
|
+
useEffect(() => () => {
|
|
1845
|
+
teardown();
|
|
1846
|
+
}, [teardown]);
|
|
1847
|
+
return /* @__PURE__ */ jsxs("box", {
|
|
1848
|
+
style: {
|
|
1849
|
+
flexDirection: "column",
|
|
1850
|
+
flexGrow: 1
|
|
1851
|
+
},
|
|
1852
|
+
children: [/* @__PURE__ */ jsxs("box", {
|
|
1853
|
+
style: {
|
|
1854
|
+
flexDirection: "column",
|
|
1855
|
+
flexGrow: 1,
|
|
1856
|
+
paddingLeft: 1,
|
|
1857
|
+
paddingRight: 1
|
|
1858
|
+
},
|
|
1859
|
+
children: [
|
|
1860
|
+
screen === "auth" && /* @__PURE__ */ jsx(AuthScreen, { onPick: onPickProvider }),
|
|
1861
|
+
screen === "sessions" && /* @__PURE__ */ jsx(SessionsScreen, {
|
|
1862
|
+
sessions,
|
|
1863
|
+
currentId: currentSession?.id ?? null,
|
|
1864
|
+
onPick: onSwitchSession,
|
|
1865
|
+
onCreate: onCreateSession
|
|
1866
|
+
}),
|
|
1867
|
+
screen === "chat" && /* @__PURE__ */ jsx(ChatScreen, {
|
|
1868
|
+
events,
|
|
1869
|
+
busy,
|
|
1870
|
+
settings,
|
|
1871
|
+
onSubmit: onSubmitPrompt,
|
|
1872
|
+
session: currentSession
|
|
1873
|
+
})
|
|
1874
|
+
]
|
|
1875
|
+
}), /* @__PURE__ */ jsx(Footer, {
|
|
1876
|
+
hints,
|
|
1877
|
+
picked,
|
|
1878
|
+
context: contextUsage
|
|
1879
|
+
})]
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
function buildHints(screen, busy, currentSession) {
|
|
1883
|
+
if (busy) return [{
|
|
1884
|
+
key: "esc",
|
|
1885
|
+
label: "abort"
|
|
1886
|
+
}];
|
|
1887
|
+
if (screen === "auth") return [
|
|
1888
|
+
{
|
|
1889
|
+
key: "↑↓",
|
|
1890
|
+
label: "navigate"
|
|
1891
|
+
},
|
|
1892
|
+
{
|
|
1893
|
+
key: "↵",
|
|
1894
|
+
label: "select"
|
|
1895
|
+
},
|
|
1896
|
+
{
|
|
1897
|
+
key: "esc",
|
|
1898
|
+
label: "exit"
|
|
1899
|
+
}
|
|
1900
|
+
];
|
|
1901
|
+
if (screen === "sessions") return [
|
|
1902
|
+
{
|
|
1903
|
+
key: "↑↓",
|
|
1904
|
+
label: "navigate"
|
|
1905
|
+
},
|
|
1906
|
+
{
|
|
1907
|
+
key: "↵",
|
|
1908
|
+
label: "open"
|
|
1909
|
+
},
|
|
1910
|
+
{
|
|
1911
|
+
key: "ctrl+,",
|
|
1912
|
+
label: "settings"
|
|
1913
|
+
},
|
|
1914
|
+
{
|
|
1915
|
+
key: "esc",
|
|
1916
|
+
label: currentSession ? "back" : "exit"
|
|
1917
|
+
}
|
|
1918
|
+
];
|
|
1919
|
+
return [
|
|
1920
|
+
{
|
|
1921
|
+
key: "↵",
|
|
1922
|
+
label: "send"
|
|
1923
|
+
},
|
|
1924
|
+
{
|
|
1925
|
+
key: "ctrl+m",
|
|
1926
|
+
label: "model"
|
|
1927
|
+
},
|
|
1928
|
+
{
|
|
1929
|
+
key: "ctrl+,",
|
|
1930
|
+
label: "settings"
|
|
1931
|
+
},
|
|
1932
|
+
{
|
|
1933
|
+
key: "esc",
|
|
1934
|
+
label: "sessions"
|
|
1935
|
+
}
|
|
1936
|
+
];
|
|
1937
|
+
}
|
|
1938
|
+
//#endregion
|
|
1939
|
+
//#region src/tui/index.tsx
|
|
1940
|
+
/**
|
|
1941
|
+
* Tracks whether `runTui` has been invoked in this process. `createCliRenderer`
|
|
1942
|
+
* installs signal handlers + hijacks stdin; calling it a second time produces
|
|
1943
|
+
* undefined behavior. We bail loudly instead.
|
|
1944
|
+
*/
|
|
1945
|
+
let runTuiInvoked = false;
|
|
1946
|
+
/**
|
|
1947
|
+
* Boot a full chat TUI with sensible defaults. **Does not return** under
|
|
1948
|
+
* normal use — it terminates the host process via `process.exit(0)` once
|
|
1949
|
+
* the user dismisses the renderer (Ctrl+C / Esc on the auth screen / a
|
|
1950
|
+
* non-zero exit code is mapped via the `catch` path below).
|
|
1951
|
+
*
|
|
1952
|
+
* **One-shot:** this function may only be invoked once per process. The
|
|
1953
|
+
* underlying OpenTUI renderer wires up global terminal state on init.
|
|
1954
|
+
*
|
|
1955
|
+
* **Why it exits the process:** after the renderer's `destroy()` restores
|
|
1956
|
+
* the terminal, React's reconciler and OpenTUI's internal listeners can
|
|
1957
|
+
* keep the Node/Bun event loop open indefinitely — the script appears to
|
|
1958
|
+
* hang in `bun run`, and under `bun --watch run` the watcher waits forever
|
|
1959
|
+
* for the child to exit. Forcing a clean exit here is the contract that
|
|
1960
|
+
* makes `runTui` a true one-shot launcher.
|
|
1961
|
+
*
|
|
1962
|
+
* Hosts that need post-renderer cleanup should mount `<App config={...} />`
|
|
1963
|
+
* against their own `createCliRenderer()` instead of calling `runTui()`.
|
|
1964
|
+
*
|
|
1965
|
+
* Env-var overrides (handy when launching from CI or restricted shells):
|
|
1966
|
+
* - `ZIDANE_STORAGE_DIR` — sets `storageDir`
|
|
1967
|
+
* - `ZIDANE_PREFIX` — sets `prefix`
|
|
1968
|
+
*
|
|
1969
|
+
* Hosts building on top of `zidane/tui` typically want their own env vars
|
|
1970
|
+
* (e.g. `MYAPP_STORAGE_DIR`); read them in your launch script and forward
|
|
1971
|
+
* to `runTui({ storageDir, prefix })`.
|
|
1972
|
+
*
|
|
1973
|
+
* ```ts
|
|
1974
|
+
* import { runTui } from 'zidane/tui'
|
|
1975
|
+
* import { createRemoteStore } from 'zidane/session' // for the `store` option
|
|
1976
|
+
*
|
|
1977
|
+
* await runTui() // ~/.zidane/sessions.db + state.json
|
|
1978
|
+
* await runTui({ prefix: '.myapp' }) // ~/.myapp/...
|
|
1979
|
+
* await runTui({ storageDir: '/data', prefix: 'myapp' })
|
|
1980
|
+
* await runTui({ providers: { custom: () => myProvider() } })
|
|
1981
|
+
* await runTui({ store: createRemoteStore({ url: '…' }) })
|
|
1982
|
+
* await runTui({ models: { anthropic: [{ id: 'claude-foo', contextWindow: 200_000 }] } })
|
|
1983
|
+
* ```
|
|
1984
|
+
*/
|
|
1985
|
+
async function runTui(options = {}) {
|
|
1986
|
+
if (runTuiInvoked) throw new Error("runTui() can only be invoked once per process. Compose `<App config={resolveConfig(...)} />` against your own renderer if you need to run multiple TUIs in the same lifetime.");
|
|
1987
|
+
runTuiInvoked = true;
|
|
1988
|
+
await init();
|
|
1989
|
+
const config = resolveConfig(options);
|
|
1990
|
+
let done = () => {};
|
|
1991
|
+
const exited = new Promise((resolve) => {
|
|
1992
|
+
done = resolve;
|
|
1993
|
+
});
|
|
1994
|
+
createRoot(await createCliRenderer({
|
|
1995
|
+
exitOnCtrlC: true,
|
|
1996
|
+
onDestroy: () => done()
|
|
1997
|
+
})).render(/* @__PURE__ */ jsx(App, { config }));
|
|
1998
|
+
await exited;
|
|
1999
|
+
process.exit(0);
|
|
2000
|
+
}
|
|
2001
|
+
//#endregion
|
|
2002
|
+
export { App, AuthScreen, COLOR, ChatScreen, ConfigProvider, DEFAULT_SETTINGS, FACTORIES, Footer, MD_STYLE, Modal, ModalRoot, ModelPickerModal, PI_PROVIDER_ID, SELECT_THEME, SessionsScreen, SettingsModal, SettingsProvider, Spinner, Transcript, ageString, createStateStore, createTuiStore, detectAuth, envKeyFor, eventsFromTurns, finalizeStreamingMarkdown, finalizeStreamingMarkdownForOwner, fmtTokens, getContextWindow, lastContextSizeFromTurns, listSessionMeta, loadState, marginTopFor, onInputSubmit, resolveConfig, runTui, saveState, shortId, titleFromTurns, toolCallPreview, toolResultText, turnContextSize, useConfig, useModal, useModalAwareFocus, useSettings, useStreamBuffer };
|
|
2003
|
+
|
|
2004
|
+
//# sourceMappingURL=tui.js.map
|