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