zidane 4.1.8 → 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 (66) hide show
  1. package/README.md +11 -2
  2. package/dist/{index-bgh-k8Mv.d.ts → agent-JhicgLOV.d.ts} +2082 -1969
  3. package/dist/agent-JhicgLOV.d.ts.map +1 -0
  4. package/dist/chat.d.ts +340 -9
  5. package/dist/chat.d.ts.map +1 -1
  6. package/dist/chat.js +2 -2
  7. package/dist/contexts.d.ts +1 -1
  8. package/dist/{index-DRoG_udt.d.ts → index-2yLUyTbc.d.ts} +34 -4
  9. package/dist/{index-DRoG_udt.d.ts.map → index-2yLUyTbc.d.ts.map} +1 -1
  10. package/dist/{index-BB4kuRh3.d.ts → index-CXVvqTQj.d.ts} +1 -1
  11. package/dist/{index-BB4kuRh3.d.ts.map → index-CXVvqTQj.d.ts.map} +1 -1
  12. package/dist/{index-Ds5YpvfZ.d.ts → index-t_W9i7Ql.d.ts} +9 -4
  13. package/dist/index-t_W9i7Ql.d.ts.map +1 -0
  14. package/dist/index.d.ts +4 -4
  15. package/dist/index.js +6 -6
  16. package/dist/{interpolate-CukJwP2G.js → interpolate-Ck970-61.js} +11 -2
  17. package/dist/interpolate-Ck970-61.js.map +1 -0
  18. package/dist/{mcp-8wClKY-3.js → mcp-Dw-fRPVk.js} +61 -65
  19. package/dist/mcp-Dw-fRPVk.js.map +1 -0
  20. package/dist/mcp.d.ts +1 -1
  21. package/dist/mcp.js +1 -1
  22. package/dist/presets-BRFH2qsQ.js +90 -0
  23. package/dist/presets-BRFH2qsQ.js.map +1 -0
  24. package/dist/presets.d.ts +3 -2
  25. package/dist/presets.js +2 -2
  26. package/dist/providers.d.ts +1 -1
  27. package/dist/session/sqlite.d.ts +13 -2
  28. package/dist/session/sqlite.d.ts.map +1 -1
  29. package/dist/session/sqlite.js +96 -38
  30. package/dist/session/sqlite.js.map +1 -1
  31. package/dist/{session-Cn68UASv.js → session-791hhrFa.js} +65 -30
  32. package/dist/session-791hhrFa.js.map +1 -0
  33. package/dist/session.d.ts +1 -1
  34. package/dist/session.js +1 -1
  35. package/dist/skills.d.ts +2 -2
  36. package/dist/skills.js +1 -1
  37. package/dist/{stats-BT9l57RS.js → stats-DZIsGqzu.js} +15 -5
  38. package/dist/stats-DZIsGqzu.js.map +1 -0
  39. package/dist/theme-pJv47erq.d.ts +1202 -0
  40. package/dist/theme-pJv47erq.d.ts.map +1 -0
  41. package/dist/{tools-C8kDot0H.js → tools-CLazLRb4.js} +475 -318
  42. package/dist/tools-CLazLRb4.js.map +1 -0
  43. package/dist/tools.d.ts +2 -2
  44. package/dist/tools.js +1 -1
  45. package/dist/tui.d.ts +303 -18
  46. package/dist/tui.d.ts.map +1 -1
  47. package/dist/tui.js +3305 -509
  48. package/dist/tui.js.map +1 -1
  49. package/dist/turn-operations-5aQu4dJg.js +3587 -0
  50. package/dist/turn-operations-5aQu4dJg.js.map +1 -0
  51. package/dist/types.d.ts +3 -3
  52. package/dist/types.js +1 -1
  53. package/package.json +6 -1
  54. package/dist/index-Ds5YpvfZ.d.ts.map +0 -1
  55. package/dist/index-bgh-k8Mv.d.ts.map +0 -1
  56. package/dist/interpolate-CukJwP2G.js.map +0 -1
  57. package/dist/mcp-8wClKY-3.js.map +0 -1
  58. package/dist/presets-BzkJDW1K.js +0 -39
  59. package/dist/presets-BzkJDW1K.js.map +0 -1
  60. package/dist/session-Cn68UASv.js.map +0 -1
  61. package/dist/stats-BT9l57RS.js.map +0 -1
  62. package/dist/theme-BlXO6yHe.d.ts +0 -503
  63. package/dist/theme-BlXO6yHe.d.ts.map +0 -1
  64. package/dist/theme-context-MungM3SY.js +0 -1713
  65. package/dist/theme-context-MungM3SY.js.map +0 -1
  66. package/dist/tools-C8kDot0H.js.map +0 -1
package/dist/tui.js CHANGED
@@ -1,11 +1,220 @@
1
- import { d as createAgent } from "./tools-C8kDot0H.js";
2
- import { n as formatTokenUsage } from "./stats-BT9l57RS.js";
3
- import { n as loadSession, t as createSession } from "./session-Cn68UASv.js";
4
- import { $ as toolCallPreview, A as isOnSafelist, B as shortId, E as useSafeModeQueue, H as useConfig, I as runOAuthLogin, J as listSessionMeta, K as eventsFromTurns, L as supportsOAuth, O as addToSafelist, P as suggestSafelistEntry, Q as titleFromTurns, R as ageString, T as useSafeModeActions, U as resolveConfig, V as ConfigProvider, Z as stripSpawnTokensLine, c as finalizeStreamingMarkdownForOwner, d as DEFAULT_SETTINGS, et as toolResultText, f as SETTINGS_CHOICES, h as useSettings, i as useSurfaces, k as getSafelist, l as turnContextSize, m as SettingsProvider, n as useColors, o as useTheme, p as SETTINGS_TOGGLES, pt as getContextWindow, q as lastContextSizeFromTurns, r as useSelectStyle, s as finalizeStreamingMarkdown, st as setProviderCredential, t as ThemeProvider, tt as detectAuth, u as useStreamBuffer, v as resolveTheme, w as SafeModeProvider, z as fmtTokens } from "./theme-context-MungM3SY.js";
5
- import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
6
- import { jsx, jsxs } from "@opentui/react/jsx-runtime";
1
+ import { d as createAgent } from "./tools-CLazLRb4.js";
2
+ import { n as formatTokenUsage } from "./stats-DZIsGqzu.js";
3
+ import { n as loadSession, t as createSession } from "./session-791hhrFa.js";
4
+ import { createTuiStore } from "./session/sqlite.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";
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";
10
+ import { Fragment, jsx, jsxs } from "@opentui/react/jsx-runtime";
11
+ //#region src/tui/modal.tsx
12
+ const ModalContext = createContext(null);
13
+ function ModalRoot({ children }) {
14
+ const [active, setActive] = useState(null);
15
+ const api = useMemo(() => ({
16
+ open: (node) => setActive(node),
17
+ close: () => setActive(null),
18
+ get isOpen() {
19
+ return active !== null;
20
+ }
21
+ }), [active]);
22
+ return /* @__PURE__ */ jsxs(ModalContext.Provider, {
23
+ value: api,
24
+ children: [/* @__PURE__ */ jsx("box", {
25
+ style: {
26
+ flexDirection: "column",
27
+ flexGrow: 1
28
+ },
29
+ children
30
+ }), active && /* @__PURE__ */ jsx("box", {
31
+ style: {
32
+ position: "absolute",
33
+ top: 0,
34
+ left: 0,
35
+ right: 0,
36
+ bottom: 0,
37
+ alignItems: "center",
38
+ justifyContent: "center",
39
+ zIndex: 100
40
+ },
41
+ children: active
42
+ })]
43
+ });
44
+ }
45
+ function useModal() {
46
+ const ctx = useContext(ModalContext);
47
+ if (!ctx) throw new Error("useModal must be used inside <ModalRoot>");
48
+ return ctx;
49
+ }
50
+ /**
51
+ * Focus computed against the modal layer.
52
+ *
53
+ * Pass a component's preferred focus state and this returns `false` whenever a
54
+ * modal is open — so focused inputs (textarea, selects) release their focus and
55
+ * stop intercepting keys behind the overlay. Pair with `focusable={false}` on
56
+ * "passive" focusables (scrollbox) so the renderer doesn't cycle focus into
57
+ * them when the primary input blurs.
58
+ */
59
+ function useModalAwareFocus(preferred = true) {
60
+ const { isOpen } = useModal();
61
+ return preferred && !isOpen;
62
+ }
63
+ /**
64
+ * Responsive modal — picks a width (and optionally a height) based on the
65
+ * live terminal size.
66
+ *
67
+ * - On a wide terminal, the modal grows to `maxWidth` so descriptions sit on
68
+ * one line and don't wrap.
69
+ * - On a narrow terminal, the modal shrinks down to `minWidth`, keeping a
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).
74
+ *
75
+ * Uses `useTerminalDimensions()` so it reflows on `SIGWINCH` without remount.
76
+ */
77
+ function Modal({ title, bottomTitle, onClose, disableEscape = false, children, maxWidth = 92, minWidth = 44, maxHeight, horizontalMargin = 4, verticalMargin = 2 }) {
78
+ const ctx = useContext(ModalContext);
79
+ const dismiss = onClose ?? ctx?.close;
80
+ const COLOR = useColors();
81
+ const SURFACE = useSurfaces();
82
+ useKeyboard((key) => {
83
+ if (key.name === "escape" && !disableEscape) dismiss?.();
84
+ });
85
+ const { width: termWidth, height: termHeight } = useTerminalDimensions();
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));
88
+ return /* @__PURE__ */ jsx("box", {
89
+ title: title ? ` ${title} ` : void 0,
90
+ bottomTitle: bottomTitle ? ` ${bottomTitle} ` : void 0,
91
+ bottomTitleAlignment: "right",
92
+ style: {
93
+ border: true,
94
+ borderColor: COLOR.borderActive,
95
+ backgroundColor: SURFACE.modal,
96
+ paddingTop: 1,
97
+ paddingBottom: 1,
98
+ paddingLeft: 2,
99
+ paddingRight: 2,
100
+ width,
101
+ ...height !== void 0 ? { height } : {},
102
+ flexDirection: "column",
103
+ gap: 1
104
+ },
105
+ children
106
+ });
107
+ }
108
+ //#endregion
109
+ //#region src/tui/agent-picker.tsx
110
+ /** Cap the scroll window — a long custom registry shouldn't push the modal off-screen. */
111
+ const VISIBLE_ROW_CAP$1 = 10;
112
+ /**
113
+ * Modal that lists the registered {@link AgentProfile}s and lets the user
114
+ * pick one. Rows show: `● selected · label description`.
115
+ *
116
+ * The accent column is intentionally compact (single-char marker) — the
117
+ * profile's `accent` color is read from the active theme so Build and Plan
118
+ * stand apart at a glance without taking horizontal space.
119
+ *
120
+ * Used by `App` (Ctrl+A binding) and exported for hosts that want to drive
121
+ * agent switching from elsewhere in their own composition.
122
+ */
123
+ function AgentPickerModal({ agents, currentAgentId, onPick }) {
124
+ const COLOR = useColors();
125
+ const SELECT_THEME = useSelectStyle();
126
+ const profiles = useMemo(() => Object.values(agents), [agents]);
127
+ const initialIndex = useMemo(() => profiles.findIndex((p) => p.id === currentAgentId), [profiles, currentAgentId]);
128
+ const options = useMemo(() => profiles.map((p) => ({
129
+ name: `${p.id === currentAgentId ? "● " : " "}${p.label}`,
130
+ description: p.description,
131
+ value: p.id
132
+ })), [profiles, currentAgentId]);
133
+ if (profiles.length === 0) return /* @__PURE__ */ jsx(EmptyState$2, {});
134
+ const visibleRows = Math.min(options.length, VISIBLE_ROW_CAP$1);
135
+ const currentMissing = initialIndex < 0;
136
+ const safeIndex = currentMissing ? 0 : initialIndex;
137
+ return /* @__PURE__ */ jsxs(Modal, {
138
+ title: "select agent",
139
+ children: [
140
+ currentMissing && /* @__PURE__ */ jsx("text", {
141
+ fg: COLOR.warn,
142
+ children: `Current agent "${currentAgentId}" is not in this registry — pick one below.`
143
+ }),
144
+ /* @__PURE__ */ jsx("select", {
145
+ ...SELECT_THEME,
146
+ focused: true,
147
+ options,
148
+ wrapSelection: true,
149
+ selectedIndex: safeIndex,
150
+ showScrollIndicator: options.length > visibleRows,
151
+ style: { height: visibleRows },
152
+ onSelect: (_idx, option) => {
153
+ if (option) onPick(option.value);
154
+ }
155
+ }),
156
+ /* @__PURE__ */ jsxs("text", {
157
+ fg: COLOR.mute,
158
+ children: [
159
+ /* @__PURE__ */ jsx("span", {
160
+ fg: COLOR.warn,
161
+ children: "↑↓"
162
+ }),
163
+ " navigate · ",
164
+ /* @__PURE__ */ jsx("span", {
165
+ fg: COLOR.warn,
166
+ children: "↵"
167
+ }),
168
+ " select · ",
169
+ /* @__PURE__ */ jsx("span", {
170
+ fg: COLOR.warn,
171
+ children: "esc"
172
+ }),
173
+ " close"
174
+ ]
175
+ })
176
+ ]
177
+ });
178
+ }
179
+ function EmptyState$2() {
180
+ const COLOR = useColors();
181
+ return /* @__PURE__ */ jsxs(Modal, {
182
+ title: "select agent",
183
+ children: [/* @__PURE__ */ jsx("text", {
184
+ fg: COLOR.dim,
185
+ children: "No agents registered."
186
+ }), /* @__PURE__ */ jsxs("text", {
187
+ fg: COLOR.mute,
188
+ children: [
189
+ "Pass an",
190
+ /* @__PURE__ */ jsx("span", {
191
+ fg: COLOR.model,
192
+ children: " agents "
193
+ }),
194
+ "registry to",
195
+ /* @__PURE__ */ jsx("span", {
196
+ fg: COLOR.model,
197
+ children: " runTui({ agents }) "
198
+ }),
199
+ "to populate this list."
200
+ ]
201
+ })]
202
+ });
203
+ }
204
+ /**
205
+ * Resolve a profile's `accent` token to a concrete theme color via the
206
+ * caller's color palette. Exposed for the Footer badge so all surfaces
207
+ * stay in sync with the picker's row tinting.
208
+ */
209
+ function accentColor(accent, COLOR) {
210
+ switch (accent) {
211
+ case "brand": return COLOR.brand;
212
+ case "warn": return COLOR.warn;
213
+ case "model": return COLOR.model;
214
+ default: return COLOR.accent;
215
+ }
216
+ }
217
+ //#endregion
9
218
  //#region src/tui/theme.ts
