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