zidane 4.1.9 → 5.0.0

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 (54) hide show
  1. package/README.md +11 -2
  2. package/dist/{agent-CMIhYhDz.d.ts → agent-JhicgLOV.d.ts} +78 -4
  3. package/dist/agent-JhicgLOV.d.ts.map +1 -0
  4. package/dist/chat.d.ts +336 -6
  5. package/dist/chat.d.ts.map +1 -1
  6. package/dist/chat.js +2 -2
  7. package/dist/{index-DAaKyadO.d.ts → index-2yLUyTbc.d.ts} +34 -4
  8. package/dist/{index-DAaKyadO.d.ts.map → index-2yLUyTbc.d.ts.map} +1 -1
  9. package/dist/{index-D6Dd6Kc0.d.ts → index-t_W9i7Ql.d.ts} +8 -3
  10. package/dist/index-t_W9i7Ql.d.ts.map +1 -0
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.js +4 -4
  13. package/dist/{interpolate-BydkV1eT.js → interpolate-Ck970-61.js} +9 -2
  14. package/dist/{interpolate-BydkV1eT.js.map → interpolate-Ck970-61.js.map} +1 -1
  15. package/dist/mcp.d.ts +1 -1
  16. package/dist/presets-BRFH2qsQ.js +90 -0
  17. package/dist/presets-BRFH2qsQ.js.map +1 -0
  18. package/dist/presets.d.ts +3 -2
  19. package/dist/presets.js +2 -2
  20. package/dist/providers.d.ts +1 -1
  21. package/dist/session/sqlite.d.ts +1 -1
  22. package/dist/session/sqlite.d.ts.map +1 -1
  23. package/dist/session/sqlite.js +28 -13
  24. package/dist/session/sqlite.js.map +1 -1
  25. package/dist/{session-B1RN0uoi.js → session-791hhrFa.js} +24 -1
  26. package/dist/session-791hhrFa.js.map +1 -0
  27. package/dist/session.d.ts +1 -1
  28. package/dist/session.js +1 -1
  29. package/dist/skills.d.ts +2 -2
  30. package/dist/skills.js +1 -1
  31. package/dist/theme-pJv47erq.d.ts +1202 -0
  32. package/dist/theme-pJv47erq.d.ts.map +1 -0
  33. package/dist/{tools-BdQENveS.js → tools-CLazLRb4.js} +81 -21
  34. package/dist/tools-CLazLRb4.js.map +1 -0
  35. package/dist/tools.d.ts +2 -2
  36. package/dist/tools.js +1 -1
  37. package/dist/tui.d.ts +258 -30
  38. package/dist/tui.d.ts.map +1 -1
  39. package/dist/tui.js +2957 -499
  40. package/dist/tui.js.map +1 -1
  41. package/dist/turn-operations-5aQu4dJg.js +3587 -0
  42. package/dist/turn-operations-5aQu4dJg.js.map +1 -0
  43. package/dist/types.d.ts +2 -2
  44. package/package.json +1 -1
  45. package/dist/agent-CMIhYhDz.d.ts.map +0 -1
  46. package/dist/index-D6Dd6Kc0.d.ts.map +0 -1
  47. package/dist/presets-4zCJzCYw.js +0 -39
  48. package/dist/presets-4zCJzCYw.js.map +0 -1
  49. package/dist/session-B1RN0uoi.js.map +0 -1
  50. package/dist/theme-Caf4AvTO.d.ts +0 -637
  51. package/dist/theme-Caf4AvTO.d.ts.map +0 -1
  52. package/dist/theme-context-DQM2lx4U.js +0 -1853
  53. package/dist/theme-context-DQM2lx4U.js.map +0 -1
  54. package/dist/tools-BdQENveS.js.map +0 -1
package/dist/tui.js CHANGED
@@ -1,12 +1,13 @@
1
- import { d as createAgent } from "./tools-BdQENveS.js";
1
+ import { d as createAgent } from "./tools-CLazLRb4.js";
2
2
  import { n as formatTokenUsage } from "./stats-DZIsGqzu.js";
3
- import { n as loadSession, t as createSession } from "./session-B1RN0uoi.js";
3
+ import { n as loadSession, t as createSession } from "./session-791hhrFa.js";
4
4
  import { createTuiStore } from "./session/sqlite.js";