10
219
  /**
11
220
  * Convert the renderer-agnostic `Theme.syntax` map (hex strings + plain
@@ -29,14 +238,123 @@ function buildMdStyle(theme) {
29
238
  }
30
239
  return SyntaxStyle.fromStyles(styles);
31
240
  }
241
+ const MdStyleContext = createContext(null);
242
+ function MdStyleProvider({ children }) {
243
+ const theme = useTheme();
244
+ const style = useMemo(() => buildMdStyle(theme), [theme]);
245
+ return createElement(MdStyleContext.Provider, { value: style }, children);
246
+ }
32
247
  /**
33
- * Active markdown / syntax-highlighting style, memoized per theme. Reading
34
- * this in a component subscribes it to theme changes a `Settings.theme`
35
- * flip immediately re-renders the affected `<markdown>` instances.
248
+ * Active markdown / syntax-highlighting style. Returns a single shared
249
+ * `SyntaxStyle` instance for the active theme built once at provider
250
+ * mount, re-built on theme switch. A `Settings.theme` flip re-paints every
251
+ * `<markdown>` that reads this hook.
252
+ *
253
+ * Throws if used outside `<MdStyleProvider>` so a missing wiring shows up
254
+ * loudly in development rather than silently rendering plain text.
36
255
  */
37
256
  function useMdStyle() {
257
+ const style = useContext(MdStyleContext);
258
+ if (!style) throw new Error("useMdStyle must be used inside <MdStyleProvider>");
259
+ return style;
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 }) {
38
311
  const theme = useTheme();
39
- return useMemo(() => buildMdStyle(theme), [theme]);
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
+ ]);
40
358
  }
41
359
  //#endregion
42
360
  //#region src/tui/components.tsx
@@ -46,20 +364,31 @@ function useMdStyle() {
46
364
  * its content changes (we only ever recreate the streaming-markdown tail).
47
365
  *
48
366
  * The outer wrapper handles top-margin per kind (and per neighbor) so spacing
49
- * 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.
50
372
  */
51
- const EventLine = memo(({ event, previous, depthOffset = 0 }) => /* @__PURE__ */ jsx("box", {
52
- style: {
53
- marginTop: marginTopFor(event, previous),
54
- alignSelf: "stretch",
55
- flexShrink: 0,
56
- flexDirection: "column"
57
- },
58
- children: /* @__PURE__ */ jsx(EventLineImpl, {
59
- event,
60
- depthOffset
61
- })
62
- }));
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
+ });
63
392
  /**
64
393
  * `@opentui/react` extends `React.JSX.IntrinsicElements`, so `onSubmit` on `<input>`
65
394
  * gets intersected with the DOM `SubmitEvent` shape and demands an unhelpful overload.
@@ -69,9 +398,18 @@ const EventLine = memo(({ event, previous, depthOffset = 0 }) => /* @__PURE__ */
69
398
  function onInputSubmit(handler) {
70
399
  return handler;
71
400
  }
