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