zidane 5.1.12 → 5.1.13

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.
Files changed (50) hide show
  1. package/dist/{agent-D0pXl4CO.d.ts → agent-skiQGYs2.d.ts} +8 -2
  2. package/dist/agent-skiQGYs2.d.ts.map +1 -0
  3. package/dist/chat.d.ts +43 -6
  4. package/dist/chat.d.ts.map +1 -1
  5. package/dist/chat.js +2 -2
  6. package/dist/{index-n4STKh9s.d.ts → index-CjPh6CRE.d.ts} +2 -2
  7. package/dist/{index-n4STKh9s.d.ts.map → index-CjPh6CRE.d.ts.map} +1 -1
  8. package/dist/{index-C9A_Ah4R.d.ts → index-YM7SipFz.d.ts} +2 -2
  9. package/dist/{index-C9A_Ah4R.d.ts.map → index-YM7SipFz.d.ts.map} +1 -1
  10. package/dist/index.d.ts +3 -3
  11. package/dist/index.js +6 -6
  12. package/dist/{login-CQNaKTLJ.js → login-Cc6Q-Fpu.js} +2 -2
  13. package/dist/{login-CQNaKTLJ.js.map → login-Cc6Q-Fpu.js.map} +1 -1
  14. package/dist/mcp.d.ts +1 -1
  15. package/dist/{messages-DiAiNhxA.js → messages-CIkO_aCH.js} +40 -4
  16. package/dist/messages-CIkO_aCH.js.map +1 -0
  17. package/dist/{presets-CYNTGGXg.js → presets-Ce79MK4J.js} +2 -2
  18. package/dist/{presets-CYNTGGXg.js.map → presets-Ce79MK4J.js.map} +1 -1
  19. package/dist/presets.d.ts +2 -2
  20. package/dist/presets.js +1 -1
  21. package/dist/{providers-6bqfXUd1.js → providers-CvriFHFU.js} +27 -8
  22. package/dist/providers-CvriFHFU.js.map +1 -0
  23. package/dist/providers.d.ts +1 -1
  24. package/dist/providers.js +2 -2
  25. package/dist/session/sqlite.d.ts +1 -1
  26. package/dist/{session-pS4Vt4dl.js → session-DtLD1Sl1.js} +2 -1
  27. package/dist/{session-pS4Vt4dl.js.map → session-DtLD1Sl1.js.map} +1 -1
  28. package/dist/session.d.ts +1 -1
  29. package/dist/session.js +2 -2
  30. package/dist/skills.d.ts +2 -2
  31. package/dist/{tool-formatters-BkbbrFyr.d.ts → tool-formatters-0aOMYbH-.d.ts} +71 -5
  32. package/dist/tool-formatters-0aOMYbH-.d.ts.map +1 -0
  33. package/dist/{tools-BoHVy2UM.js → tools-BG2wMa3X.js} +2 -2
  34. package/dist/{tools-BoHVy2UM.js.map → tools-BG2wMa3X.js.map} +1 -1
  35. package/dist/tools.d.ts +2 -2
  36. package/dist/tools.js +1 -1
  37. package/dist/tui.d.ts +44 -11
  38. package/dist/tui.d.ts.map +1 -1
  39. package/dist/tui.js +945 -276
  40. package/dist/tui.js.map +1 -1
  41. package/dist/{turn-operations-BMGp7jXI.js → turn-operations-CDmQ2h-T.js} +490 -55
  42. package/dist/turn-operations-CDmQ2h-T.js.map +1 -0
  43. package/dist/types-Bx_F8jet.js.map +1 -1
  44. package/dist/types.d.ts +2 -2
  45. package/package.json +1 -1
  46. package/dist/agent-D0pXl4CO.d.ts.map +0 -1
  47. package/dist/messages-DiAiNhxA.js.map +0 -1
  48. package/dist/providers-6bqfXUd1.js.map +0 -1
  49. package/dist/tool-formatters-BkbbrFyr.d.ts.map +0 -1
  50. package/dist/turn-operations-BMGp7jXI.js.map +0 -1
package/dist/tui.js CHANGED
@@ -1,15 +1,16 @@
1
- import { S as resolvePersistDir, b as cleanupPersistedSession, d as createAgent } from "./tools-BoHVy2UM.js";
1
+ import { S as resolvePersistDir, b as cleanupPersistedSession, d as createAgent } from "./tools-BG2wMa3X.js";
2
2
  import { s as McpOAuthProvider, t as connectMcpServers } from "./mcp-CUt-N8zn.js";
3
- import { C as summaryToTurn, a as selectFilesFromSession, r as buildPostCompactAttachments, s as compactConversation, t as loginMcpServer } from "./login-CQNaKTLJ.js";
3
+ import { C as summaryToTurn, a as selectFilesFromSession, r as buildPostCompactAttachments, s as compactConversation, t as loginMcpServer } from "./login-Cc6Q-Fpu.js";
4
4
  import { n as formatTokenUsage } from "./stats-DgOvY7wd.js";
5
- import { n as loadSession, t as createSession } from "./session-pS4Vt4dl.js";
5
+ import { n as loadSession, t as createSession } from "./session-DtLD1Sl1.js";
6
6
  import { createTuiStore } from "./session/sqlite.js";
7
- import { $ as getMcpAuthStatus, $t as toolResultText, A as isOnSafelist, An as tryOpenBrowser, B as filterModelCatalog, Bt as eventsFromTurns, C as writeSessionExport, Cn as createFilesCompletionProvider, Ct as SettingsProvider, E as useSafeModeQueue, Ft as ConfigProvider, Gt as listSessionMeta, H as buildMcpServers, Ht as isTurnHighlighted, I as splitPromptSegments, It as useConfig, Jn as modelSupportsReasoning, Kn as getContextWindow, L as runOAuthLogin, Lt as resolveConfig, Mn as detectAuth, O as addToSafelist, Ot as resolveChipColor, P as suggestSafelistEntry, Q as useMcpAuthState, Qn as piIdOf, Qt as toolCallPreview, R as supportsOAuth, Rn as setProviderCredential, St as SETTINGS_TOGGLES, T as useSafeModeActions, Tt as useSettings, Ut as isVisible, V as indexOfEntry, Vt as isEditErrorResult, W as discoverProjectMcps, Wt as lastContextSizeFromTurns, X as McpAuthProvider, Xt as stripSpawnTokensLine, Yt as selectableTurnIds, Z as useMcpAuthDispatch, _ as useStreamBuffer, _t as shortId, a as TOOL_DISPLAY, an as filetypeFromPath, at as createInteractionTools, b as discoverProjectSkills, bn as createSkillsCompletionProvider, bt as DEFAULT_SETTINGS, c as ThemeProvider, cn as findGitRoot, ct as pendingInteractionsFromTurns, d as useSurfaces, dt as useInteractionsQueue, en as turnSelectionOwnership, fn as ensureKeybindingsFile, g as turnContextSize, gr as buildPlanSystem, gt as fmtTokens, h as finalizeStreamingMarkdownForOwner, hr as buildBuildSystem, ht as compactPath, i as turnAsText, in as extractEditPayload, ir as accentColor, it as buildResumedToolResultsTurn, jn as shouldAutoCompact, k as getSafelist, kn as useCompletion, kt as resolveTheme, l as useColors, m as finalizeStreamingMarkdown, mn as matchesBinding, mt as ageString, n as deleteTurnSafely, nt as InteractionsProvider, o as displayNameFor, p as useTheme, pt as generateSessionTitle, q as createFileMcpCredentialStore, qt as marginTopFor, r as truncateTurnsAt, s as formatToolCall, st as makeRequestInteraction, tn as buildUnifiedDiff, u as useSelectStyle, ut as useInteractionsActions, v as buildSkillsConfig, vt as listProjectFiles, w as SafeModeProvider, wt as clampFps, xn as uniqueSkillNamesFromReferences, xt as SETTINGS_CHOICES, y as defaultSkillScanPaths, yt as useEnabledToggleSet, z as buildModelCatalog, zt as deriveSessionTitle } from "./turn-operations-BMGp7jXI.js";
7
+ import { $ as getMcpAuthStatus, $t as toolCallPreview, A as isOnSafelist, B as filterModelCatalog, Bn as setProviderCredential, Bt as eventsFromTurns, C as writeSessionExport, Cn as uniqueSkillNamesFromReferences, Ct as SettingsProvider, E as useSafeModeQueue, Ft as ConfigProvider, Gt as listSessionMeta, H as buildMcpServers, Ht as isTurnHighlighted, I as splitPromptSegments, It as useConfig, Jn as getContextWindow, L as runOAuthLogin, Lt as resolveConfig, Mn as tryOpenBrowser, Nn as shouldAutoCompact, O as addToSafelist, Ot as resolveChipColor, P as suggestSafelistEntry, Pn as detectAuth, Q as useMcpAuthState, R as supportsOAuth, Sn as createSkillsCompletionProvider, St as SETTINGS_TOGGLES, T as useSafeModeActions, Tn as createFilesCompletionProvider, Tt as useSettings, Ut as isVisible, V as indexOfEntry, Vt as isEditErrorResult, W as discoverProjectMcps, Wt as lastContextSizeFromTurns, X as McpAuthProvider, Xn as modelSupportsReasoning, Xt as stripSpawnTokensLine, Yt as selectableTurnIds, Z as useMcpAuthDispatch, Zt as sumRunCosts, _ as useStreamBuffer, _t as shortId, a as TOOL_DISPLAY, at as createInteractionTools, b as discoverProjectSkills, br as buildPlanSystem, bt as DEFAULT_SETTINGS, c as ThemeProvider, ct as pendingInteractionsFromTurns, d as useSurfaces, dt as useInteractionsQueue, en as toolResultText, er as piIdOf, g as turnContextSize, gn as matchesBinding, gt as fmtTokens, h as finalizeStreamingMarkdownForOwner, ht as compactPath, i as turnAsText, it as buildResumedToolResultsTurn, jn as useCompletion, k as getSafelist, kt as resolveTheme, l as useColors, m as finalizeStreamingMarkdown, mn as ensureKeybindingsFile, mt as ageString, n as deleteTurnSafely, nn as buildContextualDiff, nt as InteractionsProvider, o as displayNameFor, on as extractEditPayload, or as accentColor, p as useTheme, pt as generateSessionTitle, q as createFileMcpCredentialStore, qt as marginTopFor, r as truncateTurnsAt, rn as buildUnifiedDiff, s as formatToolCall, sn as filetypeFromPath, st as makeRequestInteraction, tn as turnSelectionOwnership, u as useSelectStyle, un as findGitRoot, ut as useInteractionsActions, v as buildSkillsConfig, vt as listProjectFiles, w as SafeModeProvider, wt as clampFps, xt as SETTINGS_CHOICES, y as defaultSkillScanPaths, yr as buildBuildSystem, yt as useEnabledToggleSet, z as buildModelCatalog, zt as deriveSessionTitle } from "./turn-operations-CDmQ2h-T.js";
8
8
  import { spawn } from "node:child_process";
9
9
  import { Buffer } from "node:buffer";
10
+ import * as fs from "node:fs";
10
11
  import { homedir } from "node:os";