72
- function Footer({ hints, picked, context }) {
73
- const COLOR = useColors();
74
- return /* @__PURE__ */ jsxs("box", {
401
+ /**
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.
406
+ */
407
+ function Footer({ hints, context }) {
408
+ const { width } = useTerminalDimensions();
409
+ const inner = Math.max(0, width - 2);
410
+ const hW = hintsLength(hints);
411
+ const cW = context ? contextIndicatorLength(context) : 0;
412
+ if (hW + (cW > 0 ? cW + 1 : 0) <= inner) return /* @__PURE__ */ jsxs("box", {
75
413
  style: {
76
414
  flexDirection: "row",
77
415
  height: 1,
@@ -79,62 +417,69 @@ function Footer({ hints, picked, context }) {
79
417
  paddingRight: 1
80
418
  },
81
419
  children: [
82
- /* @__PURE__ */ jsx("text", {
83
- fg: COLOR.dim,
84
- children: hints.map((h, i) => /* @__PURE__ */ jsxs("span", { children: [
85
- i > 0 && /* @__PURE__ */ jsx("span", {
86
- fg: COLOR.mute,
87
- children: " · "
88
- }),
89
- /* @__PURE__ */ jsx("span", {
90
- fg: COLOR.warn,
91
- children: h.key
92
- }),
93
- /* @__PURE__ */ jsx("span", {
94
- fg: COLOR.dim,
95
- children: ` ${h.label}`
96
- })
97
- ] }, i))
98
- }),
99
- picked && /* @__PURE__ */ jsx(ProviderBadge, { picked }),
420
+ /* @__PURE__ */ jsx(HintsText, { hints }),
100
421
  /* @__PURE__ */ jsx("box", { style: { flexGrow: 1 } }),
101
422
  context && /* @__PURE__ */ jsx(ContextIndicator, { context })
102
423
  ]
103
424
  });
425
+ return /* @__PURE__ */ jsxs("box", {
426
+ style: {
427
+ flexDirection: "column",
428
+ paddingLeft: 1,
429
+ paddingRight: 1
430
+ },
431
+ children: [/* @__PURE__ */ jsx("box", {
432
+ style: {
433
+ flexDirection: "row",
434
+ height: 1
435
+ },
436
+ children: /* @__PURE__ */ jsx(HintsText, { hints })
437
+ }), context && /* @__PURE__ */ jsxs("box", {
438
+ style: {
439
+ flexDirection: "row",
440
+ height: 1
441
+ },
442
+ children: [/* @__PURE__ */ jsx("box", { style: { flexGrow: 1 } }), /* @__PURE__ */ jsx(ContextIndicator, { context })]
443
+ })]
444
+ });
104
445
  }
105
- function ProviderBadge({ picked }) {
446
+ function HintsText({ hints }) {
106
447
  const COLOR = useColors();
107
- const source = picked.provider.methods[0].source;
108
- return /* @__PURE__ */ jsxs("text", {
448
+ return /* @__PURE__ */ jsx("text", {
109
449
  fg: COLOR.dim,
110
- children: [
111
- /* @__PURE__ */ jsx("span", {
112
- fg: COLOR.mute,
113
- children: " · "
114
- }),
115
- /* @__PURE__ */ jsx("span", {
116
- fg: COLOR.accent,
117
- children: picked.provider.label
118
- }),
119
- /* @__PURE__ */ jsx("span", {
120
- fg: COLOR.mute,
121
- children: " · "
122
- }),
123
- /* @__PURE__ */ jsx("span", {
124
- fg: COLOR.model,
125
- children: picked.model
126
- }),
127
- /* @__PURE__ */ jsx("span", {
128
- fg: COLOR.mute,
129
- children: " · "
130
- }),
131
- /* @__PURE__ */ jsx("span", {
132
- fg: source === "oauth" ? COLOR.accent : COLOR.warn,
133
- children: source
134
- })
135
- ]
450
+ children: renderHintSpans(hints, COLOR)
136
451
  });
137
452
  }
453
+ /**
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.
466
+ */
467
+ function renderHintSpans(hints, COLOR) {
468
+ return hints.map((h, i) => /* @__PURE__ */ jsxs("span", { children: [
469
+ i > 0 && /* @__PURE__ */ jsx("span", {
470
+ fg: COLOR.mute,
471
+ children: " · "
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));
482
+ }
138
483
  function ContextIndicator({ context }) {
139
484
  const COLOR = useColors();
140
485
  const ratio = context.max > 0 ? context.used / context.max : 0;
@@ -162,11 +507,156 @@ function ContextIndicator({ context }) {
162
507
  ]
163
508
  });
164
509
  }
165
- const SPINNER_FRAMES = [
166
- "⠋",
167
- "⠙",
168
- "⠹",
169
- "⠸",
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 }) {
560
+ const COLOR = useColors();
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
+ },
575
+ children: [
576
+ /* @__PURE__ */ jsx("span", {
577
+ fg: COLOR.mute,
578
+ children: " "
579
+ }),
580
+ /* @__PURE__ */ jsx("span", {
581
+ fg,
582
+ children: visibleTitle
583
+ }),
584
+ /* @__PURE__ */ jsx("span", {
585
+ fg: COLOR.mute,
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: " "
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)),
607
+ /* @__PURE__ */ jsx("span", {
608
+ fg: COLOR.mute,
609
+ children: " "
610
+ })
611
+ ]
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)}…`;
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
+ */
646
+ function hintsLength(hints) {
647
+ if (hints.length === 0) return 0;
648
+ return hints.reduce((sum, h, i) => sum + h.key.length + 1 + h.label.length + (i > 0 ? 3 : 0), 0);
649
+ }
650
+ function contextIndicatorLength(context) {
651
+ const ratio = context.max > 0 ? context.used / context.max : 0;
652
+ const pct = Math.round(ratio * 100);
653
+ return 4 + fmtTokens(context.used).length + 3 + fmtTokens(context.max).length + 2 + String(pct).length + 2;
654
+ }
655
+ const SPINNER_FRAMES = [
656
+ "⠋",
657
+ "⠙",
658
+ "⠹",
659
+ "⠸",
170
660
  "⠼",
171
661
  "⠴",
172
662
  "⠦",
@@ -190,10 +680,50 @@ function Spinner({ label }) {
190
680
  })]
191
681
  });
192
682
  }
193
- 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 }) {
194
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]);
195
724
  if (items.length === 0) return /* @__PURE__ */ jsx(EmptyState$1, {});
196
725
  return /* @__PURE__ */ jsx("scrollbox", {
726
+ ref: scrollboxRef,
197
727
  focusable: false,
198
728
  style: {
199
729
  flexGrow: 1,
@@ -204,14 +734,53 @@ function Transcript({ events, settings }) {
204
734
  stickyStart: "bottom",
205
735
  children: items.map((item, i) => item.kind === "event" ? /* @__PURE__ */ jsx(EventLine, {
206
736
  event: item.event,
207
- previous: item.previous
737
+ previous: item.previous,
738
+ selected: selectedTurnId !== null && item.event.turnId === selectedTurnId,
739
+ anchorId: anchors.ids[i][0]
208
740
  }, i) : /* @__PURE__ */ jsx(SubagentBlock, {
209
741
  events: item.events,
210
- previous: item.previous
742
+ previous: item.previous,
743
+ selectedTurnId,
744
+ anchorIds: anchors.ids[i]
211
745
  }, i))
212
746
  });
213
747
  }
214
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
+ /**
215
784
  * Per-event visibility — filters honor user toggles and the
216
785
  * `hideSubagentOutput` setting. When subagent output is hidden:
217
786
  * - Child-agent events are filtered down to the `spawn-start` /
@@ -292,7 +861,7 @@ function partitionTranscript(events, settings) {
292
861
  * indented twice. Grandchildren (depth ≥ 2) still indent further, so
293
862
  * nested subagents remain visually distinct.
294
863
  */
295
- function SubagentBlock({ events, previous }) {
864
+ function SubagentBlock({ events, previous, selectedTurnId = null, anchorIds }) {
296
865
  const COLOR = useColors();
297
866
  const childIds = useMemo(() => {
298
867
  const set = /* @__PURE__ */ new Set();
@@ -318,7 +887,9 @@ function SubagentBlock({ events, previous }) {
318
887
  children: events.map((evt, i) => /* @__PURE__ */ jsx(EventLine, {
319
888
  event: evt,
320
889
  previous: events[i - 1],
321
- depthOffset: 1
890
+ depthOffset: 1,
891
+ selected: selectedTurnId !== null && evt.turnId === selectedTurnId,
892
+ anchorId: anchorIds?.[i]
322
893
  }, i))
323
894
  });
324
895
  }
@@ -372,6 +943,7 @@ function rowStyle(paddingLeft) {
372
943
  */
373
944
  const MARGIN_TOP = {
374
945
  "separator": 0,
946
+ "user-prompt": 1,
375
947
  "info": 1,
376
948
  "thinking": 0,
377
949
  "tool": 1,
@@ -414,7 +986,17 @@ function EventLineImpl({ event, depthOffset = 0 }) {
414
986
  const child = isChild(event);
415
987
  switch (event.kind) {
416
988
  case "separator": return /* @__PURE__ */ jsx("text", { children: " " });
417
- 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
+ });
418
1000
  case "thinking": return /* @__PURE__ */ jsx("box", {
419
1001
  style: row,
420
1002
  children: /* @__PURE__ */ jsx("text", {
@@ -497,19 +1079,65 @@ function EventLineImpl({ event, depthOffset = 0 }) {
497
1079
  default: return /* @__PURE__ */ jsx("text", { children: safeText });
498
1080
  }
499
1081
  }
500
- /** User prompt — bordered to rhyme with the prompt input box below. */
501
- 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 }) {
502
1100
  const COLOR = useColors();
503
- return /* @__PURE__ */ jsx("box", {
504
- style: {
505
- border: true,
506
- borderColor: COLOR.borderActive,
507
- paddingLeft: 1,
508
- paddingRight: 1
509
- },
510
- 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", {
511
1111
  fg: COLOR.brand,
512
- 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
+ })]
513
1141
  })
514
1142
  });
515
1143
  }
@@ -575,93 +1203,164 @@ function ToolResultBlock({ text, indent }) {
575
1203
  });
576
1204
  }
577
1205
  //#endregion
578
- //#region src/tui/modal.tsx
579
- const ModalContext = createContext(null);
580
- function ModalRoot({ children }) {
581
- const [active, setActive] = useState(null);
582
- const api = useMemo(() => ({
583
- open: (node) => setActive(node),
584
- close: () => setActive(null),
585
- get isOpen() {
586
- return active !== null;
1206
+ //#region src/tui/toggle-list-modal.tsx
1207
+ /**
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:
1211
+ *
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.
1221
+ */
1222
+ function ToggleListModal({ catalog, keyOf, settingKey, title, renderDetail, emptyState }) {
1223
+ const COLOR = useColors();
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));
587
1238
  }
588
- }), [active]);
589
- return /* @__PURE__ */ jsxs(ModalContext.Provider, {
590
- value: api,
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", {
1245
+ fg: COLOR.warn,
1246
+ children: "esc"
1247
+ }), " close"]
1248
+ })]
1249
+ });
1250
+ return /* @__PURE__ */ jsxs(Modal, {
1251
+ title: ` ${title} · ${enabledSet.size} / ${catalog.length} enabled `,
591
1252
  children: [/* @__PURE__ */ jsx("box", {
592
- style: {
593
- flexDirection: "column",
594
- flexGrow: 1
595
- },
596
- children
597
- }), active && /* @__PURE__ */ jsx("box", {
598
- style: {
599
- position: "absolute",
600
- top: 0,
601
- left: 0,
602
- right: 0,
603
- bottom: 0,
604
- alignItems: "center",
605
- justifyContent: "center",
606
- zIndex: 100
607
- },
608
- children: active
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
+ ]
609
1299
  })]
610
1300
  });
611
1301
  }
612
- function useModal() {
613
- const ctx = useContext(ModalContext);
614
- if (!ctx) throw new Error("useModal must be used inside <ModalRoot>");
615
- return ctx;
616
- }
617
- /**
618
- * Focus computed against the modal layer.
619
- *
620
- * Pass a component's preferred focus state and this returns `false` whenever a
621
- * modal is open — so focused inputs (textarea, selects) release their focus and
622
- * stop intercepting keys behind the overlay. Pair with `focusable={false}` on
623
- * "passive" focusables (scrollbox) so the renderer doesn't cycle focus into
624
- * them when the primary input blurs.
625
- */
626
- function useModalAwareFocus(preferred = true) {
627
- const { isOpen } = useModal();
628
- return preferred && !isOpen;
629
- }
1302
+ //#endregion
1303
+ //#region src/tui/mcps-settings.tsx
630
1304
  /**
631
- * Responsive modal picks a width based on the live terminal size.
632
- *
633
- * - On a wide terminal, the modal grows to `maxWidth` so descriptions sit on
634
- * one line and don't wrap.
635
- * - On a narrow terminal, the modal shrinks down to `minWidth`, keeping a
636
- * small horizontal margin from the screen edges. Text inside wraps naturally.
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.
637
1309
  *
638
- * Uses `useTerminalDimensions()` so it reflows on `SIGWINCH` without remount.
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.
639
1313
  */
640
- function Modal({ title, onClose, children, maxWidth = 92, minWidth = 44, horizontalMargin = 4 }) {
641
- const ctx = useContext(ModalContext);
642
- const dismiss = onClose ?? ctx?.close;
1314
+ function McpsSettingsModal({ catalog }) {
643
1315
  const COLOR = useColors();
644
- const SURFACE = useSurfaces();
645
- useKeyboard((key) => {
646
- if (key.name === "escape") dismiss?.();
647
- });
648
- const { width: termWidth } = useTerminalDimensions();
649
- const width = Math.max(minWidth, Math.min(maxWidth, termWidth - horizontalMargin * 2));
650
- return /* @__PURE__ */ jsx("box", {
651
- title: title ? ` ${title} ` : void 0,
652
- style: {
653
- border: true,
654
- borderColor: COLOR.borderActive,
655
- backgroundColor: SURFACE.modal,
656
- paddingTop: 1,
657
- paddingBottom: 1,
658
- paddingLeft: 2,
659
- paddingRight: 2,
660
- width,
661
- flexDirection: "column",
662
- gap: 1
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 ?? ""}`;
663
1324
  },
664
- children
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
+ })] })
665
1364
  });
666
1365
  }
667
1366
  //#endregion
@@ -764,23 +1463,200 @@ function describeModel(m) {
764
1463
  return parts.join(" · ");
765
1464
  }
766
1465
  //#endregion
767
- //#region src/tui/screens.tsx
1466
+ //#region src/tui/completion-popup.tsx
768
1467
  /**
769
- * Build a key-binding set for the prompt textarea / API-key input. Strips the
770
- * default `return` action and reinstalls it with our preferred meaning, so the
771
- * binding wins regardless of modifier state. Pass `allowShiftReturnNewline`
772
- * to enable `shift+enter` → newline (multi-line input).
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.
773
1492
  */