5
- import { $ as toolResultText, A as isOnSafelist, B as shortId, E as useSafeModeQueue, G as eventsFromTurns, H as useConfig, I as runOAuthLogin, K as lastContextSizeFromTurns, L as supportsOAuth, O as addToSafelist, P as suggestSafelistEntry, Q as toolCallPreview, R as ageString, T as useSafeModeActions, U as resolveConfig, V as ConfigProvider, X as stripSpawnTokensLine, Z as titleFromTurns, c as finalizeStreamingMarkdownForOwner, d as DEFAULT_SETTINGS, et as detectAuth, f as SETTINGS_CHOICES, ft as getContextWindow, h as useSettings, i as useSurfaces, k as getSafelist, l as turnContextSize, m as SettingsProvider, n as useColors, o as useTheme, ot as setProviderCredential, p as SETTINGS_TOGGLES, q as listSessionMeta, r as useSelectStyle, s as finalizeStreamingMarkdown, t as ThemeProvider, u as useStreamBuffer, v as resolveTheme, w as SafeModeProvider, z as fmtTokens } from "./theme-context-DQM2lx4U.js";
5
+ import { C as useSafeModeQueue, Ct as findGitRoot, D as isOnSafelist, E as getSafelist, Et as uniqueSkillNamesFromReferences, F as supportsOAuth, Ft as detectAuth, G as shortId, H as ageString, I as buildMcpServers, J as DEFAULT_SETTINGS, K as listProjectFiles, N as splitPromptSegments, Ot as createFilesCompletionProvider, P as runOAuthLogin, Pt as useCompletion, Q as useSettings, R as discoverProjectMcps, S as useSafeModeActions, St as toolResultText, T as addToSafelist, Tt as createSkillsCompletionProvider, U as compactPath, V as generateSessionTitle, Vt as setProviderCredential, W as fmtTokens, X as SETTINGS_TOGGLES, Y as SETTINGS_CHOICES, Z as SettingsProvider, _ as discoverProjectSkills, a as ThemeProvider, b as writeSessionExport, c as useSurfaces, ct as ConfigProvider, d as finalizeStreamingMarkdown, f as finalizeStreamingMarkdownForOwner, ft as deriveSessionTitle, g as defaultSkillScanPaths, h as buildSkillsConfig, ht as listSessionMeta, i as turnAsText, j as suggestSafelistEntry, lt as useConfig, m as useStreamBuffer, mt as lastContextSizeFromTurns, n as deleteTurnSafely, nt as resolveTheme, o as useColors, p as turnContextSize, pt as eventsFromTurns, q as useEnabledToggleSet, qt as getContextWindow, r as truncateTurnsAt, s as useSelectStyle, tt as resolveChipColor, u as useTheme, ut as resolveConfig, vt as selectableTurnIds, x as SafeModeProvider, xt as toolCallPreview, yt as stripSpawnTokensLine } from "./turn-operations-5aQu4dJg.js";
6
+ import { Buffer } from "node:buffer";
6
7
  import { createContext, createElement, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
7
8
  import { RGBA, SyntaxStyle, addDefaultParsers, createCliRenderer, defaultTextareaKeyBindings, getTreeSitterClient } from "@opentui/core";
8
9
  import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
9
- import { jsx, jsxs } from "@opentui/react/jsx-runtime";
10
+ import { Fragment, jsx, jsxs } from "@opentui/react/jsx-runtime";
10
11
  //#region src/tui/modal.tsx
11
12
  const ModalContext = createContext(null);
12
13
  function ModalRoot({ children }) {
@@ -60,27 +61,34 @@ function useModalAwareFocus(preferred = true) {
60
61
  return preferred && !isOpen;
61
62
  }
62
63
  /**
63
- * Responsive modal — picks a width based on the live terminal size.
64
+ * Responsive modal — picks a width (and optionally a height) based on the
65
+ * live terminal size.
64
66
  *
65
67
  * - On a wide terminal, the modal grows to `maxWidth` so descriptions sit on
66
68
  * one line and don't wrap.
67
69
  * - On a narrow terminal, the modal shrinks down to `minWidth`, keeping a
68
70
  * small horizontal margin from the screen edges. Text inside wraps naturally.
71
+ * - When `maxHeight` is set, the same tier logic applies on the vertical
72
+ * axis — anything beyond is the consumer's job (typically a `scrollbox`
73
+ * child for long content).
69
74
  *
70
75
  * Uses `useTerminalDimensions()` so it reflows on `SIGWINCH` without remount.
71
76
  */
72
- function Modal({ title, onClose, children, maxWidth = 92, minWidth = 44, horizontalMargin = 4 }) {
77
+ function Modal({ title, bottomTitle, onClose, disableEscape = false, children, maxWidth = 92, minWidth = 44, maxHeight, horizontalMargin = 4, verticalMargin = 2 }) {
73
78
  const ctx = useContext(ModalContext);
74
79
  const dismiss = onClose ?? ctx?.close;
75
80
  const COLOR = useColors();
76
81
  const SURFACE = useSurfaces();
77
82
  useKeyboard((key) => {
78
- if (key.name === "escape") dismiss?.();
83
+ if (key.name === "escape" && !disableEscape) dismiss?.();
79
84
  });
80
- const { width: termWidth } = useTerminalDimensions();
85
+ const { width: termWidth, height: termHeight } = useTerminalDimensions();
81
86
  const width = Math.max(minWidth, Math.min(maxWidth, termWidth - horizontalMargin * 2));
87
+ const height = maxHeight === void 0 ? void 0 : Math.min(maxHeight, Math.max(0, termHeight - verticalMargin * 2));
82
88
  return /* @__PURE__ */ jsx("box", {
83
89
  title: title ? ` ${title} ` : void 0,
90
+ bottomTitle: bottomTitle ? ` ${bottomTitle} ` : void 0,
91
+ bottomTitleAlignment: "right",
84
92
  style: {
85
93
  border: true,
86
94
  borderColor: COLOR.borderActive,
@@ -90,6 +98,7 @@ function Modal({ title, onClose, children, maxWidth = 92, minWidth = 44, horizon
90
98
  paddingLeft: 2,
91
99
  paddingRight: 2,
92
100
  width,
101
+ ...height !== void 0 ? { height } : {},
93
102
  flexDirection: "column",
94
103
  gap: 1
95
104
  },
@@ -249,6 +258,104 @@ function useMdStyle() {
249
258
  if (!style) throw new Error("useMdStyle must be used inside <MdStyleProvider>");
250
259
  return style;
251
260
  }
261
+ const CHIP_TOKEN_PREFIX = "completion.reference";
262
+ /** Per-kind token name in the chip `SyntaxStyle` — e.g. `completion.reference.skills`. */
263
+ function chipTokenFor(providerId) {
264
+ return `${CHIP_TOKEN_PREFIX}.${providerId}`;
265
+ }
266
+ /** Fallback token registered for every theme — always resolves to a styleId. */
267
+ const CHIP_TOKEN_DEFAULT = chipTokenFor("default");
268
+ function buildChipStyle(theme) {
269
+ const styles = {};
270
+ for (const [providerId, chip] of Object.entries(theme.surfaces.chips)) {
271
+ if (!chip) continue;
272
+ styles[chipTokenFor(providerId)] = {
273
+ fg: RGBA.fromHex(chip.fg),
274
+ bg: RGBA.fromHex(chip.bg)
275
+ };
276
+ }
277
+ return SyntaxStyle.fromStyles(styles);
278
+ }
279
+ /**
280
+ * Resolve the styleId for a chip of the given provider id, falling back
281
+ * to {@link CHIP_TOKEN_DEFAULT} when the theme has no kind-specific
282
+ * entry. Returns `null` only when the style was built from a malformed
283
+ * theme (missing `default`) — every built-in theme satisfies the
284
+ * contract, so callers can treat `null` as "skip highlight".
285
+ */
286
+ function resolveChipStyleId(style, providerId) {
287
+ return style.getStyleId(chipTokenFor(providerId)) ?? style.getStyleId(CHIP_TOKEN_DEFAULT);
288
+ }
289
+ /**
290
+ * Convert a JS string offset in `text` to the column offset expected by
291
+ * OpenTUI's `addHighlightByCharRange`. The native API takes display-
292
+ * column offsets that EXCLUDE newlines (each `\n` consumes zero
293
+ * columns) — mirroring the convention documented in `@opentui/core`'s
294
+ * extmark wrapper. Skipping the conversion paints chips at the wrong
295
+ * column once the prompt spans multiple lines, drifting one column
296
+ * further left per preceding newline.
297
+ *
298
+ * Single-cell text covers every chip kind the built-in providers emit
299
+ * (slash-commands + `@`-prefixed file paths). Wide-cell graphemes
300
+ * (emoji, CJK) would need `stringWidth` accounting; left as a TODO
301
+ * until a chip kind actually carries them.
302
+ */
303
+ function offsetToHighlightColumn(text, offset) {
304
+ const clamped = Math.max(0, Math.min(offset, text.length));
305
+ let newlines = 0;
306
+ for (let i = 0; i < clamped; i++) if (text.charCodeAt(i) === 10) newlines++;
307
+ return clamped - newlines;
308
+ }
309
+ const ChipStyleContext = createContext(null);
310
+ function ChipStyleProvider({ children }) {
311
+ const theme = useTheme();
312
+ const style = useMemo(() => buildChipStyle(theme), [theme]);
313
+ return createElement(ChipStyleContext.Provider, { value: style }, children);
314
+ }
315
+ /**
316
+ * Active chip-highlight style for the prompt textarea. Single shared
317
+ * instance per theme so the underlying buffer style table is re-allocated
318
+ * only on a theme switch.
319
+ */
320
+ function useChipStyle() {
321
+ const style = useContext(ChipStyleContext);
322
+ if (!style) throw new Error("useChipStyle must be used inside <ChipStyleProvider>");
323
+ return style;
324
+ }
325
+ /**
326
+ * Sync per-range chip highlights onto a textarea on every `references`
327
+ * change. Encapsulates the OpenTUI plumbing — `clearAllHighlights` +
328
+ * one `addHighlightByCharRange` per ref, with JS→column-offset
329
+ * translation — so the prompt block stays focused on UX state.
330
+ *
331
+ * The hook owns the contract documented in
332
+ * {@link offsetToHighlightColumn}: refs carry JS string offsets that
333
+ * include newlines; the edit buffer's highlight API takes display
334
+ * columns that exclude them. Skipping this conversion is the difference
335
+ * between stable multi-line chips and the one-column-per-newline drift
336
+ * we shipped in the first cut.
337
+ */
338
+ function useChipHighlights(textareaRef, references, chipStyle) {
339
+ useEffect(() => {
340
+ const ta = textareaRef.current;
341
+ if (!ta) return;
342
+ ta.clearAllHighlights();
343
+ const text = ta.plainText;
344
+ for (const ref of references) {
345
+ const styleId = resolveChipStyleId(chipStyle, ref.providerId);
346
+ if (styleId == null) continue;
347
+ ta.addHighlightByCharRange({
348
+ start: offsetToHighlightColumn(text, ref.start),
349
+ end: offsetToHighlightColumn(text, ref.end),
350
+ styleId
351
+ });
352
+ }
353
+ }, [
354
+ textareaRef,
355
+ references,
356
+ chipStyle
357
+ ]);
358
+ }
252
359
  //#endregion
253
360
  //#region src/tui/components.tsx
254
361
  /**
@@ -257,20 +364,31 @@ function useMdStyle() {
257
364
  * its content changes (we only ever recreate the streaming-markdown tail).
258
365
  *
259
366
  * The outer wrapper handles top-margin per kind (and per neighbor) so spacing
260
- * is the single source of truth for inter-event breathing room.
367
+ * is the single source of truth for inter-event breathing room. Selected
368
+ * rows fill with `surfaces.selection` and absorb their `marginTop` as
369
+ * `paddingTop` — that keeps the gap above colored too so consecutive
370
+ * same-turn events read as one continuous highlighted block instead of a
371
+ * striped list.
261
372
  */
262
- const EventLine = memo(({ event, previous, depthOffset = 0 }) => /* @__PURE__ */ jsx("box", {
263
- style: {
264
- marginTop: marginTopFor(event, previous),
265
- alignSelf: "stretch",
266
- flexShrink: 0,
267
- flexDirection: "column"
268
- },
269
- children: /* @__PURE__ */ jsx(EventLineImpl, {
270
- event,
271
- depthOffset
272
- })
273
- }));
373
+ const EventLine = memo(({ event, previous, depthOffset = 0, selected = false, anchorId }) => {
374
+ const SURFACE = useSurfaces();
375
+ const gap = marginTopFor(event, previous);
376
+ return /* @__PURE__ */ jsx("box", {
377
+ id: anchorId,
378
+ style: {
379
+ marginTop: selected ? 0 : gap,
380
+ paddingTop: selected ? gap : 0,
381
+ backgroundColor: selected ? SURFACE.selection : void 0,
382
+ alignSelf: "stretch",
383
+ flexShrink: 0,
384
+ flexDirection: "column"
385
+ },
386
+ children: /* @__PURE__ */ jsx(EventLineImpl, {
387
+ event,
388
+ depthOffset
389
+ })
390
+ });
391
+ });
274
392
  /**
275
393
  * `@opentui/react` extends `React.JSX.IntrinsicElements`, so `onSubmit` on `<input>`
276
394
  * gets intersected with the DOM `SubmitEvent` shape and demands an unhelpful overload.
@@ -281,26 +399,17 @@ function onInputSubmit(handler) {
281
399
  return handler;
282
400
  }
283
401
  /**
284
- * Footer status bar. Renders as a single row when the terminal is wide enough,
285
- * otherwise stacks into two rows (agent + hints + provider on top, context
286
- * bottom-right) and falls back to one segment per row at terminal widths
287
- * where even that overflows. The width tiering is driven by plain-text
288
- * length estimates of each segment — close enough since the segments are
289
- * ASCII-heavy and worst case we under-estimate the breakpoint by a few cells.
290
- *
291
- * Agent badge is **always rendered first** so the active profile (Build /
292
- * Plan / host-custom) is the anchor on the left edge of every layout tier.
402
+ * Footer status bar. Renders as a single row when the terminal is wide
403
+ * enough, otherwise stacks the context indicator beneath the hint row.
404
+ * Width tiering is driven by plain-text length estimates close enough
405
+ * since the segments are ASCII-heavy.
293
406
  */
294
- function Footer({ hints, picked, agent, context }) {
407
+ function Footer({ hints, context }) {
295
408
  const { width } = useTerminalDimensions();
296
409
  const inner = Math.max(0, width - 2);
297
410
  const hW = hintsLength(hints);
298
- const aW = agent ? agentBadgeLength(agent) : 0;
299
- const pW = picked ? providerBadgeLength(picked) : 0;
300
411
  const cW = context ? contextIndicatorLength(context) : 0;
301
- const oneRowFits = aW + hW + pW + (cW > 0 ? cW + 1 : 0) <= inner;
302
- const leftRowFits = aW + hW + pW <= inner;
303
- if (oneRowFits) return /* @__PURE__ */ jsxs("box", {
412
+ if (hW + (cW > 0 ? cW + 1 : 0) <= inner) return /* @__PURE__ */ jsxs("box", {
304
413
  style: {
305
414
  flexDirection: "row",
306
415
  height: 1,
@@ -308,35 +417,23 @@ function Footer({ hints, picked, agent, context }) {
308
417
  paddingRight: 1
309
418
  },
310
419
  children: [
311
- agent && /* @__PURE__ */ jsx(AgentBadge, {
312
- agent,
313
- position: "leading"
314
- }),
315
420
  /* @__PURE__ */ jsx(HintsText, { hints }),
316
- picked && /* @__PURE__ */ jsx(ProviderBadge, { picked }),
317
421
  /* @__PURE__ */ jsx("box", { style: { flexGrow: 1 } }),
318
422
  context && /* @__PURE__ */ jsx(ContextIndicator, { context })
319
423
  ]
320
424
  });
321
- if (leftRowFits) return /* @__PURE__ */ jsxs("box", {
425
+ return /* @__PURE__ */ jsxs("box", {
322
426
  style: {
323
427
  flexDirection: "column",
324
428
  paddingLeft: 1,
325
429
  paddingRight: 1
326
430
  },
327
- children: [/* @__PURE__ */ jsxs("box", {
431
+ children: [/* @__PURE__ */ jsx("box", {
328
432
  style: {
329
433
  flexDirection: "row",
330
434
  height: 1
331
435
  },
332
- children: [
333
- agent && /* @__PURE__ */ jsx(AgentBadge, {
334
- agent,
335
- position: "leading"
336
- }),
337
- /* @__PURE__ */ jsx(HintsText, { hints }),
338
- picked && /* @__PURE__ */ jsx(ProviderBadge, { picked })
339
- ]
436
+ children: /* @__PURE__ */ jsx(HintsText, { hints })
340
437
  }), context && /* @__PURE__ */ jsxs("box", {
341
438
  style: {
342
439
  flexDirection: "row",
@@ -345,181 +442,211 @@ function Footer({ hints, picked, agent, context }) {
345
442
  children: [/* @__PURE__ */ jsx("box", { style: { flexGrow: 1 } }), /* @__PURE__ */ jsx(ContextIndicator, { context })]
346
443
  })]
347
444
  });
348
- return /* @__PURE__ */ jsxs("box", {
349
- style: {
350
- flexDirection: "column",
351
- paddingLeft: 1,
352
- paddingRight: 1
353
- },
354
- children: [
355
- agent && /* @__PURE__ */ jsx("box", {
356
- style: {
357
- flexDirection: "row",
358
- height: 1
359
- },
360
- children: /* @__PURE__ */ jsx(AgentBadge, {
361
- agent,
362
- position: "standalone"
363
- })
364
- }),
365
- /* @__PURE__ */ jsx("box", {
366
- style: {
367
- flexDirection: "row",
368
- height: 1
369
- },
370
- children: /* @__PURE__ */ jsx(HintsText, { hints })
371
- }),
372
- picked && /* @__PURE__ */ jsx("box", {
373
- style: {
374
- flexDirection: "row",
375
- height: 1
376
- },
377
- children: /* @__PURE__ */ jsx(ProviderBadge, {
378
- picked,
379
- standalone: true
380
- })
381
- }),
382
- context && /* @__PURE__ */ jsx("box", {
383
- style: {
384
- flexDirection: "row",
385
- height: 1
386
- },
387
- children: /* @__PURE__ */ jsx(ContextIndicator, { context })
388
- })
389
- ]
390
- });
391
445
  }
392
446
  function HintsText({ hints }) {
393
447
  const COLOR = useColors();
394
448
  return /* @__PURE__ */ jsx("text", {
395
449
  fg: COLOR.dim,
396
- children: hints.map((h, i) => /* @__PURE__ */ jsxs("span", { children: [
397
- i > 0 && /* @__PURE__ */ jsx("span", {
398
- fg: COLOR.mute,
399
- children: " · "
400
- }),
401
- /* @__PURE__ */ jsx("span", {
402
- fg: COLOR.warn,
403
- children: h.key
404
- }),
405
- /* @__PURE__ */ jsx("span", {
406
- fg: COLOR.dim,
407
- children: ` ${h.label}`
408
- })
409
- ] }, i))
450
+ children: renderHintSpans(hints, COLOR)
410
451
  });
411
452
  }
412
453
  /**
413
- * Colored badge for the active {@link AgentProfile}. The badge anchors the
414
- * left edge of the bottom bar so the current mode (Build / Plan / host
415
- * custom) is the first thing the eye lands on. Accent color comes from
416
- * the profile's `accent` token, resolved against the active theme via
417
- * `accentColor()` so picker rows and footer tinting stay in sync.
418
- *
419
- * Two render positions:
420
- *
421
- * - `leading` (default) — in-row at the very start. Renders just the
422
- * label and a trailing ` · ` separator, e.g. `Build · `. Tight,
423
- * prominent, and visually distinct from the surrounding hint text.
424
- * - `standalone` one-row-per-segment fallback for narrow terminals.
425
- * Renders `agent <label>` with no separators so the row reads
426
- * correctly on its own.
454
+ * Pure renderer for a list of {@link Hint}s as colored spans
455
+ * `<key1> <label1> · <key2> <label2> · …` with warn/dim/mute colors.
456
+ *
457
+ * Returns spans only (no enclosing `<text>`, no leading / trailing
458
+ * whitespace) so the caller can wrap it in whichever container fits
459
+ * their surface: the bottom-bar footer uses a single-row `<text>`, the
460
+ * prompt-box overlay wraps it with leading + trailing spaces so the
461
+ * outermost cells punch through the border like a native title would.
462
+ *
463
+ * Pattern matches `renderRefSpans` below pure function over an opaque
464
+ * `ThemeColors`, no internal hook calls so it composes inside any
465
+ * `<text>` regardless of where the parent grabbed its palette.
427
466
  */
428
- function AgentBadge({ agent, position = "leading" }) {
429
- const COLOR = useColors();
430
- const fg = accentColor(agent.accent, COLOR);
431
- if (position === "standalone") return /* @__PURE__ */ jsxs("text", {
432
- fg: COLOR.dim,
433
- children: [/* @__PURE__ */ jsx("span", {
434
- fg: COLOR.mute,
435
- children: "agent "
436
- }), /* @__PURE__ */ jsx("span", {
437
- fg,
438
- children: agent.label
439
- })]
440
- });
441
- return /* @__PURE__ */ jsxs("text", {
442
- fg: COLOR.dim,
443
- children: [/* @__PURE__ */ jsx("span", {
444
- fg,
445
- children: agent.label
446
- }), /* @__PURE__ */ jsx("span", {
467
+ function renderHintSpans(hints, COLOR) {
468
+ return hints.map((h, i) => /* @__PURE__ */ jsxs("span", { children: [
469
+ i > 0 && /* @__PURE__ */ jsx("span", {
447
470
  fg: COLOR.mute,
448
471
  children: " · "
449
- })]
450
- });
472
+ }),
473
+ /* @__PURE__ */ jsx("span", {
474
+ fg: h.keyColor ?? COLOR.warn,
475
+ children: h.key
476
+ }),
477
+ /* @__PURE__ */ jsx("span", {
478
+ fg: h.labelColor ?? COLOR.dim,
479
+ children: ` ${h.label}`
480
+ })
481
+ ] }, i));
451
482
  }
452
- function ProviderBadge({ picked, standalone = false }) {
483
+ function ContextIndicator({ context }) {
453
484
  const COLOR = useColors();
454
- const source = picked.provider.methods[0].source;
485
+ const ratio = context.max > 0 ? context.used / context.max : 0;
486
+ const pct = Math.round(ratio * 100);
487
+ const color = ratio >= .85 ? COLOR.error : ratio >= .6 ? COLOR.warn : COLOR.dim;
455
488
  return /* @__PURE__ */ jsxs("text", {
456
489
  fg: COLOR.dim,
457
490
  children: [
458
- !standalone && /* @__PURE__ */ jsx("span", {
459
- fg: COLOR.mute,
460
- children: " · "
461
- }),
462
- /* @__PURE__ */ jsx("span", {
463
- fg: COLOR.accent,
464
- children: picked.provider.label
465
- }),
466
491
  /* @__PURE__ */ jsx("span", {
467
492
  fg: COLOR.mute,
468
- children: " · "
493
+ children: "ctx "
469
494
  }),
470
495
  /* @__PURE__ */ jsx("span", {
471
- fg: COLOR.model,
472
- children: picked.model
496
+ fg: color,
497
+ children: fmtTokens(context.used)
473
498
  }),
474
499
  /* @__PURE__ */ jsx("span", {
475
500
  fg: COLOR.mute,
476
- children: " · "
501
+ children: ` / ${fmtTokens(context.max)} `
477
502
  }),
478
503
  /* @__PURE__ */ jsx("span", {
479
- fg: source === "oauth" ? COLOR.accent : COLOR.warn,
480
- children: source
504
+ fg: color,
505
+ children: `(${pct}%)`
481
506
  })
482
507
  ]
483
508
  });
484
509
  }
485
- function ContextIndicator({ context }) {
510
+ /**
511
+ * Width budget reservations applied to the responsive math in
512
+ * {@link TitleOverlay}. Each title / meta segment owns a leading +
513
+ * trailing mute space (the cells that punch through the underlying
514
+ * border `─`); a 2-cell `GAP` keeps title and meta visually distinct
515
+ * when both render in the same row.
516
+ */
517
+ const TITLE_OVERLAY_WRAP = 2;
518
+ const TITLE_OVERLAY_META_WRAP = 2;
519
+ const TITLE_OVERLAY_GAP = 2;
520
+ /**
521
+ * Colored title for a full-screen bordered surface. The `title` slot
522
+ * rides `titleColor` (defaults to `COLOR.brand` — the theme's primary
523
+ * anchor) on the LEFT of the top border; the optional `meta` slot
524
+ * rides on the RIGHT.
525
+ *
526
+ * `meta` accepts either:
527
+ * - a plain `string` → rendered entirely in `COLOR.dim` (the simple
528
+ * "single dim label" case, e.g. `"5 sessions"`).
529
+ * - a {@link MetaSegment} array → rendered concatenated with each
530
+ * segment's own color (lets a stat like turn count stand out from
531
+ * the surrounding separators).
532
+ *
533
+ * Responsive behavior (driven by `useTerminalDimensions`):
534
+ * - Both fit → render both with a 2-cell visual gap between them.
535
+ * - Meta doesn't fit → drop meta; title takes the full budget.
536
+ * - Title doesn't fit → truncate with a trailing `…`.
537
+ * - Terminal is degenerately narrow → render nothing.
538
+ *
539
+ * The width math assumes the immediate parent fills the screen's
540
+ * content area (the standard `flexGrow: 1` flex column). Hosts running
541
+ * the bordered box inside a narrower container should pass `parentWidth`
542
+ * to override the terminal-width assumption.
543
+ *
544
+ * @example
545
+ * ```tsx
546
+ * <box style={{ flexDirection: 'column', flexGrow: 1 }}>
547
+ * <box style={{ border: true, flexGrow: 1 }}>...</box>
548
+ * <TitleOverlay
549
+ * title="my session"
550
+ * meta={[
551
+ * { text: '#abcd' },
552
+ * { text: ' · ', color: COLOR.mute },
553
+ * { text: '5 turns', color: COLOR.warn },
554
+ * ]}
555
+ * />
556
+ * </box>
557
+ * ```
558
+ */
559
+ function TitleOverlay({ title, meta = null, titleColor, parentWidth }) {
486
560
  const COLOR = useColors();
487
- const ratio = context.max > 0 ? context.used / context.max : 0;
488
- const pct = Math.round(ratio * 100);
489
- const color = ratio >= .85 ? COLOR.error : ratio >= .6 ? COLOR.warn : COLOR.dim;
490
- return /* @__PURE__ */ jsxs("text", {
491
- fg: COLOR.dim,
561
+ const { width: termWidth } = useTerminalDimensions();
562
+ const fg = titleColor ?? COLOR.brand;
563
+ const W = Math.max(0, parentWidth ?? termWidth - 2);
564
+ const inner = Math.max(0, W - 2);
565
+ const metaLen = metaSegmentsLength(meta);
566
+ const showMeta = meta != null && metaLen > 0 && title.length + TITLE_OVERLAY_WRAP + TITLE_OVERLAY_GAP + metaLen + TITLE_OVERLAY_META_WRAP <= inner;
567
+ const titleBudget = showMeta ? inner - (metaLen + TITLE_OVERLAY_META_WRAP) - TITLE_OVERLAY_GAP - TITLE_OVERLAY_WRAP : inner - TITLE_OVERLAY_WRAP;
568
+ const visibleTitle = titleBudget <= 0 ? "" : truncateTrailing(title, titleBudget);
569
+ return /* @__PURE__ */ jsxs(Fragment, { children: [visibleTitle && /* @__PURE__ */ jsxs("text", {
570
+ style: {
571
+ position: "absolute",
572
+ top: 0,
573
+ left: 1
574
+ },
492
575
  children: [
493
576
  /* @__PURE__ */ jsx("span", {
494
577
  fg: COLOR.mute,
495
- children: "ctx "
578
+ children: " "
496
579
  }),
497
580
  /* @__PURE__ */ jsx("span", {
498
- fg: color,
499
- children: fmtTokens(context.used)
581
+ fg,
582
+ children: visibleTitle
500
583
  }),
501
584
  /* @__PURE__ */ jsx("span", {
502
585
  fg: COLOR.mute,
503
- children: ` / ${fmtTokens(context.max)} `
586
+ children: " "
587
+ })
588
+ ]
589
+ }), showMeta && meta && /* @__PURE__ */ jsxs("text", {
590
+ style: {
591
+ position: "absolute",
592
+ top: 0,
593
+ right: 1
594
+ },
595
+ children: [
596
+ /* @__PURE__ */ jsx("span", {
597
+ fg: COLOR.mute,
598
+ children: " "
504
599
  }),
600
+ typeof meta === "string" ? /* @__PURE__ */ jsx("span", {
601
+ fg: COLOR.dim,
602
+ children: meta
603
+ }) : meta.map((seg, i) => /* @__PURE__ */ jsx("span", {
604
+ fg: seg.color ?? COLOR.dim,
605
+ children: seg.text
606
+ }, i)),
505
607
  /* @__PURE__ */ jsx("span", {
506
- fg: color,
507
- children: `(${pct}%)`
608
+ fg: COLOR.mute,
609
+ children: " "
508
610
  })
509
611
  ]
510
- });
612
+ })] });
613
+ }
614
+ /** Total printed-character length of a {@link TitleOverlay} `meta` value. */
615
+ function metaSegmentsLength(meta) {
616
+ if (meta == null) return 0;
617
+ if (typeof meta === "string") return meta.length;
618
+ return meta.reduce((sum, seg) => sum + seg.text.length, 0);
619
+ }
620
+ /**
621
+ * Truncate `text` to at most `max` characters, replacing the trailing
622
+ * overflow with `…`. Edge cases:
623
+ * - `max <= 0` → empty string (no room to render at all).
624
+ * - `max === 1` → just the ellipsis glyph.
625
+ * - `text.length <= max` → unchanged.
626
+ *
627
+ * Trailing-style truncation matches the natural read order of titles:
628
+ * the prefix carries enough signal to identify the surface.
629
+ *
630
+ * Exported for unit-tests; consumers should normally lean on
631
+ * {@link TitleOverlay} instead of calling this directly.
632
+ */
633
+ function truncateTrailing(text, max) {
634
+ if (max <= 0) return "";
635
+ if (text.length <= max) return text;
636
+ if (max === 1) return "…";
637
+ return `${text.slice(0, max - 1)}…`;
511
638
  }
639
+ /**
640
+ * Plain-text width estimate for a list of {@link Hint}s rendered via
641
+ * `renderHintSpans` — `<key> <label> · <key> <label> · …`. Exported so
642
+ * the prompt-box overlay (in `screens.tsx`) can run the same responsive
643
+ * math as the bottom-bar footer when deciding whether trigger hints
644
+ * fit. Pure / total.
645
+ */
512
646
  function hintsLength(hints) {
513
647
  if (hints.length === 0) return 0;
514
648
  return hints.reduce((sum, h, i) => sum + h.key.length + 1 + h.label.length + (i > 0 ? 3 : 0), 0);
515
649
  }
516
- function providerBadgeLength(picked) {
517
- const source = picked.provider.methods[0].source;
518
- return 3 + picked.provider.label.length + 3 + picked.model.length + 3 + source.length;
519
- }
520
- function agentBadgeLength(agent) {
521
- return agent.label.length + 3;
522
- }
523
650
  function contextIndicatorLength(context) {
524
651
  const ratio = context.max > 0 ? context.used / context.max : 0;
525
652
  const pct = Math.round(ratio * 100);
@@ -553,10 +680,50 @@ function Spinner({ label }) {
553
680
  })]
554
681
  });
555
682
  }
556
- function Transcript({ events, settings }) {
683
+ /**
684
+ * Minimum scrollbar thumb size, in half-block units (OpenTUI's
685
+ * `SliderRenderable` renders the vertical thumb at 2 half-blocks per
686
+ * character cell). `8` half-blocks = 4 character cells — always large
687
+ * enough to read + grab with the mouse, never so large that it
688
+ * dominates the track on short transcripts.
689
+ */
690
+ const MIN_THUMB_HALF_BLOCKS = 8;
691
+ function Transcript({ events, settings, selectedTurnId = null }) {
557
692
  const items = useMemo(() => partitionTranscript(events, settings), [events, settings]);
693
+ const scrollboxRef = useRef(null);
694
+ useEffect(() => {
695
+ const scrollbox = scrollboxRef.current;
696
+ if (!scrollbox) return;
697
+ const slider = scrollbox.verticalScrollBar?.slider;
698
+ if (!slider || typeof slider.getVirtualThumbSize !== "function") return;
699
+ const original = slider.getVirtualThumbSize.bind(slider);
700
+ slider.getVirtualThumbSize = function() {
701
+ const upstream = original();
702
+ const virtualTrackSize = slider.height * 2;
703
+ return Math.min(virtualTrackSize, Math.max(MIN_THUMB_HALF_BLOCKS, upstream));
704
+ };
705
+ return () => {
706
+ slider.getVirtualThumbSize = original;
707
+ };
708
+ }, []);
709
+ const anchors = useMemo(() => computeTurnAnchors(items), [items]);
710
+ useEffect(() => {
711
+ if (!selectedTurnId) return;
712
+ const scrollbox = scrollboxRef.current;
713
+ if (!scrollbox) return;
714
+ const handle = requestAnimationFrame(() => {
715
+ if (selectedTurnId === anchors.lastTurnId) {
716
+ scrollbox.scrollTop = scrollbox.scrollHeight;
717
+ return;
718
+ }
719
+ const id = anchors.idByTurn.get(selectedTurnId);
720
+ if (id) scrollbox.scrollChildIntoView(id);
721
+ });
722
+ return () => cancelAnimationFrame(handle);
723
+ }, [selectedTurnId, anchors]);
558
724
  if (items.length === 0) return /* @__PURE__ */ jsx(EmptyState$1, {});
559
725
  return /* @__PURE__ */ jsx("scrollbox", {
726
+ ref: scrollboxRef,
560
727
  focusable: false,
561
728
  style: {
562
729
  flexGrow: 1,
@@ -567,14 +734,53 @@ function Transcript({ events, settings }) {
567
734
  stickyStart: "bottom",
568
735
  children: items.map((item, i) => item.kind === "event" ? /* @__PURE__ */ jsx(EventLine, {
569
736
  event: item.event,
570
- previous: item.previous
737
+ previous: item.previous,
738
+ selected: selectedTurnId !== null && item.event.turnId === selectedTurnId,
739
+ anchorId: anchors.ids[i][0]
571
740
  }, i) : /* @__PURE__ */ jsx(SubagentBlock, {
572
741
  events: item.events,
573
- previous: item.previous
742
+ previous: item.previous,
743
+ selectedTurnId,
744
+ anchorIds: anchors.ids[i]
574
745
  }, i))
575
746
  });
576
747
  }
577
748
  /**
749
+ * Per-item anchor ids for auto-scroll. Walks `items` in render order and,
750
+ * for each event, returns either:
751
+ * - `'turn-anchor-<turnId>'` — the first event of this turn (the
752
+ * scrollbox's target).
753
+ * - `undefined` — later event of an already-tagged turn (or a synthetic
754
+ * event with no `turnId`).
755
+ *
756
+ * `ids[i]` is a tuple per item: length 1 for plain events, length N for
757
+ * subagent runs (one entry per inner event). `idByTurn` is the inverse
758
+ * lookup used by the scroll effect. `lastTurnId` is the most-recently-
759
+ * rendered turn — the scroll effect special-cases it to snap to bottom.
760
+ *
761
+ * Exported so the anchor-tagging matrix can be unit-tested without rendering.
762
+ */
763
+ function computeTurnAnchors(items) {
764
+ const idByTurn = /* @__PURE__ */ new Map();
765
+ let lastTurnId;
766
+ const tag = (turnId) => {
767
+ if (!turnId) return void 0;
768
+ lastTurnId = turnId;
769
+ if (idByTurn.has(turnId)) return void 0;
770
+ const id = `turn-anchor-${turnId}`;
771
+ idByTurn.set(turnId, id);
772
+ return id;
773
+ };
774
+ const ids = [];
775
+ for (const item of items) if (item.kind === "event") ids.push([tag(item.event.turnId)]);
776
+ else ids.push(item.events.map((e) => tag(e.turnId)));
777
+ return {
778
+ idByTurn,
779
+ ids,
780
+ lastTurnId
781
+ };
782
+ }
783
+ /**
578
784
  * Per-event visibility — filters honor user toggles and the
579
785
  * `hideSubagentOutput` setting. When subagent output is hidden:
580
786
  * - Child-agent events are filtered down to the `spawn-start` /
@@ -655,7 +861,7 @@ function partitionTranscript(events, settings) {
655
861
  * indented twice. Grandchildren (depth ≥ 2) still indent further, so
656
862
  * nested subagents remain visually distinct.
657
863
  */
658
- function SubagentBlock({ events, previous }) {
864
+ function SubagentBlock({ events, previous, selectedTurnId = null, anchorIds }) {
659
865
  const COLOR = useColors();
660
866
  const childIds = useMemo(() => {
661
867
  const set = /* @__PURE__ */ new Set();
@@ -681,7 +887,9 @@ function SubagentBlock({ events, previous }) {
681
887
  children: events.map((evt, i) => /* @__PURE__ */ jsx(EventLine, {
682
888
  event: evt,
683
889
  previous: events[i - 1],
684
- depthOffset: 1
890
+ depthOffset: 1,
891
+ selected: selectedTurnId !== null && evt.turnId === selectedTurnId,
892
+ anchorId: anchorIds?.[i]
685
893
  }, i))
686
894
  });
687
895
  }
@@ -735,6 +943,7 @@ function rowStyle(paddingLeft) {
735
943
  */
736
944
  const MARGIN_TOP = {
737
945
  "separator": 0,
946
+ "user-prompt": 1,
738
947
  "info": 1,
739
948
  "thinking": 0,
740
949
  "tool": 1,
@@ -777,7 +986,17 @@ function EventLineImpl({ event, depthOffset = 0 }) {
777
986
  const child = isChild(event);
778
987
  switch (event.kind) {
779
988
  case "separator": return /* @__PURE__ */ jsx("text", { children: " " });
780
- case "info": return /* @__PURE__ */ jsx(UserPromptBlock, { text: safeText });
989
+ case "user-prompt": return /* @__PURE__ */ jsx(UserPromptBlock, {
990
+ text: safeText,
991
+ refs: event.refs
992
+ });
993
+ case "info": return /* @__PURE__ */ jsx("box", {
994
+ style: row,
995
+ children: /* @__PURE__ */ jsx("text", {
996
+ fg: COLOR.dim,
997
+ children: safeText
998
+ })
999
+ });
781
1000
  case "thinking": return /* @__PURE__ */ jsx("box", {
782
1001
  style: row,
783
1002
  children: /* @__PURE__ */ jsx("text", {
@@ -860,19 +1079,65 @@ function EventLineImpl({ event, depthOffset = 0 }) {
860
1079
  default: return /* @__PURE__ */ jsx("text", { children: safeText });
861
1080
  }
862
1081
  }
863
- /** User prompt — bordered to rhyme with the prompt input box below. */
864
- function UserPromptBlock({ text }) {
1082
+ /**
1083
+ * User prompt bordered to rhyme with the prompt input box below.
1084
+ *
1085
+ * No refs → plain text, rendered as a single `<text>` node. Mixed text +
1086
+ * refs → flex-row of word-sized atomic segments; each chip is its own
1087
+ * `<text>` painted with the theme's per-provider `chips[providerId]`
1088
+ * pair (or `chips.default` when the provider id isn't themed).
1089
+ *
1090
+ * Why flex-row instead of `<span>` siblings inside `<text>`: the OpenTUI
1091
+ * text buffer's "word" wrap breaks at every punctuation boundary, so a
1092
+ * file path like `@src/index.ts` would split between line 1 (`@src/index.`)
1093
+ * and line 2 (`ts`) on a narrow terminal — and the chip's background
1094
+ * would visually fragment across the wrap. With flex-row + flexWrap, each
1095
+ * chip is one atomic flex item; the wrap engine never breaks inside it.
1096
+ */
1097
+ /** Prompt chevron rendered ahead of every user-prompt block. */
1098
+ const USER_PROMPT_PREFIX = "❯ ";
1099
+ function UserPromptBlock({ text, refs }) {
865
1100
  const COLOR = useColors();
866
- return /* @__PURE__ */ jsx("box", {
867
- style: {
868
- border: true,
869
- borderColor: COLOR.borderActive,
870
- paddingLeft: 1,
871
- paddingRight: 1
872
- },
873
- children: /* @__PURE__ */ jsx("text", {
1101
+ const SURFACE = useSurfaces();
1102
+ const boxStyle = {
1103
+ border: true,
1104
+ borderColor: COLOR.borderActive,
1105
+ paddingLeft: 1,
1106
+ paddingRight: 1
1107
+ };
1108
+ if (!refs || refs.length === 0) return /* @__PURE__ */ jsx("box", {
1109
+ style: boxStyle,
1110
+ children: /* @__PURE__ */ jsxs("text", {
874
1111
  fg: COLOR.brand,
875
- children: text
1112
+ children: [/* @__PURE__ */ jsx("span", {
1113
+ fg: COLOR.brand,
1114
+ children: USER_PROMPT_PREFIX
1115
+ }), text]
1116
+ })
1117
+ });
1118
+ const segments = splitPromptSegments(text, refs);
1119
+ return /* @__PURE__ */ jsx("box", {
1120
+ style: boxStyle,
1121
+ children: /* @__PURE__ */ jsxs("box", {
1122
+ style: {
1123
+ flexDirection: "row",
1124
+ flexWrap: "wrap"
1125
+ },
1126
+ children: [/* @__PURE__ */ jsx("text", {
1127
+ fg: COLOR.brand,
1128
+ children: USER_PROMPT_PREFIX
1129
+ }), segments.map((seg, i) => {
1130
+ if (seg.kind === "plain") return /* @__PURE__ */ jsx("text", {
1131
+ fg: COLOR.brand,
1132
+ children: seg.text
1133
+ }, i);
1134
+ const chip = resolveChipColor(SURFACE.chips, seg.providerId);
1135
+ return /* @__PURE__ */ jsx("text", {
1136
+ fg: chip.fg,
1137
+ bg: chip.bg,
1138
+ children: seg.text
1139
+ }, i);
1140
+ })]
876
1141
  })
877
1142
  });
878
1143
  }
@@ -938,56 +1203,217 @@ function ToolResultBlock({ text, indent }) {
938
1203
  });
939
1204
  }
940
1205
  //#endregion
941
- //#region src/tui/model-picker.tsx
942
- /** Cap the visible scroll window so a 30-model list doesn't push the modal off-screen. */
943
- const VISIBLE_ROW_CAP = 12;
1206
+ //#region src/tui/toggle-list-modal.tsx
944
1207
  /**
945
- * Modal that lists the available models for the current provider and lets
946
- * the user pick one. Options come from the active `ProviderDescriptor`
947
- * either its declared `models` list or, when absent, pi-ai's built-in
948
- * registry looked up via `piProviderId`.
1208
+ * Generic list-with-checkboxes modal. Powers both the Skills and MCP
1209
+ * server pickers same state machine, same keyboard map, same row
1210
+ * geometry. Per-feature variance flows in through props:
949
1211
  *
950
- * Each row shows: `● selected · name (ctx N · reasoning · vision)`.
1212
+ * - `keyOf` extract the persisted identity (skill name, server name).
1213
+ * - `settingKey` — `'enabledSkills'` | `'enabledMcps'`.
1214
+ * - `renderDetail` — appended to each row in mute color (descriptions,
1215
+ * transports, …). Optional.
1216
+ * - `emptyState` — replacement content when `catalog` is empty.
1217
+ *
1218
+ * Renderer-agnostic state machine lives in `useEnabledToggleSet`
1219
+ * (chat layer) — a GUI shell can build its own toggle list against the
1220
+ * same hook without pulling OpenTUI.
951
1221
  */
952
- function ModelPickerModal({ models, currentModelId, onPick }) {
1222
+ function ToggleListModal({ catalog, keyOf, settingKey, title, renderDetail, emptyState }) {
953
1223
  const COLOR = useColors();
954
- const SELECT_THEME = useSelectStyle();
955
- const initialIndex = useMemo(() => models.findIndex((m) => m.id === currentModelId), [models, currentModelId]);
956
- const options = useMemo(() => models.map((m) => ({
957
- name: `${m.id === currentModelId ? "● " : " "}${m.name ?? m.id}`,
958
- description: describeModel(m),
959
- value: m.id
960
- })), [models, currentModelId]);
961
- if (models.length === 0) return /* @__PURE__ */ jsx(EmptyState, {});
962
- const visibleRows = Math.min(options.length, VISIBLE_ROW_CAP);
963
- const currentMissing = initialIndex < 0;
964
- const safeIndex = currentMissing ? 0 : initialIndex;
965
- return /* @__PURE__ */ jsxs(Modal, {
966
- title: "select model",
967
- children: [
968
- currentMissing && /* @__PURE__ */ jsx("text", {
1224
+ const { enabledSet, toggle } = useEnabledToggleSet({
1225
+ catalog,
1226
+ keyOf,
1227
+ settingKey
1228
+ });
1229
+ const [cursor, setCursorRaw] = useState(0);
1230
+ const setCursor = useCallback((update) => setCursorRaw((prev) => Math.min(Math.max(0, update(prev)), Math.max(0, catalog.length - 1))), [catalog.length]);
1231
+ const safeCursor = Math.min(cursor, Math.max(0, catalog.length - 1));
1232
+ useKeyboard((key) => {
1233
+ if (key.name === "up" || key.ctrl && key.name === "p") setCursor((c) => c - 1);
1234
+ else if (key.name === "down" || key.ctrl && key.name === "n") setCursor((c) => c + 1);
1235
+ else if (key.name === "return" || key.name === "space") {
1236
+ const entry = catalog[safeCursor];
1237
+ if (entry) toggle(keyOf(entry));
1238
+ }
1239
+ });
1240
+ if (catalog.length === 0) return /* @__PURE__ */ jsxs(Modal, {
1241
+ title,
1242
+ children: [emptyState, /* @__PURE__ */ jsxs("text", {
1243
+ fg: COLOR.mute,
1244
+ children: [/* @__PURE__ */ jsx("span", {
969
1245
  fg: COLOR.warn,
970
- children: `Current model "${currentModelId}" is not in this registry — pick one below to switch.`
971
- }),
972
- /* @__PURE__ */ jsx("select", {
973
- ...SELECT_THEME,
974
- focused: true,
975
- options,
976
- wrapSelection: true,
977
- selectedIndex: safeIndex,
978
- showScrollIndicator: options.length > visibleRows,
979
- style: { height: visibleRows },
980
- onSelect: (_idx, option) => {
981
- if (option) onPick(option.value);
982
- }
983
- }),
984
- /* @__PURE__ */ jsxs("text", {
985
- fg: COLOR.mute,
986
- children: [
987
- /* @__PURE__ */ jsx("span", {
988
- fg: COLOR.warn,
989
- children: "↑↓"
990
- }),
1246
+ children: "esc"
1247
+ }), " close"]
1248
+ })]
1249
+ });
1250
+ return /* @__PURE__ */ jsxs(Modal, {
1251
+ title: ` ${title} · ${enabledSet.size} / ${catalog.length} enabled `,
1252
+ children: [/* @__PURE__ */ jsx("box", {
1253
+ style: { flexDirection: "column" },
1254
+ children: catalog.map((entry, i) => {
1255
+ const focused = i === safeCursor;
1256
+ const name = keyOf(entry);
1257
+ const enabled = enabledSet.has(name);
1258
+ return /* @__PURE__ */ jsxs("text", {
1259
+ fg: focused ? COLOR.brand : COLOR.dim,
1260
+ children: [
1261
+ /* @__PURE__ */ jsx("span", {
1262
+ fg: focused ? COLOR.brand : COLOR.mute,
1263
+ children: focused ? "" : " "
1264
+ }),
1265
+ /* @__PURE__ */ jsx("span", {
1266
+ fg: enabled ? COLOR.accent : COLOR.mute,
1267
+ children: enabled ? "[✓] " : "[ ] "
1268
+ }),
1269
+ /* @__PURE__ */ jsx("span", {
1270
+ fg: focused ? COLOR.brand : COLOR.dim,
1271
+ children: name
1272
+ }),
1273
+ renderDetail && /* @__PURE__ */ jsxs("span", {
1274
+ fg: COLOR.mute,
1275
+ children: [" ", renderDetail(entry)]
1276
+ })
1277
+ ]
1278
+ }, name);
1279
+ })
1280
+ }), /* @__PURE__ */ jsxs("text", {
1281
+ fg: COLOR.mute,
1282
+ children: [
1283
+ /* @__PURE__ */ jsx("span", {
1284
+ fg: COLOR.warn,
1285
+ children: "↑↓"
1286
+ }),
1287
+ " navigate · ",
1288
+ /* @__PURE__ */ jsx("span", {
1289
+ fg: COLOR.warn,
1290
+ children: "↵"
1291
+ }),
1292
+ " toggle · ",
1293
+ /* @__PURE__ */ jsx("span", {
1294
+ fg: COLOR.warn,
1295
+ children: "esc"
1296
+ }),
1297
+ " close"
1298
+ ]
1299
+ })]
1300
+ });
1301
+ }
1302
+ //#endregion
1303
+ //#region src/tui/mcps-settings.tsx
1304
+ /**
1305
+ * List + toggle modal for MCP servers discovered from `.{prefix}/mcps.json`
1306
+ * / `.agents/mcps.json` (project + user). State machine lives in
1307
+ * `<ToggleListModal>` (shared with the Skills picker); this file supplies
1308
+ * the transport/command detail column and the empty-state copy.
1309
+ *
1310
+ * Toggling does NOT restart the active agent — the change applies on the
1311
+ * next session activation (the app rebuilds the agent there), keeping
1312
+ * current runs stable.
1313
+ */
1314
+ function McpsSettingsModal({ catalog }) {
1315
+ const COLOR = useColors();
1316
+ return /* @__PURE__ */ jsx(ToggleListModal, {
1317
+ catalog,
1318
+ keyOf: (d) => d.config.name,
1319
+ settingKey: "enabledMcps",
1320
+ title: "mcp servers",
1321
+ renderDetail: (entry) => {
1322
+ const transport = entry.config.transport;
1323
+ return `${transport} · ${transport === "stdio" ? entry.config.command ?? "" : entry.config.url ?? ""}`;
1324
+ },
1325
+ emptyState: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("text", {
1326
+ fg: COLOR.dim,
1327
+ children: "No MCP servers discovered."
1328
+ }), /* @__PURE__ */ jsxs("text", {
1329
+ fg: COLOR.mute,
1330
+ children: [
1331
+ "Drop a",
1332
+ /* @__PURE__ */ jsx("span", {
1333
+ fg: COLOR.model,
1334
+ children: " mcps.json "
1335
+ }),
1336
+ "into",
1337
+ /* @__PURE__ */ jsx("span", {
1338
+ fg: COLOR.model,
1339
+ children: " .zidane/ "
1340
+ }),
1341
+ "or",
1342
+ /* @__PURE__ */ jsx("span", {
1343
+ fg: COLOR.model,
1344
+ children: " .agents/ "
1345
+ }),
1346
+ "(project or",
1347
+ /* @__PURE__ */ jsx("span", {
1348
+ fg: COLOR.model,
1349
+ children: " ~/"
1350
+ }),
1351
+ "). Array of",
1352
+ /* @__PURE__ */ jsx("span", {
1353
+ fg: COLOR.model,
1354
+ children: " McpServerConfig "
1355
+ }),
1356
+ "or",
1357
+ /* @__PURE__ */ jsx("span", {
1358
+ fg: COLOR.model,
1359
+ children: " { \"mcpServers\": { ... } } "
1360
+ }),
1361
+ "."
1362
+ ]
1363
+ })] })
1364
+ });
1365
+ }
1366
+ //#endregion
1367
+ //#region src/tui/model-picker.tsx
1368
+ /** Cap the visible scroll window so a 30-model list doesn't push the modal off-screen. */
1369
+ const VISIBLE_ROW_CAP = 12;
1370
+ /**
1371
+ * Modal that lists the available models for the current provider and lets
1372
+ * the user pick one. Options come from the active `ProviderDescriptor` —
1373
+ * either its declared `models` list or, when absent, pi-ai's built-in
1374
+ * registry looked up via `piProviderId`.
1375
+ *
1376
+ * Each row shows: `● selected · name (ctx N · reasoning · vision)`.
1377
+ */
1378
+ function ModelPickerModal({ models, currentModelId, onPick }) {
1379
+ const COLOR = useColors();
1380
+ const SELECT_THEME = useSelectStyle();
1381
+ const initialIndex = useMemo(() => models.findIndex((m) => m.id === currentModelId), [models, currentModelId]);
1382
+ const options = useMemo(() => models.map((m) => ({
1383
+ name: `${m.id === currentModelId ? "● " : " "}${m.name ?? m.id}`,
1384
+ description: describeModel(m),
1385
+ value: m.id
1386
+ })), [models, currentModelId]);
1387
+ if (models.length === 0) return /* @__PURE__ */ jsx(EmptyState, {});
1388
+ const visibleRows = Math.min(options.length, VISIBLE_ROW_CAP);
1389
+ const currentMissing = initialIndex < 0;
1390
+ const safeIndex = currentMissing ? 0 : initialIndex;
1391
+ return /* @__PURE__ */ jsxs(Modal, {
1392
+ title: "select model",
1393
+ children: [
1394
+ currentMissing && /* @__PURE__ */ jsx("text", {
1395
+ fg: COLOR.warn,
1396
+ children: `Current model "${currentModelId}" is not in this registry — pick one below to switch.`
1397
+ }),
1398
+ /* @__PURE__ */ jsx("select", {
1399
+ ...SELECT_THEME,
1400
+ focused: true,
1401
+ options,
1402
+ wrapSelection: true,
1403
+ selectedIndex: safeIndex,
1404
+ showScrollIndicator: options.length > visibleRows,
1405
+ style: { height: visibleRows },
1406
+ onSelect: (_idx, option) => {
1407
+ if (option) onPick(option.value);
1408
+ }
1409
+ }),
1410
+ /* @__PURE__ */ jsxs("text", {
1411
+ fg: COLOR.mute,
1412
+ children: [
1413
+ /* @__PURE__ */ jsx("span", {
1414
+ fg: COLOR.warn,
1415
+ children: "↑↓"
1416
+ }),
991
1417
  " navigate · ",
992
1418
  /* @__PURE__ */ jsx("span", {
993
1419
  fg: COLOR.warn,
@@ -1037,6 +1463,183 @@ function describeModel(m) {
1037
1463
  return parts.join(" · ");
1038
1464
  }
1039
1465
  //#endregion
1466
+ //#region src/tui/completion-popup.tsx
1467
+ /**
1468
+ * Popover above the textarea showing the active provider's items. Provider-
1469
+ * agnostic — reads `label` + `description` off each `CompletionItem`. The
1470
+ * TUI hosts can pass any `CompletionState<TItem>`; the popup never needs
1471
+ * to know what `TItem` is.
1472
+ *
1473
+ * Geometry: 1 row of chrome + min(N, visibleRows) item rows + 1 hint row.
1474
+ * `flexShrink: 0` pins the height so a long transcript can't squeeze it.
1475
+ *
1476
+ * The popup is invisible (`null`-rendered) when `state.active` is null or
1477
+ * `state.items` is empty — the prompt block keeps its layout calm.
1478
+ *
1479
+ * Solid `backgroundColor: SURFACE.modal` is load-bearing: in `PromptBlock`
1480
+ * the popup floats over the transcript via `position: absolute`, and a
1481
+ * transparent fill would let the transcript text bleed through. Pairs
1482
+ * with the modal panel surface so floating UI shares one visual identity.
1483
+ *
1484
+ * Title overlays are painted on the top border (same trick as
1485
+ * `PromptHints`): provider label on the left in the chip-id's accent
1486
+ * color, match count on the right in dim text. Both ride absolute
1487
+ * positions so they take no flow space; the popup's height comes
1488
+ * entirely from the bordered body. The accent reuses
1489
+ * `resolveChipColor(...).bg` (foreground only, no background pill) so
1490
+ * the picker title still reads as "this is the X provider" without
1491
+ * carrying the chip pill's heavy visual weight.
1492
+ */
1493
+ function CompletionPopup({ state, visibleRows = 6 }) {
1494
+ const COLOR = useColors();
1495
+ const SURFACE = useSurfaces();
1496
+ const SELECT = useSelectStyle();
1497
+ if (!state.active) return null;
1498
+ const loading = state.loading && state.items.length === 0;
1499
+ if (state.items.length === 0 && !loading) return null;
1500
+ const chip = resolveChipColor(SURFACE.chips, state.active.provider.id);
1501
+ const providerLabel = state.active.provider.label.toLowerCase();
1502
+ let body;
1503
+ let height;
1504
+ if (loading) {
1505
+ body = /* @__PURE__ */ jsx("text", {
1506
+ fg: COLOR.dim,
1507
+ children: "loading…"
1508
+ });
1509
+ height = 3;
1510
+ } else {
1511
+ const rows = Math.min(state.items.length, visibleRows);
1512
+ const half = Math.floor(rows / 2);
1513
+ let start = Math.max(0, state.selectedIndex - half);
1514
+ if (start + rows > state.items.length) start = state.items.length - rows;
1515
+ const slice = state.items.slice(start, start + rows);
1516
+ height = 2 + rows + 1;
1517
+ body = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("box", {
1518
+ style: { flexDirection: "column" },
1519
+ children: slice.map((item, i) => {
1520
+ const focused = start + i === state.selectedIndex;
1521
+ return /* @__PURE__ */ jsx("box", {
1522
+ style: {
1523
+ height: 1,
1524
+ overflow: "hidden",
1525
+ flexShrink: 0
1526
+ },
1527
+ children: /* @__PURE__ */ jsxs("text", {
1528
+ fg: focused ? COLOR.brand : COLOR.dim,
1529
+ wrapMode: "none",
1530
+ truncate: true,
1531
+ children: [
1532
+ /* @__PURE__ */ jsx("span", {
1533
+ fg: focused ? COLOR.brand : COLOR.mute,
1534
+ children: focused ? "▶ " : " "
1535
+ }),
1536
+ /* @__PURE__ */ jsx("span", {
1537
+ fg: focused ? COLOR.brand : SELECT.textColor,
1538
+ children: item.label
1539
+ }),
1540
+ item.description && /* @__PURE__ */ jsxs("span", {
1541
+ fg: COLOR.mute,
1542
+ children: [" ", item.description]
1543
+ })
1544
+ ]
1545
+ })
1546
+ }, item.id);
1547
+ })
1548
+ }), /* @__PURE__ */ jsxs("text", {
1549
+ fg: COLOR.mute,
1550
+ children: [
1551
+ /* @__PURE__ */ jsx("span", {
1552
+ fg: COLOR.warn,
1553
+ children: "↑↓"
1554
+ }),
1555
+ " navigate · ",
1556
+ /* @__PURE__ */ jsx("span", {
1557
+ fg: COLOR.warn,
1558
+ children: "↵"
1559
+ }),
1560
+ " / ",
1561
+ /* @__PURE__ */ jsx("span", {
1562
+ fg: COLOR.warn,
1563
+ children: "tab"
1564
+ }),
1565
+ " select · ",
1566
+ /* @__PURE__ */ jsx("span", {
1567
+ fg: COLOR.warn,
1568
+ children: "esc"
1569
+ }),
1570
+ " close"
1571
+ ]
1572
+ })] });
1573
+ }
1574
+ return /* @__PURE__ */ jsxs("box", {
1575
+ style: {
1576
+ flexDirection: "column",
1577
+ flexShrink: 0
1578
+ },
1579
+ children: [
1580
+ /* @__PURE__ */ jsx("box", {
1581
+ style: {
1582
+ border: true,
1583
+ borderColor: COLOR.borderActive,
1584
+ backgroundColor: SURFACE.modal,
1585
+ paddingLeft: 1,
1586
+ paddingRight: 1,
1587
+ height,
1588
+ flexShrink: 0,
1589
+ alignSelf: "stretch",
1590
+ flexDirection: "column"
1591
+ },
1592
+ children: body
1593
+ }),
1594
+ /* @__PURE__ */ jsxs("text", {
1595
+ style: {
1596
+ position: "absolute",
1597
+ top: 0,
1598
+ left: 1
1599
+ },
1600
+ children: [
1601
+ /* @__PURE__ */ jsx("span", {
1602
+ fg: COLOR.mute,
1603
+ children: " "
1604
+ }),
1605
+ /* @__PURE__ */ jsx("span", {
1606
+ fg: chip.bg,
1607
+ children: providerLabel
1608
+ }),
1609
+ /* @__PURE__ */ jsx("span", {
1610
+ fg: COLOR.mute,
1611
+ children: " "
1612
+ })
1613
+ ]
1614
+ }),
1615
+ /* @__PURE__ */ jsxs("text", {
1616
+ style: {
1617
+ position: "absolute",
1618
+ top: 0,
1619
+ right: 1
1620
+ },
1621
+ children: [
1622
+ /* @__PURE__ */ jsx("span", {
1623
+ fg: COLOR.mute,
1624
+ children: " "
1625
+ }),
1626
+ loading ? /* @__PURE__ */ jsx("span", {
1627
+ fg: COLOR.dim,
1628
+ children: "loading…"
1629
+ }) : /* @__PURE__ */ jsx("span", {
1630
+ fg: COLOR.dim,
1631
+ children: `${state.items.length} match${state.items.length === 1 ? "" : "es"}`
1632
+ }),
1633
+ /* @__PURE__ */ jsx("span", {
1634
+ fg: COLOR.mute,
1635
+ children: " "
1636
+ })
1637
+ ]
1638
+ })
1639
+ ]
1640
+ });
1641
+ }
1642
+ //#endregion
1040
1643
  //#region src/tui/screens.tsx
1041
1644
  /**
1042
1645
  * Build a key-binding set for the prompt textarea / API-key input. Strips the
@@ -1086,7 +1689,7 @@ function AuthScreen({ onPick }) {
1086
1689
  const COLOR = useColors();
1087
1690
  const SELECT_THEME = useSelectStyle();
1088
1691
  const [providers, setProviders] = useState([]);
1089
- const refresh = useCallback(() => setProviders(detectAuth(config.paths.dir, registry)), [config.paths.dir, registry]);
1692
+ const refresh = useCallback(() => setProviders(detectAuth(config.paths.userDir, registry)), [config.paths.userDir, registry]);
1090
1693
  useEffect(() => {
1091
1694
  refresh();
1092
1695
  }, [refresh]);
@@ -1100,7 +1703,7 @@ function AuthScreen({ onPick }) {
1100
1703
  const canCancel = forceWizard && available.length > 0;
1101
1704
  return /* @__PURE__ */ jsx(SetupWizard, {
1102
1705
  registry,
1103
- dataDir: config.paths.dir,
1706
+ dataDir: config.paths.userDir,
1104
1707
  onConfigured: onWizardDone,
1105
1708
  onCancel: canCancel ? () => setForceWizard(false) : void 0
1106
1709
  });
@@ -1114,31 +1717,36 @@ function AuthScreen({ onPick }) {
1114
1717
  description: "launch the setup wizard",
1115
1718
  value: WIZARD_OPTION_VALUE
1116
1719
  }];
1117
- return /* @__PURE__ */ jsx("box", {
1118
- title: " pick a provider ",
1720
+ return /* @__PURE__ */ jsxs("box", {
1119
1721
  style: {
1120
- border: true,
1121
- borderColor: COLOR.border,
1122
- padding: 1,
1123
1722
  flexDirection: "column",
1124
1723
  flexGrow: 1
1125
1724
  },
1126
- children: /* @__PURE__ */ jsx("select", {
1127
- ...SELECT_THEME,
1128
- focused,
1129
- options,
1130
- wrapSelection: true,
1131
- onSelect: (_idx, option) => {
1132
- if (!option) return;
1133
- if (option.value === WIZARD_OPTION_VALUE) {
1134
- setForceWizard(true);
1135
- return;
1136
- }
1137
- const provider = findByKey(available, option.value);
1138
- if (provider) onPick(provider);
1725
+ children: [/* @__PURE__ */ jsx("box", {
1726
+ style: {
1727
+ border: true,
1728
+ borderColor: COLOR.border,
1729
+ padding: 1,
1730
+ flexDirection: "column",
1731
+ flexGrow: 1
1139
1732
  },
1140
- style: { flexGrow: 1 }
1141
- })
1733
+ children: /* @__PURE__ */ jsx("select", {
1734
+ ...SELECT_THEME,
1735
+ focused,
1736
+ options,
1737
+ wrapSelection: true,
1738
+ onSelect: (_idx, option) => {
1739
+ if (!option) return;
1740
+ if (option.value === WIZARD_OPTION_VALUE) {
1741
+ setForceWizard(true);
1742
+ return;
1743
+ }
1744
+ const provider = findByKey(available, option.value);
1745
+ if (provider) onPick(provider);
1746
+ },
1747
+ style: { flexGrow: 1 }
1748
+ })
1749
+ }), /* @__PURE__ */ jsx(TitleOverlay, { title: "pick a provider" })]
1142
1750
  });
1143
1751
  }
1144
1752
  function SetupWizard({ registry, dataDir, onConfigured, onCancel }) {
@@ -1180,6 +1788,13 @@ function SetupWizard({ registry, dataDir, onConfigured, onCancel }) {
1180
1788
  setError(err instanceof Error ? err.message : String(err));
1181
1789
  }
1182
1790
  }, [dataDir, onConfigured]);
1791
+ const onOAuthError = useCallback((msg) => {
1792
+ setError(msg);
1793
+ setStep((prev) => prev.kind === "oauth-running" ? {
1794
+ kind: "pick-method",
1795
+ descriptor: prev.descriptor
1796
+ } : prev);
1797
+ }, []);
1183
1798
  if (descriptors.length === 0) return /* @__PURE__ */ jsx(EmptyRegistryNotice, {});
1184
1799
  if (step.kind === "pick-provider") return /* @__PURE__ */ jsx(PickProviderStep, {
1185
1800
  descriptors,
@@ -1201,35 +1816,43 @@ function SetupWizard({ registry, dataDir, onConfigured, onCancel }) {
1201
1816
  descriptor: step.descriptor,
1202
1817
  dataDir,
1203
1818
  onSuccess: onConfigured,
1204
- onError: (msg) => {
1205
- setError(msg);
1206
- setStep({
1207
- kind: "pick-method",
1208
- descriptor: step.descriptor
1209
- });
1210
- }
1819
+ onError: onOAuthError
1211
1820
  });
1212
1821
  }
1213
1822
  /**
1214
- * Shared wrapper for every wizard step — same border + padding + flex layout
1215
- * with a customizable title and accent color. Footnote slot at the bottom for
1216
- * an error banner.
1823
+ * Shared wrapper for every wizard step — same border + padding + flex
1824
+ * layout with a customizable title and accent color. Footnote slot at the
1825
+ * bottom for an error banner.
1826
+ *
1827
+ * Title rides `accent` when present (so the empty-registry notice's red
1828
+ * title matches its red border) and falls back to `COLOR.brand`. The
1829
+ * outer flex column hosts both the bordered content box and the
1830
+ * `TitleOverlay`, which must be its sibling (not a child) so the
1831
+ * border-row paint isn't clipped by the bordered box's scissor rect.
1217
1832
  */
1218
1833
  function WizardPanel({ title, accent, error, children }) {
1219
1834
  const COLOR = useColors();
1220
1835
  return /* @__PURE__ */ jsxs("box", {
1221
- title,
1222
1836
  style: {
1223
- border: true,
1224
- borderColor: accent ?? COLOR.border,
1225
- padding: 1,
1226
- gap: 1,
1227
1837
  flexDirection: "column",
1228
1838
  flexGrow: 1
1229
1839
  },
1230
- children: [children, error && /* @__PURE__ */ jsx("text", {
1231
- fg: COLOR.error,
1232
- children: error
1840
+ children: [/* @__PURE__ */ jsxs("box", {
1841
+ style: {
1842
+ border: true,
1843
+ borderColor: accent ?? COLOR.border,
1844
+ padding: 1,
1845
+ gap: 1,
1846
+ flexDirection: "column",
1847
+ flexGrow: 1
1848
+ },
1849
+ children: [children, error && /* @__PURE__ */ jsx("text", {
1850
+ fg: COLOR.error,
1851
+ children: error
1852
+ })]
1853
+ }), /* @__PURE__ */ jsx(TitleOverlay, {
1854
+ title: title.trim(),
1855
+ titleColor: accent
1233
1856
  })]
1234
1857
  });
1235
1858
  }
@@ -1243,7 +1866,7 @@ function WizardEscHint() {
1243
1866
  function EmptyRegistryNotice() {
1244
1867
  const COLOR = useColors();
1245
1868
  return /* @__PURE__ */ jsxs(WizardPanel, {
1246
- title: " no providers configured ",
1869
+ title: "no providers configured",
1247
1870
  accent: COLOR.error,
1248
1871
  children: [/* @__PURE__ */ jsx("text", {
1249
1872
  fg: COLOR.error,
@@ -1285,7 +1908,7 @@ function PickProviderStep({ descriptors, error, onPick, onCancel }) {
1285
1908
  value: WIZARD_BACK_VALUE
1286
1909
  }] : []];
1287
1910
  return /* @__PURE__ */ jsxs(WizardPanel, {
1288
- title: onCancel ? " add or re-configure a provider " : " welcome to zidane · pick a provider ",
1911
+ title: onCancel ? "add or re-configure a provider" : "welcome to zidane · pick a provider",
1289
1912
  error,
1290
1913
  children: [!onCancel && /* @__PURE__ */ jsxs("text", {
1291
1914
  fg: COLOR.dim,
@@ -1335,7 +1958,7 @@ function PickMethodStep({ descriptor, error, onPick }) {
1335
1958
  return items;
1336
1959
  }, [descriptor]);
1337
1960
  return /* @__PURE__ */ jsxs(WizardPanel, {
1338
- title: ` configure ${descriptor.label} — pick auth method `,
1961
+ title: `configure ${descriptor.label} — pick auth method`,
1339
1962
  error,
1340
1963
  children: [/* @__PURE__ */ jsx(WizardEscHint, {}), /* @__PURE__ */ jsx("select", {
1341
1964
  ...SELECT_THEME,
@@ -1357,7 +1980,7 @@ function EnterApiKeyStep({ descriptor, error, onSubmit }) {
1357
1980
  onSubmit(descriptor, inputRef.current?.value ?? "");
1358
1981
  }, [descriptor, onSubmit]);
1359
1982
  return /* @__PURE__ */ jsxs(WizardPanel, {
1360
- title: ` configure ${descriptor.label} — paste API key `,
1983
+ title: `configure ${descriptor.label} — paste API key`,
1361
1984
  error,
1362
1985
  children: [/* @__PURE__ */ jsxs("text", {
1363
1986
  fg: COLOR.dim,
@@ -1432,7 +2055,7 @@ function OAuthRunningStep({ descriptor, dataDir, onSuccess, onError }) {
1432
2055
  onError
1433
2056
  ]);
1434
2057
  return /* @__PURE__ */ jsxs(WizardPanel, {
1435
- title: ` configure ${descriptor.label} — OAuth `,
2058
+ title: `configure ${descriptor.label} — OAuth`,
1436
2059
  children: [
1437
2060
  /* @__PURE__ */ jsx(WizardEscHint, {}),
1438
2061
  /* @__PURE__ */ jsx(Spinner, { label: status }),
@@ -1452,86 +2075,398 @@ function OAuthRunningStep({ descriptor, dataDir, onSuccess, onError }) {
1452
2075
  ]
1453
2076
  });
1454
2077
  }
1455
- const NEW_VALUE = "__new__";
1456
- function SessionsScreen({ sessions, currentId, onPick, onCreate }) {
2078
+ /**
2079
+ * Sentinel row id for the synthetic "+ new" row at the top of the
2080
+ * sessions list. Exposed so the AppShell can pass it through
2081
+ * `focusedSessionId` (or rather: the focused ROW id, which is either a
2082
+ * real session id or this sentinel) and gate global shortcuts that
2083
+ * only make sense on real sessions — `ctrl+x` is the canonical example.
2084
+ *
2085
+ * Stays a plain string (rather than a discriminated union) so the
2086
+ * parent's focus state remains a flat `string | null` — `null` means
2087
+ * "no preference yet, please default to the first session"; this
2088
+ * sentinel means "+ new is the active row".
2089
+ */
2090
+ const NEW_SESSION_ROW_ID = "__new__";
2091
+ /** Guard for the `ctrl+x` handler: only a real session id should open the details modal. */
2092
+ function isSessionRowId(rowId) {
2093
+ return rowId !== null && rowId !== "__new__";
2094
+ }
2095
+ /** Page-up / page-down jump size — half a typical visible window. */
2096
+ const PAGE_JUMP = 6;
2097
+ function SessionsScreen({ sessions, currentId, focusedSessionId, onPick, onCreate, onFocusChange, showAllProjects = false, currentProjectRoot }) {
1457
2098
  const focused = useModalAwareFocus();
1458
2099
  const COLOR = useColors();
1459
- const SELECT_THEME = useSelectStyle();
1460
- const options = useMemo(() => {
1461
- const items = [{
1462
- name: "+ new session",
1463
- description: "start fresh",
1464
- value: NEW_VALUE
1465
- }];
1466
- for (const s of sessions) {
1467
- const marker = s.id === currentId ? "● " : " ";
1468
- const turnLabel = `${s.turnCount} turn${s.turnCount === 1 ? "" : "s"}`;
1469
- items.push({
1470
- name: `${marker}${s.title}`,
1471
- description: `#${shortId(s.id)} · ${turnLabel} · ${ageString(s.updatedAt)}`,
1472
- value: s.id
1473
- });
2100
+ const rows = useMemo(() => [{
2101
+ kind: "new",
2102
+ rowId: NEW_SESSION_ROW_ID,
2103
+ meta: null
2104
+ }, ...sessions.map((meta) => ({
2105
+ kind: "session",
2106
+ rowId: meta.id,
2107
+ meta
2108
+ }))], [sessions]);
2109
+ const cursorIndex = useMemo(() => {
2110
+ if (focusedSessionId === "__new__") return 0;
2111
+ if (focusedSessionId) {
2112
+ const idx = rows.findIndex((r) => r.kind === "session" && r.rowId === focusedSessionId);
2113
+ if (idx !== -1) return idx;
1474
2114
  }
1475
- return items;
1476
- }, [sessions, currentId]);
1477
- return /* @__PURE__ */ jsx("box", {
1478
- title: " sessions ",
1479
- style: {
1480
- border: true,
1481
- borderColor: COLOR.border,
1482
- padding: 1,
1483
- flexDirection: "column",
1484
- flexGrow: 1
1485
- },
1486
- children: /* @__PURE__ */ jsx("select", {
1487
- ...SELECT_THEME,
1488
- focused,
1489
- options,
1490
- wrapSelection: true,
1491
- onSelect: (_idx, option) => {
1492
- if (!option) return;
1493
- if (option.value === NEW_VALUE) onCreate();
1494
- else if (typeof option.value === "string") onPick(option.value);
1495
- },
1496
- style: { flexGrow: 1 }
1497
- })
2115
+ return rows.length > 1 ? 1 : 0;
2116
+ }, [rows, focusedSessionId]);
2117
+ useEffect(() => {
2118
+ if (!onFocusChange) return;
2119
+ if (focusedSessionId === "__new__") return;
2120
+ if (focusedSessionId && rows.some((r) => r.kind === "session" && r.rowId === focusedSessionId)) return;
2121
+ const fallback = rows[cursorIndex];
2122
+ onFocusChange(fallback?.rowId ?? null);
2123
+ }, [
2124
+ rows,
2125
+ focusedSessionId,
2126
+ cursorIndex,
2127
+ onFocusChange
2128
+ ]);
2129
+ const moveCursor = useCallback((nextIndex) => {
2130
+ if (!onFocusChange || rows.length === 0) return;
2131
+ const row = rows[(nextIndex % rows.length + rows.length) % rows.length];
2132
+ onFocusChange(row?.rowId ?? null);
2133
+ }, [rows, onFocusChange]);
2134
+ const commitCurrent = useCallback(() => {
2135
+ const row = rows[cursorIndex];
2136
+ if (!row) return;
2137
+ if (row.kind === "new") onCreate();
2138
+ else onPick(row.rowId);
2139
+ }, [
2140
+ rows,
2141
+ cursorIndex,
2142
+ onCreate,
2143
+ onPick
2144
+ ]);
2145
+ useKeyboard((key) => {
2146
+ if (!focused) return;
2147
+ if (key.name === "up") {
2148
+ moveCursor(cursorIndex - 1);
2149
+ return;
2150
+ }
2151
+ if (key.name === "down") {
2152
+ moveCursor(cursorIndex + 1);
2153
+ return;
2154
+ }
2155
+ if (key.name === "pageup") {
2156
+ moveCursor(cursorIndex - PAGE_JUMP);
2157
+ return;
2158
+ }
2159
+ if (key.name === "pagedown") {
2160
+ moveCursor(cursorIndex + PAGE_JUMP);
2161
+ return;
2162
+ }
2163
+ if (key.name === "home") {
2164
+ moveCursor(0);
2165
+ return;
2166
+ }
2167
+ if (key.name === "end") {
2168
+ moveCursor(rows.length - 1);
2169
+ return;
2170
+ }
2171
+ if (key.name === "return") commitCurrent();
2172
+ });
2173
+ const titleMeta = useMemo(() => {
2174
+ if (sessions.length === 0) return [{
2175
+ text: "no sessions yet",
2176
+ color: COLOR.mute
2177
+ }];
2178
+ return [{
2179
+ text: String(sessions.length),
2180
+ color: COLOR.warn
2181
+ }, { text: ` session${sessions.length === 1 ? "" : "s"}` }];
2182
+ }, [sessions.length, COLOR]);
2183
+ const { width: termWidth } = useTerminalDimensions();
2184
+ const cwdMaxWidth = Math.max(16, termWidth - 8);
2185
+ return /* @__PURE__ */ jsxs("box", {
2186
+ style: {
2187
+ flexDirection: "column",
2188
+ flexGrow: 1
2189
+ },
2190
+ children: [/* @__PURE__ */ jsxs("box", {
2191
+ style: {
2192
+ border: true,
2193
+ borderColor: COLOR.border,
2194
+ padding: 1,
2195
+ flexDirection: "column",
2196
+ flexGrow: 1
2197
+ },
2198
+ children: [currentProjectRoot && /* @__PURE__ */ jsx("box", {
2199
+ style: {
2200
+ flexDirection: "column",
2201
+ flexShrink: 0,
2202
+ marginBottom: 1
2203
+ },
2204
+ children: /* @__PURE__ */ jsxs("text", {
2205
+ wrapMode: "none",
2206
+ children: [
2207
+ /* @__PURE__ */ jsx("span", {
2208
+ fg: COLOR.mute,
2209
+ children: "cwd "
2210
+ }),
2211
+ /* @__PURE__ */ jsx("span", {
2212
+ fg: COLOR.dim,
2213
+ children: compactPath(currentProjectRoot, cwdMaxWidth)
2214
+ }),
2215
+ showAllProjects && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
2216
+ fg: COLOR.mute,
2217
+ children: " · "
2218
+ }), /* @__PURE__ */ jsx("span", {
2219
+ fg: COLOR.accent,
2220
+ children: "all projects"
2221
+ })] })
2222
+ ]
2223
+ })
2224
+ }), /* @__PURE__ */ jsx("scrollbox", {
2225
+ focusable: false,
2226
+ style: { flexGrow: 1 },
2227
+ children: rows.map((row, idx) => /* @__PURE__ */ jsx(SessionRow, {
2228
+ row,
2229
+ focused: idx === cursorIndex && focused,
2230
+ isCurrent: row.kind === "session" && row.rowId === currentId,
2231
+ showProject: showAllProjects,
2232
+ currentProjectRoot
2233
+ }, row.rowId))
2234
+ })]
2235
+ }), /* @__PURE__ */ jsx(TitleOverlay, {
2236
+ title: "sessions",
2237
+ meta: titleMeta
2238
+ })]
2239
+ });
2240
+ }
2241
+ /**
2242
+ * Two-line row for `SessionsScreen`. Top line is the title with the
2243
+ * focus + active markers; bottom is the stats summary (turns / user /
2244
+ * runs / age) in the bottom-bar's warn-number + dim-label palette.
2245
+ *
2246
+ * Alignment contract: the stats line indents by `STATS_INDENT` cells —
2247
+ * exactly the width of the focus + current markers above — so the first
2248
+ * stat (the turn count) sits flush under the title's first letter.
2249
+ */
2250
+ function SessionRow({ row, focused, isCurrent, showProject = false, currentProjectRoot }) {
2251
+ const COLOR = useColors();
2252
+ const STATS_INDENT = " ";
2253
+ const focusMark = focused ? "▶ " : " ";
2254
+ const focusColor = focused ? COLOR.brand : COLOR.mute;
2255
+ const titleColor = focused ? COLOR.brand : COLOR.dim;
2256
+ if (row.kind === "new") return /* @__PURE__ */ jsxs("box", {
2257
+ style: {
2258
+ flexDirection: "column",
2259
+ flexShrink: 0,
2260
+ alignSelf: "stretch"
2261
+ },
2262
+ children: [/* @__PURE__ */ jsxs("text", {
2263
+ wrapMode: "none",
2264
+ children: [
2265
+ /* @__PURE__ */ jsx("span", {
2266
+ fg: focusColor,
2267
+ children: focusMark
2268
+ }),
2269
+ /* @__PURE__ */ jsx("span", {
2270
+ fg: COLOR.mute,
2271
+ children: " "
2272
+ }),
2273
+ /* @__PURE__ */ jsx("span", {
2274
+ fg: titleColor,
2275
+ children: "+ new session"
2276
+ })
2277
+ ]
2278
+ }), /* @__PURE__ */ jsxs("text", {
2279
+ wrapMode: "none",
2280
+ children: [/* @__PURE__ */ jsx("span", {
2281
+ fg: COLOR.mute,
2282
+ children: STATS_INDENT
2283
+ }), /* @__PURE__ */ jsx("span", {
2284
+ fg: COLOR.mute,
2285
+ children: "start fresh"
2286
+ })]
2287
+ })]
2288
+ });
2289
+ const meta = row.meta;
2290
+ const currentMark = isCurrent ? "● " : " ";
2291
+ const currentColor = isCurrent ? COLOR.accent : COLOR.mute;
2292
+ return /* @__PURE__ */ jsxs("box", {
2293
+ style: {
2294
+ flexDirection: "column",
2295
+ flexShrink: 0,
2296
+ alignSelf: "stretch"
2297
+ },
2298
+ children: [
2299
+ /* @__PURE__ */ jsxs("text", {
2300
+ wrapMode: "none",
2301
+ children: [
2302
+ /* @__PURE__ */ jsx("span", {
2303
+ fg: focusColor,
2304
+ children: focusMark
2305
+ }),
2306
+ /* @__PURE__ */ jsx("span", {
2307
+ fg: currentColor,
2308
+ children: currentMark
2309
+ }),
2310
+ /* @__PURE__ */ jsx("span", {
2311
+ fg: titleColor,
2312
+ children: meta.title
2313
+ })
2314
+ ]
2315
+ }),
2316
+ /* @__PURE__ */ jsxs("text", {
2317
+ wrapMode: "none",
2318
+ children: [
2319
+ /* @__PURE__ */ jsx("span", {
2320
+ fg: COLOR.mute,
2321
+ children: STATS_INDENT
2322
+ }),
2323
+ /* @__PURE__ */ jsx("span", {
2324
+ fg: COLOR.warn,
2325
+ children: meta.turnCount
2326
+ }),
2327
+ /* @__PURE__ */ jsx("span", {
2328
+ fg: COLOR.mute,
2329
+ children: ` turn${meta.turnCount === 1 ? "" : "s"} · `
2330
+ }),
2331
+ /* @__PURE__ */ jsx("span", {
2332
+ fg: COLOR.warn,
2333
+ children: meta.userMessageCount
2334
+ }),
2335
+ /* @__PURE__ */ jsx("span", {
2336
+ fg: COLOR.mute,
2337
+ children: ` user · `
2338
+ }),
2339
+ /* @__PURE__ */ jsx("span", {
2340
+ fg: COLOR.warn,
2341
+ children: meta.runCount
2342
+ }),
2343
+ /* @__PURE__ */ jsx("span", {
2344
+ fg: COLOR.mute,
2345
+ children: ` run${meta.runCount === 1 ? "" : "s"} · `
2346
+ }),
2347
+ /* @__PURE__ */ jsx("span", {
2348
+ fg: COLOR.mute,
2349
+ children: ageString(meta.updatedAt)
2350
+ })
2351
+ ]
2352
+ }),
2353
+ showProject && /* @__PURE__ */ jsxs("text", {
2354
+ wrapMode: "none",
2355
+ children: [/* @__PURE__ */ jsx("span", {
2356
+ fg: COLOR.mute,
2357
+ children: STATS_INDENT
2358
+ }), renderProjectLabel(meta.projectRoot, currentProjectRoot, COLOR)]
2359
+ })
2360
+ ]
2361
+ });
2362
+ }
2363
+ /**
2364
+ * Render the project label for the cross-project view. Three cases:
2365
+ *
2366
+ * - The row belongs to the current project → render "this project"
2367
+ * in `accent` so the user can quickly spot rows that match where
2368
+ * they are.
2369
+ * - The row is tagged but for a different project → render the
2370
+ * basename of the project root in `dim` (full path would be too
2371
+ * long for the row).
2372
+ * - The row is untagged (legacy) → render "untagged" in `mute`.
2373
+ */
2374
+ function renderProjectLabel(rowProject, currentProject, COLOR) {
2375
+ if (!rowProject) return /* @__PURE__ */ jsx("span", {
2376
+ fg: COLOR.mute,
2377
+ children: "untagged"
1498
2378
  });
2379
+ if (currentProject && rowProject === currentProject) return /* @__PURE__ */ jsx("span", {
2380
+ fg: COLOR.accent,
2381
+ children: "this project"
2382
+ });
2383
+ const basename = rowProject.split("/").pop() ?? rowProject;
2384
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
2385
+ fg: COLOR.mute,
2386
+ children: "project "
2387
+ }), /* @__PURE__ */ jsx("span", {
2388
+ fg: COLOR.dim,
2389
+ children: basename
2390
+ })] });
1499
2391
  }
1500
2392
  /** Visible content lines: 1 minimum, 5 maximum (textarea scrolls past 5). */
1501
2393
  const MIN_CONTENT_LINES = 1;
1502
2394
  const MAX_CONTENT_LINES = 5;
1503
- function ChatScreen({ events, busy, settings, onSubmit, session, pending, onApproval }) {
2395
+ function ChatScreen({ events, busy, settings, onSubmit, session, pending, onApproval, completionProviders, onPopupOpenChange, selectedTurnId, promptTriggerHints }) {
1504
2396
  const COLOR = useColors();
1505
- const title = useMemo(() => {
1506
- if (!session) return " untitled ";
1507
- const turns = `${session.turnCount} turn${session.turnCount === 1 ? "" : "s"}`;
1508
- return ` ${session.title} · #${shortId(session.id)} · ${turns} `;
1509
- }, [session]);
1510
- const userPrompts = useMemo(() => events.filter((e) => e.kind === "info").map((e) => e.text.replace(/^❯ /, "")), [events]);
2397
+ const titleText = session?.title ?? "untitled";
2398
+ const showSessionShortcut = !!session && !busy && !pending;
2399
+ const userMessageCount = useMemo(() => events.filter((e) => e.kind === "user-prompt").length, [events]);
2400
+ const metaSegments = useMemo(() => {
2401
+ if (!session) return null;
2402
+ const turnsSuffix = `turn${session.turnCount === 1 ? "" : "s"}`;
2403
+ const messagesSuffix = `user message${userMessageCount === 1 ? "" : "s"}`;
2404
+ const segments = [
2405
+ {
2406
+ text: String(userMessageCount),
2407
+ color: COLOR.warn
2408
+ },
2409
+ { text: ` ${messagesSuffix}` },
2410
+ {
2411
+ text: " · ",
2412
+ color: COLOR.mute
2413
+ },
2414
+ {
2415
+ text: String(session.turnCount),
2416
+ color: COLOR.warn
2417
+ },
2418
+ { text: ` ${turnsSuffix}` }
2419
+ ];
2420
+ if (showSessionShortcut) segments.push({
2421
+ text: " · ",
2422
+ color: COLOR.mute
2423
+ }, {
2424
+ text: "ctrl+x",
2425
+ color: COLOR.warn
2426
+ }, { text: " session" });
2427
+ return segments;
2428
+ }, [
2429
+ session,
2430
+ userMessageCount,
2431
+ COLOR,
2432
+ showSessionShortcut
2433
+ ]);
2434
+ const userPrompts = useMemo(() => events.filter((e) => e.kind === "user-prompt").map((e) => e.text), [events]);
1511
2435
  return /* @__PURE__ */ jsxs("box", {
1512
2436
  style: {
1513
2437
  flexDirection: "column",
1514
2438
  flexGrow: 1
1515
2439
  },
1516
- children: [/* @__PURE__ */ jsx("box", {
1517
- title,
1518
- style: {
1519
- border: true,
1520
- borderColor: COLOR.border,
1521
- flexGrow: 1,
1522
- flexDirection: "column"
1523
- },
1524
- children: /* @__PURE__ */ jsx(Transcript, {
1525
- events,
1526
- settings
2440
+ children: [
2441
+ /* @__PURE__ */ jsx("box", {
2442
+ style: {
2443
+ border: true,
2444
+ borderColor: COLOR.border,
2445
+ flexGrow: 1,
2446
+ flexDirection: "column"
2447
+ },
2448
+ children: /* @__PURE__ */ jsx(Transcript, {
2449
+ events,
2450
+ settings,
2451
+ selectedTurnId: selectedTurnId ?? null
2452
+ })
2453
+ }),
2454
+ pending ? /* @__PURE__ */ jsx(ApprovalBlock, {
2455
+ request: pending,
2456
+ onPick: onApproval
2457
+ }) : busy ? /* @__PURE__ */ jsx(BusyBlock, {}) : /* @__PURE__ */ jsx(PromptBlock, {
2458
+ userPrompts,
2459
+ onSubmit,
2460
+ completionProviders,
2461
+ onPopupOpenChange,
2462
+ selectMode: selectedTurnId != null,
2463
+ triggerHints: promptTriggerHints
2464
+ }),
2465
+ /* @__PURE__ */ jsx(TitleOverlay, {
2466
+ title: titleText,
2467
+ meta: metaSegments
1527
2468
  })
1528
- }), pending ? /* @__PURE__ */ jsx(ApprovalBlock, {
1529
- request: pending,
1530
- onPick: onApproval
1531
- }) : busy ? /* @__PURE__ */ jsx(BusyBlock, {}) : /* @__PURE__ */ jsx(PromptBlock, {
1532
- userPrompts,
1533
- onSubmit
1534
- })]
2469
+ ]
1535
2470
  });
1536
2471
  }
1537
2472
  /** Max chars per scalar argument in the approval preview. */
@@ -1551,7 +2486,10 @@ function formatApprovalArgs(input) {
1551
2486
  value = escaped.length > APPROVAL_ARG_MAX ? `"${escaped.slice(0, APPROVAL_ARG_MAX)}…"` : `"${escaped}"`;
1552
2487
  } else {
1553
2488
  const json = JSON.stringify(raw);
1554
- value = json.length > APPROVAL_ARG_MAX ? `${json.slice(0, APPROVAL_ARG_MAX)}…` : json;
2489
+ if (json.length > APPROVAL_ARG_MAX) {
2490
+ const closer = json[0] === "{" ? "…}" : json[0] === "[" ? "…]" : "…";
2491
+ value = `${json.slice(0, APPROVAL_ARG_MAX)}${closer}`;
2492
+ } else value = json;
1555
2493
  }
1556
2494
  parts.push(`${key}: ${value}`);
1557
2495
  }
@@ -1663,29 +2601,72 @@ function BusyBlock() {
1663
2601
  children: /* @__PURE__ */ jsx(Spinner, { label: "streaming response — esc to abort" })
1664
2602
  });
1665
2603
  }
1666
- function PromptBlock({ userPrompts, onSubmit }) {
2604
+ /** Stable empty providers reference — avoids `useCompletion` rerun on every render when no providers are wired. */
2605
+ const EMPTY_PROVIDERS = [];
2606
+ function PromptBlock({ userPrompts, onSubmit, completionProviders, onPopupOpenChange, selectMode = false, triggerHints }) {
1667
2607
  const focused = useModalAwareFocus();
1668
2608
  const COLOR = useColors();
1669
2609
  const textareaRef = useRef(null);
1670
2610
  /** Auto-grow: visible content rows the textarea currently occupies (clamped 1..5). */
1671
2611
  const [contentLines, setContentLines] = useState(MIN_CONTENT_LINES);
1672
2612
  /**
2613
+ * Mirror of the textarea buffer + cursor, updated on every `onContentChange`.
2614
+ * Drives `useCompletion` — the textarea stays uncontrolled (we don't push
2615
+ * React state back into it on every keystroke), this is a read-only view
2616
+ * for the engine.
2617
+ */
2618
+ const [bufferState, setBufferState] = useState({
2619
+ text: "",
2620
+ cursor: 0
2621
+ });
2622
+ /**
1673
2623
  * History navigation state. `null` = not navigating (textarea owns its content).
1674
2624
  * Once the user enters history (up at top), we snapshot the draft and cycle.
1675
2625
  */
1676
2626
  const historyRef = useRef(null);
1677
- const syncLines = useCallback(() => {
1678
- const lines = textareaRef.current?.lineCount ?? MIN_CONTENT_LINES;
1679
- setContentLines(Math.max(MIN_CONTENT_LINES, lines));
2627
+ const completion = useCompletion(bufferState, completionProviders ?? EMPTY_PROVIDERS);
2628
+ const popupOpen = completion.active != null && completion.items.length > 0;
2629
+ useEffect(() => {
2630
+ onPopupOpenChange?.(popupOpen);
2631
+ }, [popupOpen, onPopupOpenChange]);
2632
+ const chipStyle = useChipStyle();
2633
+ useChipHighlights(textareaRef, completion.references, chipStyle);
2634
+ /**
2635
+ * Pull the latest buffer state from the OpenTUI textarea ref. Called from
2636
+ * `onContentChange` + `onKeyDown` so cursor moves (without text changes)
2637
+ * also re-evaluate the active trigger.
2638
+ */
2639
+ const syncBuffer = useCallback(() => {
2640
+ const ta = textareaRef.current;
2641
+ if (!ta) return;
2642
+ setBufferState({
2643
+ text: ta.plainText,
2644
+ cursor: ta.cursorOffset
2645
+ });
2646
+ setContentLines(Math.max(MIN_CONTENT_LINES, ta.lineCount));
1680
2647
  }, []);
1681
2648
  const submit = useCallback(() => {
1682
2649
  const value = textareaRef.current?.plainText ?? "";
1683
2650
  if (!value.trim()) return;
1684
- onSubmit(value);
2651
+ onSubmit(value, completion.references);
1685
2652
  textareaRef.current?.clear();
1686
2653
  historyRef.current = null;
2654
+ setBufferState({
2655
+ text: "",
2656
+ cursor: 0
2657
+ });
1687
2658
  setContentLines(MIN_CONTENT_LINES);
1688
- }, [onSubmit]);
2659
+ }, [onSubmit, completion.references]);
2660
+ const commitCompletion = useCallback(() => {
2661
+ const result = completion.commit();
2662
+ if (!result) return false;
2663
+ const ta = textareaRef.current;
2664
+ if (!ta) return false;
2665
+ ta.setText(result.text);
2666
+ ta.cursorOffset = result.cursor;
2667
+ syncBuffer();
2668
+ return true;
2669
+ }, [completion, syncBuffer]);
1689
2670
  const cycleHistory = useCallback((direction) => {
1690
2671
  if (userPrompts.length === 0 || !textareaRef.current) return;
1691
2672
  if (historyRef.current === null) historyRef.current = {
@@ -1703,54 +2684,756 @@ function PromptBlock({ userPrompts, onSubmit }) {
1703
2684
  textareaRef.current.gotoBufferEnd();
1704
2685
  historyRef.current.idx = nextIdx;
1705
2686
  }
1706
- syncLines();
1707
- }, [userPrompts, syncLines]);
2687
+ syncBuffer();
2688
+ }, [userPrompts, syncBuffer]);
1708
2689
  /**
1709
- * Up/Down at the buffer boundary cycles prompt history (fish/zsh pattern).
1710
- * Mid-buffer up/down move the cursor normally — handled by the default
1711
- * `move-up` / `move-down` actions in `TEXTAREA_BINDINGS`.
2690
+ * Key interception. OpenTUI fires `onKeyDown` BEFORE the textarea's
2691
+ * binding table (`Renderable.keypressHandler` invokes the user
2692
+ * listener first, then gates `handleKeyPress` on `defaultPrevented`).
2693
+ * That means a single `event.preventDefault()` here cleanly skips the
2694
+ * textarea's default action — no parallel binding-filter dance, no
2695
+ * stray newline when the popup owns Enter.
2696
+ *
2697
+ * Popup-owned keys (up/down/return/tab/escape): forward to the
2698
+ * completion engine and `preventDefault` so the textarea never sees
2699
+ * them — committing a selection should land the cursor at end-of-
2700
+ * insert + the trailing space from `insertText`, never an extra `\n`.
2701
+ *
2702
+ * History keys: up/down at the top/bottom row of an idle buffer cycle
2703
+ * the prompt history. `preventDefault` after a successful cycle keeps
2704
+ * the cursor at the end of the recalled prompt; non-edge presses fall
2705
+ * through so the textarea moves the cursor as usual.
1712
2706
  */
1713
2707
  const onKeyDown = useCallback((event) => {
2708
+ if (popupOpen) {
2709
+ if (event.ctrl || event.meta) return;
2710
+ switch (event.name) {
2711
+ case "up":
2712
+ completion.selectPrev();
2713
+ event.preventDefault();
2714
+ return;
2715
+ case "down":
2716
+ completion.selectNext();
2717
+ event.preventDefault();
2718
+ return;
2719
+ case "return":
2720
+ if (event.shift) return;
2721
+ commitCompletion();
2722
+ event.preventDefault();
2723
+ return;
2724
+ case "tab":
2725
+ commitCompletion();
2726
+ event.preventDefault();
2727
+ return;
2728
+ case "escape":
2729
+ completion.dismiss();
2730
+ event.preventDefault();
2731
+ return;
2732
+ default:
2733
+ }
2734
+ return;
2735
+ }
1714
2736
  if (event.ctrl || event.shift || event.meta) return;
1715
2737
  if (event.name !== "up" && event.name !== "down") return;
1716
2738
  const buffer = textareaRef.current;
1717
2739
  if (!buffer) return;
1718
2740
  const cursorRow = buffer.logicalCursor.row;
1719
- if (event.name === "up" && cursorRow === 0) cycleHistory(-1);
1720
- else if (event.name === "down" && cursorRow === buffer.lineCount - 1) cycleHistory(1);
1721
- }, [cycleHistory]);
2741
+ if (event.name === "up" && cursorRow === 0) {
2742
+ cycleHistory(-1);
2743
+ event.preventDefault();
2744
+ } else if (event.name === "down" && cursorRow === buffer.lineCount - 1) {
2745
+ cycleHistory(1);
2746
+ event.preventDefault();
2747
+ }
2748
+ }, [
2749
+ popupOpen,
2750
+ completion,
2751
+ commitCompletion,
2752
+ cycleHistory
2753
+ ]);
1722
2754
  const boxHeight = Math.min(MAX_CONTENT_LINES, contentLines) + 2;
1723
- return /* @__PURE__ */ jsx("box", {
2755
+ return /* @__PURE__ */ jsxs("box", {
1724
2756
  style: {
1725
- border: true,
1726
- borderColor: COLOR.borderActive,
1727
- paddingLeft: 1,
1728
- paddingRight: 1,
1729
- height: boxHeight,
1730
- flexDirection: "column"
2757
+ flexDirection: "column",
2758
+ flexShrink: 0
1731
2759
  },
1732
- children: /* @__PURE__ */ jsx("textarea", {
1733
- ref: textareaRef,
1734
- focused,
1735
- keyBindings: TEXTAREA_BINDINGS,
1736
- placeholder: "Ask zidane… (enter = send · shift+enter = newline · ↑↓ at edges = history)",
2760
+ children: [/* @__PURE__ */ jsxs("box", {
1737
2761
  style: {
1738
- flexGrow: 1,
1739
- height: "100%"
2762
+ flexDirection: "column",
2763
+ flexShrink: 0
1740
2764
  },
1741
- onSubmit: submit,
1742
- onContentChange: syncLines,
1743
- onKeyDown
1744
- })
2765
+ children: [/* @__PURE__ */ jsx("box", {
2766
+ style: {
2767
+ border: true,
2768
+ borderColor: selectMode ? COLOR.warn : COLOR.borderActive,
2769
+ paddingLeft: 1,
2770
+ paddingRight: 1,
2771
+ height: boxHeight,
2772
+ flexDirection: "column"
2773
+ },
2774
+ children: /* @__PURE__ */ jsx("textarea", {
2775
+ ref: textareaRef,
2776
+ focused: focused && !selectMode,
2777
+ keyBindings: TEXTAREA_BINDINGS,
2778
+ placeholder: selectMode ? "— turn-select mode — press ⎋ to resume typing —" : "Ask zidane…",
2779
+ syntaxStyle: chipStyle,
2780
+ style: {
2781
+ flexGrow: 1,
2782
+ height: "100%"
2783
+ },
2784
+ onSubmit: submit,
2785
+ onContentChange: syncBuffer,
2786
+ onKeyDown
2787
+ })
2788
+ }), /* @__PURE__ */ jsx(PromptHints, {
2789
+ selectMode,
2790
+ triggerHints
2791
+ })]
2792
+ }), !selectMode && /* @__PURE__ */ jsx("box", {
2793
+ style: {
2794
+ position: "absolute",
2795
+ bottom: "100%",
2796
+ left: 0,
2797
+ right: 0,
2798
+ flexDirection: "column"
2799
+ },
2800
+ children: /* @__PURE__ */ jsx(CompletionPopup, { state: completion })
2801
+ })]
2802
+ });
2803
+ }
2804
+ /** Prompt-box shortcuts in normal mode — order matches reading flow. */
2805
+ const PROMPT_HINTS_NORMAL = [
2806
+ {
2807
+ key: "↵",
2808
+ label: "send"
2809
+ },
2810
+ {
2811
+ key: "shift+↵",
2812
+ label: "newline"
2813
+ },
2814
+ {
2815
+ key: "↑↓",
2816
+ label: "history"
2817
+ },
2818
+ {
2819
+ key: "ctrl+s",
2820
+ label: "messages"
2821
+ }
2822
+ ];
2823
+ /** Prompt-box shortcuts in select-turn mode — only the selection actions are valid. */
2824
+ const PROMPT_HINTS_SELECT = [
2825
+ {
2826
+ key: "↑↓",
2827
+ label: "navigate"
2828
+ },
2829
+ {
2830
+ key: "↵",
2831
+ label: "open"
2832
+ },
2833
+ {
2834
+ key: "esc",
2835
+ label: "exit"
2836
+ }
2837
+ ];
2838
+ /**
2839
+ * Inline shortcut hints for the prompt box. Drawn as an absolutely-
2840
+ * positioned overlay across the box's top border (right-aligned, one
2841
+ * cell from the corner) so it reads like a `<box title>` while keeping
2842
+ * the warn / dim / mute palette of the bottom bar — native `title` is
2843
+ * painted as part of the border with a single color and can't carry
2844
+ * per-segment hue.
2845
+ *
2846
+ * Must be declared AFTER the bordered box in its parent so the
2847
+ * renderer paints it on top, and must be a SIBLING (not a child) of
2848
+ * the box: bordered boxes clip their own absolute children via a
2849
+ * scissor rect that excludes the border row.
2850
+ *
2851
+ * Leading + trailing spaces overwrite the border characters underneath
2852
+ * the title text, mirroring native titles (`── sessions ──`).
2853
+ *
2854
+ * Swaps the hint set based on `selectMode` so the user sees only the
2855
+ * shortcuts that actually do something in the active mode.
2856
+ */
2857
+ function PromptHints({ selectMode, triggerHints }) {
2858
+ const COLOR = useColors();
2859
+ const { width: termWidth } = useTerminalDimensions();
2860
+ const primary = selectMode ? PROMPT_HINTS_SELECT : PROMPT_HINTS_NORMAL;
2861
+ const hints = useMemo(() => {
2862
+ if (selectMode || !triggerHints || triggerHints.length === 0) return primary;
2863
+ const budget = Math.max(0, termWidth - 5);
2864
+ if (hintsLength(primary) + (hintsLength(triggerHints) + 3) > budget) return primary;
2865
+ return [...primary, ...triggerHints];
2866
+ }, [
2867
+ selectMode,
2868
+ primary,
2869
+ triggerHints,
2870
+ termWidth
2871
+ ]);
2872
+ return /* @__PURE__ */ jsxs("text", {
2873
+ style: {
2874
+ position: "absolute",
2875
+ top: 0,
2876
+ right: 1
2877
+ },
2878
+ fg: COLOR.dim,
2879
+ children: [
2880
+ /* @__PURE__ */ jsx("span", {
2881
+ fg: COLOR.mute,
2882
+ children: " "
2883
+ }),
2884
+ renderHintSpans(hints, COLOR),
2885
+ /* @__PURE__ */ jsx("span", {
2886
+ fg: COLOR.mute,
2887
+ children: " "
2888
+ })
2889
+ ]
1745
2890
  });
1746
2891
  }
1747
2892
  //#endregion
1748
- //#region src/tui/settings-modal.tsx
1749
- function SettingsModal({ actions } = {}) {
1750
- const { settings, toggle, setSetting } = useSettings();
1751
- const [cursor, setCursorRaw] = useState(0);
2893
+ //#region src/tui/clipboard.ts
2894
+ /**
2895
+ * Write text to the system clipboard via the terminal's OSC 52 escape
2896
+ * sequence — `\x1b]52;c;<base64>\x07`. Modern terminals (iTerm2, kitty,
2897
+ * alacritty, wezterm, recent xterm, recent macOS Terminal) honor this;
2898
+ * tmux/screen pass it through with the right config. Older or stripped
2899
+ * terminals silently drop it — we have no way to detect that in-band.
2900
+ *
2901
+ * Why OSC 52 over `pbcopy` / `xclip` / `clip.exe`: zero dependencies, no
2902
+ * shell-out, works equally over SSH (which is where copy/paste is most
2903
+ * painful — the user's clipboard, not the remote box's clipboard).
2904
+ *
2905
+ * Returns `true` on a successful write to stdout, `false` otherwise.
2906
+ * Callers should reflect the result in UX (toast / inline message) but
2907
+ * not treat `false` as a hard failure — it just means "stdout is not a
2908
+ * TTY" or the terminal swallowed the sequence.
2909
+ */
2910
+ function writeToClipboard(text) {
2911
+ if (typeof process === "undefined" || !process.stdout?.write) return false;
2912
+ if (!process.stdout.isTTY) return false;
2913
+ try {
2914
+ const encoded = Buffer.from(text, "utf8").toString("base64");
2915
+ process.stdout.write(`\x1B]52;c;${encoded}\x07`);
2916
+ return true;
2917
+ } catch {
2918
+ return false;
2919
+ }
2920
+ }
2921
+ //#endregion
2922
+ //#region src/tui/session-details-modal.tsx
2923
+ function SessionDetailsModal({ session, title, isCurrent, actions }) {
1752
2924
  const COLOR = useColors();
1753
- const items = useMemo(() => {
2925
+ const modal = useModal();
2926
+ const { width: termWidth } = useTerminalDimensions();
2927
+ const cwdMaxWidth = Math.max(24, Math.floor(termWidth * .6) - 12);
2928
+ const [displayTitle, setDisplayTitle] = useState(title ?? (typeof session.metadata?.title === "string" ? session.metadata.title : void 0) ?? "untitled");
2929
+ const usage = aggregateUsage(session.runs);
2930
+ const turnCount = session.turns.length;
2931
+ const userMessageCount = session.turns.filter((t) => t.role === "user").length;
2932
+ const hasGenerate = actions.onGenerateTitle != null && turnCount > 0;
2933
+ const [pending, setPending] = useState(null);
2934
+ const [copyStatus, setCopyStatus] = useState("idle");
2935
+ const [titleStatus, setTitleStatus] = useState("idle");
2936
+ const [titleError, setTitleError] = useState(null);
2937
+ const [exportStatus, setExportStatus] = useState("idle");
2938
+ const [exportResult, setExportResult] = useState(null);
2939
+ const [exportError, setExportError] = useState(null);
2940
+ const hasExport = actions.onExport != null && turnCount > 0;
2941
+ const generationAbortRef = useRef(null);
2942
+ const mountedRef = useRef(true);
2943
+ useEffect(() => () => {
2944
+ mountedRef.current = false;
2945
+ generationAbortRef.current?.abort();
2946
+ generationAbortRef.current = null;
2947
+ }, []);
2948
+ const resetPeerFeedback = (keep = "none") => {
2949
+ if (keep !== "copy") setCopyStatus("idle");
2950
+ if (keep !== "export") {
2951
+ setExportStatus("idle");
2952
+ setExportResult(null);
2953
+ setExportError(null);
2954
+ }
2955
+ if (keep !== "title") {
2956
+ setTitleStatus((prev) => prev === "loading" ? prev : "idle");
2957
+ setTitleError(null);
2958
+ }
2959
+ };
2960
+ const commitDelete = () => {
2961
+ modal.close();
2962
+ actions.onDelete(session.id);
2963
+ };
2964
+ const handleCopy = () => {
2965
+ resetPeerFeedback("copy");
2966
+ setCopyStatus(writeToClipboard(session.id) ? "copied" : "failed");
2967
+ };
2968
+ const handleExport = async (format) => {
2969
+ if (!actions.onExport || exportStatus === "writing") return;
2970
+ resetPeerFeedback("export");
2971
+ setExportStatus("writing");
2972
+ try {
2973
+ const result = await actions.onExport(session.id, format);
2974
+ if (!mountedRef.current) return;
2975
+ setExportResult(result);
2976
+ setExportStatus("success");
2977
+ } catch (err) {
2978
+ if (!mountedRef.current) return;
2979
+ setExportError(err instanceof Error ? err.message : String(err));
2980
+ setExportStatus("failed");
2981
+ }
2982
+ };
2983
+ const handleGenerate = async () => {
2984
+ if (!actions.onGenerateTitle || titleStatus === "loading") return;
2985
+ resetPeerFeedback("title");
2986
+ setTitleStatus("loading");
2987
+ const ac = new AbortController();
2988
+ generationAbortRef.current = ac;
2989
+ try {
2990
+ const next = await actions.onGenerateTitle(session.id, ac.signal);
2991
+ if (!mountedRef.current || ac.signal.aborted) return;
2992
+ setDisplayTitle(next);
2993
+ setTitleStatus("idle");
2994
+ } catch (err) {
2995
+ if (!mountedRef.current || ac.signal.aborted) return;
2996
+ setTitleError(err instanceof Error ? err.message : String(err));
2997
+ setTitleStatus("failed");
2998
+ } finally {
2999
+ if (generationAbortRef.current === ac) generationAbortRef.current = null;
3000
+ }
3001
+ };
3002
+ useKeyboard((key) => {
3003
+ if (titleStatus === "loading" || exportStatus === "writing") return;
3004
+ if (key.name === "escape" && pending) {
3005
+ setPending(null);
3006
+ return;
3007
+ }
3008
+ if (key.name === "d") {
3009
+ if (pending === "delete") commitDelete();
3010
+ else setPending("delete");
3011
+ return;
3012
+ }
3013
+ if (key.name === "c") {
3014
+ setPending(null);
3015
+ handleCopy();
3016
+ return;
3017
+ }
3018
+ if (key.name === "g" && hasGenerate) {
3019
+ setPending(null);
3020
+ handleGenerate();
3021
+ return;
3022
+ }
3023
+ if (key.name === "e" && hasExport) {
3024
+ setPending(null);
3025
+ handleExport("markdown");
3026
+ return;
3027
+ }
3028
+ if (key.name === "j" && hasExport) {
3029
+ setPending(null);
3030
+ handleExport("json");
3031
+ return;
3032
+ }
3033
+ if (pending) setPending(null);
3034
+ });
3035
+ return /* @__PURE__ */ jsxs(Modal, {
3036
+ title: `session · ${displayTitle}`,
3037
+ bottomTitle: `#${shortId(session.id)} · ${turnCount} turn${turnCount === 1 ? "" : "s"}`,
3038
+ disableEscape: titleStatus === "loading" || exportStatus === "writing" || pending !== null,
3039
+ children: [
3040
+ /* @__PURE__ */ jsxs("text", {
3041
+ fg: COLOR.dim,
3042
+ children: [
3043
+ /* @__PURE__ */ jsx("span", {
3044
+ fg: COLOR.mute,
3045
+ children: "id "
3046
+ }),
3047
+ /* @__PURE__ */ jsx("span", {
3048
+ fg: COLOR.model,
3049
+ children: session.id
3050
+ }),
3051
+ isCurrent && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
3052
+ fg: COLOR.mute,
3053
+ children: " · "
3054
+ }), /* @__PURE__ */ jsx("span", {
3055
+ fg: COLOR.accent,
3056
+ children: "active"
3057
+ })] })
3058
+ ]
3059
+ }),
3060
+ /* @__PURE__ */ jsxs("text", {
3061
+ fg: COLOR.dim,
3062
+ children: [
3063
+ /* @__PURE__ */ jsx("span", {
3064
+ fg: COLOR.mute,
3065
+ children: "created "
3066
+ }),
3067
+ /* @__PURE__ */ jsx("span", {
3068
+ fg: COLOR.dim,
3069
+ children: ageString(session.createdAt)
3070
+ }),
3071
+ /* @__PURE__ */ jsx("span", {
3072
+ fg: COLOR.mute,
3073
+ children: " · "
3074
+ }),
3075
+ /* @__PURE__ */ jsx("span", {
3076
+ fg: COLOR.mute,
3077
+ children: "updated "
3078
+ }),
3079
+ /* @__PURE__ */ jsx("span", {
3080
+ fg: COLOR.dim,
3081
+ children: ageString(session.updatedAt)
3082
+ })
3083
+ ]
3084
+ }),
3085
+ /* @__PURE__ */ jsxs("text", {
3086
+ fg: COLOR.dim,
3087
+ wrapMode: "none",
3088
+ children: [/* @__PURE__ */ jsx("span", {
3089
+ fg: COLOR.mute,
3090
+ children: "cwd "
3091
+ }), session.projectRoot ? /* @__PURE__ */ jsx("span", {
3092
+ fg: COLOR.dim,
3093
+ children: compactPath(session.projectRoot, cwdMaxWidth)
3094
+ }) : /* @__PURE__ */ jsx("span", {
3095
+ fg: COLOR.mute,
3096
+ children: "untagged"
3097
+ })]
3098
+ }),
3099
+ /* @__PURE__ */ jsxs("text", {
3100
+ fg: COLOR.dim,
3101
+ children: [
3102
+ /* @__PURE__ */ jsx("span", {
3103
+ fg: COLOR.mute,
3104
+ children: "turns "
3105
+ }),
3106
+ /* @__PURE__ */ jsx("span", {
3107
+ fg: COLOR.warn,
3108
+ children: turnCount
3109
+ }),
3110
+ /* @__PURE__ */ jsx("span", {
3111
+ fg: COLOR.mute,
3112
+ children: " · "
3113
+ }),
3114
+ /* @__PURE__ */ jsx("span", {
3115
+ fg: COLOR.mute,
3116
+ children: "user "
3117
+ }),
3118
+ /* @__PURE__ */ jsx("span", {
3119
+ fg: COLOR.warn,
3120
+ children: userMessageCount
3121
+ }),
3122
+ /* @__PURE__ */ jsx("span", {
3123
+ fg: COLOR.mute,
3124
+ children: " · "
3125
+ }),
3126
+ /* @__PURE__ */ jsx("span", {
3127
+ fg: COLOR.mute,
3128
+ children: "runs "
3129
+ }),
3130
+ /* @__PURE__ */ jsx("span", {
3131
+ fg: COLOR.dim,
3132
+ children: session.runs.length
3133
+ }),
3134
+ /* @__PURE__ */ jsx("span", {
3135
+ fg: COLOR.mute,
3136
+ children: " · "
3137
+ }),
3138
+ /* @__PURE__ */ jsx("span", {
3139
+ fg: COLOR.mute,
3140
+ children: "status "
3141
+ }),
3142
+ /* @__PURE__ */ jsx("span", {
3143
+ fg: statusColor(session.status, COLOR),
3144
+ children: session.status
3145
+ })
3146
+ ]
3147
+ }),
3148
+ usage.total > 0 && /* @__PURE__ */ jsxs("text", {
3149
+ fg: COLOR.dim,
3150
+ children: [
3151
+ /* @__PURE__ */ jsx("span", {
3152
+ fg: COLOR.mute,
3153
+ children: "tokens "
3154
+ }),
3155
+ /* @__PURE__ */ jsx("span", {
3156
+ fg: COLOR.model,
3157
+ children: fmtTokens(usage.total)
3158
+ }),
3159
+ /* @__PURE__ */ jsx("span", {
3160
+ fg: COLOR.mute,
3161
+ children: " · in "
3162
+ }),
3163
+ /* @__PURE__ */ jsx("span", {
3164
+ fg: COLOR.dim,
3165
+ children: fmtTokens(usage.input)
3166
+ }),
3167
+ /* @__PURE__ */ jsx("span", {
3168
+ fg: COLOR.mute,
3169
+ children: " · out "
3170
+ }),
3171
+ /* @__PURE__ */ jsx("span", {
3172
+ fg: COLOR.dim,
3173
+ children: fmtTokens(usage.output)
3174
+ }),
3175
+ usage.cacheRead > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
3176
+ fg: COLOR.mute,
3177
+ children: " · cached "
3178
+ }), /* @__PURE__ */ jsx("span", {
3179
+ fg: COLOR.dim,
3180
+ children: fmtTokens(usage.cacheRead)
3181
+ })] }),
3182
+ usage.cost > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
3183
+ fg: COLOR.mute,
3184
+ children: " · cost "
3185
+ }), /* @__PURE__ */ jsx("span", {
3186
+ fg: COLOR.dim,
3187
+ children: `$${usage.cost.toFixed(usage.cost < .01 ? 4 : 2)}`
3188
+ })] })
3189
+ ]
3190
+ }),
3191
+ /* @__PURE__ */ jsx(ActionRow$2, {
3192
+ pending,
3193
+ copyStatus,
3194
+ titleStatus,
3195
+ titleError,
3196
+ hasGenerate,
3197
+ exportStatus,
3198
+ exportResult,
3199
+ exportError,
3200
+ hasExport
3201
+ })
3202
+ ]
3203
+ });
3204
+ }
3205
+ /**
3206
+ * Footer action row — mirrors the turn-details modal's pattern. Swaps
3207
+ * between the default hint row, a delete-confirm prompt, copy
3208
+ * success/failure feedback, an in-flight title-generation spinner, and
3209
+ * a title-generation error message. The geometry stays a single row
3210
+ * across all states so the modal body never shifts.
3211
+ */
3212
+ function ActionRow$2({ pending, copyStatus, titleStatus, titleError, hasGenerate, exportStatus, exportResult, exportError, hasExport }) {
3213
+ const COLOR = useColors();
3214
+ if (titleStatus === "loading") return /* @__PURE__ */ jsx(Spinner, { label: "generating title — please wait" });
3215
+ if (exportStatus === "writing") return /* @__PURE__ */ jsx(Spinner, { label: "exporting session — please wait" });
3216
+ if (titleStatus === "failed") return /* @__PURE__ */ jsxs("text", {
3217
+ fg: COLOR.dim,
3218
+ children: [
3219
+ /* @__PURE__ */ jsx("span", {
3220
+ fg: COLOR.error,
3221
+ children: "title generation failed"
3222
+ }),
3223
+ titleError ? /* @__PURE__ */ jsx("span", {
3224
+ fg: COLOR.mute,
3225
+ children: ` — ${titleError}`
3226
+ }) : null,
3227
+ " · ",
3228
+ /* @__PURE__ */ jsx("span", {
3229
+ fg: COLOR.warn,
3230
+ children: "g"
3231
+ }),
3232
+ " retry · ",
3233
+ /* @__PURE__ */ jsx("span", {
3234
+ fg: COLOR.warn,
3235
+ children: "esc"
3236
+ }),
3237
+ " close"
3238
+ ]
3239
+ });
3240
+ if (exportStatus === "failed") return /* @__PURE__ */ jsxs("text", {
3241
+ fg: COLOR.dim,
3242
+ children: [
3243
+ /* @__PURE__ */ jsx("span", {
3244
+ fg: COLOR.error,
3245
+ children: "export failed"
3246
+ }),
3247
+ exportError ? /* @__PURE__ */ jsx("span", {
3248
+ fg: COLOR.mute,
3249
+ children: ` — ${exportError}`
3250
+ }) : null,
3251
+ " · ",
3252
+ /* @__PURE__ */ jsx("span", {
3253
+ fg: COLOR.warn,
3254
+ children: "e"
3255
+ }),
3256
+ " / ",
3257
+ /* @__PURE__ */ jsx("span", {
3258
+ fg: COLOR.warn,
3259
+ children: "j"
3260
+ }),
3261
+ " retry · ",
3262
+ /* @__PURE__ */ jsx("span", {
3263
+ fg: COLOR.warn,
3264
+ children: "esc"
3265
+ }),
3266
+ " close"
3267
+ ]
3268
+ });
3269
+ if (pending === "delete") return /* @__PURE__ */ jsxs("text", {
3270
+ fg: COLOR.dim,
3271
+ children: [
3272
+ /* @__PURE__ */ jsx("span", {
3273
+ fg: COLOR.error,
3274
+ children: "delete this session?"
3275
+ }),
3276
+ " press ",
3277
+ /* @__PURE__ */ jsx("span", {
3278
+ fg: COLOR.error,
3279
+ children: "d"
3280
+ }),
3281
+ " again to confirm · ",
3282
+ /* @__PURE__ */ jsx("span", {
3283
+ fg: COLOR.warn,
3284
+ children: "esc"
3285
+ }),
3286
+ " cancel"
3287
+ ]
3288
+ });
3289
+ if (exportStatus === "success" && exportResult) return /* @__PURE__ */ jsxs("text", {
3290
+ fg: COLOR.dim,
3291
+ children: [
3292
+ /* @__PURE__ */ jsx("span", {
3293
+ fg: COLOR.accent,
3294
+ children: `✓ wrote ${exportResult.format === "json" ? "JSON" : "Markdown"}`
3295
+ }),
3296
+ /* @__PURE__ */ jsx("span", {
3297
+ fg: COLOR.mute,
3298
+ children: " → "
3299
+ }),
3300
+ /* @__PURE__ */ jsx("span", {
3301
+ fg: COLOR.model,
3302
+ children: compactPath(exportResult.filepath)
3303
+ }),
3304
+ " · ",
3305
+ /* @__PURE__ */ jsx("span", {
3306
+ fg: COLOR.warn,
3307
+ children: "esc"
3308
+ }),
3309
+ " close"
3310
+ ]
3311
+ });
3312
+ if (copyStatus === "copied") return /* @__PURE__ */ jsxs("text", {
3313
+ fg: COLOR.dim,
3314
+ children: [
3315
+ /* @__PURE__ */ jsx("span", {
3316
+ fg: COLOR.accent,
3317
+ children: "✓ session id copied"
3318
+ }),
3319
+ " · ",
3320
+ /* @__PURE__ */ jsx("span", {
3321
+ fg: COLOR.warn,
3322
+ children: "d"
3323
+ }),
3324
+ " delete · ",
3325
+ /* @__PURE__ */ jsx("span", {
3326
+ fg: COLOR.warn,
3327
+ children: "esc"
3328
+ }),
3329
+ " close"
3330
+ ]
3331
+ });
3332
+ if (copyStatus === "failed") return /* @__PURE__ */ jsxs("text", {
3333
+ fg: COLOR.dim,
3334
+ children: [
3335
+ /* @__PURE__ */ jsx("span", {
3336
+ fg: COLOR.error,
3337
+ children: "copy failed (terminal may not support OSC 52)"
3338
+ }),
3339
+ " · ",
3340
+ /* @__PURE__ */ jsx("span", {
3341
+ fg: COLOR.warn,
3342
+ children: "esc"
3343
+ }),
3344
+ " close"
3345
+ ]
3346
+ });
3347
+ return /* @__PURE__ */ jsxs("text", {
3348
+ fg: COLOR.dim,
3349
+ children: [
3350
+ hasGenerate && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
3351
+ fg: COLOR.warn,
3352
+ children: "g"
3353
+ }), " generate title · "] }),
3354
+ hasExport && /* @__PURE__ */ jsxs(Fragment, { children: [
3355
+ /* @__PURE__ */ jsx("span", {
3356
+ fg: COLOR.warn,
3357
+ children: "e"
3358
+ }),
3359
+ "/",
3360
+ /* @__PURE__ */ jsx("span", {
3361
+ fg: COLOR.warn,
3362
+ children: "j"
3363
+ }),
3364
+ " export md/json · "
3365
+ ] }),
3366
+ /* @__PURE__ */ jsx("span", {
3367
+ fg: COLOR.warn,
3368
+ children: "d"
3369
+ }),
3370
+ " delete · ",
3371
+ /* @__PURE__ */ jsx("span", {
3372
+ fg: COLOR.warn,
3373
+ children: "c"
3374
+ }),
3375
+ " copy id · ",
3376
+ /* @__PURE__ */ jsx("span", {
3377
+ fg: COLOR.warn,
3378
+ children: "esc"
3379
+ }),
3380
+ " close"
3381
+ ]
3382
+ });
3383
+ }
3384
+ /**
3385
+ * Sum token + cost figures across every {@link SessionRun}. Preference
3386
+ * order for a run's tally is `totalUsage` → per-`turnUsage[]` sum.
3387
+ * `turnUsage` exists on every completed run; `totalUsage` is provider-
3388
+ * specific (set by some pi-ai adapters when the API surfaces it).
3389
+ *
3390
+ * Returns an aggregate of zeroes when no usage data is available — the
3391
+ * caller decides whether to render the tokens row at all.
3392
+ */
3393
+ function aggregateUsage(runs) {
3394
+ const acc = {
3395
+ input: 0,
3396
+ output: 0,
3397
+ cacheRead: 0,
3398
+ cost: 0
3399
+ };
3400
+ for (const run of runs) {
3401
+ if (run.totalUsage) {
3402
+ acc.input += run.totalUsage.input ?? 0;
3403
+ acc.output += run.totalUsage.output ?? 0;
3404
+ acc.cacheRead += run.totalUsage.cacheRead ?? 0;
3405
+ } else if (run.turnUsage) for (const u of run.turnUsage) {
3406
+ acc.input += u.input ?? 0;
3407
+ acc.output += u.output ?? 0;
3408
+ acc.cacheRead += u.cacheRead ?? 0;
3409
+ }
3410
+ if (run.cost) acc.cost += run.cost;
3411
+ }
3412
+ return {
3413
+ ...acc,
3414
+ total: acc.input + acc.output
3415
+ };
3416
+ }
3417
+ /**
3418
+ * Color a `SessionData.status` for the metadata row — `idle` reads
3419
+ * normal, `running` warm (something's in flight), `error` red. Falls
3420
+ * back to dim for any future-added status the palette doesn't know.
3421
+ */
3422
+ function statusColor(status, COLOR) {
3423
+ switch (status) {
3424
+ case "completed": return COLOR.accent;
3425
+ case "running": return COLOR.warn;
3426
+ case "error": return COLOR.error;
3427
+ default: return COLOR.dim;
3428
+ }
3429
+ }
3430
+ //#endregion
3431
+ //#region src/tui/settings-modal.tsx
3432
+ function SettingsModal({ actions } = {}) {
3433
+ const { settings, toggle, setSetting } = useSettings();
3434
+ const [cursor, setCursorRaw] = useState(0);
3435
+ const COLOR = useColors();
3436
+ const items = useMemo(() => {
1754
3437
  const toggleItems = SETTINGS_TOGGLES.map((t) => ({
1755
3438
  kind: "toggle",
1756
3439
  ...t
@@ -1760,6 +3443,20 @@ function SettingsModal({ actions } = {}) {
1760
3443
  ...c
1761
3444
  }));
1762
3445
  const actionItems = [];
3446
+ if (actions?.onOpenSkills) actionItems.push({
3447
+ kind: "action",
3448
+ id: "skills",
3449
+ label: "Skills",
3450
+ description: "discover + toggle slash-command skills",
3451
+ onPick: actions.onOpenSkills
3452
+ });
3453
+ if (actions?.onOpenMcps) actionItems.push({
3454
+ kind: "action",
3455
+ id: "mcps",
3456
+ label: "MCP servers",
3457
+ description: "enable / disable discovered servers",
3458
+ onPick: actions.onOpenMcps
3459
+ });
1763
3460
  if (actions?.onReauth) actionItems.push({
1764
3461
  kind: "action",
1765
3462
  id: "reauth",
@@ -1818,7 +3515,7 @@ function SettingsModal({ actions } = {}) {
1818
3515
  cyclable: item.options.length > 1,
1819
3516
  focused: i === safeCursor
1820
3517
  }),
1821
- item.kind === "action" && /* @__PURE__ */ jsx(ActionRow, {
3518
+ item.kind === "action" && /* @__PURE__ */ jsx(ActionRow$1, {
1822
3519
  label: item.label,
1823
3520
  description: item.description,
1824
3521
  focused: i === safeCursor
@@ -1854,102 +3551,421 @@ function SettingsModal({ actions } = {}) {
1854
3551
  * automatically: on wide screens everything sits on one line; on narrow ones
1855
3552
  * the trailing description wraps under the label without breaking the row.
1856
3553
  */
1857
- function ToggleRow({ label, description, enabled, focused }) {
3554
+ function ToggleRow({ label, description, enabled, focused }) {
3555
+ const COLOR = useColors();
3556
+ return /* @__PURE__ */ jsxs("text", {
3557
+ fg: focused ? COLOR.brand : COLOR.dim,
3558
+ children: [
3559
+ /* @__PURE__ */ jsx("span", {
3560
+ fg: focused ? COLOR.brand : COLOR.mute,
3561
+ children: focused ? "▶ " : " "
3562
+ }),
3563
+ /* @__PURE__ */ jsx("span", {
3564
+ fg: enabled ? COLOR.accent : COLOR.mute,
3565
+ children: enabled ? "[✓] " : "[ ] "
3566
+ }),
3567
+ /* @__PURE__ */ jsx("span", {
3568
+ fg: focused ? COLOR.brand : COLOR.dim,
3569
+ children: label
3570
+ }),
3571
+ /* @__PURE__ */ jsx("span", {
3572
+ fg: COLOR.mute,
3573
+ children: ` ${description}`
3574
+ })
3575
+ ]
3576
+ });
3577
+ }
3578
+ /**
3579
+ * Choice row — `▶` marker · label · `:` · current value · description.
3580
+ *
3581
+ * Cycles through `options` on enter/space. When only one option is
3582
+ * available (`cyclable=false`) the row still renders with the current
3583
+ * value but the enter handler is a no-op — we surface this via the absence
3584
+ * of the trailing `›` affordance so it visually reads as informational.
3585
+ */
3586
+ function ChoiceRow({ label, description, value, cyclable, focused }) {
3587
+ const COLOR = useColors();
3588
+ return /* @__PURE__ */ jsxs("text", {
3589
+ fg: focused ? COLOR.brand : COLOR.dim,
3590
+ children: [
3591
+ /* @__PURE__ */ jsx("span", {
3592
+ fg: focused ? COLOR.brand : COLOR.mute,
3593
+ children: focused ? "▶ " : " "
3594
+ }),
3595
+ /* @__PURE__ */ jsx("span", {
3596
+ fg: focused ? COLOR.brand : COLOR.dim,
3597
+ children: label
3598
+ }),
3599
+ /* @__PURE__ */ jsx("span", {
3600
+ fg: COLOR.mute,
3601
+ children: ": "
3602
+ }),
3603
+ /* @__PURE__ */ jsx("span", {
3604
+ fg: focused ? COLOR.brand : COLOR.accent,
3605
+ children: value
3606
+ }),
3607
+ /* @__PURE__ */ jsx("span", {
3608
+ fg: COLOR.mute,
3609
+ children: ` ${description}`
3610
+ }),
3611
+ focused && cyclable && /* @__PURE__ */ jsx("span", {
3612
+ fg: COLOR.brand,
3613
+ children: " ↻"
3614
+ })
3615
+ ]
3616
+ });
3617
+ }
3618
+ /**
3619
+ * Action row — cursor marker · label · description · (focus-only) trailing arrow.
3620
+ *
3621
+ * The label sits in the same column as a toggle row's `[✓]` checkbox (right
3622
+ * after the 2-col cursor slot). The trailing `›` only renders when focused
3623
+ * so it reads as a "this row runs" affordance, not a static decoration on
3624
+ * every action.
3625
+ */
3626
+ function ActionRow$1({ label, description, focused }) {
3627
+ const COLOR = useColors();
3628
+ return /* @__PURE__ */ jsxs("text", {
3629
+ fg: focused ? COLOR.brand : COLOR.dim,
3630
+ children: [
3631
+ /* @__PURE__ */ jsx("span", {
3632
+ fg: focused ? COLOR.brand : COLOR.mute,
3633
+ children: focused ? "▶ " : " "
3634
+ }),
3635
+ /* @__PURE__ */ jsx("span", {
3636
+ fg: focused ? COLOR.brand : COLOR.accent,
3637
+ children: label
3638
+ }),
3639
+ /* @__PURE__ */ jsx("span", {
3640
+ fg: COLOR.mute,
3641
+ children: ` ${description}`
3642
+ }),
3643
+ focused && /* @__PURE__ */ jsx("span", {
3644
+ fg: COLOR.brand,
3645
+ children: " ›"
3646
+ })
3647
+ ]
3648
+ });
3649
+ }
3650
+ //#endregion
3651
+ //#region src/tui/skills-settings.tsx
3652
+ /**
3653
+ * List + toggle modal for discovered skills. State machine + keyboard +
3654
+ * row geometry live in `<ToggleListModal>`; this file just supplies the
3655
+ * skill-specific column (description) and the empty-state hint.
3656
+ */
3657
+ function SkillsSettingsModal({ catalog }) {
3658
+ const COLOR = useColors();
3659
+ return /* @__PURE__ */ jsx(ToggleListModal, {
3660
+ catalog,
3661
+ keyOf: (s) => s.name,
3662
+ settingKey: "enabledSkills",
3663
+ title: "skills",
3664
+ renderDetail: (skill) => skill.description,
3665
+ emptyState: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("text", {
3666
+ fg: COLOR.dim,
3667
+ children: "No skills discovered."
3668
+ }), /* @__PURE__ */ jsxs("text", {
3669
+ fg: COLOR.mute,
3670
+ children: [
3671
+ "Drop a",
3672
+ /* @__PURE__ */ jsx("span", {
3673
+ fg: COLOR.model,
3674
+ children: " SKILL.md "
3675
+ }),
3676
+ "into",
3677
+ /* @__PURE__ */ jsx("span", {
3678
+ fg: COLOR.model,
3679
+ children: " .zidane/skills/<name>/ "
3680
+ }),
3681
+ "or",
3682
+ /* @__PURE__ */ jsx("span", {
3683
+ fg: COLOR.model,
3684
+ children: " .agents/skills/<name>/ "
3685
+ }),
3686
+ "(project or",
3687
+ /* @__PURE__ */ jsx("span", {
3688
+ fg: COLOR.model,
3689
+ children: " ~/"
3690
+ }),
3691
+ ")."
3692
+ ]
3693
+ })] })
3694
+ });
3695
+ }
3696
+ //#endregion
3697
+ //#region src/tui/turn-details-modal.tsx
3698
+ /** Max chars surfaced in the scrollable preview pane. Long enough that almost everything fits without truncation. */
3699
+ const PREVIEW_CHAR_MAX = 8e3;
3700
+ /**
3701
+ * Visible rows allocated to the modal. Smaller terminals shrink down via
3702
+ * the Modal's own clamp; this is the cap on wide terminals so the modal
3703
+ * keeps a comfortable shape rather than stretching to the full height.
3704
+ */
3705
+ const MAX_MODAL_HEIGHT = 28;
3706
+ function TurnDetailsModal({ turn, index, total, actions }) {
3707
+ const COLOR = useColors();
3708
+ const modal = useModal();
3709
+ const fullText = turnAsText(turn);
3710
+ const preview = fullText.length > PREVIEW_CHAR_MAX ? `${fullText.slice(0, PREVIEW_CHAR_MAX)}\n\n…(${fullText.length - PREVIEW_CHAR_MAX} more chars)` : fullText;
3711
+ const summary = blockSummary(turn);
3712
+ const bottomTitle = `${index - 1} before · ${total - index} after`;
3713
+ const [pending, setPending] = useState(null);
3714
+ const [copyStatus, setCopyStatus] = useState("idle");
3715
+ const commitFork = () => {
3716
+ modal.close();
3717
+ actions.onFork(turn.id);
3718
+ };
3719
+ const commitDelete = () => {
3720
+ modal.close();
3721
+ actions.onDelete(turn.id);
3722
+ };
3723
+ const handleCopy = () => {
3724
+ if (!fullText) {
3725
+ setCopyStatus("failed");
3726
+ return;
3727
+ }
3728
+ setCopyStatus(writeToClipboard(fullText) ? "copied" : "failed");
3729
+ };
3730
+ useKeyboard((key) => {
3731
+ if (key.name === "escape" && pending) {
3732
+ setPending(null);
3733
+ return;
3734
+ }
3735
+ if (key.name === "f") {
3736
+ setCopyStatus("idle");
3737
+ if (pending === "fork") commitFork();
3738
+ else setPending("fork");
3739
+ return;
3740
+ }
3741
+ if (key.name === "d") {
3742
+ setCopyStatus("idle");
3743
+ if (pending === "delete") commitDelete();
3744
+ else setPending("delete");
3745
+ return;
3746
+ }
3747
+ if (key.name === "c") {
3748
+ setPending(null);
3749
+ handleCopy();
3750
+ return;
3751
+ }
3752
+ if (pending) setPending(null);
3753
+ });
3754
+ return /* @__PURE__ */ jsxs(Modal, {
3755
+ title: `turn ${index} / ${total} · ${turn.role}`,
3756
+ bottomTitle,
3757
+ maxHeight: MAX_MODAL_HEIGHT,
3758
+ disableEscape: pending !== null,
3759
+ children: [
3760
+ /* @__PURE__ */ jsxs("text", {
3761
+ fg: COLOR.dim,
3762
+ children: [
3763
+ /* @__PURE__ */ jsx("span", {
3764
+ fg: COLOR.mute,
3765
+ children: "id "
3766
+ }),
3767
+ /* @__PURE__ */ jsx("span", {
3768
+ fg: COLOR.model,
3769
+ children: shortId(turn.id)
3770
+ }),
3771
+ /* @__PURE__ */ jsx("span", {
3772
+ fg: COLOR.mute,
3773
+ children: " · "
3774
+ }),
3775
+ /* @__PURE__ */ jsx("span", {
3776
+ fg: COLOR.mute,
3777
+ children: "created "
3778
+ }),
3779
+ /* @__PURE__ */ jsx("span", {
3780
+ fg: COLOR.dim,
3781
+ children: ageString(turn.createdAt)
3782
+ }),
3783
+ turn.runId && /* @__PURE__ */ jsxs(Fragment, { children: [
3784
+ /* @__PURE__ */ jsx("span", {
3785
+ fg: COLOR.mute,
3786
+ children: " · "
3787
+ }),
3788
+ /* @__PURE__ */ jsx("span", {
3789
+ fg: COLOR.mute,
3790
+ children: "run "
3791
+ }),
3792
+ /* @__PURE__ */ jsx("span", {
3793
+ fg: COLOR.dim,
3794
+ children: turn.runId
3795
+ })
3796
+ ] })
3797
+ ]
3798
+ }),
3799
+ /* @__PURE__ */ jsxs("text", {
3800
+ fg: COLOR.dim,
3801
+ children: [/* @__PURE__ */ jsx("span", {
3802
+ fg: COLOR.mute,
3803
+ children: "blocks "
3804
+ }), /* @__PURE__ */ jsx("span", {
3805
+ fg: COLOR.dim,
3806
+ children: summary
3807
+ })]
3808
+ }),
3809
+ /* @__PURE__ */ jsx("box", {
3810
+ title: " preview ",
3811
+ style: {
3812
+ border: true,
3813
+ borderColor: COLOR.mute,
3814
+ paddingLeft: 1,
3815
+ paddingRight: 1,
3816
+ flexDirection: "column",
3817
+ flexGrow: 1,
3818
+ flexShrink: 1,
3819
+ minHeight: 5
3820
+ },
3821
+ children: preview ? /* @__PURE__ */ jsx("scrollbox", {
3822
+ focusable: false,
3823
+ style: { flexGrow: 1 },
3824
+ stickyScroll: false,
3825
+ children: /* @__PURE__ */ jsx("text", {
3826
+ fg: COLOR.dim,
3827
+ children: preview
3828
+ })
3829
+ }) : /* @__PURE__ */ jsx("text", {
3830
+ fg: COLOR.mute,
3831
+ children: "— no text content —"
3832
+ })
3833
+ }),
3834
+ /* @__PURE__ */ jsx(ActionRow, {
3835
+ pending,
3836
+ copyStatus,
3837
+ canCopy: fullText.length > 0
3838
+ })
3839
+ ]
3840
+ });
3841
+ }
3842
+ /**
3843
+ * Footer row showing the action shortcuts. When a destructive action
3844
+ * (fork / delete) is pending confirmation, the row swaps to a clear
3845
+ * "press <key> again to confirm" prompt so the user can't trigger it
3846
+ * by accident. The copy result rides the same row when present — same
3847
+ * geometry, no layout shift.
3848
+ */
3849
+ function ActionRow({ pending, copyStatus, canCopy }) {
1858
3850
  const COLOR = useColors();
1859
- return /* @__PURE__ */ jsxs("text", {
1860
- fg: focused ? COLOR.brand : COLOR.dim,
3851
+ if (pending === "fork") return /* @__PURE__ */ jsxs("text", {
3852
+ fg: COLOR.dim,
1861
3853
  children: [
1862
3854
  /* @__PURE__ */ jsx("span", {
1863
- fg: focused ? COLOR.brand : COLOR.mute,
1864
- children: focused ? " " : " "
3855
+ fg: COLOR.warn,
3856
+ children: "fork from here?"
1865
3857
  }),
3858
+ " press ",
1866
3859
  /* @__PURE__ */ jsx("span", {
1867
- fg: enabled ? COLOR.accent : COLOR.mute,
1868
- children: enabled ? "[✓] " : "[ ] "
3860
+ fg: COLOR.warn,
3861
+ children: "f"
1869
3862
  }),
3863
+ " again to confirm · ",
1870
3864
  /* @__PURE__ */ jsx("span", {
1871
- fg: focused ? COLOR.brand : COLOR.dim,
1872
- children: label
3865
+ fg: COLOR.warn,
3866
+ children: "esc"
3867
+ }),
3868
+ " cancel"
3869
+ ]
3870
+ });
3871
+ if (pending === "delete") return /* @__PURE__ */ jsxs("text", {
3872
+ fg: COLOR.dim,
3873
+ children: [
3874
+ /* @__PURE__ */ jsx("span", {
3875
+ fg: COLOR.error,
3876
+ children: "delete this turn?"
1873
3877
  }),
3878
+ " press ",
1874
3879
  /* @__PURE__ */ jsx("span", {
1875
- fg: COLOR.mute,
1876
- children: ` ${description}`
1877
- })
3880
+ fg: COLOR.error,
3881
+ children: "d"
3882
+ }),
3883
+ " again to confirm · ",
3884
+ /* @__PURE__ */ jsx("span", {
3885
+ fg: COLOR.warn,
3886
+ children: "esc"
3887
+ }),
3888
+ " cancel"
1878
3889
  ]
1879
3890
  });
1880
- }
1881
- /**
1882
- * Choice row — `▶` marker · label · `:` · current value · description.
1883
- *
1884
- * Cycles through `options` on enter/space. When only one option is
1885
- * available (`cyclable=false`) the row still renders with the current
1886
- * value but the enter handler is a no-op — we surface this via the absence
1887
- * of the trailing `›` affordance so it visually reads as informational.
1888
- */
1889
- function ChoiceRow({ label, description, value, cyclable, focused }) {
1890
- const COLOR = useColors();
1891
- return /* @__PURE__ */ jsxs("text", {
1892
- fg: focused ? COLOR.brand : COLOR.dim,
3891
+ if (copyStatus === "copied") return /* @__PURE__ */ jsxs("text", {
3892
+ fg: COLOR.dim,
1893
3893
  children: [
1894
3894
  /* @__PURE__ */ jsx("span", {
1895
- fg: focused ? COLOR.brand : COLOR.mute,
1896
- children: focused ? " " : " "
3895
+ fg: COLOR.accent,
3896
+ children: " copied"
1897
3897
  }),
3898
+ " · ",
1898
3899
  /* @__PURE__ */ jsx("span", {
1899
- fg: focused ? COLOR.brand : COLOR.dim,
1900
- children: label
3900
+ fg: COLOR.warn,
3901
+ children: "f"
1901
3902
  }),
3903
+ " fork · ",
1902
3904
  /* @__PURE__ */ jsx("span", {
1903
- fg: COLOR.mute,
1904
- children: ": "
3905
+ fg: COLOR.warn,
3906
+ children: "d"
1905
3907
  }),
3908
+ " delete · ",
1906
3909
  /* @__PURE__ */ jsx("span", {
1907
- fg: focused ? COLOR.brand : COLOR.accent,
1908
- children: value
3910
+ fg: COLOR.warn,
3911
+ children: "esc"
1909
3912
  }),
3913
+ " close"
3914
+ ]
3915
+ });
3916
+ if (copyStatus === "failed") return /* @__PURE__ */ jsxs("text", {
3917
+ fg: COLOR.dim,
3918
+ children: [
1910
3919
  /* @__PURE__ */ jsx("span", {
1911
- fg: COLOR.mute,
1912
- children: ` ${description}`
3920
+ fg: COLOR.error,
3921
+ children: "copy failed (terminal may not support OSC 52)"
1913
3922
  }),
1914
- focused && cyclable && /* @__PURE__ */ jsx("span", {
1915
- fg: COLOR.brand,
1916
- children: " ↻"
1917
- })
3923
+ " · ",
3924
+ /* @__PURE__ */ jsx("span", {
3925
+ fg: COLOR.warn,
3926
+ children: "esc"
3927
+ }),
3928
+ " close"
1918
3929
  ]
1919
3930
  });
1920
- }
1921
- /**
1922
- * Action row — cursor marker · label · description · (focus-only) trailing arrow.
1923
- *
1924
- * The label sits in the same column as a toggle row's `[✓]` checkbox (right
1925
- * after the 2-col cursor slot). The trailing `›` only renders when focused
1926
- * so it reads as a "this row runs" affordance, not a static decoration on
1927
- * every action.
1928
- */
1929
- function ActionRow({ label, description, focused }) {
1930
- const COLOR = useColors();
1931
3931
  return /* @__PURE__ */ jsxs("text", {
1932
- fg: focused ? COLOR.brand : COLOR.dim,
3932
+ fg: COLOR.dim,
1933
3933
  children: [
1934
3934
  /* @__PURE__ */ jsx("span", {
1935
- fg: focused ? COLOR.brand : COLOR.mute,
1936
- children: focused ? "" : " "
3935
+ fg: COLOR.warn,
3936
+ children: "f"
1937
3937
  }),
3938
+ " fork · ",
1938
3939
  /* @__PURE__ */ jsx("span", {
1939
- fg: focused ? COLOR.brand : COLOR.accent,
1940
- children: label
3940
+ fg: COLOR.warn,
3941
+ children: "d"
1941
3942
  }),
3943
+ " delete · ",
1942
3944
  /* @__PURE__ */ jsx("span", {
1943
- fg: COLOR.mute,
1944
- children: ` ${description}`
3945
+ fg: canCopy ? COLOR.warn : COLOR.mute,
3946
+ children: "c"
1945
3947
  }),
1946
- focused && /* @__PURE__ */ jsx("span", {
1947
- fg: COLOR.brand,
1948
- children: " ›"
1949
- })
3948
+ canCopy ? " copy · " : " (nothing to copy) · ",
3949
+ /* @__PURE__ */ jsx("span", {
3950
+ fg: COLOR.warn,
3951
+ children: "esc"
3952
+ }),
3953
+ " close"
1950
3954
  ]
1951
3955
  });
1952
3956
  }
3957
+ /**
3958
+ * Human-readable per-kind block tally — e.g. `1 text · 2 tool_call`. Skips
3959
+ * zero-count kinds so the line stays scannable; uses canonical block-type
3960
+ * names so they line up with what the LLM and persistence layer see.
3961
+ */
3962
+ function blockSummary(turn) {
3963
+ const counts = {};
3964
+ for (const block of turn.content) counts[block.type] = (counts[block.type] ?? 0) + 1;
3965
+ const parts = [];
3966
+ for (const [type, n] of Object.entries(counts)) parts.push(`${n} ${type}`);
3967
+ return parts.length === 0 ? "(empty)" : parts.join(" · ");
3968
+ }
1953
3969
  //#endregion
1954
3970
  //#region src/tui/app.tsx
1955
3971
  /**
@@ -1995,7 +4011,7 @@ function ThemedShell() {
1995
4011
  const { settings } = useSettings();
1996
4012
  return /* @__PURE__ */ jsx(ThemeProvider, {
1997
4013
  theme: useMemo(() => resolveTheme(settings.theme), [settings.theme]),
1998
- children: /* @__PURE__ */ jsx(MdStyleProvider, { children: /* @__PURE__ */ jsx(SafeModeProvider, { children: /* @__PURE__ */ jsx(ModalRoot, { children: /* @__PURE__ */ jsx(AppShell, {}) }) }) })
4014
+ children: /* @__PURE__ */ jsx(MdStyleProvider, { children: /* @__PURE__ */ jsx(ChipStyleProvider, { children: /* @__PURE__ */ jsx(SafeModeProvider, { children: /* @__PURE__ */ jsx(ModalRoot, { children: /* @__PURE__ */ jsx(AppShell, {}) }) }) }) })
1999
4015
  });
2000
4016
  }
2001
4017
  function AppShell() {
@@ -2003,11 +4019,13 @@ function AppShell() {
2003
4019
  const modal = useModal();
2004
4020
  const config = useConfig();
2005
4021
  const { settings } = useSettings();
4022
+ const COLOR = useColors();
4023
+ const SURFACE = useSurfaces();
2006
4024
  const queue = useSafeModeQueue();
2007
4025
  const { requestApproval, resolveHead, denyAll } = useSafeModeActions();
2008
4026
  const { providers: providerRegistry, agents: agentRegistry, initialAgentId, store, stateStore, modelsFor, resumeProvider, initialPicked, initialState } = config;
2009
4027
  const lastResumedSessionId = initialState.lastSessionId;
2010
- const dataDir = config.paths.dir;
4028
+ const dataDir = config.paths.userDir;
2011
4029
  const [pickedAgent, setPickedAgent] = useState(() => agentRegistry[initialAgentId] ?? Object.values(agentRegistry)[0]);
2012
4030
  const pickedAgentRef = useRef(pickedAgent);
2013
4031
  const safeModeEnabledRef = useRef(settings.safeMode);
@@ -2015,6 +4033,7 @@ function AppShell() {
2015
4033
  safeModeEnabledRef.current = settings.safeMode;
2016
4034
  }, [settings.safeMode]);
2017
4035
  const [projectDir] = useState(() => process.cwd());
4036
+ const [sessionProjectRoot] = useState(() => findGitRoot(process.cwd()) ?? process.cwd());
2018
4037
  const safelistRef = useRef(null);
2019
4038
  const readSafelist = useCallback(() => {
2020
4039
  if (safelistRef.current === null) safelistRef.current = getSafelist(dataDir, projectDir);
@@ -2023,6 +4042,61 @@ function AppShell() {
2023
4042
  useEffect(() => {
2024
4043
  safelistRef.current = null;
2025
4044
  }, [dataDir, projectDir]);
4045
+ const [skillsCatalog, setSkillsCatalog] = useState([]);
4046
+ const [mcpsCatalog, setMcpsCatalog] = useState([]);
4047
+ const [filesCatalog, setFilesCatalog] = useState([]);
4048
+ useEffect(() => {
4049
+ const ac = new AbortController();
4050
+ let cancelled = false;
4051
+ (async () => {
4052
+ try {
4053
+ const skills = await discoverProjectSkills({
4054
+ cwd: projectDir,
4055
+ prefix: config.prefix
4056
+ });
4057
+ if (!cancelled) setSkillsCatalog(skills);
4058
+ } catch (err) {
4059
+ debugLog("discoverProjectSkills failed", err);
4060
+ }
4061
+ })();
4062
+ (async () => {
4063
+ try {
4064
+ const files = await listProjectFiles({
4065
+ cwd: projectDir,
4066
+ signal: ac.signal
4067
+ });
4068
+ if (!cancelled) setFilesCatalog(files);
4069
+ } catch (err) {
4070
+ debugLog("listProjectFiles failed", err);
4071
+ }
4072
+ })();
4073
+ try {
4074
+ setMcpsCatalog(discoverProjectMcps({
4075
+ cwd: projectDir,
4076
+ prefix: config.prefix
4077
+ }));
4078
+ } catch (err) {
4079
+ debugLog("discoverProjectMcps failed", err);
4080
+ }
4081
+ return () => {
4082
+ cancelled = true;
4083
+ ac.abort();
4084
+ };
4085
+ }, [projectDir, config.prefix]);
4086
+ const skillsCatalogRef = useRef(skillsCatalog);
4087
+ skillsCatalogRef.current = skillsCatalog;
4088
+ const enabledSkillsRef = useRef(settings.enabledSkills);
4089
+ enabledSkillsRef.current = settings.enabledSkills;
4090
+ const mcpsCatalogRef = useRef(mcpsCatalog);
4091
+ mcpsCatalogRef.current = mcpsCatalog;
4092
+ const enabledMcpsRef = useRef(settings.enabledMcps);
4093
+ enabledMcpsRef.current = settings.enabledMcps;
4094
+ const filesCatalogRef = useRef(filesCatalog);
4095
+ filesCatalogRef.current = filesCatalog;
4096
+ const completionProviders = useMemo(() => [createSkillsCompletionProvider({
4097
+ getCatalog: () => skillsCatalogRef.current,
4098
+ getEnabled: () => enabledSkillsRef.current
4099
+ }), createFilesCompletionProvider({ getCatalog: () => filesCatalogRef.current })], []);
2026
4100
  /**
2027
4101
  * Single source of truth for "should this call execute?". Returns true to
2028
4102
  * let the call through, false to refuse it. Handles three short-circuits:
@@ -2064,6 +4138,13 @@ function AppShell() {
2064
4138
  const [busy, setBusy] = useState(false);
2065
4139
  /** Token count from the most recent assistant turn (caching-aware). */
2066
4140
  const [lastInputTokens, setLastInputTokens] = useState(0);
4141
+ /**
4142
+ * Active turn id when the user is in "select turn" mode (ctrl+s on the
4143
+ * chat screen). `null` means normal mode — typing is enabled, transcript
4144
+ * has no highlight. When set, the prompt textarea is unfocused so up/down
4145
+ * navigate the turn list, ↵ opens the details modal, ⎋ exits the mode.
4146
+ */
4147
+ const [selectedTurnId, setSelectedTurnId] = useState(null);
2067
4148
  const agentRef = useRef(null);
2068
4149
  const sessionRef = useRef(null);
2069
4150
  const stream = useStreamBuffer(setEvents);
@@ -2080,8 +4161,24 @@ function AppShell() {
2080
4161
  const descriptor = providerRegistry[key];
2081
4162
  if (!descriptor) throw new Error(`No provider registered for key "${key}"`);
2082
4163
  const profile = pickedAgentRef.current;
4164
+ const skillsConfig = buildSkillsConfig({
4165
+ scan: defaultSkillScanPaths({
4166
+ cwd: projectDir,
4167
+ prefix: config.prefix
4168
+ }),
4169
+ enabled: enabledSkillsRef.current
4170
+ });
4171
+ const projectMcps = buildMcpServers({
4172
+ discovered: mcpsCatalogRef.current,
4173
+ enabled: enabledMcpsRef.current
4174
+ });
2083
4175
  const agent = createAgent({
2084
4176
  ...profile.preset,
4177
+ skills: {
4178
+ ...skillsConfig,
4179
+ ...profile.preset.skills ?? {}
4180
+ },
4181
+ mcpServers: [...projectMcps, ...profile.preset.mcpServers ?? []],
2085
4182
  provider: descriptor.factory(),
2086
4183
  session
2087
4184
  });
@@ -2096,29 +4193,32 @@ function AppShell() {
2096
4193
  agent.hooks.hook("child:tool:gate", (ctx) => applyGate(ctx.name, ctx.input, ctx));
2097
4194
  agent.hooks.hook("mcp:tool:gate", (ctx) => applyGate(ctx.displayName, ctx.input, ctx));
2098
4195
  agent.hooks.hook("child:mcp:tool:gate", (ctx) => applyGate(ctx.displayName, ctx.input, ctx));
2099
- agent.hooks.hook("stream:thinking", ({ delta }) => stream.queueStreamDelta("thinking", delta));
2100
- agent.hooks.hook("stream:text", ({ delta }) => stream.queueStreamDelta("markdown", delta));
2101
- agent.hooks.hook("tool:before", ({ name, input }) => {
4196
+ agent.hooks.hook("stream:thinking", ({ delta, turnId }) => stream.queueStreamDelta("thinking", delta, { turnId }));
4197
+ agent.hooks.hook("stream:text", ({ delta, turnId }) => stream.queueStreamDelta("markdown", delta, { turnId }));
4198
+ agent.hooks.hook("tool:before", ({ name, input, turnId }) => {
2102
4199
  stream.appendImmediate({
2103
4200
  kind: "tool",
2104
4201
  text: toolCallPreview(name, input),
2105
- tool: name
4202
+ tool: name,
4203
+ turnId
2106
4204
  });
2107
4205
  });
2108
- agent.hooks.hook("tool:after", ({ name, result }) => {
4206
+ agent.hooks.hook("tool:after", ({ name, result, turnId }) => {
2109
4207
  const raw = toolResultText(result);
2110
4208
  const text = name === "spawn" ? stripSpawnTokensLine(raw) : raw;
2111
4209
  stream.appendImmediate({
2112
4210
  kind: "tool-result",
2113
4211
  text,
2114
- tool: name
4212
+ tool: name,
4213
+ turnId
2115
4214
  });
2116
4215
  });
2117
- agent.hooks.hook("mcp:tool:after", ({ displayName, result }) => {
4216
+ agent.hooks.hook("mcp:tool:after", ({ displayName, result, turnId }) => {
2118
4217
  stream.appendImmediate({
2119
4218
  kind: "tool-result",
2120
4219
  text: toolResultText(result),
2121
- tool: displayName
4220
+ tool: displayName,
4221
+ turnId
2122
4222
  });
2123
4223
  });
2124
4224
  agent.hooks.hook("turn:after", ({ usage }) => {
@@ -2151,34 +4251,38 @@ function AppShell() {
2151
4251
  depth: depth ?? 1
2152
4252
  });
2153
4253
  });
2154
- agent.hooks.hook("child:stream:thinking", ({ delta, childId, depth }) => {
4254
+ agent.hooks.hook("child:stream:thinking", ({ delta, childId, depth, turnId }) => {
2155
4255
  stream.queueStreamDelta("thinking", delta, {
2156
4256
  childId,
2157
- depth
4257
+ depth,
4258
+ turnId
2158
4259
  });
2159
4260
  });
2160
- agent.hooks.hook("child:stream:text", ({ delta, childId, depth }) => {
4261
+ agent.hooks.hook("child:stream:text", ({ delta, childId, depth, turnId }) => {
2161
4262
  stream.queueStreamDelta("markdown", delta, {
2162
4263
  childId,
2163
- depth
4264
+ depth,
4265
+ turnId
2164
4266
  });
2165
4267
  });
2166
- agent.hooks.hook("child:tool:before", ({ name, input, childId, depth }) => {
4268
+ agent.hooks.hook("child:tool:before", ({ name, input, childId, depth, turnId }) => {
2167
4269
  stream.appendImmediate({
2168
4270
  kind: "tool",
2169
4271
  text: toolCallPreview(name, input),
2170
4272
  tool: name,
2171
4273
  childId,
2172
- depth
4274
+ depth,
4275
+ turnId
2173
4276
  });
2174
4277
  });
2175
- agent.hooks.hook("child:tool:after", ({ name, result, childId, depth }) => {
4278
+ agent.hooks.hook("child:tool:after", ({ name, result, childId, depth, turnId }) => {
2176
4279
  stream.appendImmediate({
2177
4280
  kind: "tool-result",
2178
4281
  text: toolResultText(result),
2179
4282
  tool: name,
2180
4283
  childId,
2181
- depth
4284
+ depth,
4285
+ turnId
2182
4286
  });
2183
4287
  });
2184
4288
  agent.hooks.hook("child:stream:end", ({ childId }) => {
@@ -2188,13 +4292,19 @@ function AppShell() {
2188
4292
  }, [
2189
4293
  providerRegistry,
2190
4294
  stream,
2191
- gateDecision
4295
+ gateDecision,
4296
+ projectDir,
4297
+ config.prefix
2192
4298
  ]);
2193
4299
  const refreshSessions = useCallback(async () => {
2194
- const list = await listSessionMeta(store);
4300
+ const list = await listSessionMeta(store, settings.showAllProjects ? void 0 : { projectRoot: sessionProjectRoot });
2195
4301
  setSessions(list);
2196
4302
  return list;
2197
- }, [store]);
4303
+ }, [
4304
+ store,
4305
+ settings.showAllProjects,
4306
+ sessionProjectRoot
4307
+ ]);
2198
4308
  const teardown = useCallback(async () => {
2199
4309
  try {
2200
4310
  denyAll();
@@ -2215,6 +4325,7 @@ function AppShell() {
2215
4325
  await teardown();
2216
4326
  const session = (id ? await loadSession(store, id) : null) ?? await createSession({
2217
4327
  store,
4328
+ projectRoot: sessionProjectRoot,
2218
4329
  ...id ? { id } : {}
2219
4330
  });
2220
4331
  sessionRef.current = session;
@@ -2223,8 +4334,10 @@ function AppShell() {
2223
4334
  setLastInputTokens(lastContextSizeFromTurns(session.turns, session.runs));
2224
4335
  setCurrentSession({
2225
4336
  id: session.id,
2226
- title: titleFromTurns(session.turns) ?? "untitled",
4337
+ title: deriveSessionTitle(session.turns, session.metadata),
2227
4338
  turnCount: session.turns.length,
4339
+ userMessageCount: session.turns.reduce((n, t) => t.role === "user" ? n + 1 : n, 0),
4340
+ runCount: session.runs.length,
2228
4341
  updatedAt: Date.now()
2229
4342
  });
2230
4343
  setScreen("chat");
@@ -2237,7 +4350,8 @@ function AppShell() {
2237
4350
  teardown,
2238
4351
  buildAgent,
2239
4352
  store,
2240
- stateStore
4353
+ stateStore,
4354
+ sessionProjectRoot
2241
4355
  ]);
2242
4356
  useEffect(() => {
2243
4357
  if (!resumeProvider) return;
@@ -2292,7 +4406,12 @@ function AppShell() {
2292
4406
  await refreshSessions();
2293
4407
  setScreen("sessions");
2294
4408
  }, [refreshSessions]);
4409
+ const popupOpenRef = useRef(false);
4410
+ const onPopupOpenChange = useCallback((open) => {
4411
+ popupOpenRef.current = open;
4412
+ }, []);
2295
4413
  const onAbort = useCallback(() => {
4414
+ if (popupOpenRef.current) return;
2296
4415
  denyAll();
2297
4416
  agentRef.current?.abort();
2298
4417
  }, [denyAll]);
@@ -2342,7 +4461,7 @@ function AppShell() {
2342
4461
  }, [agentRegistry, onPickAgent]);
2343
4462
  const eventsLengthRef = useRef(0);
2344
4463
  eventsLengthRef.current = events.length;
2345
- const onSubmitPrompt = useCallback(async (prompt) => {
4464
+ const onSubmitPrompt = useCallback(async (prompt, references) => {
2346
4465
  const agent = agentRef.current;
2347
4466
  const session = sessionRef.current;
2348
4467
  if (!agent || !session || !picked || !prompt.trim()) return;
@@ -2350,11 +4469,23 @@ function AppShell() {
2350
4469
  kind: "separator",
2351
4470
  text: ""
2352
4471
  });
4472
+ const refSpans = references.filter((r) => r.start >= 0 && r.end > r.start).map((r) => ({
4473
+ start: r.start,
4474
+ end: r.end,
4475
+ providerId: r.providerId
4476
+ }));
2353
4477
  stream.appendImmediate({
2354
- kind: "info",
2355
- text: `❯ ${prompt}`
4478
+ kind: "user-prompt",
4479
+ text: prompt,
4480
+ ...refSpans.length > 0 ? { refs: refSpans } : {}
2356
4481
  });
2357
4482
  setBusy(true);
4483
+ const skillNames = uniqueSkillNamesFromReferences(references);
4484
+ for (const name of skillNames) try {
4485
+ await agent.activateSkill(name);
4486
+ } catch (err) {
4487
+ debugLog(`activateSkill("${name}")`, err);
4488
+ }
2358
4489
  try {
2359
4490
  await agent.run({
2360
4491
  model: picked.model,
@@ -2363,8 +4494,10 @@ function AppShell() {
2363
4494
  await session.save().catch((err) => debugLog("session.save failed", err));
2364
4495
  setCurrentSession((prev) => prev ? {
2365
4496
  ...prev,
2366
- title: titleFromTurns(session.turns) ?? prev.title,
4497
+ title: deriveSessionTitle(session.turns, session.metadata),
2367
4498
  turnCount: session.turns.length,
4499
+ userMessageCount: session.turns.reduce((n, t) => t.role === "user" ? n + 1 : n, 0),
4500
+ runCount: session.runs.length,
2368
4501
  updatedAt: Date.now()
2369
4502
  } : prev);
2370
4503
  } catch (err) {
@@ -2378,22 +4511,294 @@ function AppShell() {
2378
4511
  }
2379
4512
  }, [picked, stream]);
2380
4513
  const pendingApproval = queue[0] ?? null;
2381
- const onReauth = useCallback(() => {
2382
- if (busy || pendingApproval) return;
2383
- modal.close();
2384
- setScreen("auth");
4514
+ const onReauth = useMemo(() => {
4515
+ if (busy || pendingApproval) return void 0;
4516
+ return () => {
4517
+ modal.close();
4518
+ setScreen("auth");
4519
+ };
2385
4520
  }, [
2386
4521
  modal,
2387
4522
  busy,
2388
4523
  pendingApproval
2389
4524
  ]);
4525
+ const onOpenSkillsSettings = useCallback(() => {
4526
+ modal.open(/* @__PURE__ */ jsx(SkillsSettingsModal, { catalog: skillsCatalog }));
4527
+ }, [modal, skillsCatalog]);
4528
+ const onOpenMcpsSettings = useCallback(() => {
4529
+ modal.open(/* @__PURE__ */ jsx(McpsSettingsModal, { catalog: mcpsCatalog }));
4530
+ }, [modal, mcpsCatalog]);
2390
4531
  const hasMultipleAgents = useMemo(() => Object.keys(agentRegistry).length > 1, [agentRegistry]);
4532
+ const turnIds = useMemo(() => selectableTurnIds(events), [events]);
4533
+ /** Drop the selection if its turn disappeared (session swap, history reset). */
4534
+ useEffect(() => {
4535
+ if (selectedTurnId && !turnIds.includes(selectedTurnId)) setSelectedTurnId(null);
4536
+ }, [selectedTurnId, turnIds]);
4537
+ const inSelectMode = selectedTurnId !== null;
4538
+ const enterSelectMode = useCallback(() => {
4539
+ if (turnIds.length === 0) return;
4540
+ setSelectedTurnId(turnIds[turnIds.length - 1]);
4541
+ }, [turnIds]);
4542
+ const cycleSelectedTurn = useCallback((direction) => {
4543
+ setSelectedTurnId((prev) => {
4544
+ if (!prev || turnIds.length === 0) return prev;
4545
+ const idx = turnIds.indexOf(prev);
4546
+ if (idx === -1) return turnIds[turnIds.length - 1];
4547
+ return turnIds[Math.max(0, Math.min(turnIds.length - 1, idx + direction))];
4548
+ });
4549
+ }, [turnIds]);
4550
+ const onForkTurn = useCallback(async (turnId) => {
4551
+ const source = sessionRef.current;
4552
+ if (!source || !picked) return;
4553
+ const slice = truncateTurnsAt(source.turns, turnId);
4554
+ if (!slice || slice.length === 0) return;
4555
+ const referencedRunIds = /* @__PURE__ */ new Set();
4556
+ for (const t of slice) if (t.runId) referencedRunIds.add(t.runId);
4557
+ const inheritedRuns = source.runs.filter((r) => referencedRunIds.has(r.id)).map((r) => ({ ...r }));
4558
+ const fork = await createSession({
4559
+ store,
4560
+ ...source.projectRoot ? { projectRoot: source.projectRoot } : {}
4561
+ });
4562
+ fork.setTurns(slice);
4563
+ fork.setRuns(inheritedRuns);
4564
+ try {
4565
+ await fork.save();
4566
+ } catch (err) {
4567
+ debugLog("fork: save failed", err);
4568
+ return;
4569
+ }
4570
+ setSelectedTurnId(null);
4571
+ await activateSession(fork.id, picked.provider.key);
4572
+ }, [
4573
+ picked,
4574
+ store,
4575
+ activateSession
4576
+ ]);
4577
+ const onDeleteTurn = useCallback(async (turnId) => {
4578
+ const session = sessionRef.current;
4579
+ if (!session) return;
4580
+ const nextTurns = deleteTurnSafely(session.turns, turnId);
4581
+ if (!nextTurns) return;
4582
+ session.setTurns(nextTurns);
4583
+ try {
4584
+ await session.save();
4585
+ } catch (err) {
4586
+ debugLog("delete: save failed", err);
4587
+ return;
4588
+ }
4589
+ setEvents(eventsFromTurns(session.turns, session.runs));
4590
+ setLastInputTokens(lastContextSizeFromTurns(session.turns, session.runs));
4591
+ setCurrentSession((prev) => prev ? {
4592
+ ...prev,
4593
+ turnCount: session.turns.length,
4594
+ userMessageCount: session.turns.reduce((n, t) => t.role === "user" ? n + 1 : n, 0),
4595
+ updatedAt: Date.now()
4596
+ } : prev);
4597
+ setSelectedTurnId((prev) => {
4598
+ if (!prev) return prev;
4599
+ return nextTurns.some((t) => t.id === prev) ? prev : null;
4600
+ });
4601
+ }, []);
4602
+ /**
4603
+ * Identity of the session row the user has focused on the sessions
4604
+ * screen — single source of truth. `SessionsScreen` is rendered fully
4605
+ * controlled against it: the select's `selectedIndex` is derived from
4606
+ * this id, so when the underlying list reorders (e.g. after `generate
4607
+ * title` updates `updatedAt`, SQLite returns rows in a new order) the
4608
+ * cursor follows the IDENTITY of the row the user was on, not the
4609
+ * numerical slot it used to occupy. `ctrl+x` reads it directly.
4610
+ *
4611
+ * `null` means the cursor is on `+ new session` (or the list is
4612
+ * empty); the keyboard handler skips opening the details modal in
4613
+ * that case.
4614
+ */
4615
+ const [focusedSessionId, setFocusedSessionId] = useState(null);
4616
+ const onDeleteSession = useCallback(async (id) => {
4617
+ try {
4618
+ await store.delete(id);
4619
+ } catch (err) {
4620
+ debugLog("delete session failed", err);
4621
+ return;
4622
+ }
4623
+ const wasCurrent = id === currentSession?.id;
4624
+ if (wasCurrent) {
4625
+ await teardown();
4626
+ setCurrentSession(null);
4627
+ setEvents([]);
4628
+ setSelectedTurnId(null);
4629
+ stateStore.save({
4630
+ ...stateStore.load(),
4631
+ lastSessionId: void 0
4632
+ });
4633
+ }
4634
+ await refreshSessions();
4635
+ if (wasCurrent) setScreen("sessions");
4636
+ }, [
4637
+ store,
4638
+ currentSession,
4639
+ teardown,
4640
+ refreshSessions,
4641
+ stateStore
4642
+ ]);
4643
+ const onGenerateTitle = useCallback(async (sessionId, signal) => {
4644
+ if (!picked) throw new Error("No provider picked — open the chat screen first.");
4645
+ const descriptor = providerRegistry[picked.provider.key];
4646
+ if (!descriptor) throw new Error(`Provider "${picked.provider.key}" is not registered.`);
4647
+ let turns;
4648
+ let metadataRecord;
4649
+ let liveSession = null;
4650
+ let loadedData = null;
4651
+ if (sessionId === sessionRef.current?.id) {
4652
+ liveSession = sessionRef.current;
4653
+ turns = sessionRef.current.turns;
4654
+ metadataRecord = sessionRef.current.metadata;
4655
+ } else {
4656
+ loadedData = await store.load(sessionId);
4657
+ if (!loadedData) throw new Error("Session not found.");
4658
+ turns = loadedData.turns;
4659
+ metadataRecord = loadedData.metadata;
4660
+ }
4661
+ const title = await generateSessionTitle({
4662
+ provider: descriptor.factory(),
4663
+ model: picked.model,
4664
+ turns,
4665
+ signal
4666
+ });
4667
+ if (liveSession) {
4668
+ liveSession.setMeta("title", title);
4669
+ await liveSession.save().catch((err) => debugLog("generate-title: save failed", err));
4670
+ } else {
4671
+ if (!loadedData) throw new Error("Session disappeared mid-generation.");
4672
+ const nextMeta = {
4673
+ ...metadataRecord ?? {},
4674
+ title
4675
+ };
4676
+ await store.save({
4677
+ ...loadedData,
4678
+ metadata: nextMeta,
4679
+ updatedAt: Date.now()
4680
+ }).catch((err) => debugLog("generate-title: store.save failed", err));
4681
+ }
4682
+ setCurrentSession((prev) => prev && prev.id === sessionId ? {
4683
+ ...prev,
4684
+ title,
4685
+ updatedAt: Date.now()
4686
+ } : prev);
4687
+ await refreshSessions().catch((err) => debugLog("generate-title: refreshSessions failed", err));
4688
+ return title;
4689
+ }, [
4690
+ picked,
4691
+ providerRegistry,
4692
+ store,
4693
+ refreshSessions
4694
+ ]);
4695
+ const onExportSession = useCallback(async (sessionId, format) => {
4696
+ let data = null;
4697
+ if (sessionId === sessionRef.current?.id) data = sessionRef.current.toJSON();
4698
+ else data = await store.load(sessionId);
4699
+ if (!data) throw new Error("Session not found.");
4700
+ return {
4701
+ filepath: (await writeSessionExport({
4702
+ session: data,
4703
+ format,
4704
+ cwd: projectDir,
4705
+ prefix: config.prefix
4706
+ })).filepath,
4707
+ format
4708
+ };
4709
+ }, [
4710
+ store,
4711
+ projectDir,
4712
+ config.prefix
4713
+ ]);
4714
+ const openSessionDetails = useCallback(async (sessionId) => {
4715
+ const data = await store.load(sessionId);
4716
+ if (!data) {
4717
+ debugLog("openSessionDetails: session not found", sessionId);
4718
+ return;
4719
+ }
4720
+ modal.open(/* @__PURE__ */ jsx(SessionDetailsModal, {
4721
+ session: data,
4722
+ title: sessionId === currentSession?.id ? currentSession.title : void 0,
4723
+ isCurrent: sessionId === currentSession?.id,
4724
+ actions: {
4725
+ onDelete: onDeleteSession,
4726
+ onExport: onExportSession,
4727
+ ...picked ? { onGenerateTitle } : {}
4728
+ }
4729
+ }));
4730
+ }, [
4731
+ modal,
4732
+ store,
4733
+ currentSession,
4734
+ onDeleteSession,
4735
+ picked,
4736
+ onGenerateTitle,
4737
+ onExportSession
4738
+ ]);
4739
+ const openSelectedTurn = useCallback(() => {
4740
+ const id = selectedTurnId;
4741
+ if (!id) return;
4742
+ const session = sessionRef.current;
4743
+ if (!session) return;
4744
+ const turn = session.turns.find((t) => t.id === id);
4745
+ if (!turn) return;
4746
+ const index = turnIds.indexOf(id) + 1;
4747
+ modal.open(/* @__PURE__ */ jsx(TurnDetailsModal, {
4748
+ turn,
4749
+ index,
4750
+ total: turnIds.length,
4751
+ actions: {
4752
+ onFork: onForkTurn,
4753
+ onDelete: onDeleteTurn
4754
+ }
4755
+ }));
4756
+ }, [
4757
+ modal,
4758
+ selectedTurnId,
4759
+ turnIds,
4760
+ onForkTurn,
4761
+ onDeleteTurn
4762
+ ]);
2391
4763
  useKeyboard((key) => {
2392
4764
  if (modal.isOpen) return;
4765
+ if (inSelectMode && screen === "chat") {
4766
+ if (key.name === "up") {
4767
+ cycleSelectedTurn(-1);
4768
+ return;
4769
+ }
4770
+ if (key.name === "down") {
4771
+ cycleSelectedTurn(1);
4772
+ return;
4773
+ }
4774
+ if (key.name === "return") {
4775
+ openSelectedTurn();
4776
+ return;
4777
+ }
4778
+ if (key.name === "escape") {
4779
+ setSelectedTurnId(null);
4780
+ return;
4781
+ }
4782
+ return;
4783
+ }
2393
4784
  if (key.ctrl && key.name === "," && screen !== "auth") {
2394
- modal.open(/* @__PURE__ */ jsx(SettingsModal, { actions: { onReauth } }));
4785
+ modal.open(/* @__PURE__ */ jsx(SettingsModal, { actions: {
4786
+ onReauth,
4787
+ onOpenSkills: onOpenSkillsSettings,
4788
+ onOpenMcps: onOpenMcpsSettings
4789
+ } }));
2395
4790
  return;
2396
4791
  }
4792
+ if (key.ctrl && key.name === "x") {
4793
+ if (screen === "chat" && currentSession && !busy && !pendingApproval) {
4794
+ openSessionDetails(currentSession.id);
4795
+ return;
4796
+ }
4797
+ if (screen === "sessions" && isSessionRowId(focusedSessionId)) {
4798
+ openSessionDetails(focusedSessionId);
4799
+ return;
4800
+ }
4801
+ }
2397
4802
  if (key.ctrl && key.name === "m" && screen === "chat" && picked && !busy) {
2398
4803
  modal.open(/* @__PURE__ */ jsx(ModelPickerModal, {
2399
4804
  models: modelsFor(picked.provider.key),
@@ -2402,6 +4807,10 @@ function AppShell() {
2402
4807
  }));
2403
4808
  return;
2404
4809
  }
4810
+ if (key.ctrl && key.name === "s" && screen === "chat" && !busy && !pendingApproval) {
4811
+ enterSelectMode();
4812
+ return;
4813
+ }
2405
4814
  if (key.ctrl && key.name === "a" && screen === "chat" && hasMultipleAgents && !busy) {
2406
4815
  modal.open(/* @__PURE__ */ jsx(AgentPickerModal, {
2407
4816
  agents: agentRegistry,
@@ -2428,12 +4837,43 @@ function AppShell() {
2428
4837
  }
2429
4838
  renderer.destroy();
2430
4839
  });
2431
- const hints = useMemo(() => buildHints(screen, busy, !!pendingApproval, currentSession, hasMultipleAgents), [
4840
+ const hints = useMemo(() => buildHints({
4841
+ screen,
4842
+ busy,
4843
+ pending: !!pendingApproval,
4844
+ currentSession,
4845
+ hasMultipleAgents,
4846
+ modelLabel: picked?.model ?? null,
4847
+ modelColor: COLOR.model,
4848
+ agentLabel: pickedAgent.label,
4849
+ agentColor: accentColor(pickedAgent.accent, COLOR)
4850
+ }), [
2432
4851
  screen,
2433
4852
  busy,
2434
4853
  pendingApproval,
2435
4854
  currentSession,
2436
- hasMultipleAgents
4855
+ hasMultipleAgents,
4856
+ picked,
4857
+ pickedAgent,
4858
+ COLOR
4859
+ ]);
4860
+ const promptTriggerHints = useMemo(() => {
4861
+ const out = [];
4862
+ if (filesCatalog.length > 0) out.push({
4863
+ key: "@",
4864
+ label: "files",
4865
+ keyColor: resolveChipColor(SURFACE.chips, "files").bg
4866
+ });
4867
+ if (skillsCatalog.length > 0) out.push({
4868
+ key: "/",
4869
+ label: "skills",
4870
+ keyColor: resolveChipColor(SURFACE.chips, "skills").bg
4871
+ });
4872
+ return out;
4873
+ }, [
4874
+ filesCatalog,
4875
+ skillsCatalog,
4876
+ SURFACE
2437
4877
  ]);
2438
4878
  const contextUsage = useMemo(() => {
2439
4879
  if (screen !== "chat" || !picked) return null;
@@ -2470,8 +4910,12 @@ function AppShell() {
2470
4910
  screen === "sessions" && /* @__PURE__ */ jsx(SessionsScreen, {
2471
4911
  sessions,
2472
4912
  currentId: currentSession?.id ?? null,
4913
+ focusedSessionId,
2473
4914
  onPick: onSwitchSession,
2474
- onCreate: onCreateSession
4915
+ onCreate: onCreateSession,
4916
+ onFocusChange: setFocusedSessionId,
4917
+ showAllProjects: settings.showAllProjects,
4918
+ currentProjectRoot: sessionProjectRoot
2475
4919
  }),
2476
4920
  screen === "chat" && /* @__PURE__ */ jsx(ChatScreen, {
2477
4921
  events,
@@ -2480,18 +4924,26 @@ function AppShell() {
2480
4924
  onSubmit: onSubmitPrompt,
2481
4925
  session: currentSession,
2482
4926
  pending: pendingApproval,
2483
- onApproval: resolveHead
4927
+ onApproval: resolveHead,
4928
+ completionProviders,
4929
+ onPopupOpenChange,
4930
+ selectedTurnId,
4931
+ promptTriggerHints
2484
4932
  })
2485
4933
  ]
2486
4934
  }), /* @__PURE__ */ jsx(Footer, {
2487
4935
  hints,
2488
- picked,
2489
- agent: screen === "chat" && hasMultipleAgents ? pickedAgent : null,
2490
4936
  context: contextUsage
2491
4937
  })]
2492
4938
  });
2493
4939
  }
2494
- function buildHints(screen, busy, pending, currentSession, hasMultipleAgents) {
4940
+ /**
4941
+ * Build the footer's shortcut hints for the current screen. On the chat
4942
+ * screen the model id rides next to its `ctrl+m` shortcut and the agent
4943
+ * label rides next to `shift+tab`, each in its accent color — the bar
4944
+ * doubles as the status display without needing separate badges.
4945
+ */
4946
+ function buildHints({ screen, busy, pending, currentSession, hasMultipleAgents, modelLabel, modelColor, agentLabel, agentColor }) {
2495
4947
  if (pending) return [
2496
4948
  {
2497
4949
  key: "↑↓",
@@ -2533,6 +4985,10 @@ function buildHints(screen, busy, pending, currentSession, hasMultipleAgents) {
2533
4985
  key: "↵",
2534
4986
  label: "open"
2535
4987
  },
4988
+ {
4989
+ key: "ctrl+x",
4990
+ label: "session"
4991
+ },
2536
4992
  {
2537
4993
  key: "ctrl+,",
2538
4994
  label: "settings"
@@ -2543,18 +4999,20 @@ function buildHints(screen, busy, pending, currentSession, hasMultipleAgents) {
2543
4999
  }
2544
5000
  ];
2545
5001
  return [
2546
- {
2547
- key: "↵",
2548
- label: "send"
2549
- },
2550
5002
  ...hasMultipleAgents ? [{
2551
5003
  key: "shift+tab",
2552
- label: "agent"
5004
+ label: agentLabel,
5005
+ labelColor: agentColor
2553
5006
  }] : [],
2554
- {
5007
+ ...modelLabel ? [{
2555
5008
  key: "ctrl+m",
2556
- label: "model"
2557
- },
5009
+ label: modelLabel,
5010
+ labelColor: modelColor
5011
+ }] : [],
5012
+ ...currentSession ? [{
5013
+ key: "ctrl+x",
5014
+ label: "session"
5015
+ }] : [],
2558
5016
  {
2559
5017
  key: "ctrl+,",
2560
5018
  label: "settings"
@@ -2732,6 +5190,6 @@ async function runTui(options = {}) {
2732
5190
  process.exit(0);
2733
5191
  }
2734
5192
  //#endregion
2735
- export { AgentPickerModal, App, AuthScreen, ChatScreen, Footer, Modal, ModalRoot, ModelPickerModal, SessionsScreen, SettingsModal, Spinner, Transcript, accentColor, buildMdStyle, isVisible, marginTopFor, onInputSubmit, runTui, useMdStyle, useModal, useModalAwareFocus };
5193
+ export { AgentPickerModal, App, AuthScreen, ChatScreen, CompletionPopup, Footer, McpsSettingsModal, Modal, ModalRoot, ModelPickerModal, SessionsScreen, SettingsModal, SkillsSettingsModal, Spinner, ToggleListModal, Transcript, accentColor, buildMdStyle, hintsLength, isVisible, marginTopFor, onInputSubmit, renderHintSpans, runTui, splitPromptSegments, useMdStyle, useModal, useModalAwareFocus };
2736
5194
 
2737
5195
  //# sourceMappingURL=tui.js.map