11
12
  import { createContext, createElement, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
12
- import { RGBA, SyntaxStyle, addDefaultParsers, createCliRenderer, decodePasteBytes, defaultTextareaKeyBindings, getTreeSitterClient, stripAnsiSequences } from "@opentui/core";
13
+ import { BoxRenderable, CodeRenderable, RGBA, SyntaxStyle, TextRenderable, addDefaultParsers, createCliRenderer, decodePasteBytes, defaultTextareaKeyBindings, getTreeSitterClient, stripAnsiSequences } from "@opentui/core";
13
14
  import { createRoot, useKeyboard, useRenderer, useSelectionHandler, useTerminalDimensions } from "@opentui/react";
14
15
  import { Fragment, jsx, jsxs } from "@opentui/react/jsx-runtime";
15
16
  //#region src/tui/modal.tsx
@@ -317,6 +318,160 @@ function writeToClipboard(text) {
317
318
  return osc || helper;
318
319
  }
319
320
  //#endregion
321
+ //#region src/tui/color-gradient.ts
322
+ /** Parse `#rrggbb` (case-insensitive) into `[r, g, b]` 0–255 integers. */
323
+ function parseHex(hex) {
324
+ const h = hex.replace("#", "");
325
+ return [
326
+ Number.parseInt(h.slice(0, 2), 16),
327
+ Number.parseInt(h.slice(2, 4), 16),
328
+ Number.parseInt(h.slice(4, 6), 16)
329
+ ];
330
+ }
331
+ /** Convert sRGB 0–255 → HSL 0–1. */
332
+ function rgbToHsl(r, g, b) {
333
+ r /= 255;
334
+ g /= 255;
335
+ b /= 255;
336
+ const max = Math.max(r, g, b);
337
+ const min = Math.min(r, g, b);
338
+ const l = (max + min) / 2;
339
+ if (max === min) return [
340
+ 0,
341
+ 0,
342
+ l
343
+ ];
344
+ const d = max - min;
345
+ const s = l > .5 ? d / (2 - max - min) : d / (max + min);
346
+ let h;
347
+ if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
348
+ else if (max === g) h = (b - r) / d + 2;
349
+ else h = (r - g) / d + 4;
350
+ return [
351
+ h / 6,
352
+ s,
353
+ l
354
+ ];
355
+ }
356
+ /** Convert HSL 0–1 → sRGB 0–255. Standard piecewise formula. */
357
+ function hslToRgb(h, s, l) {
358
+ if (s === 0) return [
359
+ l * 255,
360
+ l * 255,
361
+ l * 255
362
+ ];
363
+ const hue2rgb = (p, q, t) => {
364
+ if (t < 0) t += 1;
365
+ if (t > 1) t -= 1;
366
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
367
+ if (t < 1 / 2) return q;
368
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
369
+ return p;
370
+ };
371
+ const q = l < .5 ? l * (1 + s) : l + s - l * s;
372
+ const p = 2 * l - q;
373
+ return [
374
+ hue2rgb(p, q, h + 1 / 3) * 255,
375
+ hue2rgb(p, q, h) * 255,
376
+ hue2rgb(p, q, h - 1 / 3) * 255
377
+ ];
378
+ }
379
+ function toHex(rgb) {
380
+ const pad = (v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0");
381
+ return `#${pad(rgb[0])}${pad(rgb[1])}${pad(rgb[2])}`;
382
+ }
383
+ /**
384
+ * Blend two hex colors in HSL space with shortest-path hue interpolation.
385
+ * `t` ∈ [0, 1]; `t=0` returns `from`, `t=1` returns `to`.
386
+ */
387
+ function blendHsl(from, to, t) {
388
+ const [r1, g1, b1] = parseHex(from);
389
+ const [r2, g2, b2] = parseHex(to);
390
+ const [h1, s1, l1] = rgbToHsl(r1, g1, b1);
391
+ const [h2, s2, l2] = rgbToHsl(r2, g2, b2);
392
+ let dh = h2 - h1;
393
+ if (dh > .5) dh -= 1;
394
+ else if (dh < -.5) dh += 1;
395
+ return toHex(hslToRgb((h1 + dh * t + 1) % 1, s1 + (s2 - s1) * t, l1 + (l2 - l1) * t));
396
+ }
397
+ /**
398
+ * Static gradient ramp of length `n` going from `from` (index 0) to
399
+ * `to` (index n-1) in HSL space. For the cycling A→B→A→B ramp the
400
+ * throbber uses, see `buildCycleRamp` in `crush-throbber.tsx`.
401
+ */
402
+ function buildLinearRamp(from, to, n) {
403
+ if (n <= 0) return [];
404
+ if (n === 1) return [blendHsl(from, to, .5)];
405
+ const ramp = [];
406
+ for (let i = 0; i < n; i++) ramp.push(blendHsl(from, to, i / (n - 1)));
407
+ return ramp;
408
+ }
409
+ //#endregion
410
+ //#region src/tui/crush-throbber.tsx
411
+ /** @jsxImportSource @opentui/react */
412
+ const CRUSH_RUNES = "0123456789abcdefABCDEF~!@#$£€%^&*()+=_";
413
+ const TICK_MS = 50;
414
+ const ELLIPSIS_FRAMES = [
415
+ ".",
416
+ "..",
417
+ "...",
418
+ ""
419
+ ];
420
+ const ELLIPSIS_TICKS = 8;
421
+ const BIRTH_MAX_MS = 1e3;
422
+ /**
423
+ * Build a gradient ramp of length `n` traversing the keys `from → to →
424
+ * from → to` (matching Crush's `CycleColors=true` mode, which prerenders
425
+ * `width*3` stops across four anchor points so the drift can loop without
426
+ * a visible seam).
427
+ */
428
+ function buildCycleRamp(from, to, n) {
429
+ const segLen = Math.floor(n / 3);
430
+ const ramp = [];
431
+ for (let i = 0; i < segLen; i++) ramp.push(blendHsl(from, to, i / segLen));
432
+ for (let i = 0; i < segLen; i++) ramp.push(blendHsl(to, from, i / segLen));
433
+ const tail = n - 2 * segLen;
434
+ for (let i = 0; i < tail; i++) ramp.push(blendHsl(from, to, i / Math.max(1, tail)));
435
+ return ramp;
436
+ }
437
+ function CrushThrobber({ label, size = 15, from, to, labelColor }) {
438
+ const COLOR = useColors();
439
+ const cells = Math.max(1, size);
440
+ const [frame, setFrame] = useState(0);
441
+ useEffect(() => {
442
+ const id = setInterval(() => setFrame((f) => f + 1), TICK_MS);
443
+ return () => clearInterval(id);
444
+ }, []);
445
+ const birthOffsets = useMemo(() => Array.from({ length: cells }, () => Math.random() * BIRTH_MAX_MS), [cells]);
446
+ const startRef = useRef(0);
447
+ if (startRef.current === 0) startRef.current = Date.now();
448
+ const ramp = useMemo(() => buildCycleRamp(from, to, cells * 3), [
449
+ from,
450
+ to,
451
+ cells
452
+ ]);
453
+ const elapsed = Date.now() - startRef.current;
454
+ const initialized = elapsed >= BIRTH_MAX_MS;
455
+ const rampLen = ramp.length;
456
+ const offset = (frame % rampLen + rampLen) % rampLen;
457
+ const labelTint = labelColor ?? COLOR.dim;
458
+ const ellipsis = initialized && label ? ELLIPSIS_FRAMES[Math.floor(frame / ELLIPSIS_TICKS) % ELLIPSIS_FRAMES.length] : "";
459
+ return /* @__PURE__ */ jsxs("text", { children: [Array.from({ length: cells }, (_, i) => {
460
+ const ch = initialized || elapsed >= birthOffsets[i] ? CRUSH_RUNES[Math.floor(Math.random() * 38)] : ".";
461
+ const fg = ramp[(offset + i) % rampLen];
462
+ return /* @__PURE__ */ jsx("span", {
463
+ fg,
464
+ children: ch
465
+ }, i);
466
+ }), label !== void 0 && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
467
+ fg: labelTint,
468
+ children: ` ${label}`
469
+ }), /* @__PURE__ */ jsx("span", {
470
+ fg: labelTint,
471
+ children: ellipsis
472
+ })] })] });
473
+ }
474
+ //#endregion
320
475
  //#region src/tui/theme.ts
321
476
  /**
322
477
  * Convert the renderer-agnostic `Theme.syntax` map (hex strings + plain
@@ -506,12 +661,15 @@ function onInputSubmit(handler) {
506
661
  * Width tiering is driven by plain-text length estimates — close enough
507
662
  * since the segments are ASCII-heavy.
508
663
  */