774
- function makeSubmitBindings(allowShiftReturnNewline) {
775
- const base = defaultTextareaKeyBindings.filter((b) => b.name !== "return");
776
- return allowShiftReturnNewline ? [
777
- ...base,
778
- {
779
- name: "return",
780
- action: "submit"
781
- },
782
- {
783
- name: "return",
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
1643
+ //#region src/tui/screens.tsx
1644
+ /**
1645
+ * Build a key-binding set for the prompt textarea / API-key input. Strips the
1646
+ * default `return` action and reinstalls it with our preferred meaning, so the
1647
+ * binding wins regardless of modifier state. Pass `allowShiftReturnNewline`
1648
+ * to enable `shift+enter` → newline (multi-line input).
1649
+ */
1650
+ function makeSubmitBindings(allowShiftReturnNewline) {
1651
+ const base = defaultTextareaKeyBindings.filter((b) => b.name !== "return");
1652
+ return allowShiftReturnNewline ? [
1653
+ ...base,
1654
+ {
1655
+ name: "return",
1656
+ action: "submit"
1657
+ },
1658
+ {
1659
+ name: "return",
784
1660
  shift: true,
785
1661
  action: "newline"
786
1662
  }
@@ -813,7 +1689,7 @@ function AuthScreen({ onPick }) {
813
1689
  const COLOR = useColors();
814
1690
  const SELECT_THEME = useSelectStyle();
815
1691
  const [providers, setProviders] = useState([]);
816
- 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]);
817
1693
  useEffect(() => {
818
1694
  refresh();
819
1695
  }, [refresh]);
@@ -827,7 +1703,7 @@ function AuthScreen({ onPick }) {
827
1703
  const canCancel = forceWizard && available.length > 0;
828
1704
  return /* @__PURE__ */ jsx(SetupWizard, {
829
1705
  registry,
830
- dataDir: config.paths.dir,
1706
+ dataDir: config.paths.userDir,
831
1707
  onConfigured: onWizardDone,
832
1708
  onCancel: canCancel ? () => setForceWizard(false) : void 0
833
1709
  });
@@ -841,31 +1717,36 @@ function AuthScreen({ onPick }) {
841
1717
  description: "launch the setup wizard",
842
1718
  value: WIZARD_OPTION_VALUE
843
1719
  }];
844
- return /* @__PURE__ */ jsx("box", {
845
- title: " pick a provider ",
1720
+ return /* @__PURE__ */ jsxs("box", {
846
1721
  style: {
847
- border: true,
848
- borderColor: COLOR.border,
849
- padding: 1,
850
1722
  flexDirection: "column",
851
1723
  flexGrow: 1
852
1724
  },
853
- children: /* @__PURE__ */ jsx("select", {
854
- ...SELECT_THEME,
855
- focused,
856
- options,
857
- wrapSelection: true,
858
- onSelect: (_idx, option) => {
859
- if (!option) return;
860
- if (option.value === WIZARD_OPTION_VALUE) {
861
- setForceWizard(true);
862
- return;
863
- }
864
- const provider = findByKey(available, option.value);
865
- 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
866
1732
  },
867
- style: { flexGrow: 1 }
868
- })
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" })]
869
1750
  });
870
1751
  }
871
1752
  function SetupWizard({ registry, dataDir, onConfigured, onCancel }) {
@@ -907,6 +1788,13 @@ function SetupWizard({ registry, dataDir, onConfigured, onCancel }) {
907
1788
  setError(err instanceof Error ? err.message : String(err));
908
1789
  }
909
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
+ }, []);
910
1798
  if (descriptors.length === 0) return /* @__PURE__ */ jsx(EmptyRegistryNotice, {});
911
1799
  if (step.kind === "pick-provider") return /* @__PURE__ */ jsx(PickProviderStep, {
912
1800
  descriptors,
@@ -928,35 +1816,43 @@ function SetupWizard({ registry, dataDir, onConfigured, onCancel }) {
928
1816
  descriptor: step.descriptor,
929
1817
  dataDir,
930
1818
  onSuccess: onConfigured,
931
- onError: (msg) => {
932
- setError(msg);
933
- setStep({
934
- kind: "pick-method",
935
- descriptor: step.descriptor
936
- });
937
- }
1819
+ onError: onOAuthError
938
1820
  });
939
1821
  }
940
1822
  /**
941
- * Shared wrapper for every wizard step — same border + padding + flex layout
942
- * with a customizable title and accent color. Footnote slot at the bottom for
943
- * 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.
944
1832
  */
945
1833
  function WizardPanel({ title, accent, error, children }) {
946
1834
  const COLOR = useColors();
947
1835
  return /* @__PURE__ */ jsxs("box", {
948
- title,
949
1836
  style: {
950
- border: true,
951
- borderColor: accent ?? COLOR.border,
952
- padding: 1,
953
- gap: 1,
954
1837
  flexDirection: "column",
955
1838
  flexGrow: 1
956
1839
  },
957
- children: [children, error && /* @__PURE__ */ jsx("text", {
958
- fg: COLOR.error,
959
- 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
960
1856
  })]
961
1857
  });
962
1858
  }
@@ -970,7 +1866,7 @@ function WizardEscHint() {
970
1866
  function EmptyRegistryNotice() {
971
1867
  const COLOR = useColors();
972
1868
  return /* @__PURE__ */ jsxs(WizardPanel, {
973
- title: " no providers configured ",
1869
+ title: "no providers configured",
974
1870
  accent: COLOR.error,
975
1871
  children: [/* @__PURE__ */ jsx("text", {
976
1872
  fg: COLOR.error,
@@ -1012,7 +1908,7 @@ function PickProviderStep({ descriptors, error, onPick, onCancel }) {
1012
1908
  value: WIZARD_BACK_VALUE
1013
1909
  }] : []];
1014
1910
  return /* @__PURE__ */ jsxs(WizardPanel, {
1015
- 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",
1016
1912
  error,
1017
1913
  children: [!onCancel && /* @__PURE__ */ jsxs("text", {
1018
1914
  fg: COLOR.dim,
@@ -1062,7 +1958,7 @@ function PickMethodStep({ descriptor, error, onPick }) {
1062
1958
  return items;
1063
1959
  }, [descriptor]);
1064
1960
  return /* @__PURE__ */ jsxs(WizardPanel, {
1065
- title: ` configure ${descriptor.label} — pick auth method `,
1961
+ title: `configure ${descriptor.label} — pick auth method`,
1066
1962
  error,
1067
1963
  children: [/* @__PURE__ */ jsx(WizardEscHint, {}), /* @__PURE__ */ jsx("select", {
1068
1964
  ...SELECT_THEME,
@@ -1084,7 +1980,7 @@ function EnterApiKeyStep({ descriptor, error, onSubmit }) {
1084
1980
  onSubmit(descriptor, inputRef.current?.value ?? "");
1085
1981
  }, [descriptor, onSubmit]);
1086
1982
  return /* @__PURE__ */ jsxs(WizardPanel, {
1087
- title: ` configure ${descriptor.label} — paste API key `,
1983
+ title: `configure ${descriptor.label} — paste API key`,
1088
1984
  error,
1089
1985
  children: [/* @__PURE__ */ jsxs("text", {
1090
1986
  fg: COLOR.dim,
@@ -1159,7 +2055,7 @@ function OAuthRunningStep({ descriptor, dataDir, onSuccess, onError }) {
1159
2055
  onError
1160
2056
  ]);
1161
2057
  return /* @__PURE__ */ jsxs(WizardPanel, {
1162
- title: ` configure ${descriptor.label} — OAuth `,
2058
+ title: `configure ${descriptor.label} — OAuth`,
1163
2059
  children: [
1164
2060
  /* @__PURE__ */ jsx(WizardEscHint, {}),
1165
2061
  /* @__PURE__ */ jsx(Spinner, { label: status }),
@@ -1179,153 +2075,468 @@ function OAuthRunningStep({ descriptor, dataDir, onSuccess, onError }) {
1179
2075
  ]
1180
2076
  });
1181
2077
  }
1182
- const NEW_VALUE = "__new__";
1183
- 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 }) {
1184
2098
  const focused = useModalAwareFocus();
1185
2099
  const COLOR = useColors();
1186
- const SELECT_THEME = useSelectStyle();
1187
- const options = useMemo(() => {
1188
- const items = [{
1189
- name: "+ new session",
1190
- description: "start fresh",
1191
- value: NEW_VALUE
1192
- }];
1193
- for (const s of sessions) {
1194
- const marker = s.id === currentId ? "● " : " ";
1195
- const turnLabel = `${s.turnCount} turn${s.turnCount === 1 ? "" : "s"}`;
1196
- items.push({
1197
- name: `${marker}${s.title}`,
1198
- description: `#${shortId(s.id)} · ${turnLabel} · ${ageString(s.updatedAt)}`,
1199
- value: s.id
1200
- });
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;
1201
2114
  }
1202
- return items;
1203
- }, [sessions, currentId]);
1204
- return /* @__PURE__ */ jsx("box", {
1205
- title: " sessions ",
1206
- style: {
1207
- border: true,
1208
- borderColor: COLOR.border,
1209
- padding: 1,
1210
- flexDirection: "column",
1211
- flexGrow: 1
1212
- },
1213
- children: /* @__PURE__ */ jsx("select", {
1214
- ...SELECT_THEME,
1215
- focused,
1216
- options,
1217
- wrapSelection: true,
1218
- onSelect: (_idx, option) => {
1219
- if (!option) return;
1220
- if (option.value === NEW_VALUE) onCreate();
1221
- else if (typeof option.value === "string") onPick(option.value);
1222
- },
1223
- style: { flexGrow: 1 }
1224
- })
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();
1225
2172
  });
1226
- }
1227
- /** Visible content lines: 1 minimum, 5 maximum (textarea scrolls past 5). */
1228
- const MIN_CONTENT_LINES = 1;
1229
- const MAX_CONTENT_LINES = 5;
1230
- function ChatScreen({ events, busy, settings, onSubmit, session, pending, onApproval }) {
1231
- const COLOR = useColors();
1232
- const title = useMemo(() => {
1233
- if (!session) return " untitled ";
1234
- const turns = `${session.turnCount} turn${session.turnCount === 1 ? "" : "s"}`;
1235
- return ` ${session.title} · #${shortId(session.id)} · ${turns} `;
1236
- }, [session]);
1237
- const userPrompts = useMemo(() => events.filter((e) => e.kind === "info").map((e) => e.text.replace(/^❯ /, "")), [events]);
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);
1238
2185
  return /* @__PURE__ */ jsxs("box", {
1239
2186
  style: {
1240
2187
  flexDirection: "column",
1241
2188
  flexGrow: 1
1242
2189
  },
1243
- children: [/* @__PURE__ */ jsx("box", {
1244
- title,
2190
+ children: [/* @__PURE__ */ jsxs("box", {
1245
2191
  style: {
1246
2192
  border: true,
1247
2193
  borderColor: COLOR.border,
1248
- flexGrow: 1,
1249
- flexDirection: "column"
2194
+ padding: 1,
2195
+ flexDirection: "column",
2196
+ flexGrow: 1
1250
2197
  },
1251
- children: /* @__PURE__ */ jsx(Transcript, {
1252
- events,
1253
- settings
1254
- })
1255
- }), pending ? /* @__PURE__ */ jsx(ApprovalBlock, {
1256
- request: pending,
1257
- onPick: onApproval
1258
- }) : busy ? /* @__PURE__ */ jsx(BusyBlock, {}) : /* @__PURE__ */ jsx(PromptBlock, {
1259
- userPrompts,
1260
- onSubmit
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
1261
2238
  })]
1262
2239
  });
1263
2240
  }
1264
- /** Max chars per scalar argument in the approval preview. */
1265
- const APPROVAL_ARG_MAX = 80;
1266
- /**
1267
- * Render `{ path: 'x.ts', contents: 'long string' }` as
1268
- * `path: "x.ts", contents: "long string…"` — readable, per-key, truncated
1269
- * per value rather than dumping `JSON.stringify(input)` (which produces an
1270
- * illegible 50KB blob for `write_file` etc.).
1271
- */
1272
- function formatApprovalArgs(input) {
1273
- const parts = [];
1274
- for (const [key, raw] of Object.entries(input)) {
1275
- let value;
1276
- if (typeof raw === "string") {
1277
- const escaped = raw.replace(/\n/g, "\\n");
1278
- value = escaped.length > APPROVAL_ARG_MAX ? `"${escaped.slice(0, APPROVAL_ARG_MAX)}…"` : `"${escaped}"`;
1279
- } else {
1280
- const json = JSON.stringify(raw);
1281
- value = json.length > APPROVAL_ARG_MAX ? `${json.slice(0, APPROVAL_ARG_MAX)}…` : json;
1282
- }
1283
- parts.push(`${key}: ${value}`);
1284
- }
1285
- return parts.join(", ");
1286
- }
1287
2241
  /**
1288
- * Inline approval picker replaces the chat input while a tool call is
1289
- * pending. Three options:
1290
- * - **accept once** let this call execute, don't persist anything.
1291
- * - **accept + remember** — execute + add a `projects.json` entry so the
1292
- * same shape doesn't prompt again in this directory.
1293
- * - **deny** — refuse the call. The model gets `Blocked: …` and adapts.
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.
1294
2245
  *
1295
- * Esc aborts the whole run via the parent keyboard handler; per-call
1296
- * accept/deny only happens through the select below.
1297
- *
1298
- * Layout is fully pinned so the picker never overlaps with itself or the
1299
- * transcript above:
1300
- *
1301
- * - Outer `<box>` has an explicit `height`. The slot below the transcript
1302
- * adapts (the chat container is column-flex), so we control exactly how
1303
- * many rows we occupy.
1304
- * - Summary row is a `<box height: 1, overflow: hidden>` wrapping a
1305
- * `<text wrapMode="none">` — a 500-char tool-call preview can never
1306
- * wrap to row 2 and push the select off-screen.
1307
- * - `<select showDescription={false}>` keeps each option to exactly one
1308
- * row. Hints live in the `name` string after a `·` separator. Without
1309
- * this, the default `showDescription: true` makes every option take 2
1310
- * rows, and a `height: options.length` select would overdraw into the
1311
- * summary above (the original bug).
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.
1312
2249
  */
1313
- function ApprovalBlock({ request, onPick }) {
1314
- const focused = useModalAwareFocus();
2250
+ function SessionRow({ row, focused, isCurrent, showProject = false, currentProjectRoot }) {
1315
2251
  const COLOR = useColors();
1316
- const SELECT_THEME = useSelectStyle();
1317
- const summary = useMemo(() => `${request.tool}(${formatApprovalArgs(request.input)})`, [request.tool, request.input]);
1318
- const options = useMemo(() => {
1319
- return [
1320
- {
1321
- name: "accept once · allow this call only",
1322
- description: "",
1323
- value: "accept-once"
1324
- },
1325
- {
1326
- name: `accept + remember · add "${suggestSafelistEntry(request.tool, request.input)}" to projects.json`,
1327
- description: "",
1328
- value: "accept-safelist"
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"
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
+ })] });
2391
+ }
2392
+ /** Visible content lines: 1 minimum, 5 maximum (textarea scrolls past 5). */
2393
+ const MIN_CONTENT_LINES = 1;
2394
+ const MAX_CONTENT_LINES = 5;
2395
+ function ChatScreen({ events, busy, settings, onSubmit, session, pending, onApproval, completionProviders, onPopupOpenChange, selectedTurnId, promptTriggerHints }) {
2396
+ const COLOR = useColors();
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]);
2435
+ return /* @__PURE__ */ jsxs("box", {
2436
+ style: {
2437
+ flexDirection: "column",
2438
+ flexGrow: 1
2439
+ },
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
2468
+ })
2469
+ ]
2470
+ });
2471
+ }
2472
+ /** Max chars per scalar argument in the approval preview. */
2473
+ const APPROVAL_ARG_MAX = 80;
2474
+ /**
2475
+ * Render `{ path: 'x.ts', contents: 'long string' }` as
2476
+ * `path: "x.ts", contents: "long string…"` — readable, per-key, truncated
2477
+ * per value rather than dumping `JSON.stringify(input)` (which produces an
2478
+ * illegible 50KB blob for `write_file` etc.).
2479
+ */
2480
+ function formatApprovalArgs(input) {
2481
+ const parts = [];
2482
+ for (const [key, raw] of Object.entries(input)) {
2483
+ let value;
2484
+ if (typeof raw === "string") {
2485
+ const escaped = raw.replace(/\n/g, "\\n");
2486
+ value = escaped.length > APPROVAL_ARG_MAX ? `"${escaped.slice(0, APPROVAL_ARG_MAX)}…"` : `"${escaped}"`;
2487
+ } else {
2488
+ const json = JSON.stringify(raw);
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;
2493
+ }
2494
+ parts.push(`${key}: ${value}`);
2495
+ }
2496
+ return parts.join(", ");
2497
+ }
2498
+ /**
2499
+ * Inline approval picker — replaces the chat input while a tool call is
2500
+ * pending. Three options:
2501
+ * - **accept once** — let this call execute, don't persist anything.
2502
+ * - **accept + remember** — execute + add a `projects.json` entry so the
2503
+ * same shape doesn't prompt again in this directory.
2504
+ * - **deny** — refuse the call. The model gets `Blocked: …` and adapts.
2505
+ *
2506
+ * Esc aborts the whole run via the parent keyboard handler; per-call
2507
+ * accept/deny only happens through the select below.
2508
+ *
2509
+ * Layout is fully pinned so the picker never overlaps with itself or the
2510
+ * transcript above:
2511
+ *
2512
+ * - Outer `<box>` has an explicit `height`. The slot below the transcript
2513
+ * adapts (the chat container is column-flex), so we control exactly how
2514
+ * many rows we occupy.
2515
+ * - Summary row is a `<box height: 1, overflow: hidden>` wrapping a
2516
+ * `<text wrapMode="none">` — a 500-char tool-call preview can never
2517
+ * wrap to row 2 and push the select off-screen.
2518
+ * - `<select showDescription={false}>` keeps each option to exactly one
2519
+ * row. Hints live in the `name` string after a `·` separator. Without
2520
+ * this, the default `showDescription: true` makes every option take 2
2521
+ * rows, and a `height: options.length` select would overdraw into the
2522
+ * summary above (the original bug).
2523
+ */
2524
+ function ApprovalBlock({ request, onPick }) {
2525
+ const focused = useModalAwareFocus();
2526
+ const COLOR = useColors();
2527
+ const SELECT_THEME = useSelectStyle();
2528
+ const summary = useMemo(() => `${request.tool}(${formatApprovalArgs(request.input)})`, [request.tool, request.input]);
2529
+ const options = useMemo(() => {
2530
+ return [
2531
+ {
2532
+ name: "accept once · allow this call only",
2533
+ description: "",
2534
+ value: "accept-once"
2535
+ },
2536
+ {
2537
+ name: `accept + remember · add "${suggestSafelistEntry(request.tool, request.input)}" to projects.json`,
2538
+ description: "",
2539
+ value: "accept-safelist"
1329
2540
  },
1330
2541
  {
1331
2542
  name: "deny · refuse — the model will see Blocked",
@@ -1390,29 +2601,72 @@ function BusyBlock() {
1390
2601
  children: /* @__PURE__ */ jsx(Spinner, { label: "streaming response — esc to abort" })
1391
2602
  });
1392
2603
  }
1393
- 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 }) {
1394
2607
  const focused = useModalAwareFocus();
1395
2608
  const COLOR = useColors();
1396
2609
  const textareaRef = useRef(null);
1397
2610
  /** Auto-grow: visible content rows the textarea currently occupies (clamped 1..5). */
1398
2611
  const [contentLines, setContentLines] = useState(MIN_CONTENT_LINES);
1399
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
+ /**
1400
2623
  * History navigation state. `null` = not navigating (textarea owns its content).
1401
2624
  * Once the user enters history (up at top), we snapshot the draft and cycle.
1402
2625
  */
1403
2626
  const historyRef = useRef(null);
1404
- const syncLines = useCallback(() => {
1405
- const lines = textareaRef.current?.lineCount ?? MIN_CONTENT_LINES;
1406
- 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));
1407
2647
  }, []);
1408
2648
  const submit = useCallback(() => {
1409
2649
  const value = textareaRef.current?.plainText ?? "";
1410
2650
  if (!value.trim()) return;
1411
- onSubmit(value);
2651
+ onSubmit(value, completion.references);
1412
2652
  textareaRef.current?.clear();
1413
2653
  historyRef.current = null;
2654
+ setBufferState({
2655
+ text: "",
2656
+ cursor: 0
2657
+ });
1414
2658
  setContentLines(MIN_CONTENT_LINES);
1415
- }, [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]);
1416
2670
  const cycleHistory = useCallback((direction) => {
1417
2671
  if (userPrompts.length === 0 || !textareaRef.current) return;
1418
2672
  if (historyRef.current === null) historyRef.current = {
@@ -1430,47 +2684,749 @@ function PromptBlock({ userPrompts, onSubmit }) {
1430
2684
  textareaRef.current.gotoBufferEnd();
1431
2685
  historyRef.current.idx = nextIdx;
1432
2686
  }
1433
- syncLines();
1434
- }, [userPrompts, syncLines]);
2687
+ syncBuffer();
2688
+ }, [userPrompts, syncBuffer]);
1435
2689
  /**
1436
- * Up/Down at the buffer boundary cycles prompt history (fish/zsh pattern).
1437
- * Mid-buffer up/down move the cursor normally — handled by the default
1438
- * `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.
1439
2706
  */
1440
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
+ }
1441
2736
  if (event.ctrl || event.shift || event.meta) return;
1442
2737
  if (event.name !== "up" && event.name !== "down") return;
1443
2738
  const buffer = textareaRef.current;
1444
2739
  if (!buffer) return;
1445
2740
  const cursorRow = buffer.logicalCursor.row;
1446
- if (event.name === "up" && cursorRow === 0) cycleHistory(-1);
1447
- else if (event.name === "down" && cursorRow === buffer.lineCount - 1) cycleHistory(1);
1448
- }, [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
+ ]);
1449
2754
  const boxHeight = Math.min(MAX_CONTENT_LINES, contentLines) + 2;
1450
- return /* @__PURE__ */ jsx("box", {
2755
+ return /* @__PURE__ */ jsxs("box", {
1451
2756
  style: {
1452
- border: true,
1453
- borderColor: COLOR.borderActive,
1454
- paddingLeft: 1,
1455
- paddingRight: 1,
1456
- height: boxHeight,
1457
- flexDirection: "column"
2757
+ flexDirection: "column",
2758
+ flexShrink: 0
1458
2759
  },
1459
- children: /* @__PURE__ */ jsx("textarea", {
1460
- ref: textareaRef,
1461
- focused,
1462
- keyBindings: TEXTAREA_BINDINGS,
1463
- placeholder: "Ask zidane… (enter = send · shift+enter = newline · ↑↓ at edges = history)",
2760
+ children: [/* @__PURE__ */ jsxs("box", {
1464
2761
  style: {
1465
- flexGrow: 1,
1466
- height: "100%"
2762
+ flexDirection: "column",
2763
+ flexShrink: 0
1467
2764
  },
1468
- onSubmit: submit,
1469
- onContentChange: syncLines,
1470
- onKeyDown
1471
- })
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
+ ]
2890
+ });
2891
+ }
2892
+ //#endregion
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 }) {
2924
+ const COLOR = useColors();
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
+ ]
1472
3382
  });
1473
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
+ }
1474
3430
  //#endregion
1475
3431
  //#region src/tui/settings-modal.tsx
1476
3432
  function SettingsModal({ actions } = {}) {
@@ -1487,6 +3443,20 @@ function SettingsModal({ actions } = {}) {
1487
3443
  ...c
1488
3444
  }));
1489
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
+ });
1490
3460
  if (actions?.onReauth) actionItems.push({
1491
3461
  kind: "action",
1492
3462
  id: "reauth",
@@ -1545,7 +3515,7 @@ function SettingsModal({ actions } = {}) {
1545
3515
  cyclable: item.options.length > 1,
1546
3516
  focused: i === safeCursor
1547
3517
  }),
1548
- item.kind === "action" && /* @__PURE__ */ jsx(ActionRow, {
3518
+ item.kind === "action" && /* @__PURE__ */ jsx(ActionRow$1, {
1549
3519
  label: item.label,
1550
3520
  description: item.description,
1551
3521
  focused: i === safeCursor
@@ -1587,96 +3557,415 @@ function ToggleRow({ label, description, enabled, focused }) {
1587
3557
  fg: focused ? COLOR.brand : COLOR.dim,
1588
3558
  children: [
1589
3559
  /* @__PURE__ */ jsx("span", {
1590
- fg: focused ? COLOR.brand : COLOR.mute,
1591
- children: focused ? "▶ " : " "
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 }) {
3850
+ const COLOR = useColors();
3851
+ if (pending === "fork") return /* @__PURE__ */ jsxs("text", {
3852
+ fg: COLOR.dim,
3853
+ children: [
3854
+ /* @__PURE__ */ jsx("span", {
3855
+ fg: COLOR.warn,
3856
+ children: "fork from here?"
3857
+ }),
3858
+ " press ",
3859
+ /* @__PURE__ */ jsx("span", {
3860
+ fg: COLOR.warn,
3861
+ children: "f"
3862
+ }),
3863
+ " again to confirm · ",
3864
+ /* @__PURE__ */ jsx("span", {
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?"
1592
3877
  }),
3878
+ " press ",
1593
3879
  /* @__PURE__ */ jsx("span", {
1594
- fg: enabled ? COLOR.accent : COLOR.mute,
1595
- children: enabled ? "[✓] " : "[ ] "
3880
+ fg: COLOR.error,
3881
+ children: "d"
1596
3882
  }),
3883
+ " again to confirm · ",
1597
3884
  /* @__PURE__ */ jsx("span", {
1598
- fg: focused ? COLOR.brand : COLOR.dim,
1599
- children: label
3885
+ fg: COLOR.warn,
3886
+ children: "esc"
1600
3887
  }),
1601
- /* @__PURE__ */ jsx("span", {
1602
- fg: COLOR.mute,
1603
- children: ` ${description}`
1604
- })
3888
+ " cancel"
1605
3889
  ]
1606
3890
  });
1607
- }
1608
- /**
1609
- * Choice row — `▶` marker · label · `:` · current value · description.
1610
- *
1611
- * Cycles through `options` on enter/space. When only one option is
1612
- * available (`cyclable=false`) the row still renders with the current
1613
- * value but the enter handler is a no-op — we surface this via the absence
1614
- * of the trailing `›` affordance so it visually reads as informational.
1615
- */
1616
- function ChoiceRow({ label, description, value, cyclable, focused }) {
1617
- const COLOR = useColors();
1618
- return /* @__PURE__ */ jsxs("text", {
1619
- fg: focused ? COLOR.brand : COLOR.dim,
3891
+ if (copyStatus === "copied") return /* @__PURE__ */ jsxs("text", {
3892
+ fg: COLOR.dim,
1620
3893
  children: [
1621
3894
  /* @__PURE__ */ jsx("span", {
1622
- fg: focused ? COLOR.brand : COLOR.mute,
1623
- children: focused ? " " : " "
3895
+ fg: COLOR.accent,
3896
+ children: " copied"
1624
3897
  }),
3898
+ " · ",
1625
3899
  /* @__PURE__ */ jsx("span", {
1626
- fg: focused ? COLOR.brand : COLOR.dim,
1627
- children: label
3900
+ fg: COLOR.warn,
3901
+ children: "f"
1628
3902
  }),
3903
+ " fork · ",
1629
3904
  /* @__PURE__ */ jsx("span", {
1630
- fg: COLOR.mute,
1631
- children: ": "
3905
+ fg: COLOR.warn,
3906
+ children: "d"
1632
3907
  }),
3908
+ " delete · ",
1633
3909
  /* @__PURE__ */ jsx("span", {
1634
- fg: focused ? COLOR.brand : COLOR.accent,
1635
- children: value
3910
+ fg: COLOR.warn,
3911
+ children: "esc"
1636
3912
  }),
3913
+ " close"
3914
+ ]
3915
+ });
3916
+ if (copyStatus === "failed") return /* @__PURE__ */ jsxs("text", {
3917
+ fg: COLOR.dim,
3918
+ children: [
1637
3919
  /* @__PURE__ */ jsx("span", {
1638
- fg: COLOR.mute,
1639
- children: ` ${description}`
3920
+ fg: COLOR.error,
3921
+ children: "copy failed (terminal may not support OSC 52)"
1640
3922
  }),
1641
- focused && cyclable && /* @__PURE__ */ jsx("span", {
1642
- fg: COLOR.brand,
1643
- children: " ↻"
1644
- })
3923
+ " · ",
3924
+ /* @__PURE__ */ jsx("span", {
3925
+ fg: COLOR.warn,
3926
+ children: "esc"
3927
+ }),
3928
+ " close"
1645
3929
  ]
1646
3930
  });
1647
- }
1648
- /**
1649
- * Action row — cursor marker · label · description · (focus-only) trailing arrow.
1650
- *
1651
- * The label sits in the same column as a toggle row's `[✓]` checkbox (right
1652
- * after the 2-col cursor slot). The trailing `›` only renders when focused
1653
- * so it reads as a "this row runs" affordance, not a static decoration on
1654
- * every action.
1655
- */
1656
- function ActionRow({ label, description, focused }) {
1657
- const COLOR = useColors();
1658
3931
  return /* @__PURE__ */ jsxs("text", {
1659
- fg: focused ? COLOR.brand : COLOR.dim,
3932
+ fg: COLOR.dim,
1660
3933
  children: [
1661
3934
  /* @__PURE__ */ jsx("span", {
1662
- fg: focused ? COLOR.brand : COLOR.mute,
1663
- children: focused ? "" : " "
3935
+ fg: COLOR.warn,
3936
+ children: "f"
1664
3937
  }),
3938
+ " fork · ",
1665
3939
  /* @__PURE__ */ jsx("span", {
1666
- fg: focused ? COLOR.brand : COLOR.accent,
1667
- children: label
3940
+ fg: COLOR.warn,
3941
+ children: "d"
1668
3942
  }),
3943
+ " delete · ",
1669
3944
  /* @__PURE__ */ jsx("span", {
1670
- fg: COLOR.mute,
1671
- children: ` ${description}`
3945
+ fg: canCopy ? COLOR.warn : COLOR.mute,
3946
+ children: "c"
1672
3947
  }),
1673
- focused && /* @__PURE__ */ jsx("span", {
1674
- fg: COLOR.brand,
1675
- children: " ›"
1676
- })
3948
+ canCopy ? " copy · " : " (nothing to copy) · ",
3949
+ /* @__PURE__ */ jsx("span", {
3950
+ fg: COLOR.warn,
3951
+ children: "esc"
3952
+ }),
3953
+ " close"
1677
3954
  ]
1678
3955
  });
1679
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
+ }
1680
3969
  //#endregion
1681
3970
  //#region src/tui/app.tsx
1682
3971
  /**
@@ -1722,7 +4011,7 @@ function ThemedShell() {
1722
4011
  const { settings } = useSettings();
1723
4012
  return /* @__PURE__ */ jsx(ThemeProvider, {
1724
4013
  theme: useMemo(() => resolveTheme(settings.theme), [settings.theme]),
1725
- 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, {}) }) }) }) })
1726
4015
  });
1727
4016
  }
1728
4017
  function AppShell() {
@@ -1730,16 +4019,21 @@ function AppShell() {
1730
4019
  const modal = useModal();
1731
4020
  const config = useConfig();
1732
4021
  const { settings } = useSettings();
4022
+ const COLOR = useColors();
4023
+ const SURFACE = useSurfaces();
1733
4024
  const queue = useSafeModeQueue();
1734
4025
  const { requestApproval, resolveHead, denyAll } = useSafeModeActions();
1735
- const { providers: providerRegistry, preset, store, stateStore, modelsFor, resumeProvider, initialPicked, initialState } = config;
4026
+ const { providers: providerRegistry, agents: agentRegistry, initialAgentId, store, stateStore, modelsFor, resumeProvider, initialPicked, initialState } = config;
1736
4027
  const lastResumedSessionId = initialState.lastSessionId;
1737
- const dataDir = config.paths.dir;
4028
+ const dataDir = config.paths.userDir;
4029
+ const [pickedAgent, setPickedAgent] = useState(() => agentRegistry[initialAgentId] ?? Object.values(agentRegistry)[0]);
4030
+ const pickedAgentRef = useRef(pickedAgent);
1738
4031
  const safeModeEnabledRef = useRef(settings.safeMode);
1739
4032
  useEffect(() => {
1740
4033
  safeModeEnabledRef.current = settings.safeMode;
1741
4034
  }, [settings.safeMode]);
1742
4035
  const [projectDir] = useState(() => process.cwd());
4036
+ const [sessionProjectRoot] = useState(() => findGitRoot(process.cwd()) ?? process.cwd());
1743
4037
  const safelistRef = useRef(null);
1744
4038
  const readSafelist = useCallback(() => {
1745
4039
  if (safelistRef.current === null) safelistRef.current = getSafelist(dataDir, projectDir);
@@ -1748,6 +4042,61 @@ function AppShell() {
1748
4042
  useEffect(() => {
1749
4043
  safelistRef.current = null;
1750
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 })], []);
1751
4100
  /**
1752
4101
  * Single source of truth for "should this call execute?". Returns true to
1753
4102
  * let the call through, false to refuse it. Handles three short-circuits:
@@ -1789,6 +4138,13 @@ function AppShell() {
1789
4138
  const [busy, setBusy] = useState(false);
1790
4139
  /** Token count from the most recent assistant turn (caching-aware). */
1791
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);
1792
4148
  const agentRef = useRef(null);
1793
4149
  const sessionRef = useRef(null);
1794
4150
  const stream = useStreamBuffer(setEvents);
@@ -1804,8 +4160,25 @@ function AppShell() {
1804
4160
  const buildAgent = useCallback((session, key) => {
1805
4161
  const descriptor = providerRegistry[key];
1806
4162
  if (!descriptor) throw new Error(`No provider registered for key "${key}"`);
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
+ });
1807
4175
  const agent = createAgent({
1808
- ...preset,
4176
+ ...profile.preset,
4177
+ skills: {
4178
+ ...skillsConfig,
4179
+ ...profile.preset.skills ?? {}
4180
+ },
4181
+ mcpServers: [...projectMcps, ...profile.preset.mcpServers ?? []],
1809
4182
  provider: descriptor.factory(),
1810
4183
  session
1811
4184
  });
@@ -1820,29 +4193,32 @@ function AppShell() {
1820
4193
  agent.hooks.hook("child:tool:gate", (ctx) => applyGate(ctx.name, ctx.input, ctx));
1821
4194
  agent.hooks.hook("mcp:tool:gate", (ctx) => applyGate(ctx.displayName, ctx.input, ctx));
1822
4195
  agent.hooks.hook("child:mcp:tool:gate", (ctx) => applyGate(ctx.displayName, ctx.input, ctx));
1823
- agent.hooks.hook("stream:thinking", ({ delta }) => stream.queueStreamDelta("thinking", delta));
1824
- agent.hooks.hook("stream:text", ({ delta }) => stream.queueStreamDelta("markdown", delta));
1825
- 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 }) => {
1826
4199
  stream.appendImmediate({
1827
4200
  kind: "tool",
1828
4201
  text: toolCallPreview(name, input),
1829
- tool: name
4202
+ tool: name,
4203
+ turnId
1830
4204
  });
1831
4205
  });
1832
- agent.hooks.hook("tool:after", ({ name, result }) => {
4206
+ agent.hooks.hook("tool:after", ({ name, result, turnId }) => {
1833
4207
  const raw = toolResultText(result);
1834
4208
  const text = name === "spawn" ? stripSpawnTokensLine(raw) : raw;
1835
4209
  stream.appendImmediate({
1836
4210
  kind: "tool-result",
1837
4211
  text,
1838
- tool: name
4212
+ tool: name,
4213
+ turnId
1839
4214
  });
1840
4215
  });
1841
- agent.hooks.hook("mcp:tool:after", ({ displayName, result }) => {
4216
+ agent.hooks.hook("mcp:tool:after", ({ displayName, result, turnId }) => {
1842
4217
  stream.appendImmediate({
1843
4218
  kind: "tool-result",
1844
4219
  text: toolResultText(result),
1845
- tool: displayName
4220
+ tool: displayName,
4221
+ turnId
1846
4222
  });
1847
4223
  });
1848
4224
  agent.hooks.hook("turn:after", ({ usage }) => {
@@ -1875,34 +4251,38 @@ function AppShell() {
1875
4251
  depth: depth ?? 1
1876
4252
  });
1877
4253
  });
1878
- agent.hooks.hook("child:stream:thinking", ({ delta, childId, depth }) => {
4254
+ agent.hooks.hook("child:stream:thinking", ({ delta, childId, depth, turnId }) => {
1879
4255
  stream.queueStreamDelta("thinking", delta, {
1880
4256
  childId,
1881
- depth
4257
+ depth,
4258
+ turnId
1882
4259
  });
1883
4260
  });
1884
- agent.hooks.hook("child:stream:text", ({ delta, childId, depth }) => {
4261
+ agent.hooks.hook("child:stream:text", ({ delta, childId, depth, turnId }) => {
1885
4262
  stream.queueStreamDelta("markdown", delta, {
1886
4263
  childId,
1887
- depth
4264
+ depth,
4265
+ turnId
1888
4266
  });
1889
4267
  });
1890
- agent.hooks.hook("child:tool:before", ({ name, input, childId, depth }) => {
4268
+ agent.hooks.hook("child:tool:before", ({ name, input, childId, depth, turnId }) => {
1891
4269
  stream.appendImmediate({
1892
4270
  kind: "tool",
1893
4271
  text: toolCallPreview(name, input),
1894
4272
  tool: name,
1895
4273
  childId,
1896
- depth
4274
+ depth,
4275
+ turnId
1897
4276
  });
1898
4277
  });
1899
- agent.hooks.hook("child:tool:after", ({ name, result, childId, depth }) => {
4278
+ agent.hooks.hook("child:tool:after", ({ name, result, childId, depth, turnId }) => {
1900
4279
  stream.appendImmediate({
1901
4280
  kind: "tool-result",
1902
4281
  text: toolResultText(result),
1903
4282
  tool: name,
1904
4283
  childId,
1905
- depth
4284
+ depth,
4285
+ turnId
1906
4286
  });
1907
4287
  });
1908
4288
  agent.hooks.hook("child:stream:end", ({ childId }) => {
@@ -1911,35 +4291,53 @@ function AppShell() {
1911
4291
  return agent;
1912
4292
  }, [
1913
4293
  providerRegistry,
1914
- preset,
1915
4294
  stream,
1916
- gateDecision
4295
+ gateDecision,
4296
+ projectDir,
4297
+ config.prefix
1917
4298
  ]);
1918
4299
  const refreshSessions = useCallback(async () => {
1919
- const list = await listSessionMeta(store);
4300
+ const list = await listSessionMeta(store, settings.showAllProjects ? void 0 : { projectRoot: sessionProjectRoot });
1920
4301
  setSessions(list);
1921
4302
  return list;
1922
- }, [store]);
4303
+ }, [
4304
+ store,
4305
+ settings.showAllProjects,
4306
+ sessionProjectRoot
4307
+ ]);
1923
4308
  const teardown = useCallback(async () => {
4309
+ try {
4310
+ denyAll();
4311
+ } catch (err) {
4312
+ debugLog("teardown: denyAll failed", err);
4313
+ }
4314
+ try {
4315
+ agentRef.current?.abort();
4316
+ } catch (err) {
4317
+ debugLog("teardown: agent.abort failed", err);
4318
+ }
1924
4319
  stream.reset();
1925
4320
  await agentRef.current?.destroy().catch((err) => debugLog("agent.destroy failed", err));
1926
4321
  agentRef.current = null;
1927
4322
  sessionRef.current = null;
1928
- }, [stream]);
4323
+ }, [stream, denyAll]);
1929
4324
  const activateSession = useCallback(async (id, key) => {
1930
4325
  await teardown();
1931
4326
  const session = (id ? await loadSession(store, id) : null) ?? await createSession({
1932
4327
  store,
4328
+ projectRoot: sessionProjectRoot,
1933
4329
  ...id ? { id } : {}
1934
4330
  });
1935
4331
  sessionRef.current = session;
1936
4332
  agentRef.current = buildAgent(session, key);
1937
4333
  setEvents(eventsFromTurns(session.turns, session.runs));
1938
- setLastInputTokens(lastContextSizeFromTurns(session.turns));
4334
+ setLastInputTokens(lastContextSizeFromTurns(session.turns, session.runs));
1939
4335
  setCurrentSession({
1940
4336
  id: session.id,
1941
- title: titleFromTurns(session.turns) ?? "untitled",
4337
+ title: deriveSessionTitle(session.turns, session.metadata),
1942
4338
  turnCount: session.turns.length,
4339
+ userMessageCount: session.turns.reduce((n, t) => t.role === "user" ? n + 1 : n, 0),
4340
+ runCount: session.runs.length,
1943
4341
  updatedAt: Date.now()
1944
4342
  });
1945
4343
  setScreen("chat");
@@ -1952,7 +4350,8 @@ function AppShell() {
1952
4350
  teardown,
1953
4351
  buildAgent,
1954
4352
  store,
1955
- stateStore
4353
+ stateStore,
4354
+ sessionProjectRoot
1956
4355
  ]);
1957
4356
  useEffect(() => {
1958
4357
  if (!resumeProvider) return;
@@ -2007,7 +4406,12 @@ function AppShell() {
2007
4406
  await refreshSessions();
2008
4407
  setScreen("sessions");
2009
4408
  }, [refreshSessions]);
4409
+ const popupOpenRef = useRef(false);
4410
+ const onPopupOpenChange = useCallback((open) => {
4411
+ popupOpenRef.current = open;
4412
+ }, []);
2010
4413
  const onAbort = useCallback(() => {
4414
+ if (popupOpenRef.current) return;
2011
4415
  denyAll();
2012
4416
  agentRef.current?.abort();
2013
4417
  }, [denyAll]);
@@ -2029,19 +4433,59 @@ function AppShell() {
2029
4433
  });
2030
4434
  modal.close();
2031
4435
  }, [modal, stateStore]);
2032
- const onSubmitPrompt = useCallback(async (prompt) => {
4436
+ const onPickAgent = useCallback(async (id) => {
4437
+ const profile = agentRegistry[id];
4438
+ if (!profile) return;
4439
+ pickedAgentRef.current = profile;
4440
+ setPickedAgent(profile);
4441
+ stateStore.save({
4442
+ ...stateStore.load(),
4443
+ lastAgent: id
4444
+ });
4445
+ modal.close();
4446
+ if (picked && currentSession && !busy) await activateSession(currentSession.id, picked.provider.key);
4447
+ }, [
4448
+ agentRegistry,
4449
+ picked,
4450
+ currentSession,
4451
+ busy,
4452
+ activateSession,
4453
+ stateStore,
4454
+ modal
4455
+ ]);
4456
+ const onCycleAgent = useCallback(async () => {
4457
+ const ids = Object.keys(agentRegistry);
4458
+ if (ids.length <= 1) return;
4459
+ const nextId = ids[(ids.indexOf(pickedAgentRef.current.id) + 1) % ids.length];
4460
+ await onPickAgent(nextId);
4461
+ }, [agentRegistry, onPickAgent]);
4462
+ const eventsLengthRef = useRef(0);
4463
+ eventsLengthRef.current = events.length;
4464
+ const onSubmitPrompt = useCallback(async (prompt, references) => {
2033
4465
  const agent = agentRef.current;
2034
4466
  const session = sessionRef.current;
2035
4467
  if (!agent || !session || !picked || !prompt.trim()) return;
2036
- if (events.length > 0) stream.appendImmediate({
4468
+ if (eventsLengthRef.current > 0) stream.appendImmediate({
2037
4469
  kind: "separator",
2038
4470
  text: ""
2039
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
+ }));
2040
4477
  stream.appendImmediate({
2041
- kind: "info",
2042
- text: `❯ ${prompt}`
4478
+ kind: "user-prompt",
4479
+ text: prompt,
4480
+ ...refSpans.length > 0 ? { refs: refSpans } : {}
2043
4481
  });
2044
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
+ }
2045
4489
  try {
2046
4490
  await agent.run({
2047
4491
  model: picked.model,
@@ -2050,8 +4494,10 @@ function AppShell() {
2050
4494
  await session.save().catch((err) => debugLog("session.save failed", err));
2051
4495
  setCurrentSession((prev) => prev ? {
2052
4496
  ...prev,
2053
- title: titleFromTurns(session.turns) ?? prev.title,
4497
+ title: deriveSessionTitle(session.turns, session.metadata),
2054
4498
  turnCount: session.turns.length,
4499
+ userMessageCount: session.turns.reduce((n, t) => t.role === "user" ? n + 1 : n, 0),
4500
+ runCount: session.runs.length,
2055
4501
  updatedAt: Date.now()
2056
4502
  } : prev);
2057
4503
  } catch (err) {
@@ -2063,22 +4509,296 @@ function AppShell() {
2063
4509
  stream.flushAndUpdate(finalizeStreamingMarkdown);
2064
4510
  setBusy(false);
2065
4511
  }
4512
+ }, [picked, stream]);
4513
+ const pendingApproval = queue[0] ?? null;
4514
+ const onReauth = useMemo(() => {
4515
+ if (busy || pendingApproval) return void 0;
4516
+ return () => {
4517
+ modal.close();
4518
+ setScreen("auth");
4519
+ };
4520
+ }, [
4521
+ modal,
4522
+ busy,
4523
+ pendingApproval
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]);
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);
2066
4572
  }, [
2067
4573
  picked,
2068
- events.length,
2069
- stream
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
2070
4762
  ]);
2071
- const onReauth = useCallback(() => {
2072
- modal.close();
2073
- setScreen("auth");
2074
- }, [modal]);
2075
- const pendingApproval = queue[0] ?? null;
2076
4763
  useKeyboard((key) => {
2077
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
+ }
2078
4784
  if (key.ctrl && key.name === "," && screen !== "auth") {
2079
- modal.open(/* @__PURE__ */ jsx(SettingsModal, { actions: { onReauth } }));
4785
+ modal.open(/* @__PURE__ */ jsx(SettingsModal, { actions: {
4786
+ onReauth,
4787
+ onOpenSkills: onOpenSkillsSettings,
4788
+ onOpenMcps: onOpenMcpsSettings
4789
+ } }));
2080
4790
  return;
2081
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
+ }
2082
4802
  if (key.ctrl && key.name === "m" && screen === "chat" && picked && !busy) {
2083
4803
  modal.open(/* @__PURE__ */ jsx(ModelPickerModal, {
2084
4804
  models: modelsFor(picked.provider.key),
@@ -2087,6 +4807,22 @@ function AppShell() {
2087
4807
  }));
2088
4808
  return;
2089
4809
  }
4810
+ if (key.ctrl && key.name === "s" && screen === "chat" && !busy && !pendingApproval) {
4811
+ enterSelectMode();
4812
+ return;
4813
+ }
4814
+ if (key.ctrl && key.name === "a" && screen === "chat" && hasMultipleAgents && !busy) {
4815
+ modal.open(/* @__PURE__ */ jsx(AgentPickerModal, {
4816
+ agents: agentRegistry,
4817
+ currentAgentId: pickedAgent.id,
4818
+ onPick: onPickAgent
4819
+ }));
4820
+ return;
4821
+ }
4822
+ if (key.shift && key.name === "tab" && screen === "chat" && hasMultipleAgents && !busy) {
4823
+ onCycleAgent();
4824
+ return;
4825
+ }
2090
4826
  if (key.name !== "escape") return;
2091
4827
  if (busy || pendingApproval) return onAbort();
2092
4828
  if (screen === "chat") return onOpenSessions();
@@ -2101,11 +4837,43 @@ function AppShell() {
2101
4837
  }
2102
4838
  renderer.destroy();
2103
4839
  });
2104
- const hints = useMemo(() => buildHints(screen, busy, !!pendingApproval, currentSession), [
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
+ }), [
2105
4851
  screen,
2106
4852
  busy,
2107
4853
  pendingApproval,
2108
- currentSession
4854
+ currentSession,
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
2109
4877
  ]);
2110
4878
  const contextUsage = useMemo(() => {
2111
4879
  if (screen !== "chat" || !picked) return null;
@@ -2142,8 +4910,12 @@ function AppShell() {
2142
4910
  screen === "sessions" && /* @__PURE__ */ jsx(SessionsScreen, {
2143
4911
  sessions,
2144
4912
  currentId: currentSession?.id ?? null,
4913
+ focusedSessionId,
2145
4914
  onPick: onSwitchSession,
2146
- onCreate: onCreateSession
4915
+ onCreate: onCreateSession,
4916
+ onFocusChange: setFocusedSessionId,
4917
+ showAllProjects: settings.showAllProjects,
4918
+ currentProjectRoot: sessionProjectRoot
2147
4919
  }),
2148
4920
  screen === "chat" && /* @__PURE__ */ jsx(ChatScreen, {
2149
4921
  events,
@@ -2152,17 +4924,26 @@ function AppShell() {
2152
4924
  onSubmit: onSubmitPrompt,
2153
4925
  session: currentSession,
2154
4926
  pending: pendingApproval,
2155
- onApproval: resolveHead
4927
+ onApproval: resolveHead,
4928
+ completionProviders,
4929
+ onPopupOpenChange,
4930
+ selectedTurnId,
4931
+ promptTriggerHints
2156
4932
  })
2157
4933
  ]
2158
4934
  }), /* @__PURE__ */ jsx(Footer, {
2159
4935
  hints,
2160
- picked,
2161
4936
  context: contextUsage
2162
4937
  })]
2163
4938
  });
2164
4939
  }
2165
- function buildHints(screen, busy, pending, currentSession) {
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 }) {
2166
4947
  if (pending) return [
2167
4948
  {
2168
4949
  key: "↑↓",
@@ -2204,6 +4985,10 @@ function buildHints(screen, busy, pending, currentSession) {
2204
4985
  key: "↵",
2205
4986
  label: "open"
2206
4987
  },
4988
+ {
4989
+ key: "ctrl+x",
4990
+ label: "session"
4991
+ },
2207
4992
  {
2208
4993
  key: "ctrl+,",
2209
4994
  label: "settings"
@@ -2214,14 +4999,20 @@ function buildHints(screen, busy, pending, currentSession) {
2214
4999
  }
2215
5000
  ];
2216
5001
  return [
2217
- {
2218
- key: "",
2219
- label: "send"
2220
- },
2221
- {
5002
+ ...hasMultipleAgents ? [{
5003
+ key: "shift+tab",
5004
+ label: agentLabel,
5005
+ labelColor: agentColor
5006
+ }] : [],
5007
+ ...modelLabel ? [{
2222
5008
  key: "ctrl+m",
2223
- label: "model"
2224
- },
5009
+ label: modelLabel,
5010
+ labelColor: modelColor
5011
+ }] : [],
5012
+ ...currentSession ? [{
5013
+ key: "ctrl+x",
5014
+ label: "session"
5015
+ }] : [],
2225
5016
  {
2226
5017
  key: "ctrl+,",
2227
5018
  label: "settings"
@@ -2363,7 +5154,7 @@ let runTuiInvoked = false;
2363
5154
  * to `runTui({ storageDir, prefix })`.
2364
5155
  *
2365
5156
  * ```ts
2366
- * import { BUILTIN_PROVIDERS } from 'zidane/chat'
5157
+ * import { BUILTIN_AGENTS, BUILTIN_PROVIDERS } from 'zidane/chat'
2367
5158
  * import { runTui } from 'zidane/tui'
2368
5159
  * import { createRemoteStore } from 'zidane/session' // for the `store` option
2369
5160
  *
@@ -2372,6 +5163,7 @@ let runTuiInvoked = false;
2372
5163
  * await runTui({ storageDir: '/data', prefix: 'myapp' })
2373
5164
  * await runTui({ providers: { ...BUILTIN_PROVIDERS, mine: myDescriptor } })
2374
5165
  * await runTui({ store: createRemoteStore({ url: '…' }) })
5166
+ * await runTui({ agents: { ...BUILTIN_AGENTS, debug: myDebugProfile } })
2375
5167
  * ```
2376
5168
  */
2377
5169
  async function runTui(options = {}) {
@@ -2381,19 +5173,23 @@ async function runTui(options = {}) {
2381
5173
  const cause = err instanceof Error ? err.message : String(err);
2382
5174
  process.stderr.write(`[zidane/tui] tree-sitter setup failed: ${cause}\n`);
2383
5175
  });
2384
- const config = resolveConfig(options);
5176
+ const config = resolveConfig({
5177
+ ...options,
5178
+ store: options.store ?? ((paths) => createTuiStore(paths.db))
5179
+ });
2385
5180
  let done = () => {};
2386
5181
  const exited = new Promise((resolve) => {
2387
5182
  done = resolve;
2388
5183
  });
2389
5184
  createRoot(await createCliRenderer({
2390
5185
  exitOnCtrlC: true,
2391
- onDestroy: () => done()
5186
+ onDestroy: () => done(),
5187
+ debounceDelay: 0
2392
5188
  })).render(/* @__PURE__ */ jsx(App, { config }));
2393
5189
  await exited;
2394
5190
  process.exit(0);
2395
5191
  }
2396
5192
  //#endregion
2397
- export { App, AuthScreen, ChatScreen, Footer, Modal, ModalRoot, ModelPickerModal, SessionsScreen, SettingsModal, Spinner, Transcript, 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 };
2398
5194
 
2399
5195
  //# sourceMappingURL=tui.js.map