509
- function Footer({ hints, context }) {
664
+ function Footer({ hints, context, cost = null, status = null }) {
510
665
  const { width } = useTerminalDimensions();
666
+ const showCost = typeof cost === "number" && cost > 0;
511
667
  const inner = Math.max(0, width - 2);
512
668
  const hW = hintsLength(hints);
513
- const cW = context ? contextIndicatorLength(context) : 0;
514
- if (hW + (cW > 0 ? cW + 1 : 0) <= inner) return /* @__PURE__ */ jsxs("box", {
669
+ const ctxW = context ? contextIndicatorLength(context) : 0;
670
+ const costW = showCost ? costIndicatorLength(cost) : 0;
671
+ const rightW = costW + ctxW + (costW > 0 && ctxW > 0 ? 3 : 0);
672
+ if (hW + (status === "asking" ? 3 : status ? 2 : 0) + (rightW > 0 ? rightW + 1 : 0) <= inner) return /* @__PURE__ */ jsxs("box", {
515
673
  style: {
516
674
  flexDirection: "row",
517
675
  height: 1,
@@ -520,8 +678,12 @@ function Footer({ hints, context }) {
520
678
  },
521
679
  children: [
522
680
  /* @__PURE__ */ jsx(HintsText, { hints }),
681
+ /* @__PURE__ */ jsx(FooterStatusIcon, { status }),
523
682
  /* @__PURE__ */ jsx("box", { style: { flexGrow: 1 } }),
524
- context && /* @__PURE__ */ jsx(ContextIndicator, { context })
683
+ /* @__PURE__ */ jsx(RightStatus, {
684
+ cost: showCost ? cost : null,
685
+ context
686
+ })
525
687
  ]
526
688
  });
527
689
  const stackedHints = clipHintsToWidth(hints, inner);
@@ -531,21 +693,48 @@ function Footer({ hints, context }) {
531
693
  paddingLeft: 1,
532
694
  paddingRight: 1
533
695
  },
534
- children: [stackedHints.length > 0 && /* @__PURE__ */ jsx("box", {
696
+ children: [(stackedHints.length > 0 || status) && /* @__PURE__ */ jsxs("box", {
535
697
  style: {
536
698
  flexDirection: "row",
537
699
  height: 1
538
700
  },
539
- children: /* @__PURE__ */ jsx(HintsText, { hints: stackedHints })
540
- }), context && /* @__PURE__ */ jsxs("box", {
701
+ children: [/* @__PURE__ */ jsx(HintsText, { hints: stackedHints }), /* @__PURE__ */ jsx(FooterStatusIcon, { status })]
702
+ }), rightW > 0 && /* @__PURE__ */ jsxs("box", {
541
703
  style: {
542
704
  flexDirection: "row",
543
705
  height: 1
544
706
  },
545
- children: [/* @__PURE__ */ jsx("box", { style: { flexGrow: 1 } }), /* @__PURE__ */ jsx(ContextIndicator, { context })]
707
+ children: [/* @__PURE__ */ jsx("box", { style: { flexGrow: 1 } }), /* @__PURE__ */ jsx(RightStatus, {
708
+ cost: showCost ? cost : null,
709
+ context
710
+ })]
546
711
  })]
547
712
  });
548
713
  }
714
+ function RightStatus({ cost, context }) {
715
+ const COLOR = useColors();
716
+ if (cost == null && !context) return null;
717
+ return /* @__PURE__ */ jsxs("text", { children: [
718
+ cost != null && /* @__PURE__ */ jsx(CostIndicator, { cost }),
719
+ cost != null && context && /* @__PURE__ */ jsx("span", {
720
+ fg: COLOR.mute,
721
+ children: " · "
722
+ }),
723
+ context && /* @__PURE__ */ jsx(ContextIndicator, { context })
724
+ ] });
725
+ }
726
+ /**
727
+ * Right-edge status glyph in the Footer's left section. Renders a single
728
+ * cell preceded by a 1ch gap when active; nothing at all when `status` is
729
+ * null. Color / glyph mapping mirrors the old title-overlay logic so the
730
+ * cue reads the same just relocated to the bottom bar.
731
+ */
732
+ function FooterStatusIcon({ status }) {
733
+ const COLOR = useColors();
734
+ if (!status) return null;
735
+ const glyph = status === "asking" ? /* @__PURE__ */ jsx("span", { children: "❓" }) : status === "busy" ? /* @__PURE__ */ jsx(StatusSpinner, { color: COLOR.warn }) : /* @__PURE__ */ jsx(StatusSpinner, { color: COLOR.accent });
736
+ return /* @__PURE__ */ jsxs("text", { children: [/* @__PURE__ */ jsx("span", { children: " " }), glyph] });
737
+ }
549
738
  function HintsText({ hints }) {
550
739
  const COLOR = useColors();
551
740
  return /* @__PURE__ */ jsx("text", {
@@ -596,27 +785,35 @@ function ContextIndicator({ context }) {
596
785
  const ratio = context.max > 0 ? context.used / context.max : 0;
597
786
  const pct = Math.round(ratio * 100);
598
787
  const color = ratio >= .85 ? COLOR.error : ratio >= .6 ? COLOR.warn : COLOR.dim;
599
- return /* @__PURE__ */ jsxs("text", {
600
- fg: COLOR.dim,
601
- children: [
602
- /* @__PURE__ */ jsx("span", {
603
- fg: COLOR.mute,
604
- children: "ctx "
605
- }),
606
- /* @__PURE__ */ jsx("span", {
607
- fg: color,
608
- children: fmtTokens(context.used)
609
- }),
610
- /* @__PURE__ */ jsx("span", {
611
- fg: COLOR.mute,
612
- children: ` / ${fmtTokens(context.max)} `
613
- }),
614
- /* @__PURE__ */ jsx("span", {
615
- fg: color,
616
- children: `(${pct}%)`
617
- })
618
- ]
619
- });
788
+ return /* @__PURE__ */ jsxs("span", { children: [
789
+ /* @__PURE__ */ jsx("span", {
790
+ fg: COLOR.mute,
791
+ children: "ctx "
792
+ }),
793
+ /* @__PURE__ */ jsx("span", {
794
+ fg: color,
795
+ children: fmtTokens(context.used)
796
+ }),
797
+ /* @__PURE__ */ jsx("span", {
798
+ fg: COLOR.mute,
799
+ children: ` / ${fmtTokens(context.max)} `
800
+ }),
801
+ /* @__PURE__ */ jsx("span", {
802
+ fg: color,
803
+ children: `(${pct}%)`
804
+ })
805
+ ] });
806
+ }
807
+ function CostIndicator({ cost }) {
808
+ const COLOR = useColors();
809
+ const fg = COLOR.money ?? COLOR.warn;
810
+ return /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsx("span", {
811
+ fg: COLOR.mute,
812
+ children: "$"
813
+ }), /* @__PURE__ */ jsx("span", {
814
+ fg,
815
+ children: formatCost(cost)
816
+ })] });
620
817
  }
621
818
  /**
622
819
  * Width budget reservations applied to the responsive math in
@@ -629,10 +826,23 @@ const TITLE_OVERLAY_WRAP = 2;
629
826
  const TITLE_OVERLAY_META_WRAP = 2;
630
827
  const TITLE_OVERLAY_GAP = 2;
631
828
  /**
829
+ * Render text as a per-character HSL gradient between two hex colors.
830
+ * Used by the title overlay so the chat header sweeps the theme's
831
+ * throbber pair (Charple→Dolly for Crush) instead of sitting flat on
832
+ * the brand color.
833
+ */
834
+ function renderGradientText(text, from, to) {
835
+ const ramp = buildLinearRamp(from, to, text.length);
836
+ return text.split("").map((ch, i) => /* @__PURE__ */ jsx("span", {
837
+ fg: ramp[i],
838
+ children: ch
839
+ }, i));
840
+ }
841
+ /**
632
842
  * Colored title for a full-screen bordered surface. The `title` slot
633
- * rides `titleColor` (defaults to `COLOR.brand` the theme's primary
634
- * anchor) on the LEFT of the top border; the optional `meta` slot
635
- * rides on the RIGHT.
843
+ * rides `titleColor` (defaults to a per-character gradient across the
844
+ * theme's throbber pair, fallback brand→accent) on the LEFT of the top
845
+ * border; the optional `meta` slot rides on the RIGHT.
636
846
  *
637
847
  * `meta` accepts either:
638
848
  * - a plain `string` → rendered entirely in `COLOR.dim` (the simple
@@ -667,13 +877,16 @@ const TITLE_OVERLAY_GAP = 2;
667
877
  * </box>
668
878
  * ```
669
879
  */
670
- function TitleOverlay({ title, meta = null, titleColor, parentWidth, statusIcon = null }) {
880
+ function TitleOverlay({ title, meta = null, titleColor, parentWidth, statusIcon = null, statusIconCells = 1 }) {
671
881
  const COLOR = useColors();
672
882
  const { width: termWidth } = useTerminalDimensions();
883
+ const useGradient = titleColor === void 0;
884
+ const gradientFrom = COLOR.throbber?.to ?? COLOR.accent;
885
+ const gradientTo = COLOR.throbber?.from ?? COLOR.brand;
673
886
  const fg = titleColor ?? COLOR.brand;
674
887
  const W = Math.max(0, parentWidth ?? termWidth - 2);
675
888
  const inner = Math.max(0, W - 2);
676
- const iconReserve = statusIcon ? 2 : 0;
889
+ const iconReserve = statusIcon ? 1 + statusIconCells : 0;
677
890
  const metaLen = metaSegmentsLength(meta);
678
891
  const showMeta = meta != null && metaLen > 0 && title.length + iconReserve + TITLE_OVERLAY_WRAP + TITLE_OVERLAY_GAP + metaLen + TITLE_OVERLAY_META_WRAP <= inner;
679
892
  const titleBudget = (showMeta ? inner - (metaLen + TITLE_OVERLAY_META_WRAP) - TITLE_OVERLAY_GAP - TITLE_OVERLAY_WRAP : inner - TITLE_OVERLAY_WRAP) - iconReserve;
@@ -689,7 +902,7 @@ function TitleOverlay({ title, meta = null, titleColor, parentWidth, statusIcon
689
902
  fg: COLOR.mute,
690
903
  children: " "
691
904
  }),
692
- /* @__PURE__ */ jsx("span", {
905
+ useGradient && visibleTitle.length > 0 ? renderGradientText(visibleTitle, gradientFrom, gradientTo) : /* @__PURE__ */ jsx("span", {
693
906
  fg,
694
907
  children: visibleTitle
695
908
  }),
@@ -800,17 +1013,23 @@ function contextIndicatorLength(context) {
800
1013
  const pct = Math.round(ratio * 100);
801
1014
  return 4 + fmtTokens(context.used).length + 3 + fmtTokens(context.max).length + 2 + String(pct).length + 2;
802
1015
  }
1016
+ function formatCost(cost) {
1017
+ return cost.toFixed(cost < .01 ? 4 : 2);
1018
+ }
1019
+ function costIndicatorLength(cost) {
1020
+ return 1 + formatCost(cost).length;
1021
+ }
803
1022
  const SPINNER_FRAMES = [
804
- "",
805
- "",
806
- "",
807
- "",
808
- "",
809
- "",
810
- "",
811
- "",
812
- "",
813
- ""
1023
+ "▰▱▱▱▱",
1024
+ "▰▰▱▱▱",
1025
+ "▰▰▰▱▱",
1026
+ "▰▰▰▰▱",
1027
+ "▰▰▰▰▰",
1028
+ "▱▰▰▰▰",
1029
+ "▱▱▰▰▰",
1030
+ "▱▱▱▰▰",
1031
+ "▱▱▱▱▰",
1032
+ "▱▱▱▱▱"
814
1033
  ];
815
1034
  const SPINNER_INTERVAL_MS = 80;
816
1035
  /**
@@ -850,9 +1069,11 @@ function StatusSpinner({ color }) {
850
1069
  children: useSpinnerFrame()
851
1070
  });
852
1071
  }
853
- function Transcript({ events, settings, selectedTurnId = null }) {
1072
+ function Transcript({ events, settings, selectedTurnId = null, busy = false }) {
854
1073
  const COLOR = useColors();
855
1074
  const items = useMemo(() => partitionTranscript(events, settings), [events, settings]);
1075
+ const showThrobber = busy;
1076
+ const throbberLabel = events.length > 0 && events[events.length - 1].kind === "thinking" ? "Thinking" : void 0;
856
1077
  const ownership = useMemo(() => turnSelectionOwnership(events), [events]);
857
1078
  const scrollboxRef = useRef(null);
858
1079
  const anchors = useMemo(() => computeTurnAnchors(items), [items]);
@@ -875,8 +1096,8 @@ function Transcript({ events, settings, selectedTurnId = null }) {
875
1096
  anchors,
876
1097
  ownership
877
1098
  ]);
878
- if (items.length === 0) return /* @__PURE__ */ jsx(EmptyState, {});
879
- return /* @__PURE__ */ jsx("scrollbox", {
1099
+ if (items.length === 0 && !showThrobber) return /* @__PURE__ */ jsx(EmptyState, {});
1100
+ return /* @__PURE__ */ jsxs("scrollbox", {
880
1101
  ref: scrollboxRef,
881
1102
  focusable: false,
882
1103
  style: {
@@ -893,7 +1114,7 @@ function Transcript({ events, settings, selectedTurnId = null }) {
893
1114
  foregroundColor: COLOR.mute
894
1115
  }
895
1116
  },
896
- children: items.map((item, i) => item.kind === "event" ? /* @__PURE__ */ jsx(EventLine, {
1117
+ children: [items.map((item, i) => item.kind === "event" ? /* @__PURE__ */ jsx(EventLine, {
897
1118
  event: item.event,
898
1119
  previous: item.previous,
899
1120
  selected: isTurnHighlighted(item.event, selectedTurnId, ownership),
@@ -903,7 +1124,14 @@ function Transcript({ events, settings, selectedTurnId = null }) {
903
1124
  previous: item.previous,
904
1125
  selectedTurnId,
905
1126
  anchorIds: anchors.ids[i]
906
- }, i))
1127
+ }, i)), showThrobber && /* @__PURE__ */ jsx("box", {
1128
+ style: { marginTop: items.length > 0 ? 1 : 0 },
1129
+ children: /* @__PURE__ */ jsx(CrushThrobber, {
1130
+ label: throbberLabel,
1131
+ from: COLOR.throbber?.from ?? COLOR.brand,
1132
+ to: COLOR.throbber?.to ?? COLOR.accent
1133
+ })
1134
+ })]
907
1135
  });
908
1136
  }
909
1137
  /**
@@ -1127,7 +1355,6 @@ function EventLineImpl({ event, depthOffset = 0 }) {
1127
1355
  style: row,
1128
1356
  children: /* @__PURE__ */ jsx(MarkdownBlock, {
1129
1357
  text: event.text,
1130
- streaming: event.streaming ?? false,
1131
1358
  dim: child
1132
1359
  })
1133
1360
  });
@@ -1276,13 +1503,10 @@ function UserPromptBlock({ text, refs }) {
1276
1503
  };
1277
1504
  if (!refs || refs.length === 0) return /* @__PURE__ */ jsx("box", {
1278
1505
  style: boxStyle,
1279
- children: /* @__PURE__ */ jsxs("text", {
1506
+ children: /* @__PURE__ */ jsxs("text", { children: [/* @__PURE__ */ jsx("span", {
1280
1507
  fg: COLOR.brand,
1281
- children: [/* @__PURE__ */ jsx("span", {
1282
- fg: COLOR.brand,
1283
- children: USER_PROMPT_PREFIX
1284
- }), text]
1285
- })
1508
+ children: USER_PROMPT_PREFIX
1509
+ }), text] })
1286
1510
  });
1287
1511
  const segments = splitPromptSegments(text, refs);
1288
1512
  return /* @__PURE__ */ jsx("box", {
@@ -1296,10 +1520,7 @@ function UserPromptBlock({ text, refs }) {
1296
1520
  fg: COLOR.brand,
1297
1521
  children: USER_PROMPT_PREFIX
1298
1522
  }), segments.map((seg, i) => {
1299
- if (seg.kind === "plain") return /* @__PURE__ */ jsx("text", {
1300
- fg: COLOR.brand,
1301
- children: seg.text
1302
- }, i);
1523
+ if (seg.kind === "plain") return /* @__PURE__ */ jsx("text", { children: seg.text }, i);
1303
1524
  const chip = resolveChipColor(SURFACE.chips, seg.providerId);
1304
1525
  return /* @__PURE__ */ jsx("text", {
1305
1526
  fg: chip.fg,
@@ -1310,214 +1531,310 @@ function UserPromptBlock({ text, refs }) {
1310
1531
  })
1311
1532
  });
1312
1533
  }
1534
+ /** Click-feedback delay before `[copied]` reverts to `[copy]`, in ms. */
1535
+ const COPY_FEEDBACK_MS = 1200;
1313
1536
  /**
1314
- * Split markdown text into alternating prose / fenced-code segments.
1537
+ * BoxRenderable subclass that hosts a `[lang] … [copy]` header above
1538
+ * an inner {@link CodeRenderable} body. Returned from
1539
+ * {@link makeMarkdownRenderNode} for every `code` token so the affordance
1540
+ * lives entirely inside OpenTUI — React doesn't see the wrapper.
1541
+ *
1542
+ * The accessor pairs below mirror the CodeRenderable surface (`content`,
1543
+ * `filetype`, `syntaxStyle`, `fg`, `bg`, `conceal`, `streaming`) so
1544
+ * coalesced mode's `applyCodeBlockRenderable` — which writes those
1545
+ * properties on the wrapper for every delta — lands on the body where
1546
+ * it produces visible output.
1315
1547
  *
1316
- * Recognizes both ` ``` ` and `~~~` fences, with arbitrary length ≥ 3,
1317
- * matching CommonMark's "fence indicator must be at least as long to
1318
- * close" rule. Info-strings (the bit after the opening fence) are kept
1319
- * as the `lang` hint; any trailing whitespace is trimmed. Closing
1320
- * fences are detected on a line of their own (whitespace tolerated).
1548
+ * Two of those forwarders deliberately ignore the value the markdown
1549
+ * writes:
1321
1550
  *
1322
- * Unclosed fences fall back to emitting the would-be-code body as a
1323
- * trailing prose segment so no content is dropped — finalized markdown
1324
- * isn't expected to ship one, but the fallback keeps the renderer
1325
- * truthful when the model produces malformed output.
1551
+ * - `drawUnstyledText` is pinned to `false` so the body's `content`
1552
+ * setter stays on the "preserve last-styled buffer while async
1553
+ * highlight runs" path. The markdown's `applyCodeBlockRenderable`
1554
+ * writes `!(streaming && concealCode)` here, which evaluates to
1555
+ * `true` for our defaults — that would put the body on the
1556
+ * synchronous `textBuffer.setText` path and reintroduce a per-delta
1557
+ * plain → styled colour swap.
1326
1558
  *
1327
- * Exported for unit tests.
1559
+ * - `marginBottom` is pinned to `0`. The markdown writes
1560
+ * `getInterBlockMargin(code) = 1` here on every delta; combined with
1561
+ * the `marginTop: 1` `renderNode` applies to the next block, it
1562
+ * would stack into a two-row gap below every fence. Pinning to 0
1563
+ * makes the next block's `marginTop` the single source of truth for
1564
+ * inter-block spacing.
1328
1565
  */
1329
- function splitMarkdownCodeBlocks(text) {
1330
- const lines = text.split("\n");
1331
- const segments = [];
1332
- let prose = [];
1333
- let code = [];
1334
- let inFence = false;
1335
- let fenceChar = "";
1336
- let fenceLen = 0;
1337
- let lang = "";
1338
- const flushProse = () => {
1339
- if (prose.length > 0) {
1340
- segments.push({
1341
- kind: "prose",
1342
- content: prose.join("\n")
1343
- });
1344
- prose = [];
1345
- }
1346
- };
1347
- for (const line of lines) if (!inFence) {
1348
- const open = line.match(/^(`{3,}|~{3,})([^\n`~]*)$/);
1349
- if (open) {
1350
- flushProse();
1351
- inFence = true;
1352
- fenceChar = open[1][0];
1353
- fenceLen = open[1].length;
1354
- lang = open[2].trim();
1355
- code = [];
1356
- continue;
1357
- }
1358
- prose.push(line);
1359
- } else {
1360
- const close = line.match(/^(`{3,}|~{3,})\s*$/);
1361
- if (close && close[1][0] === fenceChar && close[1].length >= fenceLen) {
1362
- segments.push({
1363
- kind: "code",
1364
- content: code.join("\n"),
1365
- lang
1366
- });
1367
- inFence = false;
1368
- code = [];
1369
- continue;
1566
+ var CodeBlockWrapper = class extends BoxRenderable {
1567
+ /** Live code body owned by this wrapper. Updates pass through here. */
1568
+ body;
1569
+ /** Header chrome — re-themed on every {@link applyTheme} call. */
1570
+ header;
1571
+ spacer;
1572
+ langLabel;
1573
+ button;
1574
+ /** Bag we belong to. Used for live theme lookups and self-deregistration. */
1575
+ bag;
1576
+ /** Pending feedback-reset timer for the `[copied]` flash. */
1577
+ feedbackTimer = null;
1578
+ /** Whether the button is currently showing `[copied]`. */
1579
+ copied = false;
1580
+ /**
1581
+ * Latest body content, captured every time the markdown writes to
1582
+ * `content`. Read by the click handler so a copy click pushes the
1583
+ * up-to-date code (not whatever was there on first mount).
1584
+ */
1585
+ latestContent;
1586
+ constructor(ctx, body, options) {
1587
+ const { colors, surfaces } = options.bag;
1588
+ super(ctx, {
1589
+ flexDirection: "column",
1590
+ flexShrink: 0,
1591
+ alignSelf: "stretch",
1592
+ backgroundColor: surfaces.background
1593
+ });
1594
+ this.body = body;
1595
+ this.bag = options.bag;
1596
+ this.latestContent = body.content;
1597
+ body.marginTop = 0;
1598
+ body.marginBottom = 0;
1599
+ body.drawUnstyledText = false;
1600
+ this.header = new BoxRenderable(ctx, {
1601
+ flexDirection: "row",
1602
+ height: 1,
1603
+ alignSelf: "stretch",
1604
+ backgroundColor: surfaces.background
1605
+ });
1606
+ this.langLabel = new TextRenderable(ctx, {
1607
+ content: options.lang && options.lang.length > 0 ? options.lang : "code",
1608
+ fg: colors.mute,
1609
+ selectable: false
1610
+ });
1611
+ this.spacer = new BoxRenderable(ctx, {
1612
+ flexGrow: 1,
1613
+ backgroundColor: surfaces.background
1614
+ });
1615
+ this.button = new TextRenderable(ctx, {
1616
+ content: "[copy]",
1617
+ fg: colors.warn,
1618
+ selectable: false
1619
+ });
1620
+ this.button.onMouseDown = (event) => {
1621
+ event.stopPropagation();
1622
+ event.preventDefault();
1623
+ if (!writeToClipboard(this.latestContent)) return;
1624
+ const live = this.bag.colors;
1625
+ if (!this.copied) {
1626
+ this.copied = true;
1627
+ this.button.content = "[copied]";
1628
+ this.button.fg = live.accent;
1629
+ }
1630
+ if (this.feedbackTimer) clearTimeout(this.feedbackTimer);
1631
+ this.feedbackTimer = setTimeout(() => {
1632
+ this.copied = false;
1633
+ this.feedbackTimer = null;
1634
+ if (this.button.isDestroyed) return;
1635
+ this.button.content = "[copy]";
1636
+ this.button.fg = this.bag.colors.warn;
1637
+ }, COPY_FEEDBACK_MS);
1638
+ };
1639
+ this.header.add(this.langLabel);
1640
+ this.header.add(this.spacer);
1641
+ this.header.add(this.button);
1642
+ this.add(this.header);
1643
+ this.add(this.body);
1644
+ options.bag.wrappers.add(this);
1645
+ }
1646
+ /**
1647
+ * Re-paint header chrome with the current theme. Called from
1648
+ * {@link MarkdownBlock}'s `useEffect` after every theme switch — the
1649
+ * body's `fg`/`bg` are already re-driven by the markdown's own
1650
+ * `rerenderBlocks` pass through our forwarding accessors, so we only
1651
+ * need to refresh the bits the markdown doesn't manage: the wrapper's
1652
+ * own background, the header row, the spacer, the lang label, and the
1653
+ * `[copy]`/`[copied]` button colour (which depends on `this.copied`).
1654
+ */
1655
+ applyTheme(colors, surfaces) {
1656
+ this.backgroundColor = surfaces.background;
1657
+ this.header.backgroundColor = surfaces.background;
1658
+ this.spacer.backgroundColor = surfaces.background;
1659
+ this.langLabel.fg = colors.mute;
1660
+ this.button.fg = this.copied ? colors.accent : colors.warn;
1661
+ }
1662
+ get content() {
1663
+ return this.body.content;
1664
+ }
1665
+ set content(value) {
1666
+ this.latestContent = value;
1667
+ this.body.content = value;
1668
+ }
1669
+ get filetype() {
1670
+ return this.body.filetype;
1671
+ }
1672
+ set filetype(value) {
1673
+ this.body.filetype = value;
1674
+ }
1675
+ get syntaxStyle() {
1676
+ return this.body.syntaxStyle;
1677
+ }
1678
+ set syntaxStyle(value) {
1679
+ this.body.syntaxStyle = value;
1680
+ }
1681
+ get fg() {
1682
+ return this.body.fg;
1683
+ }
1684
+ set fg(value) {
1685
+ this.body.fg = value;
1686
+ }
1687
+ get bg() {
1688
+ return this.body.bg;
1689
+ }
1690
+ set bg(value) {
1691
+ this.body.bg = value;
1692
+ }
1693
+ get conceal() {
1694
+ return this.body.conceal;
1695
+ }
1696
+ set conceal(value) {
1697
+ this.body.conceal = value;
1698
+ }
1699
+ get streaming() {
1700
+ return this.body.streaming;
1701
+ }
1702
+ set streaming(value) {
1703
+ this.body.streaming = value;
1704
+ }
1705
+ /**
1706
+ * Always reports `false`: see the setter doc.
1707
+ */
1708
+ get drawUnstyledText() {
1709
+ return false;
1710
+ }
1711
+ /**
1712
+ * Pinned to `false` regardless of what the markdown writes. See the
1713
+ * class doc — leaving this open to the markdown's default would put
1714
+ * the body's `content` setter on the synchronous-plain-text path on
1715
+ * every delta, replacing the styled buffer with raw text for one
1716
+ * frame and producing a visible colour swap.
1717
+ */
1718
+ set drawUnstyledText(_value) {
1719
+ if (this.body.drawUnstyledText !== false) this.body.drawUnstyledText = false;
1720
+ }
1721
+ /**
1722
+ * Always reports `0`: see the setter doc.
1723
+ */
1724
+ get marginBottom() {
1725
+ return 0;
1726
+ }
1727
+ /**
1728
+ * Pinned to `0`. The next block's `marginTop` (which `renderNode`
1729
+ * sets to `1` on every non-first block) is the single source of
1730
+ * inter-block spacing. Letting the markdown also write
1731
+ * `getInterBlockMargin(code) = 1` here on every delta would stack to
1732
+ * a two-row gap below every fence.
1733
+ */
1734
+ set marginBottom(_value) {}
1735
+ destroy() {
1736
+ this.bag.wrappers.delete(this);
1737
+ if (this.feedbackTimer) {
1738
+ clearTimeout(this.feedbackTimer);
1739
+ this.feedbackTimer = null;
1370
1740
  }
1371
- code.push(line);
1741
+ super.destroy();
1372
1742
  }
1373
- if (inFence) prose.push(`${fenceChar.repeat(fenceLen)}${lang}`, ...code);
1374
- flushProse();
1375
- return segments;
1376
- }
1377
- /** Fade-out delay for the "[copied]" affordance, in ms. */
1378
- const COPY_FEEDBACK_MS = 1200;
1379
- /**
1380
- * Fence info-string → canonical Tree-sitter filetype.
1381
- *
1382
- * OpenTUI's built-in parsers register `typescript`, `javascript`,
1383
- * `markdown`, `zig`; the `<markdown>` renderer has an internal
1384
- * `infoStringMap` that translates short fence info-strings (`ts`, `js`,
1385
- * `tsx`, `jsx`, `md`) into those canonical names before dispatching the
1386
- * embedded fence. Our standalone `<code>` (lifted from the markdown body
1387
- * so we can pin a copy header) skips that map, so we re-apply the same
1388
- * translation here. Without it, ` ```ts ` would land as plain text while
1389
- * ` ```typescript ` would highlight — a regression vs. the pre-copy
1390
- * render path.
1391
- *
1392
- * Custom parsers registered in `tui/tree-sitter.ts` (`python`, `bash`,
1393
- * `rust`, `go`, `yaml`, `html`, `css`, `json`) carry their own aliases
1394
- * via `addDefaultParsers`, so the OpenTUI client resolves `py`, `sh`,
1395
- * `rs`, `golang`, `yml`, `htm`, `zsh` natively — no entry needed here.
1396
- */
1397
- const FILETYPE_ALIASES = {
1398
- ts: "typescript",
1399
- tsx: "typescript",
1400
- typescriptreact: "typescript",
1401
- js: "javascript",
1402
- jsx: "javascript",
1403
- mjs: "javascript",
1404
- cjs: "javascript",
1405
- javascriptreact: "javascript",
1406
- md: "markdown"
1407
1743
  };
1408
1744
  /**
1409
- * Map a raw fence info-string to a Tree-sitter filetype key understood
1410
- * by the parser client. Returns `undefined` for empty / `text` / `plain`
1411
- * info-strings so the renderer paints plain text instead of dispatching
1412
- * to an inappropriate parser.
1745
+ * Block-index regex applied to OpenTUI's auto-generated renderable ids
1746
+ * (`${markdown.id}-block-${index}`). The trailing digits give us the
1747
+ * block's position inside the markdown's child list, which is the only
1748
+ * piece of context we need to gate the "non-first block → marginTop: 1"
1749
+ * spacing rule.
1413
1750
  */
1414
- function canonicalFiletype(lang) {
1415
- if (!lang) return void 0;
1416
- const key = lang.toLowerCase().trim();
1417
- if (!key || key === "text" || key === "plain" || key === "plaintext" || key === "txt") return void 0;
1418
- return FILETYPE_ALIASES[key] ?? key;
1419
- }
1751
+ const BLOCK_ID_INDEX_RE = /-block-(\d+)$/;
1420
1752
  /**
1421
- * Single fenced code block with a clickable `[copy]` header. The header
1422
- * row carries:
1423
- * - the language label on the left (dim, falls back to `code`),
1424
- * - a clickable `[copy]` / `[copied]` indicator on the right.
1753
+ * Build a stable `renderNode` callback wired to a live `RenderNodeBag`.
1754
+ *
1755
+ * Returns:
1756
+ * - `code` tokens {@link CodeBlockWrapper} (hosts the `[lang] …
1757
+ * [copy]` header).
1758
+ * - everything else (prose, heading, list, table, …) → the renderable
1759
+ * we got from `context.defaultRender()`, mutated in place.
1425
1760
  *
1426
- * Clicking the indicator pushes the code body to the OS clipboard via
1427
- * OSC 52 (see `writeToClipboard`). The mouseDown also stops propagation
1428
- * so it doesn't initiate an in-app text selection on the surrounding
1429
- * code, and we suppress selection on the button text itself so dragging
1430
- * over it doesn't extend a selection started elsewhere.
1761
+ * Critical: we MUST return the renderable we mutated, not `undefined`.
1762
+ * OpenTUI's coalesced-mode loop, when `renderNode` returns a falsy
1763
+ * value, creates a fresh default renderable (`markdown.ts ~8851`) so
1764
+ * anything we mutated via `defaultRender()` is thrown away.
1431
1765
  *
1432
- * Body uses the native `<code>` renderable so the snippet picks up the
1433
- * same Tree-sitter highlighting the embedded markdown fences would have.
1434
- * The `filetype` is only set when we got a non-empty info-string;
1435
- * otherwise `<code>` renders plain text better than guessing a wrong
1436
- * language and shipping nonsensical highlights.
1766
+ * Tables: returning the default `TextTableRenderable` looks like it
1767
+ * should orphan the markdown's `tableContentCache` tuple (incremental
1768
+ * table updates depend on it), but the markdown has a follow-up branch
1769
+ * (`markdown.ts ~8854`) that re-builds the cache from the token when
1770
+ * `renderNode` returned a `TextTableRenderable` without one. So we can
1771
+ * safely mutate + return the table renderable too, and the spacing
1772
+ * rule applies uniformly.
1773
+ *
1774
+ * Every non-first block gets `marginTop: 1`. This reimplements the
1775
+ * inter-block spacing that top-level mode would compute from the
1776
+ * parser's space tokens — coalesced mode + `renderNode` strips them
1777
+ * (see the file-level comment), so we recompute the spacing ourselves.
1778
+ * The result is bit-identical to top-level mode for every
1779
+ * well-formatted markdown source.
1780
+ */
1781
+ function makeMarkdownRenderNode(bag) {
1782
+ return (token, context) => {
1783
+ const inner = context.defaultRender();
1784
+ if (!inner) return void 0;
1785
+ const topMargin = parseBlockIndex(inner.id) === 0 ? 0 : 1;
1786
+ if (token.type === "code" && inner instanceof CodeRenderable) {
1787
+ const lang = typeof token.lang === "string" ? token.lang : "";
1788
+ const wrapper = new CodeBlockWrapper(bag.current.ctx, inner, {
1789
+ lang,
1790
+ bag: bag.current
1791
+ });
1792
+ wrapper.marginTop = topMargin;
1793
+ return wrapper;
1794
+ }
1795
+ inner.marginTop = topMargin;
1796
+ return inner;
1797
+ };
1798
+ }
1799
+ /**
1800
+ * Pull the trailing block index out of an OpenTUI renderable id. Returns
1801
+ * `0` for any id that doesn't match the expected `…-block-N` shape — the
1802
+ * spacing rule then treats unparseable ids as "first block" (no
1803
+ * `marginTop`), which is the safer side of the rendering edge case.
1437
1804
  */
1438
- const FencedCodeBlock = memo(({ content, lang, dim }) => {
1805
+ function parseBlockIndex(id) {
1806
+ if (!id) return 0;
1807
+ const match = BLOCK_ID_INDEX_RE.exec(id);
1808
+ if (!match) return 0;
1809
+ const parsed = Number.parseInt(match[1] ?? "", 10);
1810
+ return Number.isFinite(parsed) ? parsed : 0;
1811
+ }
1812
+ const MarkdownBlock = memo(({ text, dim }) => {
1439
1813
  const COLOR = useColors();
1814
+ const SURFACE = useSurfaces();
1440
1815
  const mdStyle = useMdStyle();
1441
- const [copied, setCopied] = useState(false);
1816
+ const renderer = useRenderer();
1817
+ const bag = useRef({
1818
+ ctx: renderer,
1819
+ colors: COLOR,
1820
+ surfaces: SURFACE,
1821
+ wrappers: /* @__PURE__ */ new Set()
1822
+ });
1823
+ bag.current.ctx = renderer;
1824
+ bag.current.colors = COLOR;
1825
+ bag.current.surfaces = SURFACE;
1442
1826
  useEffect(() => {
1443
- if (!copied) return;
1444
- const id = setTimeout(setCopied, COPY_FEEDBACK_MS, false);
1445
- return () => clearTimeout(id);
1446
- }, [copied]);
1447
- const onCopy = useCallback(() => {
1448
- setCopied(writeToClipboard(content));
1449
- }, [content]);
1450
- const filetype = canonicalFiletype(lang);
1451
- const label = lang && lang.length > 0 ? lang : "code";
1452
- const buttonColor = copied ? COLOR.accent : COLOR.warn;
1453
- const buttonText = copied ? "[copied]" : "[copy]";
1454
- return /* @__PURE__ */ jsxs("box", {
1455
- style: {
1456
- flexDirection: "column",
1457
- flexShrink: 0,
1458
- alignSelf: "stretch"
1459
- },
1460
- children: [/* @__PURE__ */ jsxs("box", {
1461
- style: {
1462
- flexDirection: "row",
1463
- height: 1,
1464
- alignSelf: "stretch"
1465
- },
1466
- children: [
1467
- /* @__PURE__ */ jsx("text", {
1468
- selectable: false,
1469
- fg: COLOR.mute,
1470
- children: label
1471
- }),
1472
- /* @__PURE__ */ jsx("box", { style: { flexGrow: 1 } }),
1473
- /* @__PURE__ */ jsx("text", {
1474
- selectable: false,
1475
- fg: buttonColor,
1476
- onMouseDown: (e) => {
1477
- e.stopPropagation();
1478
- e.preventDefault();
1479
- onCopy();
1480
- },
1481
- children: buttonText
1482
- })
1483
- ]
1484
- }), /* @__PURE__ */ jsx("code", {
1485
- content,
1486
- ...filetype ? { filetype } : {},
1487
- syntaxStyle: mdStyle,
1488
- ...dim ? { fg: COLOR.dim } : {}
1489
- })]
1490
- });
1491
- });
1492
- FencedCodeBlock.displayName = "FencedCodeBlock";
1493
- const MarkdownBlock = memo(({ text, streaming, dim }) => {
1494
- const COLOR = useColors();
1495
- const mdStyle = useMdStyle();
1496
- const segments = useMemo(() => streaming ? null : splitMarkdownCodeBlocks(text), [text, streaming]);
1497
- if (!segments || segments.length === 0 || !segments.some((s) => s.kind === "code")) return /* @__PURE__ */ jsx("markdown", {
1827
+ for (const wrapper of bag.current.wrappers) wrapper.applyTheme(COLOR, SURFACE);
1828
+ }, [COLOR, SURFACE]);
1829
+ const renderNode = useMemo(() => makeMarkdownRenderNode(bag), []);
1830
+ return /* @__PURE__ */ jsx("markdown", {
1498
1831
  content: text,
1499
1832
  syntaxStyle: mdStyle,
1500
- streaming,
1501
- internalBlockMode: streaming ? "top-level" : "coalesced",
1502
- fg: dim ? COLOR.dim : void 0
1503
- });
1504
- return /* @__PURE__ */ jsx("box", {
1505
- style: {
1506
- flexDirection: "column",
1507
- flexShrink: 0,
1508
- alignSelf: "stretch"
1509
- },
1510
- children: segments.map((seg, i) => seg.kind === "code" ? /* @__PURE__ */ jsx(FencedCodeBlock, {
1511
- content: seg.content,
1512
- lang: seg.lang,
1513
- dim
1514
- }, i) : /* @__PURE__ */ jsx("markdown", {
1515
- content: seg.content,
1516
- syntaxStyle: mdStyle,
1517
- streaming: false,
1518
- internalBlockMode: "coalesced",
1519
- fg: dim ? COLOR.dim : void 0
1520
- }, i))
1833
+ streaming: true,
1834
+ internalBlockMode: "coalesced",
1835
+ fg: dim ? COLOR.dim : void 0,
1836
+ bg: SURFACE.background,
1837
+ renderNode
1521
1838
  });
1522
1839
  });
1523
1840
  MarkdownBlock.displayName = "MarkdownBlock";
@@ -1607,6 +1924,7 @@ function EditDiffBlock({ payload, dim }) {
1607
1924
  ...SURFACE.diff.contextBg ? { contextBg: SURFACE.diff.contextBg } : {},
1608
1925
  addedSignColor: SURFACE.diff.addFg,
1609
1926
  removedSignColor: SURFACE.diff.removeFg,
1927
+ treeSitterClient: getTreeSitterClient(),
1610
1928
  ...dim ? { fg: COLOR.dim } : {}
1611
1929
  })]
1612
1930
  });
@@ -2303,6 +2621,206 @@ function CompletionPopup({ state, visibleRows = 6 }) {
2303
2621
  });
2304
2622
  }
2305
2623
  //#endregion
2624
+ //#region src/tui/file-edit-approval-modal.tsx
2625
+ const FILE_EDIT_TOOLS = new Set([
2626
+ "edit",
2627
+ "multi_edit",
2628
+ "write_file"
2629
+ ]);
2630
+ function isFileEditTool(tool) {
2631
+ return FILE_EDIT_TOOLS.has(tool);
2632
+ }
2633
+ const ACTIONS = [
2634
+ {
2635
+ label: "Allow",
2636
+ decision: "accept-once",
2637
+ shortcut: "a"
2638
+ },
2639
+ {
2640
+ label: "Allow for session",
2641
+ decision: "accept-session",
2642
+ shortcut: "s"
2643
+ },
2644
+ {
2645
+ label: "Always allow",
2646
+ decision: "accept-safelist",
2647
+ shortcut: "p"
2648
+ },
2649
+ {
2650
+ label: "Deny",
2651
+ decision: "deny",
2652
+ shortcut: "d",
2653
+ destructive: true
2654
+ }
2655
+ ];
2656
+ function FileEditApprovalModal({ request, onDecide }) {
2657
+ const COLOR = useColors();
2658
+ const SURFACE = useSurfaces();
2659
+ const mdStyle = useMdStyle();
2660
+ const { width: termWidth } = useTerminalDimensions();
2661
+ const modal = useModal();
2662
+ const targetPath = String(request.input.path ?? "");
2663
+ const [priorContent, priorError] = useMemo(() => {
2664
+ try {
2665
+ return [fs.readFileSync(targetPath, "utf8"), null];
2666
+ } catch (e) {
2667
+ if (e.code === "ENOENT") return ["", null];
2668
+ return ["", e.message];
2669
+ }
2670
+ }, [targetPath]);
2671
+ const payload = useMemo(() => {
2672
+ try {
2673
+ return extractEditPayload(request.tool, request.input, priorContent);
2674
+ } catch {
2675
+ return null;
2676
+ }
2677
+ }, [
2678
+ request.tool,
2679
+ request.input,
2680
+ priorContent
2681
+ ]);
2682
+ const diffText = useMemo(() => payload ? buildContextualDiff(payload, priorContent, 6) : "", [payload, priorContent]);
2683
+ const [selected, setSelected] = useState(0);
2684
+ const filetype = useMemo(() => filetypeFromPath(targetPath), [targetPath]);
2685
+ const decide = useCallback((decision) => {
2686
+ onDecide(decision);
2687
+ modal.close();
2688
+ }, [onDecide, modal]);
2689
+ useKeyboard((key) => {
2690
+ if (key.name === "left") {
2691
+ setSelected((i) => (i - 1 + ACTIONS.length) % ACTIONS.length);
2692
+ return;
2693
+ }
2694
+ if (key.name === "right" || key.name === "tab") {
2695
+ setSelected((i) => (i + 1) % ACTIONS.length);
2696
+ return;
2697
+ }
2698
+ if (key.name === "return") {
2699
+ decide(ACTIONS[selected].decision);
2700
+ return;
2701
+ }
2702
+ if (key.name === "escape") {
2703
+ decide("deny");
2704
+ return;
2705
+ }
2706
+ const ch = (key.sequence ?? "").toLowerCase();
2707
+ const hit = ACTIONS.find((a) => a.shortcut === ch);
2708
+ if (hit) decide(hit.decision);
2709
+ });
2710
+ const useSplit = termWidth >= 100;
2711
+ const diffWidth = Math.min(termWidth - 6, 140);
2712
+ const diffHeight = Math.max(8, Math.min(28, diffText.split("\n").length + 2));
2713
+ return /* @__PURE__ */ jsxs("box", {
2714
+ title: " Permission Request ",
2715
+ bottomTitle: " ←/→ navigate · enter confirm · esc deny · a/s/d shortcuts ",
2716
+ style: {
2717
+ border: true,
2718
+ borderColor: COLOR.brand,
2719
+ backgroundColor: SURFACE.modal,
2720
+ padding: 1,
2721
+ flexDirection: "column",
2722
+ width: diffWidth + 6,
2723
+ flexShrink: 0
2724
+ },
2725
+ children: [
2726
+ /* @__PURE__ */ jsx("box", {
2727
+ style: {
2728
+ flexDirection: "row",
2729
+ height: 1,
2730
+ marginBottom: 1,
2731
+ flexShrink: 0
2732
+ },
2733
+ children: /* @__PURE__ */ jsxs("text", {
2734
+ wrapMode: "none",
2735
+ children: [
2736
+ /* @__PURE__ */ jsx("span", {
2737
+ fg: COLOR.brand,
2738
+ children: request.tool
2739
+ }),
2740
+ /* @__PURE__ */ jsx("span", {
2741
+ fg: COLOR.mute,
2742
+ children: " · "
2743
+ }),
2744
+ /* @__PURE__ */ jsx("span", {
2745
+ fg: COLOR.model,
2746
+ children: targetPath || "(no path)"
2747
+ })
2748
+ ]
2749
+ })
2750
+ }),
2751
+ priorError ? /* @__PURE__ */ jsx("box", {
2752
+ style: {
2753
+ height: 3,
2754
+ flexShrink: 0,
2755
+ marginBottom: 1
2756
+ },
2757
+ children: /* @__PURE__ */ jsx("text", {
2758
+ fg: COLOR.error,
2759
+ wrapMode: "word",
2760
+ children: `Couldn't read ${targetPath}: ${priorError}`
2761
+ })
2762
+ }) : /* @__PURE__ */ jsx("box", {
2763
+ style: {
2764
+ border: true,
2765
+ borderColor: COLOR.mute,
2766
+ width: diffWidth + 2,
2767
+ height: diffHeight + 2,
2768
+ flexShrink: 0,
2769
+ marginBottom: 1
2770
+ },
2771
+ children: /* @__PURE__ */ jsx("diff", {
2772
+ diff: diffText,
2773
+ view: useSplit ? "split" : "unified",
2774
+ wrapMode: "word",
2775
+ showLineNumbers: true,
2776
+ syntaxStyle: mdStyle,
2777
+ treeSitterClient: getTreeSitterClient(),
2778
+ ...filetype ? { filetype } : {},
2779
+ addedBg: SURFACE.diff.addBg,
2780
+ removedBg: SURFACE.diff.removeBg,
2781
+ ...SURFACE.diff.addContentBg ? { addedContentBg: SURFACE.diff.addContentBg } : {},
2782
+ ...SURFACE.diff.removeContentBg ? { removedContentBg: SURFACE.diff.removeContentBg } : {},
2783
+ addedSignColor: SURFACE.diff.addFg,
2784
+ removedSignColor: SURFACE.diff.removeFg,
2785
+ style: {
2786
+ width: diffWidth,
2787
+ height: diffHeight
2788
+ }
2789
+ })
2790
+ }),
2791
+ /* @__PURE__ */ jsx("box", {
2792
+ style: {
2793
+ flexDirection: "row",
2794
+ height: 1,
2795
+ flexShrink: 0
2796
+ },
2797
+ children: ACTIONS.map((action, i) => {
2798
+ const isSelected = i === selected;
2799
+ const tint = action.destructive ? COLOR.error : COLOR.brand;
2800
+ const labelText = ` ${action.label} (${action.shortcut}) `;
2801
+ return /* @__PURE__ */ jsx("box", {
2802
+ style: {
2803
+ marginRight: 2,
2804
+ flexShrink: 0
2805
+ },
2806
+ children: isSelected ? /* @__PURE__ */ jsx("text", {
2807
+ bg: tint,
2808
+ fg: SURFACE.background,
2809
+ wrapMode: "none",
2810
+ children: labelText
2811
+ }) : /* @__PURE__ */ jsx("text", {
2812
+ bg: SURFACE.selection,
2813
+ fg: COLOR.dim,
2814
+ wrapMode: "none",
2815
+ children: labelText
2816
+ })
2817
+ }, action.decision);
2818
+ })
2819
+ })
2820
+ ]
2821
+ });
2822
+ }
2823
+ //#endregion
2306
2824
  //#region src/tui/interaction-block.tsx
2307
2825
  const COMMENT_TEXTAREA_BINDINGS = [
2308
2826
  ...defaultTextareaKeyBindings.filter((b) => b.name !== "return" && !(b.name === "a" && b.ctrl && !b.shift && !b.meta)),
@@ -3584,7 +4102,7 @@ function SessionsScreen({ sessions, currentId, focusedSessionId, onPick, onCreat
3584
4102
  if (cwdBudget < 6) return countSegs;
3585
4103
  const segs = [{
3586
4104
  text: compactPath(currentProjectRoot, cwdBudget),
3587
- color: COLOR.dim
4105
+ color: COLOR.model
3588
4106
  }];
3589
4107
  if (showAllProjects) segs.push({
3590
4108
  text: " · ",
@@ -3818,6 +4336,7 @@ function renderProjectLabel(rowProject, currentProject, COLOR) {
3818
4336
  /** Visible content lines: 1 minimum, 5 maximum (textarea scrolls past 5). */
3819
4337
  const MIN_CONTENT_LINES = 1;
3820
4338
  const MAX_CONTENT_LINES = 5;
4339
+ const CWD_DISPLAY = compactPath(process.cwd());
3821
4340
  /**
3822
4341
  * Stable empty-array reference — keeps `queuedMessages` default referentially
3823
4342
  * stable across renders so memoized children don't bust their deps. Declared
@@ -3830,6 +4349,8 @@ function ChatScreen({ events, busy, compacting = false, queuedMessages = EMPTY_Q
3830
4349
  const titleText = session?.title ?? "untitled";
3831
4350
  const showSessionShortcut = !!session && !busy && !pending && !pendingInteraction;
3832
4351
  const userMessageCount = useMemo(() => events.filter((e) => e.kind === "user-prompt").length, [events]);
4352
+ const { width: termWidth } = useTerminalDimensions();
4353
+ const hasStatusIcon = busy || compacting;
3833
4354
  const metaSegments = useMemo(() => {
3834
4355
  if (!session) return null;
3835
4356
  const turnsSuffix = `turn${session.turnCount === 1 ? "" : "s"}`;
@@ -3857,15 +4378,28 @@ function ChatScreen({ events, busy, compacting = false, queuedMessages = EMPTY_Q
3857
4378
  text: "ctrl+x",
3858
4379
  color: COLOR.warn
3859
4380
  }, { text: " session" });
4381
+ const OVERLAY_RESERVED = 6;
4382
+ const iconReserve = hasStatusIcon ? 2 : 0;
4383
+ const statsLen = segments.reduce((sum, s) => sum + s.text.length, 0);
4384
+ const cwdBudget = Math.max(0, termWidth - 4 - titleText.length - iconReserve - OVERLAY_RESERVED - statsLen - 3);
4385
+ if (cwdBudget >= 6) segments.unshift({
4386
+ text: compactPath(CWD_DISPLAY, cwdBudget),
4387
+ color: COLOR.dim
4388
+ }, {
4389
+ text: " · ",
4390
+ color: COLOR.mute
4391
+ });
3860
4392
  return segments;
3861
4393
  }, [
3862
4394
  session,
3863
4395
  userMessageCount,
3864
4396
  COLOR,
3865
- showSessionShortcut
4397
+ showSessionShortcut,
4398
+ termWidth,
4399
+ titleText,
4400
+ hasStatusIcon
3866
4401
  ]);
3867
4402
  const userPrompts = useMemo(() => events.filter((e) => e.kind === "user-prompt").map((e) => e.text), [events]);
3868
- const titleStatusIcon = busy ? /* @__PURE__ */ jsx(StatusSpinner, { color: COLOR.warn }) : compacting ? /* @__PURE__ */ jsx(StatusSpinner, { color: COLOR.accent }) : null;
3869
4403
  const [completionPopupOpen, setCompletionPopupOpen] = useState(false);
3870
4404
  const handlePopupOpenChange = useCallback((open) => {
3871
4405
  setCompletionPopupOpen(open);
@@ -3887,7 +4421,8 @@ function ChatScreen({ events, busy, compacting = false, queuedMessages = EMPTY_Q
3887
4421
  children: /* @__PURE__ */ jsx(Transcript, {
3888
4422
  events,
3889
4423
  settings,
3890
- selectedTurnId: selectedTurnId ?? null
4424
+ selectedTurnId: selectedTurnId ?? null,
4425
+ busy: busy && !pending && !pendingInteraction
3891
4426
  })
3892
4427
  }),
3893
4428
  pending ? /* @__PURE__ */ jsx(ApprovalBlock, {
@@ -3912,8 +4447,7 @@ function ChatScreen({ events, busy, compacting = false, queuedMessages = EMPTY_Q
3912
4447
  })] }),
3913
4448
  /* @__PURE__ */ jsx(TitleOverlay, {
3914
4449
  title: titleText,
3915
- meta: metaSegments,
3916
- statusIcon: titleStatusIcon
4450
+ meta: metaSegments
3917
4451
  })
3918
4452
  ]
3919
4453
  });
@@ -3974,6 +4508,22 @@ function ApprovalBlock({ request, onPick }) {
3974
4508
  const focused = useModalAwareFocus();
3975
4509
  const COLOR = useColors();
3976
4510
  const SELECT_THEME = useSelectStyle();
4511
+ const modal = useModal();
4512
+ const isFileEdit = isFileEditTool(request.tool);
4513
+ useEffect(() => {
4514
+ if (!isFileEdit) return;
4515
+ modal.open(/* @__PURE__ */ jsx(FileEditApprovalModal, {
4516
+ request,
4517
+ onDecide: onPick
4518
+ }));
4519
+ return () => modal.close();
4520
+ }, [
4521
+ isFileEdit,
4522
+ request,
4523
+ onPick,
4524
+ modal
4525
+ ]);
4526
+ if (isFileEdit) return null;
3977
4527
  const summary = useMemo(() => `${request.tool}(${formatApprovalArgs(request.input)})`, [request.tool, request.input]);
3978
4528
  const options = useMemo(() => {
3979
4529
  return [
@@ -3982,6 +4532,11 @@ function ApprovalBlock({ request, onPick }) {
3982
4532
  description: "",
3983
4533
  value: "accept-once"
3984
4534
  },
4535
+ {
4536
+ name: "accept for session · auto-approve matching calls until you quit",
4537
+ description: "",
4538
+ value: "accept-session"
4539
+ },
3985
4540
  {
3986
4541
  name: `accept + remember · add "${suggestSafelistEntry(request.tool, request.input)}" to projects.json`,
3987
4542
  description: "",
@@ -6464,6 +7019,10 @@ function AppShell() {
6464
7019
  useEffect(() => {
6465
7020
  persistToolResultsRef.current = settings.persistToolResults;
6466
7021
  }, [settings.persistToolResults]);
7022
+ const allowInteractionRef = useRef(settings.allowInteraction);
7023
+ useEffect(() => {
7024
+ allowInteractionRef.current = settings.allowInteraction;
7025
+ }, [settings.allowInteraction]);
6467
7026
  const autoCompactRef = useRef(settings.autoCompact);
6468
7027
  useEffect(() => {
6469
7028
  autoCompactRef.current = settings.autoCompact;
@@ -6472,6 +7031,10 @@ function AppShell() {
6472
7031
  useEffect(() => {
6473
7032
  autoCompactThresholdRef.current = settings.autoCompactThreshold;
6474
7033
  }, [settings.autoCompactThreshold]);
7034
+ const smoothStreamingRef = useRef(settings.smoothStreaming);
7035
+ useEffect(() => {
7036
+ smoothStreamingRef.current = settings.smoothStreaming;
7037
+ }, [settings.smoothStreaming]);
6475
7038
  const [projectDir] = useState(() => findGitRoot(process.cwd()) ?? process.cwd());
6476
7039
  const safelistRef = useRef(null);
6477
7040
  const readSafelist = useCallback(() => {
@@ -6481,6 +7044,7 @@ function AppShell() {
6481
7044
  useEffect(() => {
6482
7045
  safelistRef.current = null;
6483
7046
  }, [dataDir, projectDir]);
7047
+ const sessionSafelistRef = useRef(/* @__PURE__ */ new Set());
6484
7048
  const [skillsCatalog, setSkillsCatalog] = useState([]);
6485
7049
  const [mcpsCatalog, setMcpsCatalog] = useState([]);
6486
7050
  const [mcpsErrors, setMcpsErrors] = useState([]);
@@ -6576,9 +7140,11 @@ function AppShell() {
6576
7140
  const gateDecision = useCallback(async (tool, input) => {
6577
7141
  if (!safeModeEnabledRef.current) return true;
6578
7142
  if (isOnSafelist(readSafelist(), tool, input)) return true;
7143
+ if (isOnSafelist([...sessionSafelistRef.current], tool, input)) return true;
6579
7144
  const decision = await requestApproval(tool, input);
6580
7145
  if (decision === "deny") return false;
6581
- if (decision === "accept-safelist") {
7146
+ if (decision === "accept-session") sessionSafelistRef.current.add(suggestSafelistEntry(tool, input));
7147
+ else if (decision === "accept-safelist") {
6582
7148
  addToSafelist(dataDir, projectDir, suggestSafelistEntry(tool, input));
6583
7149
  safelistRef.current = null;
6584
7150
  }
@@ -6636,6 +7202,13 @@ function AppShell() {
6636
7202
  /** Token count from the most recent assistant turn (caching-aware). */
6637
7203
  const [lastInputTokens, setLastInputTokens] = useState(0);
6638
7204
  /**
7205
+ * Cumulative USD cost across every run in the active session — seeded
7206
+ * from `session.runs` on activation and topped up by each `turn:after`'s
7207
+ * `usage.cost`. Only providers that report cost (currently OpenRouter)
7208
+ * contribute; everywhere else this stays at 0 and the footer hides it.
7209
+ */
7210
+ const [sessionCost, setSessionCost] = useState(0);
7211
+ /**
6639
7212
  * Synchronous mirror of {@link lastInputTokens} for callbacks that need
6640
7213
  * to read the freshest value after `await agent.run()` resolves. The
6641
7214
  * `turn:after` hook updates both the state (drives the footer) and this
@@ -6695,7 +7268,7 @@ function AppShell() {
6695
7268
  * via `useEffect` once `triggerAutoCompactIfNeeded` is defined below.
6696
7269
  */
6697
7270
  const triggerAutoCompactRef = useRef(() => {});
6698
- const stream = useStreamBuffer(setEvents);
7271
+ const stream = useStreamBuffer(setEvents, { getSmooth: useCallback(() => smoothStreamingRef.current, []) });
6699
7272
  const makePicked = useCallback((provider, modelId) => {
6700
7273
  const descriptor = providerRegistry[provider.key];
6701
7274
  if (!descriptor) return null;
@@ -6711,6 +7284,14 @@ function AppShell() {
6711
7284
  model
6712
7285
  };
6713
7286
  }, [providerRegistry, initialState]);
7287
+ const cancelRunOnDenial = useCallback((reason) => {
7288
+ denyAll();
7289
+ interactions.cancelAll(reason);
7290
+ messageQueueRef.current = [];
7291
+ setMessageQueue([]);
7292
+ setQueueSelectionIndex(null);
7293
+ agentRef.current?.abort();
7294
+ }, [denyAll, interactions]);
6714
7295
  const buildAgent = useCallback((session, key) => {
6715
7296
  const descriptor = providerRegistry[key];
6716
7297
  if (!descriptor) throw new Error(`No provider registered for key "${key}"`);
@@ -6726,11 +7307,13 @@ function AppShell() {
6726
7307
  discovered: mcpsCatalogRef.current,
6727
7308
  enabled: enabledMcpsRef.current
6728
7309
  });
6729
- const interactionTools = createInteractionTools({ requestInteraction: makeRequestInteraction(interactions) });
7310
+ const allowInteraction = allowInteractionRef.current !== false;
7311
+ const interactionTools = allowInteraction ? createInteractionTools({ requestInteraction: makeRequestInteraction(interactions) }) : {};
6730
7312
  const actualCwd = process.cwd();
6731
7313
  const envOpts = {
6732
7314
  cwd: actualCwd,
6733
- ...projectDir !== actualCwd ? { projectRoot: projectDir } : {}
7315
+ ...projectDir !== actualCwd ? { projectRoot: projectDir } : {},
7316
+ allowInteraction
6734
7317
  };
6735
7318
  const builtInSystem = profile.id === "build" ? buildBuildSystem(envOpts) : profile.id === "plan" ? buildPlanSystem(envOpts) : null;
6736
7319
  const persistDir = resolvePersistDir({
@@ -6769,6 +7352,7 @@ function AppShell() {
6769
7352
  if (!await gateDecision(name, input)) {
6770
7353
  ctx.block = true;
6771
7354
  ctx.reason = "User denied this tool call";
7355
+ cancelRunOnDenial("user denied a tool call");
6772
7356
  }
6773
7357
  };
6774
7358
  agent.hooks.hook("tool:gate", (ctx) => applyGate(ctx.name, ctx.input, ctx));
@@ -6852,6 +7436,8 @@ function AppShell() {
6852
7436
  const tokens = turnContextSize(usage);
6853
7437
  setLastInputTokens(tokens);
6854
7438
  lastInputTokensRef.current = tokens;
7439
+ const turnCost = usage.cost ?? 0;
7440
+ if (turnCost > 0) setSessionCost((prev) => prev + turnCost);
6855
7441
  }
6856
7442
  stream.flushAndUpdate(finalizeStreamingMarkdown);
6857
7443
  });
@@ -6926,6 +7512,7 @@ function AppShell() {
6926
7512
  providerRegistry,
6927
7513
  stream,
6928
7514
  gateDecision,
7515
+ cancelRunOnDenial,
6929
7516
  projectDir,
6930
7517
  config.prefix,
6931
7518
  interactions,
@@ -6969,6 +7556,7 @@ function AppShell() {
6969
7556
  setMessageQueue([]);
6970
7557
  setQueueSelectionIndex(null);
6971
7558
  runningRef.current = false;
7559
+ sessionSafelistRef.current.clear();
6972
7560
  }, [
6973
7561
  stream,
6974
7562
  denyAll,
@@ -7050,6 +7638,7 @@ function AppShell() {
7050
7638
  const replayedTokens = lastContextSizeFromTurns(session.turns, session.runs);
7051
7639
  setLastInputTokens(replayedTokens);
7052
7640
  lastInputTokensRef.current = replayedTokens;
7641
+ setSessionCost(sumRunCosts(session.runs));
7053
7642
  setCurrentSession({
7054
7643
  id: session.id,
7055
7644
  title: deriveSessionTitle(session.turns, session.metadata),
@@ -7160,13 +7749,12 @@ function AppShell() {
7160
7749
  }, []);
7161
7750
  const onAbort = useCallback(() => {
7162
7751
  if (popupOpenRef.current) return;
7163
- denyAll();
7164
- interactions.cancelAll("user aborted run");
7165
- messageQueueRef.current = [];
7166
- setMessageQueue([]);
7167
- setQueueSelectionIndex(null);
7168
- agentRef.current?.abort();
7169
- }, [denyAll, interactions]);
7752
+ cancelRunOnDenial("user aborted run");
7753
+ }, [cancelRunOnDenial]);
7754
+ const onInteractionResolve = useCallback((response) => {
7755
+ interactions.resolveHead(response);
7756
+ if (response.kind === "plan" && response.decision === "reject") cancelRunOnDenial("user rejected a plan");
7757
+ }, [interactions, cancelRunOnDenial]);
7170
7758
  const enterQueueSelection = useCallback(() => {
7171
7759
  if (messageQueueRef.current.length === 0) return;
7172
7760
  setQueueSelectionIndex(messageQueueRef.current.length - 1);
@@ -7621,6 +8209,7 @@ function AppShell() {
7621
8209
  const replayedTokens = lastContextSizeFromTurns(session.turns, session.runs);
7622
8210
  setLastInputTokens(replayedTokens);
7623
8211
  lastInputTokensRef.current = replayedTokens;
8212
+ setSessionCost(sumRunCosts(session.runs));
7624
8213
  setCurrentSession((prev) => prev ? {
7625
8214
  ...prev,
7626
8215
  turnCount: session.turns.length,
@@ -8044,6 +8633,7 @@ function AppShell() {
8044
8633
  }
8045
8634
  if (key.name !== "escape") return;
8046
8635
  if (busy || pendingApproval) return onAbort();
8636
+ if (popupOpenRef.current) return;
8047
8637
  if (screen === "chat") return onOpenSessions();
8048
8638
  if (screen === "sessions") {
8049
8639
  if (currentSession) setScreen("chat");
@@ -8133,13 +8723,15 @@ function AppShell() {
8133
8723
  lastInputTokens,
8134
8724
  providerRegistry
8135
8725
  ]);
8726
+ const footerCost = screen === "chat" ? sessionCost : null;
8136
8727
  useEffect(() => () => {
8137
8728
  teardown();
8138
8729
  }, [teardown]);
8139
8730
  return /* @__PURE__ */ jsxs("box", {
8140
8731
  style: {
8141
8732
  flexDirection: "column",
8142
- flexGrow: 1
8733
+ flexGrow: 1,
8734
+ backgroundColor: SURFACE.background
8143
8735
  },
8144
8736
  children: [/* @__PURE__ */ jsxs("box", {
8145
8737
  style: {
@@ -8174,7 +8766,7 @@ function AppShell() {
8174
8766
  pending: pendingApproval,
8175
8767
  onApproval: resolveHead,
8176
8768
  pendingInteraction,
8177
- onInteraction: interactions.resolveHead,
8769
+ onInteraction: onInteractionResolve,
8178
8770
  completionProviders,
8179
8771
  onPopupOpenChange,
8180
8772
  selectedTurnId,
@@ -8183,7 +8775,9 @@ function AppShell() {
8183
8775
  ]
8184
8776
  }), /* @__PURE__ */ jsx(Footer, {
8185
8777
  hints,
8186
- context: contextUsage
8778
+ context: contextUsage,
8779
+ cost: footerCost,
8780
+ status: pendingInteraction?.kind === "question" ? "asking" : busy ? "busy" : compacting ? "compacting" : null
8187
8781
  })]
8188
8782
  });
8189
8783
  }
@@ -8359,6 +8953,18 @@ function shortChord(spec) {
8359
8953
  * https://opentui.com/docs/reference/tree-sitter/#use-local-files).
8360
8954
  * - If a download fails (offline / firewall), the language renders as
8361
8955
  * plain `markup.raw.block` — no crash, just no syntax color.
8956
+ * - **Self-hosted wasm.** Languages whose upstream grammars don't ship
8957
+ * `.wasm` releases (currently: SQL) are built ahead of time and
8958
+ * vendored under `tui/parsers/`; the TUI binary embeds the file via
8959
+ * `with { type: 'file' }` (see `tui/src/tree-sitter-bundle.ts`),
8960
+ * extracts it to a tmpdir at startup, and points the SDK at the
8961
+ * extracted path through a `ZIDANE_LOCAL_TREE_SITTER_<X>_WASM` env
8962
+ * var — keeps the repo's privacy boundary intact (no
8963
+ * `raw.githubusercontent.com` fetches) and works offline / inside
8964
+ * air-gapped runners. SDK consumers without these env vars set just
8965
+ * skip the affected grammars. See `tui/parsers/README.md` for the
8966
+ * rebuild recipe and `LOCAL_PARSERS` below for the env-var
8967
+ * contract.
8362
8968
  *
8363
8969
  * Versions are pinned in the WASM URLs so a grammar repo's `master`
8364
8970
  * landing a breaking change can't silently affect us.
@@ -8413,8 +9019,71 @@ const EXTRA_PARSERS = [
8413
9019
  filetype: "css",
8414
9020
  wasm: "https://github.com/tree-sitter/tree-sitter-css/releases/download/v0.23.2/tree-sitter-css.wasm",
8415
9021
  queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-css/v0.23.2/queries/highlights.scm"] }
9022
+ },
9023
+ {
9024
+ filetype: "haskell",
9025
+ aliases: ["hs"],
9026
+ wasm: "https://github.com/tree-sitter/tree-sitter-haskell/releases/download/v0.23.1/tree-sitter-haskell.wasm",
9027
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-haskell/v0.23.1/queries/highlights.scm"] }
9028
+ },
9029
+ {
9030
+ filetype: "c",
9031
+ wasm: "https://github.com/tree-sitter/tree-sitter-c/releases/download/v0.24.1/tree-sitter-c.wasm",
9032
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-c/v0.24.1/queries/highlights.scm"] }
9033
+ },
9034
+ {
9035
+ filetype: "cpp",
9036
+ aliases: [
9037
+ "c++",
9038
+ "cxx",
9039
+ "cc"
9040
+ ],
9041
+ wasm: "https://github.com/tree-sitter/tree-sitter-cpp/releases/download/v0.23.4/tree-sitter-cpp.wasm",
9042
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-cpp/v0.23.4/queries/highlights.scm"] }
9043
+ },
9044
+ {
9045
+ filetype: "csharp",
9046
+ aliases: ["cs", "c#"],
9047
+ wasm: "https://github.com/tree-sitter/tree-sitter-c-sharp/releases/download/v0.23.1/tree-sitter-c_sharp.wasm",
9048
+ queries: { highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-c-sharp/v0.23.1/queries/highlights.scm"] }
9049
+ },
9050
+ {
9051
+ filetype: "elixir",
9052
+ aliases: ["ex", "exs"],
9053
+ wasm: "https://github.com/elixir-lang/tree-sitter-elixir/releases/download/v0.3.4/tree-sitter-elixir.wasm",
9054
+ queries: { highlights: ["https://raw.githubusercontent.com/elixir-lang/tree-sitter-elixir/v0.3.4/queries/highlights.scm"] }
8416
9055
  }
8417
9056
  ];
9057
+ const LOCAL_PARSERS = [{
9058
+ template: {
9059
+ filetype: "sql",
9060
+ aliases: [
9061
+ "psql",
9062
+ "mysql",
9063
+ "sqlite",
9064
+ "plsql"
9065
+ ],
9066
+ queries: { highlights: ["https://raw.githubusercontent.com/DerekStride/tree-sitter-sql/v0.3.11/queries/highlights.scm"] }
9067
+ },
9068
+ pathEnvVar: "ZIDANE_LOCAL_TREE_SITTER_SQL_WASM"
9069
+ }];
9070
+ /**
9071
+ * Walk {@link LOCAL_PARSERS} and return the parsers whose `pathEnvVar`
9072
+ * resolves to a non-empty string. Stripped entries log a one-shot warning
9073
+ * to stderr so a TUI host with broken bundle setup doesn't fail
9074
+ * silently.
9075
+ */
9076
+ function resolveLocalParsers() {
9077
+ const resolved = [];
9078
+ for (const { template, pathEnvVar } of LOCAL_PARSERS) {
9079
+ const wasm = process.env[pathEnvVar];
9080
+ if (wasm && wasm.length > 0) resolved.push({
9081
+ ...template,
9082
+ wasm
9083
+ });
9084
+ }
9085
+ return resolved;
9086
+ }
8418
9087
  let registered = false;
8419
9088
  /**
8420
9089
  * Register the extra Tree-sitter parsers + start the worker. Idempotent —
@@ -8424,7 +9093,7 @@ let registered = false;
8424
9093
  async function setupTreeSitter() {
8425
9094
  if (registered) return;
8426
9095
  registered = true;
8427
- addDefaultParsers(EXTRA_PARSERS);
9096
+ addDefaultParsers([...EXTRA_PARSERS, ...resolveLocalParsers()]);
8428
9097
  await getTreeSitterClient().initialize();
8429
9098
  }
8430
9099
  //#endregion