acryldev 0.1.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/LICENSE +21 -0
- package/README.md +32 -0
- package/bin.js +4303 -0
- package/package.json +207 -0
package/bin.js
ADDED
|
@@ -0,0 +1,4303 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { join, relative, resolve } from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
8
|
+
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
9
|
+
import { writeFileSync } from "node:fs";
|
|
10
|
+
import { DEFAULT_PROFILE_BUNDLES, boot, composeEntries, healProfilesModuleFallback, initProfile, loadProfile, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
11
|
+
import { diffLines } from "diff";
|
|
12
|
+
import { Container, Editor, Key, KeybindingsManager, ProcessTerminal, ScrollView, TUI_KEYBINDINGS, Text, TuiAltScreen, VStack, matchesKey, setKeybindings, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
13
|
+
import { readdir } from "node:fs/promises";
|
|
14
|
+
//#region src/cli/node-launcher.ts
|
|
15
|
+
/** Return the child Node invocation required to expose Cordis HMR internals. */
|
|
16
|
+
function exposedInternalsInvocation(input) {
|
|
17
|
+
if (input.execArgv.includes("--expose-internals")) return void 0;
|
|
18
|
+
return [
|
|
19
|
+
"--expose-internals",
|
|
20
|
+
...input.execArgv,
|
|
21
|
+
input.script,
|
|
22
|
+
...input.args
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
/** Re-execute this CLI under Node with the Cordis HMR prerequisite enabled. */
|
|
26
|
+
async function relaunchWithExposedInternals(input) {
|
|
27
|
+
const invocation = exposedInternalsInvocation(input);
|
|
28
|
+
if (invocation === void 0) return false;
|
|
29
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
30
|
+
const child = spawn(process.execPath, invocation, { stdio: "inherit" });
|
|
31
|
+
child.once("error", reject);
|
|
32
|
+
child.once("exit", (code) => resolve(code ?? 1));
|
|
33
|
+
});
|
|
34
|
+
process.exitCode = exitCode;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region ../acryl-harness-runtime/lib/index.mjs
|
|
39
|
+
function contentText(content) {
|
|
40
|
+
return content.filter((block) => {
|
|
41
|
+
return block.type === "text" && typeof block.text === "string";
|
|
42
|
+
}).map((block) => block.text).join("");
|
|
43
|
+
}
|
|
44
|
+
function transcript(events) {
|
|
45
|
+
const items = [];
|
|
46
|
+
for (const event of events) {
|
|
47
|
+
if (event.type === "user/message" && event.data.source.kind === "user") {
|
|
48
|
+
const text = contentText(event.data.content);
|
|
49
|
+
if (text !== "") items.push(Object.freeze({
|
|
50
|
+
id: `event-${event.seq}`,
|
|
51
|
+
author: "user",
|
|
52
|
+
text
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
if (event.type === "assistant/message") {
|
|
56
|
+
const text = contentText(event.data.message.content);
|
|
57
|
+
if (text !== "") items.push(Object.freeze({
|
|
58
|
+
id: `event-${event.seq}`,
|
|
59
|
+
author: "assistant",
|
|
60
|
+
text
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return Object.freeze(items);
|
|
65
|
+
}
|
|
66
|
+
function tools(events) {
|
|
67
|
+
const current = /* @__PURE__ */ new Map();
|
|
68
|
+
for (const event of events) {
|
|
69
|
+
if (event.type === "tool/call") current.set(event.data.callId, Object.freeze({
|
|
70
|
+
callId: event.data.callId,
|
|
71
|
+
name: event.data.name,
|
|
72
|
+
status: "running"
|
|
73
|
+
}));
|
|
74
|
+
if (event.type === "tool/result") {
|
|
75
|
+
const callId = event.data.message.source.callId;
|
|
76
|
+
const existing = current.get(callId);
|
|
77
|
+
if (existing !== void 0) current.set(callId, Object.freeze({
|
|
78
|
+
...existing,
|
|
79
|
+
status: "succeeded"
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return Object.freeze([...current.values()]);
|
|
84
|
+
}
|
|
85
|
+
function status(agent) {
|
|
86
|
+
return agent.status === "running" ? "running" : "idle";
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Runtime-owned adapter over one native DSH agent/session. It creates or resumes
|
|
90
|
+
* the native agent and derives every presentation value from its durable log.
|
|
91
|
+
*/
|
|
92
|
+
function createAcrylSessionBridge(ctx, options) {
|
|
93
|
+
const handles = /* @__PURE__ */ new Map();
|
|
94
|
+
const subscribers = /* @__PURE__ */ new Map();
|
|
95
|
+
const eventListeners = /* @__PURE__ */ new Map();
|
|
96
|
+
let disposed = false;
|
|
97
|
+
const notify = (sessionId) => {
|
|
98
|
+
const listeners = subscribers.get(sessionId);
|
|
99
|
+
if (listeners === void 0) return;
|
|
100
|
+
snapshot(sessionId).then((next) => {
|
|
101
|
+
for (const listener of listeners) try {
|
|
102
|
+
listener(next);
|
|
103
|
+
} catch {}
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
const offSessionEvent = ctx.on("session/event", (session, event) => {
|
|
107
|
+
if (!handles.has(session.id)) return;
|
|
108
|
+
notify(session.id);
|
|
109
|
+
const listeners = eventListeners.get(session.id);
|
|
110
|
+
if (listeners === void 0) return;
|
|
111
|
+
for (const listener of listeners) try {
|
|
112
|
+
listener(event);
|
|
113
|
+
} catch {}
|
|
114
|
+
});
|
|
115
|
+
const snapshot = async (sessionId) => {
|
|
116
|
+
const agent = agentFor(sessionId);
|
|
117
|
+
return Object.freeze({
|
|
118
|
+
profile: options.profile,
|
|
119
|
+
generationId: options.generationId,
|
|
120
|
+
attachment: options.attachment,
|
|
121
|
+
sessionId: agent.id,
|
|
122
|
+
agentStatus: status(agent),
|
|
123
|
+
transcript: transcript(agent.session.events),
|
|
124
|
+
tools: tools(agent.session.events)
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
const agentFor = (sessionId) => {
|
|
128
|
+
if (disposed) throw new Error("ACRYL session bridge is disposed");
|
|
129
|
+
const handle = handles.get(sessionId);
|
|
130
|
+
if (handle === void 0) throw new Error(`ACRYL session ${sessionId} is not active`);
|
|
131
|
+
return handle.agent;
|
|
132
|
+
};
|
|
133
|
+
return Object.freeze({
|
|
134
|
+
async open(resumeSessionId) {
|
|
135
|
+
if (disposed) throw new Error("ACRYL session bridge is disposed");
|
|
136
|
+
if (handles.size !== 0) throw new Error("ACRYL session bridge already has an active session");
|
|
137
|
+
const defaultModel = ctx.get("agentDefaultModel");
|
|
138
|
+
if (defaultModel === void 0) throw new Error("ACRYL profile has no default agent model");
|
|
139
|
+
const selection = defaultModel.currentSelection();
|
|
140
|
+
const handle = resumeSessionId === void 0 ? await ctx.agents.create({
|
|
141
|
+
sessionId: SessionId(`acryl-session-${crypto.randomUUID()}`),
|
|
142
|
+
meta: { cwd: options.cwd },
|
|
143
|
+
agentOptions: {
|
|
144
|
+
provider: selection.provider,
|
|
145
|
+
model: selection.model
|
|
146
|
+
}
|
|
147
|
+
}) : await ctx.agents.resume({
|
|
148
|
+
resumeSessionId: SessionId(resumeSessionId),
|
|
149
|
+
agentOptions: {
|
|
150
|
+
provider: selection.provider,
|
|
151
|
+
model: selection.model
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
handles.set(handle.agent.id, handle);
|
|
155
|
+
return handle.agent.id;
|
|
156
|
+
},
|
|
157
|
+
snapshot,
|
|
158
|
+
events(sessionId) {
|
|
159
|
+
return agentFor(sessionId).session.events;
|
|
160
|
+
},
|
|
161
|
+
async subscribe(sessionId, listener, _onError) {
|
|
162
|
+
agentFor(sessionId);
|
|
163
|
+
const listeners = subscribers.get(sessionId) ?? /* @__PURE__ */ new Set();
|
|
164
|
+
subscribers.set(sessionId, listeners);
|
|
165
|
+
listeners.add(listener);
|
|
166
|
+
try {
|
|
167
|
+
listener(await snapshot(sessionId));
|
|
168
|
+
} catch {}
|
|
169
|
+
let active = true;
|
|
170
|
+
return Object.freeze({
|
|
171
|
+
whenError() {
|
|
172
|
+
return new Promise(() => {});
|
|
173
|
+
},
|
|
174
|
+
async dispose() {
|
|
175
|
+
if (!active) return;
|
|
176
|
+
active = false;
|
|
177
|
+
listeners.delete(listener);
|
|
178
|
+
if (listeners.size === 0) subscribers.delete(sessionId);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
async subscribeEvents(sessionId, listener) {
|
|
183
|
+
agentFor(sessionId);
|
|
184
|
+
const listeners = eventListeners.get(sessionId) ?? /* @__PURE__ */ new Set();
|
|
185
|
+
eventListeners.set(sessionId, listeners);
|
|
186
|
+
listeners.add(listener);
|
|
187
|
+
let active = true;
|
|
188
|
+
return Object.freeze({ async dispose() {
|
|
189
|
+
if (!active) return;
|
|
190
|
+
active = false;
|
|
191
|
+
listeners.delete(listener);
|
|
192
|
+
if (listeners.size === 0) eventListeners.delete(sessionId);
|
|
193
|
+
} });
|
|
194
|
+
},
|
|
195
|
+
async submitPrompt(input) {
|
|
196
|
+
const agent = agentFor(input.sessionId);
|
|
197
|
+
if (input.text.trim() === "") throw new Error("ACRYL prompt must not be empty");
|
|
198
|
+
const accepted = new Promise((resolve) => {
|
|
199
|
+
const off = ctx.on("session/event", (session, event) => {
|
|
200
|
+
if (session !== agent.session || event.type !== "user/message") return;
|
|
201
|
+
off();
|
|
202
|
+
resolve();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
agent.followup(createUserMessage({
|
|
206
|
+
content: [{
|
|
207
|
+
type: "text",
|
|
208
|
+
text: input.text
|
|
209
|
+
}],
|
|
210
|
+
source: { kind: "user" }
|
|
211
|
+
}));
|
|
212
|
+
await accepted;
|
|
213
|
+
},
|
|
214
|
+
async cancel(sessionId) {
|
|
215
|
+
agentFor(sessionId).cancel({ kind: "user" });
|
|
216
|
+
},
|
|
217
|
+
async dispose() {
|
|
218
|
+
if (disposed) return;
|
|
219
|
+
disposed = true;
|
|
220
|
+
offSessionEvent();
|
|
221
|
+
subscribers.clear();
|
|
222
|
+
eventListeners.clear();
|
|
223
|
+
const activeHandles = [...handles.values()];
|
|
224
|
+
handles.clear();
|
|
225
|
+
const sessions = ctx.get("sessions");
|
|
226
|
+
for (const handle of activeHandles) {
|
|
227
|
+
try {
|
|
228
|
+
await handle.agent.whenIdle();
|
|
229
|
+
} catch {}
|
|
230
|
+
try {
|
|
231
|
+
await sessions?.flush(handle.agent.session);
|
|
232
|
+
} catch {}
|
|
233
|
+
}
|
|
234
|
+
await Promise.all(activeHandles.map((handle) => handle.dispose()));
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
const dshInstallAnchor = createRequire(import.meta.url).resolve("@deepseek-ai/dsh/package.json");
|
|
239
|
+
const profileRoot = "[]\n";
|
|
240
|
+
const ACRYL_RUNTIME_ROWS = [
|
|
241
|
+
{
|
|
242
|
+
id: "system-prompt",
|
|
243
|
+
name: "@deepseek-ai/dsh-system-prompt",
|
|
244
|
+
config: { persona: "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}." }
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
id: "agent-presets",
|
|
248
|
+
name: "@deepseek-ai/dsh-agent-presets",
|
|
249
|
+
config: { default: "standard" }
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
id: "session-stats",
|
|
253
|
+
name: "@deepseek-ai/dsh-session-stats"
|
|
254
|
+
}
|
|
255
|
+
];
|
|
256
|
+
/** Boot one normal pinned-Harness ACRYL profile in a single Cordis root. */
|
|
257
|
+
async function bootAcrylHarnessProfile(options) {
|
|
258
|
+
if (options.profile.trim() === "") throw new Error("ACRYL Harness profile must not be empty");
|
|
259
|
+
initProfile(resolveProfileDir(options.profile), DEFAULT_PROFILE_BUNDLES);
|
|
260
|
+
healProfilesModuleFallback(dshInstallAnchor);
|
|
261
|
+
const profile = loadProfile("acryl", options.profile, dshInstallAnchor);
|
|
262
|
+
const rootConfig = join(profile.dir, "cordis.yml");
|
|
263
|
+
writeFileSync(rootConfig, profileRoot);
|
|
264
|
+
const patches = structuredClone([
|
|
265
|
+
...profile.layers.flatMap((layer) => layer.patches),
|
|
266
|
+
...ACRYL_RUNTIME_ROWS,
|
|
267
|
+
...profile.patches
|
|
268
|
+
]);
|
|
269
|
+
if (composeEntries([patches]).find((entry) => entry.id === "hmr")?.disabled !== true && !process.execArgv.includes("--expose-internals")) throw new Error("ACRYL profile enables Cordis HMR and must be launched with Node --expose-internals");
|
|
270
|
+
const ctx = await boot("acryl", rootConfig, patches, options.prepare);
|
|
271
|
+
let disposed = false;
|
|
272
|
+
return Object.freeze({
|
|
273
|
+
ctx,
|
|
274
|
+
profileDirectory: profile.dir,
|
|
275
|
+
async dispose() {
|
|
276
|
+
if (disposed) return;
|
|
277
|
+
disposed = true;
|
|
278
|
+
await ctx.fiber.dispose();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
//#endregion
|
|
283
|
+
//#region src/host/direct.ts
|
|
284
|
+
/** Start one normal local Harness runtime for this terminal surface. */
|
|
285
|
+
/**
|
|
286
|
+
* A local surface owns its normal DSH/Cordis root. Durable DSH sessions, not
|
|
287
|
+
* `.acryl/control` experiments, provide continuity across later launches.
|
|
288
|
+
*/
|
|
289
|
+
async function startDirectHost(options) {
|
|
290
|
+
if (options.profile.trim() === "") throw new Error("ACRYL direct host profile must not be empty");
|
|
291
|
+
const runtime = await bootAcrylHarnessProfile({ profile: options.profile });
|
|
292
|
+
const ctx = runtime.ctx;
|
|
293
|
+
let disposed = false;
|
|
294
|
+
return Object.freeze({
|
|
295
|
+
ctx,
|
|
296
|
+
profile: options.profile,
|
|
297
|
+
generationId: options.generationId ?? randomUUID(),
|
|
298
|
+
runtimeState: ctx.get("sessions") !== void 0 && ctx.get("agents") !== void 0 ? "ready" : "unavailable",
|
|
299
|
+
async dispose() {
|
|
300
|
+
if (disposed) return;
|
|
301
|
+
disposed = true;
|
|
302
|
+
await runtime.dispose();
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region src/tui/theme.ts
|
|
308
|
+
/**
|
|
309
|
+
* DeepSeek brand palette for the TUI. Keep color decisions semantic so every
|
|
310
|
+
* Ink surface and the raw-ANSI `render.ts`/`bannerText.ts` helpers share the
|
|
311
|
+
* same visual language — see `DESIGN.md` for the full rationale and the
|
|
312
|
+
* component-mapping guide this table is drawn from.
|
|
313
|
+
*
|
|
314
|
+
* The terminal still owns its background and default foreground; these are
|
|
315
|
+
* foreground tokens for interactive, stateful, and brand elements only —
|
|
316
|
+
* this TUI renders to native scrollback (no painted panel backgrounds), so
|
|
317
|
+
* DeepSeek's `surface`/`bg-dark`/`border-dim` tokens are deliberately not
|
|
318
|
+
* represented here.
|
|
319
|
+
* @module @tomowang/dsh-tui/tui/theme
|
|
320
|
+
*/
|
|
321
|
+
const theme = {
|
|
322
|
+
/** DeepSeek Blue — brand banner/ASCII, active input border. */
|
|
323
|
+
primary: "#4F6BFE",
|
|
324
|
+
/** Electric Cyan — section headers, streaming/progress indicators. */
|
|
325
|
+
secondary: "#38BDF8",
|
|
326
|
+
/** Slate Indigo — badges (active provider/model). */
|
|
327
|
+
accent: "#818CF8",
|
|
328
|
+
/** Thought Violet — reasoning/thinking content, set apart from assistant text; see `formatReasoningSummary`/`formatStreamingText` in `src/render.ts`. */
|
|
329
|
+
reasoning: "#A855F7",
|
|
330
|
+
/** Mint Emerald. */
|
|
331
|
+
success: "#34D399",
|
|
332
|
+
/** Amber Sun. */
|
|
333
|
+
warning: "#FBBF24",
|
|
334
|
+
/** Coral Red. */
|
|
335
|
+
error: "#F87171",
|
|
336
|
+
/** DeepSeek uses its primary blue for informational UI. */
|
|
337
|
+
info: "#4F6BFE",
|
|
338
|
+
/** Slate Gray — dim/secondary text (labels, hints, timestamps). */
|
|
339
|
+
muted: "#94A3B8"
|
|
340
|
+
};
|
|
341
|
+
/** 24-bit-color ANSI wrapper, shared by every raw-ANSI formatter (`render.ts`, `markdown.ts`, `bannerText.ts`) and every pi-tui component theme adapter. */
|
|
342
|
+
function fg(hex) {
|
|
343
|
+
const n = Number.parseInt(hex.slice(1), 16);
|
|
344
|
+
const r = n >> 16 & 255;
|
|
345
|
+
const g = n >> 8 & 255;
|
|
346
|
+
const b = n & 255;
|
|
347
|
+
return (s) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`;
|
|
348
|
+
}
|
|
349
|
+
//#endregion
|
|
350
|
+
//#region src/markdown.ts
|
|
351
|
+
/**
|
|
352
|
+
* Terminal Markdown rendering for assistant text. `render.ts` prints
|
|
353
|
+
* assistant/tool output straight to native scrollback via raw ANSI, so this
|
|
354
|
+
* module detects whether a text blob is (at least partly) Markdown before
|
|
355
|
+
* paying the cost of styling it — plain prose keeps rendering exactly as it
|
|
356
|
+
* always has, only text carrying real Markdown syntax gets headers, bold,
|
|
357
|
+
* lists, code spans, tables, etc. converted to ANSI.
|
|
358
|
+
* @module @tomowang/dsh-tui/markdown
|
|
359
|
+
*/
|
|
360
|
+
const ESC$1 = "\x1B[";
|
|
361
|
+
const dim$3 = fg(theme.muted);
|
|
362
|
+
const cyan$1 = fg(theme.secondary);
|
|
363
|
+
const primary$1 = fg(theme.primary);
|
|
364
|
+
const bold$11 = (s) => `${ESC$1}1m${s}${ESC$1}0m`;
|
|
365
|
+
const italic = (s) => `${ESC$1}3m${s}${ESC$1}0m`;
|
|
366
|
+
const strike = (s) => `${ESC$1}9m${s}${ESC$1}0m`;
|
|
367
|
+
const underline = (s) => `${ESC$1}4m${s}${ESC$1}0m`;
|
|
368
|
+
/** Wrap `label` as an OSC 8 terminal hyperlink to `url`; terminals without OSC 8 support just print `label` and ignore the surrounding escapes. */
|
|
369
|
+
function hyperlink(url, label) {
|
|
370
|
+
return `\x1b]8;;${url}\x1b\\${label}\x1b]8;;\x1b\\`;
|
|
371
|
+
}
|
|
372
|
+
const FENCE_RE = /^(\s*)(`{3,}|~{3,})\s*(\S*)\s*$/;
|
|
373
|
+
const ATX_HEADER_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
|
|
374
|
+
const HR_RE = /^ {0,3}(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})$/;
|
|
375
|
+
const BLOCKQUOTE_RE = /^(\s*)((?:>\s?)+)(.*)$/;
|
|
376
|
+
const UNORDERED_RE = /^(\s*)([-*+])\s+(.*)$/;
|
|
377
|
+
const ORDERED_RE = /^(\s*)(\d+)([.)])\s+(.*)$/;
|
|
378
|
+
const TABLE_ROW_RE = /^\s*\|.*\|\s*$/;
|
|
379
|
+
const TABLE_SEPARATOR_CELL_RE = /^:?-+:?$/;
|
|
380
|
+
const LINK_RE = /\[([^\]\n]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/;
|
|
381
|
+
const BOLD_RE = /\*\*([^*\n]+)\*\*|__([^_\n]+)__/;
|
|
382
|
+
const INLINE_CODE_RE = /`([^`\n]+)`/;
|
|
383
|
+
const ITALIC_RE = /(?<!\*)\*(?!\*)([^*\n]+)\*(?!\*)|(?<!_)_(?!_)([^_\n]+)_(?!_)/;
|
|
384
|
+
const STRIKE_RE = /~~([^~\n]+)~~/;
|
|
385
|
+
const LINK_RE_G = new RegExp(LINK_RE.source, "g");
|
|
386
|
+
const BOLD_RE_G = new RegExp(BOLD_RE.source, "g");
|
|
387
|
+
const ITALIC_RE_G = new RegExp(ITALIC_RE.source, "g");
|
|
388
|
+
const STRIKE_RE_G = new RegExp(STRIKE_RE.source, "g");
|
|
389
|
+
const INLINE_CODE_RE_G = new RegExp(INLINE_CODE_RE.source, "g");
|
|
390
|
+
/**
|
|
391
|
+
* Heuristically decides whether `text` carries Markdown markup worth
|
|
392
|
+
* rendering, as opposed to plain prose that happens to contain a stray `*`
|
|
393
|
+
* or `_`. Block-level syntax (fenced code, headers, rules, quotes, lists,
|
|
394
|
+
* table rows) and unambiguous inline syntax (links, bold, strikethrough,
|
|
395
|
+
* inline code) each single-handedly qualify. Lone single-`*`/`_` emphasis is
|
|
396
|
+
* deliberately excluded: it is the highest false-positive-risk cue (globs,
|
|
397
|
+
* multiplication, snake_case, `*args`) and easy to get wrong on its own, so
|
|
398
|
+
* it only ever renders as emphasis when some other signal already confirmed
|
|
399
|
+
* the text is Markdown.
|
|
400
|
+
*/
|
|
401
|
+
function looksLikeMarkdown(text) {
|
|
402
|
+
for (const line of text.split("\n")) if (FENCE_RE.test(line) || ATX_HEADER_RE.test(line) || HR_RE.test(line) || BLOCKQUOTE_RE.test(line) || UNORDERED_RE.test(line) || ORDERED_RE.test(line) || TABLE_ROW_RE.test(line)) return true;
|
|
403
|
+
return LINK_RE.test(text) || BOLD_RE.test(text) || STRIKE_RE.test(text) || INLINE_CODE_RE.test(text);
|
|
404
|
+
}
|
|
405
|
+
/** Style links, bold, strikethrough, and emphasis in a span already known to contain no inline code. */
|
|
406
|
+
function applyNonCodeInline(text) {
|
|
407
|
+
let working = text.replaceAll(LINK_RE_G, (_match, label, url) => hyperlink(url, underline(primary$1(label))));
|
|
408
|
+
working = working.replaceAll(BOLD_RE_G, (_match, a, b) => bold$11(a ?? b ?? ""));
|
|
409
|
+
working = working.replaceAll(STRIKE_RE_G, (_match, t) => strike(t));
|
|
410
|
+
return working.replaceAll(ITALIC_RE_G, (_match, a, b) => italic(a ?? b ?? ""));
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Style one line's inline Markdown (links, bold, strikethrough, inline
|
|
414
|
+
* code, emphasis). Splits on inline code spans first — `String.split` with
|
|
415
|
+
* a single-capture-group regex interleaves the code contents (odd indices)
|
|
416
|
+
* between the surrounding plain-text spans (even indices) — so a code
|
|
417
|
+
* span's contents can never be mistaken for bold/italic/link syntax.
|
|
418
|
+
*/
|
|
419
|
+
function applyInline(text) {
|
|
420
|
+
return text.split(INLINE_CODE_RE_G).map((part, i) => i % 2 === 1 ? cyan$1(part) : applyNonCodeInline(part)).join("");
|
|
421
|
+
}
|
|
422
|
+
/** Split a line already confirmed by `TABLE_ROW_RE` into trimmed cells, dropping the framing `|`. */
|
|
423
|
+
function splitTableRow(line) {
|
|
424
|
+
return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* A GFM delimiter row: every cell is dashes with optional leading/trailing
|
|
428
|
+
* colons for alignment. Returns each column's alignment, or `null` when
|
|
429
|
+
* `line` isn't a valid delimiter row — the caller then treats the preceding
|
|
430
|
+
* line as ordinary text rather than a table header.
|
|
431
|
+
*/
|
|
432
|
+
function parseTableSeparator(line) {
|
|
433
|
+
const cells = splitTableRow(line);
|
|
434
|
+
const aligns = [];
|
|
435
|
+
for (const cell of cells) {
|
|
436
|
+
if (!TABLE_SEPARATOR_CELL_RE.test(cell)) return null;
|
|
437
|
+
const left = cell.startsWith(":");
|
|
438
|
+
const right = cell.endsWith(":");
|
|
439
|
+
aligns.push(left && right ? "center" : right ? "right" : "left");
|
|
440
|
+
}
|
|
441
|
+
return aligns;
|
|
442
|
+
}
|
|
443
|
+
/** Every escape this module's own `fg`/`bold`/`italic`/`strike`/`underline`/`hyperlink` helpers emit: SGR sequences and OSC 8 hyperlinks (BEL- or ST-terminated). */
|
|
444
|
+
const ANSI_RE$1 = /\x1b\][^\x07]*\x07|\x1b\][^\x1b]*\x1b\\|\x1b\[[0-9;]*m/g;
|
|
445
|
+
/**
|
|
446
|
+
* Visible length of an already-styled cell: ANSI escapes have string length
|
|
447
|
+
* but no on-screen width, so measuring `styled.length` directly would
|
|
448
|
+
* overcount by however many escape bytes the cell's markup produced.
|
|
449
|
+
* Stripping them first — rather than measuring the pre-styling raw source
|
|
450
|
+
* — also fixes the companion bug that source length would otherwise cause:
|
|
451
|
+
* `**bold**`'s raw text is 4 characters longer than what it renders as, so
|
|
452
|
+
* a column sized from raw source overcounts a bold cell's true width, and
|
|
453
|
+
* that same bold cell then looks "wide enough" and gets under-padded
|
|
454
|
+
* relative to its plain siblings. Like the rest of this module, "visible"
|
|
455
|
+
* means UTF-16 code units after stripping escapes, not a true display-width
|
|
456
|
+
* count, so a wide/astral cell (CJK, emoji) can still under-pad — a known
|
|
457
|
+
* limitation shared with the fixed-width horizontal rule below.
|
|
458
|
+
*/
|
|
459
|
+
function visibleLength(styled) {
|
|
460
|
+
return styled.replace(ANSI_RE$1, "").length;
|
|
461
|
+
}
|
|
462
|
+
/** Pad an already-styled cell out to `width`, measuring by `visibleLength` rather than `styled.length`. */
|
|
463
|
+
function padCell(styled, width, align) {
|
|
464
|
+
const gap = Math.max(0, width - visibleLength(styled));
|
|
465
|
+
if (align === "right") return " ".repeat(gap) + styled;
|
|
466
|
+
if (align === "center") {
|
|
467
|
+
const left = Math.floor(gap / 2);
|
|
468
|
+
return " ".repeat(left) + styled + " ".repeat(gap - left);
|
|
469
|
+
}
|
|
470
|
+
return styled + " ".repeat(gap);
|
|
471
|
+
}
|
|
472
|
+
/** Pad one row of already-styled cells (header or body) to a single column-aligned line. */
|
|
473
|
+
function formatTableRow(styledCells, widths, aligns, isHeader) {
|
|
474
|
+
return widths.map((width, i) => padCell(isHeader ? bold$11(styledCells[i] ?? "") : styledCells[i] ?? "", width, aligns[i] ?? "left")).join(dim$3(" │ "));
|
|
475
|
+
}
|
|
476
|
+
/** A dim horizontal rule under the header row, with a cross at each column boundary. */
|
|
477
|
+
function formatTableRule(widths) {
|
|
478
|
+
return dim$3(widths.map((width) => "─".repeat(width)).join("─┼─"));
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Render Markdown source to ANSI-styled terminal text: headers, fenced/
|
|
482
|
+
* inline code, block quotes, ordered/unordered lists, rules, tables, links,
|
|
483
|
+
* bold, strikethrough, and emphasis. Text that `looksLikeMarkdown` rejects
|
|
484
|
+
* passes through byte-for-byte unchanged.
|
|
485
|
+
*/
|
|
486
|
+
function renderMarkdown(text) {
|
|
487
|
+
if (!looksLikeMarkdown(text)) return text;
|
|
488
|
+
const out = [];
|
|
489
|
+
let inCode = false;
|
|
490
|
+
let fenceChar = "";
|
|
491
|
+
let fenceLen = 0;
|
|
492
|
+
const lines = text.split("\n");
|
|
493
|
+
for (let i = 0; i < lines.length; i++) {
|
|
494
|
+
const line = lines[i];
|
|
495
|
+
if (!inCode && TABLE_ROW_RE.test(line) && i + 1 < lines.length) {
|
|
496
|
+
const aligns = TABLE_ROW_RE.test(lines[i + 1]) ? parseTableSeparator(lines[i + 1]) : null;
|
|
497
|
+
if (aligns !== null) {
|
|
498
|
+
const styledHeader = splitTableRow(line).map((cell) => applyInline(cell));
|
|
499
|
+
const styledRows = [];
|
|
500
|
+
let j = i + 2;
|
|
501
|
+
for (; j < lines.length && TABLE_ROW_RE.test(lines[j]); j++) styledRows.push(splitTableRow(lines[j]).map((cell) => applyInline(cell)));
|
|
502
|
+
const columns = Math.max(styledHeader.length, ...styledRows.map((row) => row.length), aligns.length);
|
|
503
|
+
const widths = Array.from({ length: columns }, (_, col) => Math.max(visibleLength(styledHeader[col] ?? ""), ...styledRows.map((row) => visibleLength(row[col] ?? ""))));
|
|
504
|
+
const columnAligns = Array.from({ length: columns }, (_, col) => aligns[col] ?? "left");
|
|
505
|
+
out.push(formatTableRow(styledHeader, widths, columnAligns, true));
|
|
506
|
+
out.push(formatTableRule(widths));
|
|
507
|
+
for (const styledRow of styledRows) out.push(formatTableRow(styledRow, widths, columnAligns, false));
|
|
508
|
+
i = j - 1;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const fence = FENCE_RE.exec(line);
|
|
513
|
+
if (fence !== null && (!inCode || fence[2][0] === fenceChar && fence[2].length >= fenceLen)) {
|
|
514
|
+
if (inCode) inCode = false;
|
|
515
|
+
else {
|
|
516
|
+
inCode = true;
|
|
517
|
+
fenceChar = fence[2][0];
|
|
518
|
+
fenceLen = fence[2].length;
|
|
519
|
+
if (fence[3] !== "") out.push(dim$3(fence[3]));
|
|
520
|
+
}
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (inCode) {
|
|
524
|
+
out.push(dim$3(line));
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const header = ATX_HEADER_RE.exec(line);
|
|
528
|
+
if (header !== null) {
|
|
529
|
+
const level = header[1].length;
|
|
530
|
+
const content = applyInline(header[2]);
|
|
531
|
+
out.push(level === 1 ? bold$11(primary$1(content)) : level === 2 ? bold$11(cyan$1(content)) : bold$11(content));
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (HR_RE.test(line)) {
|
|
535
|
+
out.push(dim$3("─".repeat(40)));
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const quote = BLOCKQUOTE_RE.exec(line);
|
|
539
|
+
if (quote !== null) {
|
|
540
|
+
const depth = (quote[2].match(/>/g) ?? []).length;
|
|
541
|
+
out.push(`${dim$3("▏".repeat(depth))} ${applyInline(quote[3])}`);
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const unordered = UNORDERED_RE.exec(line);
|
|
545
|
+
if (unordered !== null) {
|
|
546
|
+
out.push(`${unordered[1]}${cyan$1("•")} ${applyInline(unordered[3])}`);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
const ordered = ORDERED_RE.exec(line);
|
|
550
|
+
if (ordered !== null) {
|
|
551
|
+
out.push(`${ordered[1]}${cyan$1(`${ordered[2]}${ordered[3]}`)} ${applyInline(ordered[4])}`);
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
out.push(applyInline(line));
|
|
555
|
+
}
|
|
556
|
+
return out.join("\n");
|
|
557
|
+
}
|
|
558
|
+
//#endregion
|
|
559
|
+
//#region src/render.ts
|
|
560
|
+
/**
|
|
561
|
+
* Terminal projection of durable session events. The TUI renders only from the
|
|
562
|
+
* append-only session log, so a resumed session replays through the exact same
|
|
563
|
+
* code path as live events.
|
|
564
|
+
* @module @tomowang/dsh-tui/render
|
|
565
|
+
*/
|
|
566
|
+
const dim$2 = fg(theme.muted);
|
|
567
|
+
const cyan = fg(theme.secondary);
|
|
568
|
+
const red = fg(theme.error);
|
|
569
|
+
const green = fg(theme.success);
|
|
570
|
+
const yellow = fg(theme.warning);
|
|
571
|
+
const violet$1 = fg(theme.reasoning);
|
|
572
|
+
/** Line cap for a settled shell-escape (`!`) run's body in the permanent transcript; `<Static>` prints can't be redrawn, so a long run is summarized there — tool calls/results don't use this cap, since the transcript only ever shows their one-line collapsed summary (see `formatToolCardSummary`), with full detail available via `formatToolCardDetail` in the Tool Cards overlay. */
|
|
573
|
+
const MAX_CARD_LINES = 20;
|
|
574
|
+
/** `diff` package's `maxEditLength`: bounds worst-case diff cost on a huge file, mirroring the removed first-party TUI's default. */
|
|
575
|
+
const MAX_DIFF_EDIT_LENGTH = 1e3;
|
|
576
|
+
/** Clamp one-line summaries so tool arguments cannot flood the transcript. */
|
|
577
|
+
function truncate(text, max) {
|
|
578
|
+
const oneLine = text.replaceAll("\n", " ");
|
|
579
|
+
return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`;
|
|
580
|
+
}
|
|
581
|
+
/** Join the text blocks of a message content array. */
|
|
582
|
+
function textOf(content) {
|
|
583
|
+
return content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
584
|
+
}
|
|
585
|
+
/** Join the reasoning/thinking blocks of a message content array, distinct from its visible `textOf`. */
|
|
586
|
+
function reasoningOf(content) {
|
|
587
|
+
return content.filter((block) => block.type === "reasoning").map((block) => block.text).join("");
|
|
588
|
+
}
|
|
589
|
+
/** Preview length for a settled step's reasoning summary line — long enough to be useful, short enough that a long thinking block never floods the transcript; the full text is always in `/trajectory`. */
|
|
590
|
+
const REASONING_SUMMARY_LENGTH = 80;
|
|
591
|
+
/** Format one settled message's reasoning/thinking content as a single collapsed line — the label plus a short preview — never the full body, which stays available via `/trajectory`. */
|
|
592
|
+
function formatReasoningSummary(text) {
|
|
593
|
+
return violet$1(`✦ think · ${truncate(text, REASONING_SUMMARY_LENGTH)}`);
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Format one settled step's text (and, ahead of it, a one-line reasoning
|
|
597
|
+
* summary when the step had any), for the permanent transcript.
|
|
598
|
+
*/
|
|
599
|
+
function formatSettledMessage(text, reasoningText) {
|
|
600
|
+
const parts = [];
|
|
601
|
+
if (reasoningText !== "") parts.push(formatReasoningSummary(reasoningText));
|
|
602
|
+
if (text !== "") parts.push(renderMarkdown(text));
|
|
603
|
+
return parts.length === 0 ? void 0 : `\n${parts.join("\n")}\n`;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Format the in-progress step's live region: while reasoning has started but
|
|
607
|
+
* no visible text has arrived yet, an animated `spinnerChar thinking` line
|
|
608
|
+
* stands in for the raw, fast-scrolling reasoning body (its settled one-line
|
|
609
|
+
* `✦ think · …` summary appears in the transcript once the step lands —
|
|
610
|
+
* see `formatSettledMessage`); once text starts streaming, that text is
|
|
611
|
+
* shown directly.
|
|
612
|
+
*/
|
|
613
|
+
function formatStreamingText(text, reasoningText = "", spinnerChar = "✦") {
|
|
614
|
+
if (text === "" && reasoningText === "") return void 0;
|
|
615
|
+
if (text === "") return `\n${violet$1(`${spinnerChar} thinking`)}\n`;
|
|
616
|
+
return `\n${renderMarkdown(text)}\n`;
|
|
617
|
+
}
|
|
618
|
+
/** One local shell-escape run's header + output lines, shared by the settled and in-flight renderers below. `exitCode` is `null` while still running. */
|
|
619
|
+
function formatShellLines(command, output, exitCode) {
|
|
620
|
+
const lines = [`${yellow("!")} ${command}`];
|
|
621
|
+
if (output !== "") lines.push(...splitLines(output).map(dim$2));
|
|
622
|
+
if (exitCode !== null) lines.push(exitCode === 0 ? dim$2(`[exit ${exitCode}]`) : red(`[exit ${exitCode}]`));
|
|
623
|
+
return lines;
|
|
624
|
+
}
|
|
625
|
+
/** Format one settled local shell-escape run (`!` prompt-mode) for the permanent transcript, mirroring a `terminal` tool card. */
|
|
626
|
+
function formatShellRun(command, output, exitCode) {
|
|
627
|
+
return `\n${capLines(formatShellLines(command, output, exitCode), MAX_CARD_LINES).join("\n")}\n`;
|
|
628
|
+
}
|
|
629
|
+
/** Format the in-progress shell-escape run's accumulated output for the live region, mirroring `formatStreamingText`'s settle-without-jump framing. */
|
|
630
|
+
function formatShellRunLive(command, output) {
|
|
631
|
+
return `\n${capLines(formatShellLines(command, output, null), MAX_CARD_LINES).join("\n")}\n`;
|
|
632
|
+
}
|
|
633
|
+
/** Parse a tool call's JSON-encoded arguments; malformed JSON can't be handed to a presenter. */
|
|
634
|
+
function parseJson(text) {
|
|
635
|
+
try {
|
|
636
|
+
return {
|
|
637
|
+
valid: true,
|
|
638
|
+
value: JSON.parse(text)
|
|
639
|
+
};
|
|
640
|
+
} catch {
|
|
641
|
+
return { valid: false };
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
/** Render a value for display: a string as-is, anything else as pretty JSON. */
|
|
645
|
+
function pretty(value) {
|
|
646
|
+
if (typeof value === "string") return value;
|
|
647
|
+
return JSON.stringify(value, null, 2) ?? String(value);
|
|
648
|
+
}
|
|
649
|
+
/** A string's content lines: empty text is zero lines, a trailing newline terminates the last line. */
|
|
650
|
+
function splitLines(text) {
|
|
651
|
+
if (text === "") return [];
|
|
652
|
+
return (text.endsWith("\n") ? text.slice(0, -1) : text).split("\n");
|
|
653
|
+
}
|
|
654
|
+
/** Cap a card body to `max` lines, appending a dim summary of what was omitted. */
|
|
655
|
+
function capLines(lines, max) {
|
|
656
|
+
if (lines.length <= max) return [...lines];
|
|
657
|
+
const omitted = lines.length - max;
|
|
658
|
+
return [...lines.slice(0, max), dim$2(`… +${omitted} line${omitted === 1 ? "" : "s"} omitted`)];
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* One file's change as +/- diff lines under a dim path header. A `null` prior
|
|
662
|
+
* text (new file, or a call-time overwrite with no before-image) renders the
|
|
663
|
+
* whole new text as additions; a comparison beyond `MAX_DIFF_EDIT_LENGTH` falls
|
|
664
|
+
* back to whole-side add/remove so a huge file can't stall formatting.
|
|
665
|
+
*/
|
|
666
|
+
function renderFileDiff(diff) {
|
|
667
|
+
const lines = [dim$2(diff.path)];
|
|
668
|
+
if (diff.oldText === null) {
|
|
669
|
+
for (const line of splitLines(diff.newText)) lines.push(green(`+ ${line}`));
|
|
670
|
+
return lines;
|
|
671
|
+
}
|
|
672
|
+
const changes = diffLines(diff.oldText, diff.newText, { maxEditLength: MAX_DIFF_EDIT_LENGTH });
|
|
673
|
+
if (changes === void 0) {
|
|
674
|
+
lines.push(dim$2(`[diff omitted: over ${MAX_DIFF_EDIT_LENGTH} changed lines]`));
|
|
675
|
+
for (const line of splitLines(diff.oldText)) lines.push(red(`- ${line}`));
|
|
676
|
+
for (const line of splitLines(diff.newText)) lines.push(green(`+ ${line}`));
|
|
677
|
+
return lines;
|
|
678
|
+
}
|
|
679
|
+
for (const change of changes) {
|
|
680
|
+
const prefix = change.added ? "+" : change.removed ? "-" : " ";
|
|
681
|
+
const color = change.added ? green : change.removed ? red : dim$2;
|
|
682
|
+
for (const line of splitLines(change.value)) lines.push(color(`${prefix} ${line}`));
|
|
683
|
+
}
|
|
684
|
+
return lines;
|
|
685
|
+
}
|
|
686
|
+
/** One or more `FileDiff`s, blank-line separated when there's more than one. */
|
|
687
|
+
function renderFileDiffs(diffs) {
|
|
688
|
+
return diffs.flatMap((fileDiff, index) => (index > 0 ? [""] : []).concat(renderFileDiff(fileDiff)));
|
|
689
|
+
}
|
|
690
|
+
/** Today's flat one-line fallback for a pending call, unchanged: no tool, no presenter, bad JSON, or a throwing/`undefined` presenter. */
|
|
691
|
+
function fallbackCallLine(name, rawArgs) {
|
|
692
|
+
return `${cyan("⚙")} ${name} ${dim$2(truncate(rawArgs, 100))}`;
|
|
693
|
+
}
|
|
694
|
+
/** Resolve a `tool/call`'s presented view, or `undefined` for any condition that keeps the flat fallback. */
|
|
695
|
+
function presentCallSafely(name, rawArgs, getTool) {
|
|
696
|
+
const tool = getTool?.(name);
|
|
697
|
+
if (tool?.presentCall === void 0) return void 0;
|
|
698
|
+
const parsed = parseJson(rawArgs);
|
|
699
|
+
if (!parsed.valid) return void 0;
|
|
700
|
+
try {
|
|
701
|
+
return tool.presentCall(parsed.value);
|
|
702
|
+
} catch {
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
/** A presented pending call's lines: a cyan header (the presenter's title) plus card-specific body. */
|
|
707
|
+
function formatCallLines(view) {
|
|
708
|
+
const header = `${cyan("⚙")} ${view.title}`;
|
|
709
|
+
if (view.card === "terminal") {
|
|
710
|
+
const lines = [];
|
|
711
|
+
if (view.description !== void 0 && view.description !== "") lines.push(dim$2(view.description));
|
|
712
|
+
lines.push(header);
|
|
713
|
+
if (view.cwd !== void 0) lines.push(dim$2(view.cwd));
|
|
714
|
+
return lines;
|
|
715
|
+
}
|
|
716
|
+
if (view.card === "diff") return [header, ...renderFileDiffs(view.diffs)];
|
|
717
|
+
return [header, ...view.rawInput === void 0 ? [] : splitLines(pretty(view.rawInput)).map(dim$2)];
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* A presented call's one-line identity. A `TerminalCallView`'s `title` is
|
|
721
|
+
* deliberately just the bare command with no verb (unlike a `generic`/`diff`
|
|
722
|
+
* title, which a presenter writes to already read as one, e.g. "Read foo.ts")
|
|
723
|
+
* — so on its own it reads as arbitrary text with no hint it was a shell
|
|
724
|
+
* call. Label it with the tool's own name so the one-line summary still
|
|
725
|
+
* names both what ran and how — the command itself is the detail a reader
|
|
726
|
+
* wants here; its optional `description` stays a detail-view-only addition
|
|
727
|
+
* (`formatCallLines` already shows it above the command there).
|
|
728
|
+
*/
|
|
729
|
+
function callSummaryTitle(name, view) {
|
|
730
|
+
if (view.card !== "terminal") return view.title;
|
|
731
|
+
return `${name.length === 0 ? name : name.charAt(0).toUpperCase() + name.slice(1)}: ${view.title}`;
|
|
732
|
+
}
|
|
733
|
+
/** A pending call's one-line title, presenting through the tool's `presentCall` when available — shared by the live region's spinner row. */
|
|
734
|
+
function pendingCallTitle(name, rawArgs, getTool) {
|
|
735
|
+
const view = presentCallSafely(name, rawArgs, getTool);
|
|
736
|
+
return view === void 0 ? `${name} ${truncate(rawArgs, 100)}` : callSummaryTitle(name, view);
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Format every tool call that's been sent but has no `tool/result` yet, for
|
|
740
|
+
* the live region: one line per call, the shared spinner frame standing in
|
|
741
|
+
* for the settled ✓/✖ icon it'll collapse to once its result lands and it
|
|
742
|
+
* becomes a single transcript line (see `formatToolCardSummary`).
|
|
743
|
+
*/
|
|
744
|
+
function formatPendingToolCalls(calls, spinnerChar, getTool) {
|
|
745
|
+
if (calls.length === 0) return "";
|
|
746
|
+
return `\n${calls.map((call) => `${cyan(spinnerChar)} ${pendingCallTitle(call.name, call.arguments, getTool)}`).join("\n")}\n`;
|
|
747
|
+
}
|
|
748
|
+
/** Resolve a `tool/result`'s presented view, or `undefined` for any condition that keeps the flat fallback. */
|
|
749
|
+
function presentResultSafely(callId, result, options) {
|
|
750
|
+
const call = options.getToolCall?.(callId);
|
|
751
|
+
if (call === void 0) return void 0;
|
|
752
|
+
const tool = options.getTool?.(call.name);
|
|
753
|
+
if (tool?.presentResult === void 0) return void 0;
|
|
754
|
+
const parsed = parseJson(call.arguments);
|
|
755
|
+
if (!parsed.valid) return void 0;
|
|
756
|
+
try {
|
|
757
|
+
const view = tool.presentResult(parsed.value, result);
|
|
758
|
+
return view === void 0 ? void 0 : {
|
|
759
|
+
name: call.name,
|
|
760
|
+
view
|
|
761
|
+
};
|
|
762
|
+
} catch {
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/** A `tool/result`'s paired `tool/call`'s presented one-line identity, when both the call and a presenter for it resolve — the "pending-state title" a result view's own optional `title` defers to when omitted (see `ToolResultView` docs in `@deepseek-ai/dsh-tools`). */
|
|
767
|
+
function resolveCallTitle(callId, options) {
|
|
768
|
+
const call = options.getToolCall?.(callId);
|
|
769
|
+
if (call === void 0) return void 0;
|
|
770
|
+
const view = presentCallSafely(call.name, call.arguments, options.getTool);
|
|
771
|
+
return view === void 0 ? void 0 : callSummaryTitle(call.name, view);
|
|
772
|
+
}
|
|
773
|
+
/** Shared by the transcript's compact line and the Tool Cards overlay's summary/detail, so all three read the same icon and presented view instead of re-deriving it. */
|
|
774
|
+
function resolveToolResult(event, options) {
|
|
775
|
+
if (event.data.error !== void 0) return {
|
|
776
|
+
kind: "error",
|
|
777
|
+
line: `${red("✖")} ${event.data.error.code}: ${event.data.error.name}`
|
|
778
|
+
};
|
|
779
|
+
const [block] = event.data.message.content;
|
|
780
|
+
const failed = block.isError === true;
|
|
781
|
+
const icon = failed ? red("✖") : cyan("✓");
|
|
782
|
+
const callId = event.data.message.source.callId;
|
|
783
|
+
const presented = presentResultSafely(callId, {
|
|
784
|
+
content: block.content,
|
|
785
|
+
isError: failed,
|
|
786
|
+
...event.data.meta !== void 0 ? { meta: event.data.meta } : {}
|
|
787
|
+
}, options);
|
|
788
|
+
return {
|
|
789
|
+
kind: "ok",
|
|
790
|
+
icon,
|
|
791
|
+
content: block.content,
|
|
792
|
+
presented,
|
|
793
|
+
callTitle: resolveCallTitle(callId, options)
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
/** A presented completed call's lines: an outcome-colored header plus card-specific body. `callTitle` is the paired call's presented title — a result view's own `title` field defers to it (the "pending-state title") when omitted, so it comes before the flat fallback name. */
|
|
797
|
+
function formatResultLines(fallbackName, callTitle, icon, rawContent, view) {
|
|
798
|
+
const header = `${icon} ${view.title ?? callTitle ?? fallbackName}`;
|
|
799
|
+
switch (view.card) {
|
|
800
|
+
case "generic": return [header, ...splitLines(textOf(view.content ?? rawContent)).map(dim$2)];
|
|
801
|
+
case "terminal": {
|
|
802
|
+
const lines = [header];
|
|
803
|
+
if (view.output !== void 0 && view.output !== "") lines.push(...splitLines(view.output).map(dim$2));
|
|
804
|
+
if (view.exitCode !== void 0) lines.push(dim$2(`[exit ${view.exitCode}]`));
|
|
805
|
+
if (view.signal !== void 0) lines.push(red(`[signal ${view.signal}]`));
|
|
806
|
+
return lines;
|
|
807
|
+
}
|
|
808
|
+
case "diff": return [header, ...renderFileDiffs(view.diffs)];
|
|
809
|
+
case "search": {
|
|
810
|
+
const lines = [header];
|
|
811
|
+
let shown = 0;
|
|
812
|
+
if (view.shape === "matches") for (const file of view.files) {
|
|
813
|
+
lines.push(dim$2(file.path));
|
|
814
|
+
for (const match of file.matches) lines.push(dim$2(` ${match.lineNumber}: ${match.line}`));
|
|
815
|
+
shown += file.matches.length;
|
|
816
|
+
}
|
|
817
|
+
else {
|
|
818
|
+
for (const path of view.paths) lines.push(dim$2(path));
|
|
819
|
+
shown = view.paths.length;
|
|
820
|
+
}
|
|
821
|
+
if (view.truncated) lines.push(dim$2(`… showing ${shown} of ${view.total}`));
|
|
822
|
+
return lines;
|
|
823
|
+
}
|
|
824
|
+
case "read": {
|
|
825
|
+
const lines = [`${icon} ${view.title ?? callTitle ?? view.path}`];
|
|
826
|
+
for (const line of view.lines) lines.push(dim$2(`${line.number}: ${line.text}`));
|
|
827
|
+
if (view.totalLines > 0) {
|
|
828
|
+
const last = view.offset + view.lines.length - 1;
|
|
829
|
+
lines.push(dim$2(`[${view.offset}-${last} of ${view.totalLines}]`));
|
|
830
|
+
}
|
|
831
|
+
return lines;
|
|
832
|
+
}
|
|
833
|
+
case "web": {
|
|
834
|
+
const lines = [header];
|
|
835
|
+
if (view.kind === "search") {
|
|
836
|
+
for (const source of view.sources) lines.push(dim$2(`${source.title ?? source.url} — ${source.url}`));
|
|
837
|
+
if (view.answer !== void 0 && view.answer !== "") lines.push(...splitLines(view.answer).map(dim$2));
|
|
838
|
+
} else lines.push(dim$2(`${view.url} [${view.statusCode}]`));
|
|
839
|
+
if (view.truncated) lines.push(dim$2("… truncated"));
|
|
840
|
+
return lines;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* One `goal/change` mutation's transcript line, mirroring the durable
|
|
846
|
+
* ledger's `operation`. An explicit `switch` with no `default` over the
|
|
847
|
+
* post-`clear` operation union (rather than a sequential `if` chain ending
|
|
848
|
+
* in an implicit "must be block") so a future `dsh-goal` operation the TUI
|
|
849
|
+
* doesn't know about fails to compile here instead of silently rendering
|
|
850
|
+
* the wrong line.
|
|
851
|
+
*/
|
|
852
|
+
function goalChangeLine(change) {
|
|
853
|
+
if (change.operation === "clear") return `${dim$2("🗑")} goal cleared`;
|
|
854
|
+
const goal = change.goal;
|
|
855
|
+
switch (change.operation) {
|
|
856
|
+
case "create": return `${cyan("🎯")} goal set: ${goal.objective}`;
|
|
857
|
+
case "edit": return `${cyan("🎯")} goal updated: ${goal.objective}`;
|
|
858
|
+
case "pause": return `${yellow("⏸")} goal paused: ${goal.objective}`;
|
|
859
|
+
case "resume": return `${green("▶")} goal resumed: ${goal.objective}`;
|
|
860
|
+
case "complete": return `${green("✓")} goal complete: ${goal.objective}`;
|
|
861
|
+
case "block": return `${red("⛔")} goal blocked${goal.blockedReason === void 0 ? "" : `: ${goal.blockedReason.code}: ${goal.blockedReason.message}`}`;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Format one durable session event as a terminal line, or `undefined` for
|
|
866
|
+
* events this viewer does not present. Unknown event types are silently
|
|
867
|
+
* skipped: the log's vocabulary is merge-extensible and a transcript viewer
|
|
868
|
+
* must tolerate events from plugins it does not know.
|
|
869
|
+
* @param event - the durable session event to project.
|
|
870
|
+
* @param options - replay/live rendering context, plus optional tool-presentation resolvers.
|
|
871
|
+
*/
|
|
872
|
+
function formatEvent(event, options) {
|
|
873
|
+
switch (event.type) {
|
|
874
|
+
case "user/message": {
|
|
875
|
+
const source = event.data.source;
|
|
876
|
+
if (source.kind === "user") {
|
|
877
|
+
const text = textOf(event.data.content);
|
|
878
|
+
return text === "" ? void 0 : `${dim$2("you ›")} ${text}`;
|
|
879
|
+
}
|
|
880
|
+
if (source.kind === "plugin") {
|
|
881
|
+
const summary = source.form === "notice" ? source.summary : void 0;
|
|
882
|
+
return `${dim$2("⊕ context ›")} ${source.plugin}${summary === void 0 ? "" : ` · ${summary}`}`;
|
|
883
|
+
}
|
|
884
|
+
if (source.kind === "goal") return `${dim$2("⊕ goal ›")} round ${source.round}`;
|
|
885
|
+
return `${dim$2("⊕ context ›")} ${source.kind}`;
|
|
886
|
+
}
|
|
887
|
+
case "assistant/message": {
|
|
888
|
+
const content = event.data.message.content;
|
|
889
|
+
return formatSettledMessage(textOf(content), reasoningOf(content));
|
|
890
|
+
}
|
|
891
|
+
case "tool/call": return;
|
|
892
|
+
case "tool/result": return formatToolCardSummary(event, options);
|
|
893
|
+
case "turn/end": {
|
|
894
|
+
const reason = event.data.reason;
|
|
895
|
+
if (reason.kind === "error") return `${red("✖")} ${reason.error.code}: ${reason.error.message}`;
|
|
896
|
+
else if (reason.kind === "aborted") return `${yellow("⏹")} ${dim$2("turn canceled")}`;
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
case "compaction/summary": return `${cyan("⊙")} compacted ${event.data.shadowedSeqs.length} items (~${event.data.shadowedTokenCount} tokens)`;
|
|
900
|
+
case "compaction/end": return event.data.error === void 0 ? void 0 : `${red("✖")} compaction: ${event.data.error}`;
|
|
901
|
+
case "goal/change": return goalChangeLine(event.data);
|
|
902
|
+
default: return;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* A `tool/call`/`tool/result` event's one-line summary — the Tool Cards
|
|
907
|
+
* overlay's collapsed row. Distinct from `formatEvent`'s own card rendering
|
|
908
|
+
* (which can be multi-line even at its most compact), because the overlay
|
|
909
|
+
* needs a genuine single line to toggle open from.
|
|
910
|
+
*/
|
|
911
|
+
function formatToolCardSummary(event, options) {
|
|
912
|
+
if (event.type === "tool/call") {
|
|
913
|
+
const view = presentCallSafely(event.data.name, event.data.arguments, options.getTool);
|
|
914
|
+
return view === void 0 ? fallbackCallLine(event.data.name, event.data.arguments) : `${cyan("⚙")} ${callSummaryTitle(event.data.name, view)}`;
|
|
915
|
+
}
|
|
916
|
+
if (event.type === "tool/result") {
|
|
917
|
+
const resolved = resolveToolResult(event, options);
|
|
918
|
+
if (resolved.kind === "error") return resolved.line;
|
|
919
|
+
const { icon, content, presented, callTitle } = resolved;
|
|
920
|
+
if (presented === void 0) {
|
|
921
|
+
const text = truncate(textOf(content), 100);
|
|
922
|
+
return text === "" ? icon : `${icon} ${dim$2(text)}`;
|
|
923
|
+
}
|
|
924
|
+
return `${icon} ${presented.view.title ?? callTitle ?? presented.name}`;
|
|
925
|
+
}
|
|
926
|
+
return "";
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Full, uncapped presentation lines for a `tool/call`/`tool/result` event.
|
|
930
|
+
* Unlike `formatEvent`, this never truncates or omits — the Tool Cards
|
|
931
|
+
* overlay scrolls its own window over the result instead of relying on a
|
|
932
|
+
* fixed line cap, so it needs the complete card body to scroll through.
|
|
933
|
+
*/
|
|
934
|
+
function formatToolCardDetail(event, options) {
|
|
935
|
+
if (event.type === "tool/call") {
|
|
936
|
+
const view = presentCallSafely(event.data.name, event.data.arguments, options.getTool);
|
|
937
|
+
return view === void 0 ? [fallbackCallLine(event.data.name, event.data.arguments)] : formatCallLines(view);
|
|
938
|
+
}
|
|
939
|
+
if (event.type === "tool/result") {
|
|
940
|
+
const resolved = resolveToolResult(event, options);
|
|
941
|
+
if (resolved.kind === "error") return [resolved.line];
|
|
942
|
+
const { icon, content, presented, callTitle } = resolved;
|
|
943
|
+
if (presented === void 0) {
|
|
944
|
+
const text = textOf(content);
|
|
945
|
+
return text === "" ? [icon] : [icon, ...splitLines(text)];
|
|
946
|
+
}
|
|
947
|
+
return formatResultLines(presented.name, callTitle, icon, content, presented.view);
|
|
948
|
+
}
|
|
949
|
+
return [];
|
|
950
|
+
}
|
|
951
|
+
//#endregion
|
|
952
|
+
//#region src/tui/store.ts
|
|
953
|
+
const EMPTY_STATS = {
|
|
954
|
+
sessionStats: void 0,
|
|
955
|
+
tokenUsage: void 0,
|
|
956
|
+
contextPressure: void 0,
|
|
957
|
+
contextBreakdown: void 0
|
|
958
|
+
};
|
|
959
|
+
const EMPTY_FILE_INDEX = {
|
|
960
|
+
candidates: void 0,
|
|
961
|
+
loading: false
|
|
962
|
+
};
|
|
963
|
+
const CLOSED_OVERLAY = { kind: "none" };
|
|
964
|
+
/** Mutable projection; `getSnapshot`/`subscribe` satisfy `useSyncExternalStore`. */
|
|
965
|
+
var TuiStore = class {
|
|
966
|
+
state;
|
|
967
|
+
listeners = /* @__PURE__ */ new Set();
|
|
968
|
+
lastSeq;
|
|
969
|
+
streamingAssembler;
|
|
970
|
+
streamingKey;
|
|
971
|
+
toolCalls = /* @__PURE__ */ new Map();
|
|
972
|
+
pendingToolCallsMap = /* @__PURE__ */ new Map();
|
|
973
|
+
constructor(initial) {
|
|
974
|
+
const lastSeq = initial.events.at(-1)?.seq ?? 0;
|
|
975
|
+
this.lastSeq = lastSeq;
|
|
976
|
+
for (const event of initial.events) if (event.type === "tool/call") {
|
|
977
|
+
const call = {
|
|
978
|
+
name: event.data.name,
|
|
979
|
+
arguments: event.data.arguments
|
|
980
|
+
};
|
|
981
|
+
this.toolCalls.set(event.data.callId, call);
|
|
982
|
+
this.pendingToolCallsMap.set(event.data.callId, call);
|
|
983
|
+
} else if (event.type === "tool/result") this.pendingToolCallsMap.delete(event.data.message.source.callId);
|
|
984
|
+
this.state = {
|
|
985
|
+
events: initial.events.filter((event) => event.type !== "assistant/chunk"),
|
|
986
|
+
replayThrough: lastSeq,
|
|
987
|
+
status: "idle",
|
|
988
|
+
queued: [],
|
|
989
|
+
notice: void 0,
|
|
990
|
+
overlay: CLOSED_OVERLAY,
|
|
991
|
+
permission: void 0,
|
|
992
|
+
goal: void 0,
|
|
993
|
+
title: void 0,
|
|
994
|
+
stats: EMPTY_STATS,
|
|
995
|
+
preset: void 0,
|
|
996
|
+
streaming: void 0,
|
|
997
|
+
pendingToolCalls: this.pendingToolCallsSnapshot(),
|
|
998
|
+
shellRun: void 0,
|
|
999
|
+
shellHistory: [],
|
|
1000
|
+
fileIndex: EMPTY_FILE_INDEX,
|
|
1001
|
+
updateHint: void 0
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
getSnapshot = () => this.state;
|
|
1005
|
+
/** The `tool/call` a later `tool/result` correlates with, by `callId`; `undefined` when its call was never seen (e.g. log truncation). */
|
|
1006
|
+
getToolCall = (callId) => this.toolCalls.get(callId);
|
|
1007
|
+
subscribe = (listener) => {
|
|
1008
|
+
this.listeners.add(listener);
|
|
1009
|
+
return () => this.listeners.delete(listener);
|
|
1010
|
+
};
|
|
1011
|
+
/** Append one live session event, ignoring anything already seeded/seen. */
|
|
1012
|
+
appendEvent(event) {
|
|
1013
|
+
if (event.seq <= this.lastSeq) return;
|
|
1014
|
+
this.lastSeq = event.seq;
|
|
1015
|
+
if (event.type === "tool/call") {
|
|
1016
|
+
const call = {
|
|
1017
|
+
name: event.data.name,
|
|
1018
|
+
arguments: event.data.arguments
|
|
1019
|
+
};
|
|
1020
|
+
this.toolCalls.set(event.data.callId, call);
|
|
1021
|
+
this.pendingToolCallsMap.set(event.data.callId, call);
|
|
1022
|
+
this.set({
|
|
1023
|
+
events: [...this.state.events, event],
|
|
1024
|
+
pendingToolCalls: this.pendingToolCallsSnapshot()
|
|
1025
|
+
});
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (event.type === "tool/result") {
|
|
1029
|
+
this.pendingToolCallsMap.delete(event.data.message.source.callId);
|
|
1030
|
+
this.set({
|
|
1031
|
+
events: [...this.state.events, event],
|
|
1032
|
+
pendingToolCalls: this.pendingToolCallsSnapshot()
|
|
1033
|
+
});
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
if (event.type === "assistant/chunk") {
|
|
1037
|
+
this.foldChunk(event.data);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
if (event.type === "assistant/message") {
|
|
1041
|
+
this.streamingAssembler = void 0;
|
|
1042
|
+
this.streamingKey = void 0;
|
|
1043
|
+
this.set({
|
|
1044
|
+
events: [...this.state.events, event],
|
|
1045
|
+
streaming: void 0
|
|
1046
|
+
});
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
this.set({ events: [...this.state.events, event] });
|
|
1050
|
+
}
|
|
1051
|
+
/** Snapshot `pendingToolCallsMap` into `TuiState`'s array shape, in call order. */
|
|
1052
|
+
pendingToolCallsSnapshot() {
|
|
1053
|
+
return [...this.pendingToolCallsMap.entries()].map(([callId, call]) => ({
|
|
1054
|
+
callId,
|
|
1055
|
+
...call
|
|
1056
|
+
}));
|
|
1057
|
+
}
|
|
1058
|
+
/** Fold one raw stream chunk into the in-flight step's live text, keyed by `{turn, step}`. */
|
|
1059
|
+
foldChunk(data) {
|
|
1060
|
+
const { turn, step, chunk } = data;
|
|
1061
|
+
if (this.streamingKey?.turn !== turn || this.streamingKey?.step !== step) {
|
|
1062
|
+
this.streamingAssembler = new BlockAssembler();
|
|
1063
|
+
this.streamingKey = {
|
|
1064
|
+
turn,
|
|
1065
|
+
step
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
this.streamingAssembler.push(chunk);
|
|
1069
|
+
const blocks = this.streamingAssembler.blocks();
|
|
1070
|
+
const text = textOf(blocks);
|
|
1071
|
+
const reasoningText = reasoningOf(blocks);
|
|
1072
|
+
this.set({ streaming: text === "" && reasoningText === "" ? void 0 : {
|
|
1073
|
+
turn,
|
|
1074
|
+
step,
|
|
1075
|
+
text,
|
|
1076
|
+
reasoningText
|
|
1077
|
+
} });
|
|
1078
|
+
}
|
|
1079
|
+
setStatus(status) {
|
|
1080
|
+
if (status === this.state.status) return;
|
|
1081
|
+
this.set({ status });
|
|
1082
|
+
}
|
|
1083
|
+
setQueued(queued) {
|
|
1084
|
+
this.set({ queued });
|
|
1085
|
+
}
|
|
1086
|
+
setNotice(notice) {
|
|
1087
|
+
this.set({ notice });
|
|
1088
|
+
}
|
|
1089
|
+
setPermission(permission) {
|
|
1090
|
+
this.set({ permission });
|
|
1091
|
+
}
|
|
1092
|
+
/** Refresh the session's current goal from the 'goal' session projection; `undefined` when the projection unit isn't composed, `null` before the first create or after a clear. */
|
|
1093
|
+
setGoal(goal) {
|
|
1094
|
+
this.set({ goal });
|
|
1095
|
+
}
|
|
1096
|
+
/** Refresh the session's current title from the 'title' session projection; `undefined` when `dsh-session-title` isn't composed, `null` before the first accepted title. */
|
|
1097
|
+
setTitle(title) {
|
|
1098
|
+
this.set({ title });
|
|
1099
|
+
}
|
|
1100
|
+
setStats(stats) {
|
|
1101
|
+
this.set({ stats });
|
|
1102
|
+
}
|
|
1103
|
+
setPreset(preset) {
|
|
1104
|
+
this.set({ preset });
|
|
1105
|
+
}
|
|
1106
|
+
shellRunSeq = 0;
|
|
1107
|
+
/** Begin one local shell-escape run; its output accumulates via `appendShellOutput` until `finishShellRun` settles it into the transcript. */
|
|
1108
|
+
startShellRun(command) {
|
|
1109
|
+
const id = ++this.shellRunSeq;
|
|
1110
|
+
this.set({ shellRun: {
|
|
1111
|
+
id,
|
|
1112
|
+
command,
|
|
1113
|
+
output: ""
|
|
1114
|
+
} });
|
|
1115
|
+
return id;
|
|
1116
|
+
}
|
|
1117
|
+
/** Append one chunk of stdout/stderr to the in-flight run; a no-op once it's settled or superseded by a later run. */
|
|
1118
|
+
appendShellOutput(id, chunk) {
|
|
1119
|
+
if (this.state.shellRun?.id !== id) return;
|
|
1120
|
+
this.set({ shellRun: {
|
|
1121
|
+
...this.state.shellRun,
|
|
1122
|
+
output: this.state.shellRun.output + chunk
|
|
1123
|
+
} });
|
|
1124
|
+
}
|
|
1125
|
+
/** Settle the in-flight run into the permanent transcript; a no-op once it's already settled or superseded. */
|
|
1126
|
+
finishShellRun(id, exitCode) {
|
|
1127
|
+
if (this.state.shellRun?.id !== id) return;
|
|
1128
|
+
const { command, output } = this.state.shellRun;
|
|
1129
|
+
this.set({
|
|
1130
|
+
shellRun: void 0,
|
|
1131
|
+
shellHistory: [...this.state.shellHistory, {
|
|
1132
|
+
id,
|
|
1133
|
+
command,
|
|
1134
|
+
output,
|
|
1135
|
+
exitCode,
|
|
1136
|
+
afterSeq: this.lastSeq
|
|
1137
|
+
}]
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
/** Open the `/model` overlay to a fresh, loading provider list. */
|
|
1141
|
+
openModelProfile() {
|
|
1142
|
+
this.set({ overlay: {
|
|
1143
|
+
kind: "modelProfile",
|
|
1144
|
+
modelProfile: {
|
|
1145
|
+
view: "list",
|
|
1146
|
+
providers: void 0,
|
|
1147
|
+
selected: 0,
|
|
1148
|
+
draft: void 0,
|
|
1149
|
+
formKey: 0,
|
|
1150
|
+
discovered: void 0,
|
|
1151
|
+
busy: true,
|
|
1152
|
+
error: void 0
|
|
1153
|
+
}
|
|
1154
|
+
} });
|
|
1155
|
+
}
|
|
1156
|
+
/** Open the `/trajectory` ledger overlay. */
|
|
1157
|
+
openTrajectory() {
|
|
1158
|
+
this.set({ overlay: { kind: "trajectory" } });
|
|
1159
|
+
}
|
|
1160
|
+
/** Open the expandable Tool Cards inspector. */
|
|
1161
|
+
openToolCards() {
|
|
1162
|
+
this.set({ overlay: { kind: "toolCards" } });
|
|
1163
|
+
}
|
|
1164
|
+
/** Open the `/context` usage overlay. */
|
|
1165
|
+
openContext() {
|
|
1166
|
+
this.set({ overlay: { kind: "context" } });
|
|
1167
|
+
}
|
|
1168
|
+
/** Open the `/plugins` loaded-plugin-tree overlay with a snapshotted row list. */
|
|
1169
|
+
openPlugins(rows) {
|
|
1170
|
+
this.set({ overlay: {
|
|
1171
|
+
kind: "plugins",
|
|
1172
|
+
rows
|
|
1173
|
+
} });
|
|
1174
|
+
}
|
|
1175
|
+
/** Open the `/presets` overlay to a fresh, loading roster. */
|
|
1176
|
+
openAgentPresets(init) {
|
|
1177
|
+
this.set({ overlay: {
|
|
1178
|
+
kind: "agentPresets",
|
|
1179
|
+
agentPresets: {
|
|
1180
|
+
rows: [],
|
|
1181
|
+
selected: 0,
|
|
1182
|
+
current: init.current,
|
|
1183
|
+
blank: init.blank,
|
|
1184
|
+
busy: true,
|
|
1185
|
+
error: void 0
|
|
1186
|
+
}
|
|
1187
|
+
} });
|
|
1188
|
+
}
|
|
1189
|
+
/** Present one pending tool-approval decision, taking over the live region. */
|
|
1190
|
+
openApproval(approval) {
|
|
1191
|
+
this.set({ overlay: {
|
|
1192
|
+
kind: "approval",
|
|
1193
|
+
approval
|
|
1194
|
+
} });
|
|
1195
|
+
}
|
|
1196
|
+
/** Present one pending question, taking over the live region. */
|
|
1197
|
+
openUserQuestion(userQuestion) {
|
|
1198
|
+
this.set({ overlay: {
|
|
1199
|
+
kind: "userQuestion",
|
|
1200
|
+
userQuestion
|
|
1201
|
+
} });
|
|
1202
|
+
}
|
|
1203
|
+
/** Close whichever overlay is open, restoring the normal prompt/status controls. */
|
|
1204
|
+
closeOverlay() {
|
|
1205
|
+
this.set({ overlay: CLOSED_OVERLAY });
|
|
1206
|
+
}
|
|
1207
|
+
/** Patch the open `/model` overlay's sub-state; a no-op once it's closed. */
|
|
1208
|
+
updateModelProfile(patch) {
|
|
1209
|
+
if (this.state.overlay.kind !== "modelProfile") return;
|
|
1210
|
+
this.set({ overlay: {
|
|
1211
|
+
kind: "modelProfile",
|
|
1212
|
+
modelProfile: {
|
|
1213
|
+
...this.state.overlay.modelProfile,
|
|
1214
|
+
...patch
|
|
1215
|
+
}
|
|
1216
|
+
} });
|
|
1217
|
+
}
|
|
1218
|
+
/** Patch the open `/presets` overlay's sub-state; a no-op once it's closed. */
|
|
1219
|
+
updateAgentPresets(patch) {
|
|
1220
|
+
if (this.state.overlay.kind !== "agentPresets") return;
|
|
1221
|
+
this.set({ overlay: {
|
|
1222
|
+
kind: "agentPresets",
|
|
1223
|
+
agentPresets: {
|
|
1224
|
+
...this.state.overlay.agentPresets,
|
|
1225
|
+
...patch
|
|
1226
|
+
}
|
|
1227
|
+
} });
|
|
1228
|
+
}
|
|
1229
|
+
/** Move the `/presets` overlay's list cursor. */
|
|
1230
|
+
selectAgentPresetRow(index) {
|
|
1231
|
+
this.updateAgentPresets({ selected: index });
|
|
1232
|
+
}
|
|
1233
|
+
/** Mark the `@`-mention file index as loading; a no-op once candidates are already present. */
|
|
1234
|
+
setFileIndexLoading() {
|
|
1235
|
+
if (this.state.fileIndex.candidates !== void 0) return;
|
|
1236
|
+
this.set({ fileIndex: {
|
|
1237
|
+
candidates: void 0,
|
|
1238
|
+
loading: true
|
|
1239
|
+
} });
|
|
1240
|
+
}
|
|
1241
|
+
/** Settle the `@`-mention file index once `loadFileIndex` resolves. */
|
|
1242
|
+
setFileIndex(candidates) {
|
|
1243
|
+
this.set({ fileIndex: {
|
|
1244
|
+
candidates,
|
|
1245
|
+
loading: false
|
|
1246
|
+
} });
|
|
1247
|
+
}
|
|
1248
|
+
/** Record a newer npm-published version found by the startup update check; persists for the session (not cleared by `/clear`'s notice reset) until dismissed by a fresh check finding none. */
|
|
1249
|
+
setUpdateHint(version) {
|
|
1250
|
+
this.set({ updateHint: version });
|
|
1251
|
+
}
|
|
1252
|
+
set(partial) {
|
|
1253
|
+
this.state = {
|
|
1254
|
+
...this.state,
|
|
1255
|
+
...partial
|
|
1256
|
+
};
|
|
1257
|
+
for (const listener of this.listeners) listener();
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
//#endregion
|
|
1261
|
+
//#region src/tui/logoArt.generated.ts
|
|
1262
|
+
/**
|
|
1263
|
+
* Generated by `node scripts/generate-logo.mjs` from
|
|
1264
|
+
* `assets/deepseek-128px.png` via chafa (half-block, 16x16, rgb color
|
|
1265
|
+
* space). Do not hand-edit — re-run the script after the source image
|
|
1266
|
+
* changes; requires `chafa` installed locally (e.g. `brew install chafa`).
|
|
1267
|
+
* @module @tomowang/dsh-tui/tui/logoArt.generated
|
|
1268
|
+
*/
|
|
1269
|
+
/** One entry per output row; each line carries embedded 24-bit ANSI SGR color codes. */
|
|
1270
|
+
const LOGO_HALF_BLOCK = [
|
|
1271
|
+
"\x1B[0m \x1B[0m",
|
|
1272
|
+
" \x1B[38;2;60;84;201m▄\x1B[38;2;76;106;253m▄▄▄▄\x1B[38;2;75;105;251;48;2;49;69;164m▄\x1B[0m \x1B[38;2;75;105;251m▄\x1B[38;2;44;61;147m▄\x1B[0m \x1B[38;2;58;81;193m▄\x1B[0m",
|
|
1273
|
+
"\x1B[38;2;46;64;153m▄\x1B[38;2;76;106;253;48;2;69;96;231m▄\x1B[48;2;76;106;253m▄▄▄▄▄▄\x1B[48;2;69;96;231m▄\x1B[0m\x1B[38;2;71;99;238m▄\x1B[0m \x1B[7m\x1B[38;2;73;103;246m▄\x1B[0m\x1B[38;2;76;106;253;48;2;76;106;253m▄\x1B[38;2;75;105;252;48;2;73;102;245m▄\x1B[38;2;55;77;185;48;2;76;106;253m▄\x1B[0m\x1B[7m\x1B[38;2;52;73;175m▄\x1B[0m",
|
|
1274
|
+
"\x1B[38;2;67;93;224;48;2;76;106;253m▌\x1B[0m \x1B[7m\x1B[38;2;39;55;132m▄\x1B[38;2;69;96;231m▄\x1B[0m\x1B[38;2;48;67;160;48;2;76;106;253m▄\x1B[38;2;76;106;253m▄▄\x1B[38;2;69;96;230;48;2;48;67;161m▌\x1B[0m\x1B[7m\x1B[38;2;68;95;228m▄\x1B[0m\x1B[38;2;67;94;224;48;2;73;102;245m▄\x1B[38;2;76;106;253;48;2;48;67;160m▄\x1B[48;2;76;106;253m▄\x1B[0m\x1B[38;2;53;74;177m▌\x1B[0m \x1B[0m",
|
|
1275
|
+
"\x1B[38;2;40;57;136;48;2;75;105;252m▌\x1B[0m\x1B[38;2;69;96;230m▌\x1B[0m \x1B[7m\x1B[38;2;45;63;151m▄\x1B[0m\x1B[38;2;61;86;206;48;2;76;106;253m▄\x1B[38;2;76;106;253m▄\x1B[0m\x1B[38;2;76;106;253m▄\x1B[48;2;41;58;139m▄\x1B[48;2;76;106;253m▄\x1B[38;2;73;103;246;48;2;44;62;148m▌\x1B[0m \x1B[0m",
|
|
1276
|
+
" \x1B[7m\x1B[38;2;75;104;250m▄\x1B[0m\x1B[38;2;76;106;253;48;2;40;56;135m▄\x1B[0m\x1B[38;2;43;61;146m▄\x1B[0m \x1B[7m\x1B[38;2;50;70;167m▌\x1B[0m\x1B[38;2;64;90;215m▄\x1B[0m \x1B[7m\x1B[38;2;73;102;244m▄\x1B[0m\x1B[38;2;74;103;247;48;2;76;106;253m▄\x1B[38;2;76;106;253m▄\x1B[38;2;68;96;229;48;2;39;55;132m▌\x1B[0m \x1B[0m",
|
|
1277
|
+
" \x1B[7m\x1B[38;2;73;102;245m▄\x1B[38;2;74;103;248m▄\x1B[0m\x1B[38;2;53;74;178;48;2;75;104;250m▄\x1B[38;2;57;79;189;48;2;76;106;253m▄\x1B[38;2;50;71;169;48;2;74;103;247m▄\x1B[0m\x1B[7m\x1B[38;2;50;71;169m▄\x1B[38;2;46;64;154m▄\x1B[38;2;52;72;173m▄\x1B[38;2;58;82;196m▄\x1B[0m \x1B[0m",
|
|
1278
|
+
" \x1B[0m"
|
|
1279
|
+
];
|
|
1280
|
+
//#endregion
|
|
1281
|
+
//#region src/tui/bannerText.ts
|
|
1282
|
+
/**
|
|
1283
|
+
* Pure text builder for the startup banner: a bordered box with the brand
|
|
1284
|
+
* logo on the left and session info on the right. Kept dependency-free (no
|
|
1285
|
+
* Ink/React) so it's trivial to reason about and test in isolation,
|
|
1286
|
+
* mirroring `render.ts`'s hand-rolled ANSI-aware formatting.
|
|
1287
|
+
* @module @tomowang/dsh-tui/tui/bannerText
|
|
1288
|
+
*/
|
|
1289
|
+
const ESC = "\x1B[";
|
|
1290
|
+
const bold$10 = (s) => `${ESC}1m${s}${ESC}0m`;
|
|
1291
|
+
const dim$1 = fg(theme.muted);
|
|
1292
|
+
const primary = fg(theme.primary);
|
|
1293
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[a-zA-Z]/g;
|
|
1294
|
+
const visibleWidth$1 = (s) => s.replace(ANSI_RE, "").length;
|
|
1295
|
+
const padVisible = (s, width) => s + " ".repeat(Math.max(0, width - visibleWidth$1(s)));
|
|
1296
|
+
const LOGO_WIDTH = 16;
|
|
1297
|
+
const LOGO_MARGIN = 2;
|
|
1298
|
+
const LEFT_WIDTH = 20;
|
|
1299
|
+
const MIN_WIDTH = 56;
|
|
1300
|
+
const MAX_WIDTH = 96;
|
|
1301
|
+
function clamp(value, min, max) {
|
|
1302
|
+
return Math.min(max, Math.max(min, value));
|
|
1303
|
+
}
|
|
1304
|
+
/** Pads `rows` with blank lines, split evenly top/bottom, to reach `height`. */
|
|
1305
|
+
function centerRows(rows, height) {
|
|
1306
|
+
const pad = Math.max(0, height - rows.length);
|
|
1307
|
+
const top = Math.floor(pad / 2);
|
|
1308
|
+
const bottom = pad - top;
|
|
1309
|
+
return [
|
|
1310
|
+
...Array(top).fill(""),
|
|
1311
|
+
...rows,
|
|
1312
|
+
...Array(bottom).fill("")
|
|
1313
|
+
];
|
|
1314
|
+
}
|
|
1315
|
+
function topBorder(title, total) {
|
|
1316
|
+
const inner = total - 2;
|
|
1317
|
+
const label = ` ${title} `;
|
|
1318
|
+
const leftDashes = 3;
|
|
1319
|
+
const rightDashes = Math.max(1, inner - leftDashes - label.length);
|
|
1320
|
+
return `╭${"─".repeat(leftDashes)}${primary(label)}${"─".repeat(rightDashes)}╮`;
|
|
1321
|
+
}
|
|
1322
|
+
function buildLeftColumn() {
|
|
1323
|
+
const margin = " ".repeat(LOGO_MARGIN);
|
|
1324
|
+
return LOGO_HALF_BLOCK.map((line) => margin + padVisible(line, LOGO_WIDTH) + margin);
|
|
1325
|
+
}
|
|
1326
|
+
function buildRightColumn(content, width) {
|
|
1327
|
+
return [
|
|
1328
|
+
bold$10(primary("DeepSeek Harness")),
|
|
1329
|
+
"",
|
|
1330
|
+
dim$1(`${content.provider}/${content.model}`),
|
|
1331
|
+
dim$1(truncate(content.cwd, width))
|
|
1332
|
+
];
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Builds the full multi-line banner text, sized to the given terminal
|
|
1336
|
+
* width: title bar, then a fixed-width logo column beside a session-info
|
|
1337
|
+
* column (vertically centered against each other), then the bottom border.
|
|
1338
|
+
*/
|
|
1339
|
+
function buildBannerText(content, columns) {
|
|
1340
|
+
const total = clamp(columns, MIN_WIDTH, MAX_WIDTH);
|
|
1341
|
+
const inner = total - 2;
|
|
1342
|
+
const top = topBorder(`dsh-tui v${content.version}`, total);
|
|
1343
|
+
const bottom = `╰${"─".repeat(inner)}╯`;
|
|
1344
|
+
const rightWidth = inner - LEFT_WIDTH - 1;
|
|
1345
|
+
const left = buildLeftColumn();
|
|
1346
|
+
const right = buildRightColumn(content, rightWidth);
|
|
1347
|
+
const height = Math.max(left.length, right.length);
|
|
1348
|
+
const leftRows = centerRows(left, height);
|
|
1349
|
+
const rightRows = centerRows(right, height);
|
|
1350
|
+
return [
|
|
1351
|
+
top,
|
|
1352
|
+
...leftRows.map((line, i) => `│${padVisible(line, LEFT_WIDTH)}│${padVisible(rightRows[i] ?? "", rightWidth)}│`),
|
|
1353
|
+
bottom
|
|
1354
|
+
].join("\n");
|
|
1355
|
+
}
|
|
1356
|
+
//#endregion
|
|
1357
|
+
//#region src/tui/statsFormat.ts
|
|
1358
|
+
/**
|
|
1359
|
+
* Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three digits).
|
|
1360
|
+
* @param n - token count.
|
|
1361
|
+
* @returns display string.
|
|
1362
|
+
*/
|
|
1363
|
+
function formatTokens(n) {
|
|
1364
|
+
const scaled = (v) => v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10);
|
|
1365
|
+
if (n < 1e3) return String(n);
|
|
1366
|
+
if (n < 1e6) return `${scaled(n / 1e3)}K`;
|
|
1367
|
+
return `${scaled(n / 1e6)}M`;
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* Compact duration: 45.2s under a minute, 2m42s from there on.
|
|
1371
|
+
* @param ms - duration in milliseconds.
|
|
1372
|
+
* @returns display string.
|
|
1373
|
+
*/
|
|
1374
|
+
function formatDuration(ms) {
|
|
1375
|
+
const s = ms / 1e3;
|
|
1376
|
+
if (s < 60) return `${Math.round(s * 10) / 10}s`;
|
|
1377
|
+
const whole = Math.round(s);
|
|
1378
|
+
return `${Math.floor(whole / 60)}m${whole % 60}s`;
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Compact throughput: one decimal under 10 tok/s, whole above.
|
|
1382
|
+
* @param tps - tokens per second.
|
|
1383
|
+
* @returns display string.
|
|
1384
|
+
*/
|
|
1385
|
+
function formatTokensPerSecond(tps) {
|
|
1386
|
+
const clamped = Math.max(0, tps);
|
|
1387
|
+
return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10);
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Sum the three disjoint prompt-side billing buckets.
|
|
1391
|
+
* @param usage - the session's token-usage projection value.
|
|
1392
|
+
* @returns billed input tokens.
|
|
1393
|
+
*/
|
|
1394
|
+
function billedInputTokens(usage) {
|
|
1395
|
+
return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Cache-hit share of prompt-side input over the whole durable log.
|
|
1399
|
+
* @param usage - the session's token-usage projection value.
|
|
1400
|
+
* @returns rounded integer percent, or null when no input was billed.
|
|
1401
|
+
*/
|
|
1402
|
+
function cacheHitPercent(usage) {
|
|
1403
|
+
const denominator = billedInputTokens(usage);
|
|
1404
|
+
return denominator === 0 ? null : Math.round(usage.cacheReadTokens / denominator * 100);
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Build the pipe-separated stats line for the status bar, e.g.
|
|
1408
|
+
* `1 turns · 1 steps| LLM 4.3s| TTFT avg 1.1s · 131 tok/s| Cache hit 80%| Input 9.1K tok · Output 412 tok`.
|
|
1409
|
+
* A group with no data drops out whole; an empty return means nothing to show yet.
|
|
1410
|
+
* @param stats - whole-log turn/step counts and wall times, or `undefined` without the projection unit mounted.
|
|
1411
|
+
* @param usage - whole-log provider token usage, or `undefined` without the projection unit mounted.
|
|
1412
|
+
* @returns the joined line, or `''` when there is nothing to display.
|
|
1413
|
+
*/
|
|
1414
|
+
function buildStatsLine(stats, usage) {
|
|
1415
|
+
const groups = [];
|
|
1416
|
+
if (stats !== void 0 && stats.steps > 0) {
|
|
1417
|
+
groups.push(`${stats.turns} turns · ${stats.steps} steps`);
|
|
1418
|
+
const durations = [];
|
|
1419
|
+
if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`);
|
|
1420
|
+
if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`);
|
|
1421
|
+
if (durations.length > 0) groups.push(durations.join(" · "));
|
|
1422
|
+
const speeds = [];
|
|
1423
|
+
if (stats.ttftSteps > 0) speeds.push(`TTFT avg ${formatDuration(stats.ttftMs / stats.ttftSteps)}`);
|
|
1424
|
+
if (stats.decodeMs > 0) speeds.push(`${formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1e3))} tok/s`);
|
|
1425
|
+
if (speeds.length > 0) groups.push(speeds.join(" · "));
|
|
1426
|
+
}
|
|
1427
|
+
if (usage !== void 0 && (billedInputTokens(usage) > 0 || usage.outputTokens > 0)) {
|
|
1428
|
+
const cacheHit = cacheHitPercent(usage);
|
|
1429
|
+
if (cacheHit !== null) groups.push(`Cache hit ${cacheHit}%`);
|
|
1430
|
+
groups.push(`Input ${formatTokens(billedInputTokens(usage))} tok · Output ${formatTokens(usage.outputTokens)} tok`);
|
|
1431
|
+
}
|
|
1432
|
+
return groups.join("| ");
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Derive occupancy from the newest pressure sample, or `null` while either
|
|
1436
|
+
* side (a usage sample, a known route capacity) hasn't arrived yet.
|
|
1437
|
+
* @param pressure - the session's context-pressure projection value.
|
|
1438
|
+
* @returns occupancy figures, or `null` when there is nothing to show yet.
|
|
1439
|
+
*/
|
|
1440
|
+
function contextOccupancy(pressure) {
|
|
1441
|
+
const usedTokens = pressure?.projectedTokens ?? pressure?.pressureTokens;
|
|
1442
|
+
if (usedTokens === void 0 || pressure?.contextWindow === void 0) return null;
|
|
1443
|
+
return {
|
|
1444
|
+
percent: Math.min(100, Math.round(usedTokens / pressure.contextWindow * 100)),
|
|
1445
|
+
usedTokens,
|
|
1446
|
+
contextWindow: pressure.contextWindow
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
/**
|
|
1450
|
+
* Build the always-on compact context-usage line, e.g. `Context 1% · ~8.1K / 1M tok`.
|
|
1451
|
+
* @param pressure - the session's context-pressure projection value.
|
|
1452
|
+
* @returns the display line, or `''` when there is nothing to show yet.
|
|
1453
|
+
*/
|
|
1454
|
+
function buildContextLine(pressure) {
|
|
1455
|
+
const occupancy = contextOccupancy(pressure);
|
|
1456
|
+
if (occupancy === null) return "";
|
|
1457
|
+
return `Context ${occupancy.percent}% · ~${formatTokens(occupancy.usedTokens)} / ${formatTokens(occupancy.contextWindow)} tok`;
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Proportional breakdown rows for the `/context` overlay's bar. The three
|
|
1461
|
+
* heuristic figures are composition only — they do not sum to
|
|
1462
|
+
* `occupancy.usedTokens` — so segment widths are scaled to `occupancy.percent`
|
|
1463
|
+
* rather than treated as an independent total; see `ContextBreakdownProjection`'s doc comment.
|
|
1464
|
+
* @param occupancy - this session's occupancy figures, or `null` without a usage sample yet.
|
|
1465
|
+
* @param breakdown - the session's context-breakdown projection value.
|
|
1466
|
+
* @returns the three rows in System/Tools/Messages order, or `[]` when there is nothing to show yet.
|
|
1467
|
+
*/
|
|
1468
|
+
function contextBreakdownRows(occupancy, breakdown) {
|
|
1469
|
+
if (occupancy === null || breakdown === void 0) return [];
|
|
1470
|
+
const total = breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens;
|
|
1471
|
+
if (total === 0) return [];
|
|
1472
|
+
const scale = (tokens) => occupancy.percent * tokens / total;
|
|
1473
|
+
return [
|
|
1474
|
+
{
|
|
1475
|
+
label: "System prompt",
|
|
1476
|
+
tokens: breakdown.systemTokens,
|
|
1477
|
+
width: scale(breakdown.systemTokens)
|
|
1478
|
+
},
|
|
1479
|
+
{
|
|
1480
|
+
label: "Tools",
|
|
1481
|
+
tokens: breakdown.toolsTokens,
|
|
1482
|
+
width: scale(breakdown.toolsTokens)
|
|
1483
|
+
},
|
|
1484
|
+
{
|
|
1485
|
+
label: "Messages",
|
|
1486
|
+
tokens: breakdown.messageTokens,
|
|
1487
|
+
width: scale(breakdown.messageTokens)
|
|
1488
|
+
}
|
|
1489
|
+
];
|
|
1490
|
+
}
|
|
1491
|
+
//#endregion
|
|
1492
|
+
//#region src/sessionId.ts
|
|
1493
|
+
/**
|
|
1494
|
+
* Session id prefix helpers shared between id generation/resolution
|
|
1495
|
+
* (`src/index.ts`) and display (`src/tui/StatusBar.tsx`). `session-` is the
|
|
1496
|
+
* de facto convention for top-level interactive sessions across the harness
|
|
1497
|
+
* (web portal, headless bundle), so ids keep the prefix on disk; only
|
|
1498
|
+
* user-facing text strips it.
|
|
1499
|
+
* @module @tomowang/dsh-tui/sessionId
|
|
1500
|
+
*/
|
|
1501
|
+
const SESSION_ID_PREFIX = "session-";
|
|
1502
|
+
/** Strip the `session-` prefix for display, if present. */
|
|
1503
|
+
function stripSessionIdPrefix(id) {
|
|
1504
|
+
return id.startsWith(SESSION_ID_PREFIX) ? id.slice(8) : id;
|
|
1505
|
+
}
|
|
1506
|
+
//#endregion
|
|
1507
|
+
//#region src/tui/liveText.ts
|
|
1508
|
+
const dim = fg(theme.muted);
|
|
1509
|
+
const accent = fg(theme.accent);
|
|
1510
|
+
const warning$1 = fg(theme.warning);
|
|
1511
|
+
function buildStatusBarText(params) {
|
|
1512
|
+
const { sessionId, provider, model, status, queuedCount, presetLabel, eventCount, spinnerChar } = params;
|
|
1513
|
+
const queuedSuffix = queuedCount > 0 ? ` · ${queuedCount} queued` : "";
|
|
1514
|
+
const presetSegment = presetLabel === void 0 ? "" : ` · ${presetLabel}`;
|
|
1515
|
+
const spinnerPart = status === "running" ? spinnerChar : "";
|
|
1516
|
+
return dim(`session ${stripSessionIdPrefix(sessionId)} · `) + accent(`${provider}/${model}`) + dim(`${presetSegment} · ${spinnerPart} ${status}${queuedSuffix} · ${eventCount} events`);
|
|
1517
|
+
}
|
|
1518
|
+
function previewOf(message) {
|
|
1519
|
+
return truncate(message.content.filter((block) => block.type === "text").map((block) => block.text).join(""), 80);
|
|
1520
|
+
}
|
|
1521
|
+
function buildQueuedText(queued) {
|
|
1522
|
+
if (queued.length === 0) return "";
|
|
1523
|
+
return queued.map((message) => dim(`↳ queued: ${previewOf(message)}`)).join("\n");
|
|
1524
|
+
}
|
|
1525
|
+
const PERMISSION_LABELS = {
|
|
1526
|
+
"read-only": "Read Only",
|
|
1527
|
+
"workspace-write": "Workspace Write",
|
|
1528
|
+
"danger-full-access": "Full Access",
|
|
1529
|
+
custom: "Custom"
|
|
1530
|
+
};
|
|
1531
|
+
const PERMISSION_ICONS = {
|
|
1532
|
+
"read-only": "⊘",
|
|
1533
|
+
"workspace-write": "✎",
|
|
1534
|
+
"danger-full-access": "‼",
|
|
1535
|
+
custom: "⊛"
|
|
1536
|
+
};
|
|
1537
|
+
const PERMISSION_COLORS = {
|
|
1538
|
+
"read-only": theme.info,
|
|
1539
|
+
"workspace-write": theme.success,
|
|
1540
|
+
"danger-full-access": theme.error,
|
|
1541
|
+
custom: theme.muted
|
|
1542
|
+
};
|
|
1543
|
+
/**
|
|
1544
|
+
* Persistent low-key dock row nudging the reader to upgrade once the
|
|
1545
|
+
* startup registry check (`src/updateCheck.ts`) finds a newer published
|
|
1546
|
+
* version; renders nothing while unchecked or already current. Unlike
|
|
1547
|
+
* `notice`, this isn't cleared on the next input — it's meant to stay
|
|
1548
|
+
* visible for the rest of the session, mirroring how `gh`/`npm` surface an
|
|
1549
|
+
* available-update line.
|
|
1550
|
+
*/
|
|
1551
|
+
function buildUpdateHintText(currentVersion, latestVersion) {
|
|
1552
|
+
if (latestVersion === void 0) return "";
|
|
1553
|
+
return warning$1(`⬆ dsh-tui update available: v${currentVersion} → v${latestVersion}`) + dim(" (run `dsh plugin --profile tui add @tomowang/dsh-tui` to upgrade)");
|
|
1554
|
+
}
|
|
1555
|
+
function buildPermissionText(permission) {
|
|
1556
|
+
if (permission === void 0) return "";
|
|
1557
|
+
const icon = PERMISSION_ICONS[permission.current] ?? "•";
|
|
1558
|
+
const label = PERMISSION_LABELS[permission.current] ?? permission.current;
|
|
1559
|
+
return `${fg(PERMISSION_COLORS[permission.current] ?? theme.muted)(`${icon} ${label}`)}${dim(" (shift+tab to cycle)")}`;
|
|
1560
|
+
}
|
|
1561
|
+
/** Human label for one durable goal phase — the single source of truth shared by the `/goal` notice (`index.ts`) and this strip. */
|
|
1562
|
+
function goalPhaseLabel(phase) {
|
|
1563
|
+
switch (phase) {
|
|
1564
|
+
case "active": return "active";
|
|
1565
|
+
case "paused": return "paused";
|
|
1566
|
+
case "blocked": return "blocked";
|
|
1567
|
+
case "complete": return "complete";
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
/** Phase color for the goal glyph + label; active reads green, paused amber, blocked coral. */
|
|
1571
|
+
const GOAL_PHASE_COLORS = {
|
|
1572
|
+
active: theme.success,
|
|
1573
|
+
paused: theme.warning,
|
|
1574
|
+
blocked: theme.error
|
|
1575
|
+
};
|
|
1576
|
+
/** Long-objective cap for the goal strip, matching the queued-preview cap. */
|
|
1577
|
+
const GOAL_OBJECTIVE_LIMIT = 80;
|
|
1578
|
+
/**
|
|
1579
|
+
* The goal strip docked above the composer — the terminal GoalBar. Mirrors
|
|
1580
|
+
* the web portal's rendering rule exactly: loading (`undefined` — projection
|
|
1581
|
+
* unit not composed), absent/cleared (`null`), and complete goals render
|
|
1582
|
+
* nothing; a present goal shows a goal glyph, its phase label, and the
|
|
1583
|
+
* truncated objective, with the blocker explanation appended for a blocked
|
|
1584
|
+
* goal (the portal shows it as a hover tooltip, which a terminal cannot).
|
|
1585
|
+
* Mutations live on the `/goal` command, not on the strip.
|
|
1586
|
+
*/
|
|
1587
|
+
/**
|
|
1588
|
+
* Terminal window/tab title: `<session title> — dsh-tui` once the optional
|
|
1589
|
+
* `dsh-session-title` service has accepted one for this session, or just
|
|
1590
|
+
* `dsh-tui` before that (loading) or without the service composed —
|
|
1591
|
+
* mirroring the harness's own `<session title> — <configured title>` OSC 0
|
|
1592
|
+
* convention. Plain text, never ANSI-colored: an OSC 0 title string is
|
|
1593
|
+
* displayed verbatim by the terminal chrome, not interpreted as SGR.
|
|
1594
|
+
*/
|
|
1595
|
+
function buildTerminalTitle(title) {
|
|
1596
|
+
return title === null || title === void 0 ? "dsh-tui" : `${title} — dsh-tui`;
|
|
1597
|
+
}
|
|
1598
|
+
function buildGoalBarText(goal) {
|
|
1599
|
+
if (goal === void 0 || goal === null || goal.goal.phase === "complete") return "";
|
|
1600
|
+
const snapshot = goal.goal;
|
|
1601
|
+
const color = fg(GOAL_PHASE_COLORS[snapshot.phase] ?? theme.muted);
|
|
1602
|
+
const label = goalPhaseLabel(snapshot.phase);
|
|
1603
|
+
const objective = truncate(snapshot.objective, GOAL_OBJECTIVE_LIMIT);
|
|
1604
|
+
const blocker = snapshot.phase === "blocked" && snapshot.blockedReason !== void 0 ? dim(` · ${snapshot.blockedReason.code}: ${truncate(snapshot.blockedReason.message, GOAL_OBJECTIVE_LIMIT)}`) : "";
|
|
1605
|
+
return `${color(`🎯 ${label}`)} · ${objective}${blocker}`;
|
|
1606
|
+
}
|
|
1607
|
+
//#endregion
|
|
1608
|
+
//#region src/tui/text.ts
|
|
1609
|
+
/**
|
|
1610
|
+
* Thin pi-tui `Component` wrappers around already-ANSI-styled strings.
|
|
1611
|
+
* `render.ts`/`markdown.ts`/`bannerText.ts` produce terminal-ready text
|
|
1612
|
+
* (colors, bold, links baked in via raw SGR/OSC sequences) — these wrappers
|
|
1613
|
+
* exist only to satisfy pi-tui's `Component` interface (`render(width)`,
|
|
1614
|
+
* `invalidate()`) without re-styling that text, mirroring how the old Ink
|
|
1615
|
+
* `<Text>` usage printed these strings unmodified.
|
|
1616
|
+
* @module @tomowang/dsh-tui/tui/text
|
|
1617
|
+
*/
|
|
1618
|
+
/** Left/right margin applied to main-panel message content, so it doesn't sit flush against either terminal edge. */
|
|
1619
|
+
const TRANSCRIPT_MARGIN = 2;
|
|
1620
|
+
const TRANSCRIPT_INDENT = " ".repeat(TRANSCRIPT_MARGIN);
|
|
1621
|
+
/** Word-wraps already-ANSI-styled text to fit within `width` minus the transcript's left/right margin, then indents every resulting line. Used by the live-region rows (streaming text, pending tool calls, live shell output), which rebuild their string from the store on every render — see `createTranscriptLine` for the settled, append-only transcript rows, which get the same margin from pi-tui's own `Text` instead so repeated renders can be cached. */
|
|
1622
|
+
function padTranscriptText(text, width) {
|
|
1623
|
+
if (text === "") return [];
|
|
1624
|
+
return wrapTextWithAnsi(text, Math.max(1, width - TRANSCRIPT_MARGIN * 2)).map((line) => `${TRANSCRIPT_INDENT}${line}`);
|
|
1625
|
+
}
|
|
1626
|
+
/** A settled transcript line: pi-tui's `Text` component wraps to width and applies the same left/right margin as `padTranscriptText`, but — unlike our own `Component`s here — caches its wrapped output keyed on `(text, width)`, so appended transcript history isn't re-wrapped on every unrelated store update (e.g. a streaming token delta) the way a hand-rolled render() would. Content is fixed at construction — transcript rows are append-only and never mutated after being added. */
|
|
1627
|
+
function createTranscriptLine(text) {
|
|
1628
|
+
return new Text(text, TRANSCRIPT_MARGIN, 0);
|
|
1629
|
+
}
|
|
1630
|
+
/** A block of pre-styled text rebuilt from the current viewport width on every render — for content (the banner) whose own layout is width-responsive. */
|
|
1631
|
+
var DynamicText = class {
|
|
1632
|
+
build;
|
|
1633
|
+
constructor(build) {
|
|
1634
|
+
this.build = build;
|
|
1635
|
+
}
|
|
1636
|
+
invalidate() {}
|
|
1637
|
+
render(width) {
|
|
1638
|
+
const text = this.build(width);
|
|
1639
|
+
return text === "" ? [] : text.split("\n");
|
|
1640
|
+
}
|
|
1641
|
+
};
|
|
1642
|
+
//#endregion
|
|
1643
|
+
//#region src/tui/commands.ts
|
|
1644
|
+
const SLASH_COMMANDS = [
|
|
1645
|
+
{
|
|
1646
|
+
command: "/help",
|
|
1647
|
+
description: "Show help and available commands"
|
|
1648
|
+
},
|
|
1649
|
+
{
|
|
1650
|
+
command: "/model",
|
|
1651
|
+
description: "Manage LLM provider profiles"
|
|
1652
|
+
},
|
|
1653
|
+
{
|
|
1654
|
+
command: "/trajectory",
|
|
1655
|
+
description: "Browse the turn/step event ledger"
|
|
1656
|
+
},
|
|
1657
|
+
{
|
|
1658
|
+
command: "/tools",
|
|
1659
|
+
description: "Browse and expand tool cards"
|
|
1660
|
+
},
|
|
1661
|
+
{
|
|
1662
|
+
command: "/context",
|
|
1663
|
+
description: "Show context window usage"
|
|
1664
|
+
},
|
|
1665
|
+
{
|
|
1666
|
+
command: "/plugins",
|
|
1667
|
+
description: "Show the loaded plugin tree"
|
|
1668
|
+
},
|
|
1669
|
+
{
|
|
1670
|
+
command: "/presets",
|
|
1671
|
+
description: "Show and switch agent presets (only while the session is blank)"
|
|
1672
|
+
},
|
|
1673
|
+
{
|
|
1674
|
+
command: "/goal",
|
|
1675
|
+
description: "Set or view the long-running goal: /goal <objective> | clear | edit <objective> | pause | resume"
|
|
1676
|
+
},
|
|
1677
|
+
{
|
|
1678
|
+
command: "/plan",
|
|
1679
|
+
description: "Enter plan mode, optionally with a message; /plan off to leave"
|
|
1680
|
+
},
|
|
1681
|
+
{
|
|
1682
|
+
command: "/compact",
|
|
1683
|
+
description: "Summarize and compact session history"
|
|
1684
|
+
},
|
|
1685
|
+
{
|
|
1686
|
+
command: "/clear",
|
|
1687
|
+
description: "Clear the screen and start a new session"
|
|
1688
|
+
},
|
|
1689
|
+
{
|
|
1690
|
+
command: "/exit",
|
|
1691
|
+
description: "Exit dsh-tui"
|
|
1692
|
+
},
|
|
1693
|
+
{
|
|
1694
|
+
command: "/quit",
|
|
1695
|
+
description: "Exit dsh-tui"
|
|
1696
|
+
}
|
|
1697
|
+
];
|
|
1698
|
+
Math.max(...SLASH_COMMANDS.map((c) => c.command.length));
|
|
1699
|
+
function matchSlashCommands(query) {
|
|
1700
|
+
return SLASH_COMMANDS.filter((c) => c.command.startsWith(query));
|
|
1701
|
+
}
|
|
1702
|
+
function commandQuery(value) {
|
|
1703
|
+
const query = value.trim();
|
|
1704
|
+
const isCommandMode = value.startsWith("/") && !/\s/.test(query);
|
|
1705
|
+
return {
|
|
1706
|
+
isCommandMode,
|
|
1707
|
+
matches: isCommandMode ? matchSlashCommands(query) : []
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
/** `/plan` on its own, or followed by whitespace — matches the harness's own `/plan [message]`/`/plan off` syntax. */
|
|
1711
|
+
const PLAN_COMMAND = /^\/plan(?:$|\s)/u;
|
|
1712
|
+
/**
|
|
1713
|
+
* `/plan`'s argument takes free text (a message, or the literal `off`), so unlike every other
|
|
1714
|
+
* command it can't route through {@link matchSlashCommands}'s whitespace-free matching.
|
|
1715
|
+
* @param text - Raw submitted line.
|
|
1716
|
+
* @returns The trimmed argument text, or `undefined` when `text` isn't a `/plan` invocation.
|
|
1717
|
+
*/
|
|
1718
|
+
function parsePlanCommand(text) {
|
|
1719
|
+
const trimmed = text.trim();
|
|
1720
|
+
if (!PLAN_COMMAND.test(trimmed)) return void 0;
|
|
1721
|
+
return trimmed.slice(5).trim();
|
|
1722
|
+
}
|
|
1723
|
+
/** `/goal` on its own, or followed by whitespace — its objective is free text, so it shares `/plan`'s parse-ahead shape. */
|
|
1724
|
+
const GOAL_COMMAND = /^\/goal(?:$|\s)/u;
|
|
1725
|
+
/**
|
|
1726
|
+
* Parse a `/goal` invocation exactly the way `@deepseek-ai/dsh-command-goal`'s own
|
|
1727
|
+
* `parseGoalCommand` does — bare `/goal` shows the current goal, the control words
|
|
1728
|
+
* `clear`/`pause`/`resume` (case-insensitive) mutate it, `edit <objective>` replaces
|
|
1729
|
+
* the objective (bare `edit` is an error), and any other text is a create objective.
|
|
1730
|
+
* @param text - Raw submitted line.
|
|
1731
|
+
* @returns The parsed command, or `undefined` when `text` isn't a `/goal` invocation.
|
|
1732
|
+
*/
|
|
1733
|
+
function parseGoalCommand(text) {
|
|
1734
|
+
const trimmed = text.trim();
|
|
1735
|
+
if (!GOAL_COMMAND.test(trimmed)) return void 0;
|
|
1736
|
+
const input = trimmed.slice(5).trim();
|
|
1737
|
+
if (input.length === 0) return { kind: "show" };
|
|
1738
|
+
const control = input.toLowerCase();
|
|
1739
|
+
if (control === "clear") return { kind: "clear" };
|
|
1740
|
+
if (control === "pause") return { kind: "pause" };
|
|
1741
|
+
if (control === "resume") return { kind: "resume" };
|
|
1742
|
+
if (control === "edit") return { kind: "invalid-edit" };
|
|
1743
|
+
if (/^edit(?=\s)/iu.test(input)) return {
|
|
1744
|
+
kind: "edit",
|
|
1745
|
+
objective: input.slice(4).trim()
|
|
1746
|
+
};
|
|
1747
|
+
return {
|
|
1748
|
+
kind: "create",
|
|
1749
|
+
objective: input
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
function runSlashCommand(command, actions) {
|
|
1753
|
+
switch (command) {
|
|
1754
|
+
case "/help":
|
|
1755
|
+
actions.help();
|
|
1756
|
+
return;
|
|
1757
|
+
case "/exit":
|
|
1758
|
+
case "/quit":
|
|
1759
|
+
actions.shutdown();
|
|
1760
|
+
return;
|
|
1761
|
+
case "/clear":
|
|
1762
|
+
actions.clear();
|
|
1763
|
+
return;
|
|
1764
|
+
case "/model":
|
|
1765
|
+
actions.openModelProfile();
|
|
1766
|
+
return;
|
|
1767
|
+
case "/trajectory":
|
|
1768
|
+
actions.openTrajectory();
|
|
1769
|
+
return;
|
|
1770
|
+
case "/tools":
|
|
1771
|
+
actions.openToolCards();
|
|
1772
|
+
return;
|
|
1773
|
+
case "/context":
|
|
1774
|
+
actions.openContext();
|
|
1775
|
+
return;
|
|
1776
|
+
case "/plugins":
|
|
1777
|
+
actions.openPlugins();
|
|
1778
|
+
return;
|
|
1779
|
+
case "/presets":
|
|
1780
|
+
actions.openAgentPresets();
|
|
1781
|
+
return;
|
|
1782
|
+
case "/compact":
|
|
1783
|
+
actions.compact();
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
//#endregion
|
|
1788
|
+
//#region src/tui/piTheme.ts
|
|
1789
|
+
const bold$9 = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
1790
|
+
const selectListTheme = {
|
|
1791
|
+
selectedPrefix: fg(theme.primary),
|
|
1792
|
+
selectedText: (s) => bold$9(fg(theme.primary)(s)),
|
|
1793
|
+
description: fg(theme.muted),
|
|
1794
|
+
scrollInfo: fg(theme.muted),
|
|
1795
|
+
noMatch: fg(theme.muted)
|
|
1796
|
+
};
|
|
1797
|
+
const editorTheme = {
|
|
1798
|
+
borderColor: fg(theme.primary),
|
|
1799
|
+
selectList: selectListTheme
|
|
1800
|
+
};
|
|
1801
|
+
const shellModeEditorBorderColor = fg(theme.warning);
|
|
1802
|
+
const NOT_MENTION = {
|
|
1803
|
+
isMentionMode: false,
|
|
1804
|
+
query: "",
|
|
1805
|
+
start: -1
|
|
1806
|
+
};
|
|
1807
|
+
/**
|
|
1808
|
+
* Find the `@`-mention token, if any, ending at `cursor`.
|
|
1809
|
+
* @param value - the full prompt buffer.
|
|
1810
|
+
* @param cursor - the buffer offset the reader is currently editing at.
|
|
1811
|
+
* @returns the open mention's query/span, or `isMentionMode: false` outside one.
|
|
1812
|
+
*/
|
|
1813
|
+
function mentionQuery(value, cursor) {
|
|
1814
|
+
let i = cursor;
|
|
1815
|
+
while (i > 0 && !/\s/.test(value[i - 1])) i--;
|
|
1816
|
+
if (i === cursor || value[i] !== "@") return NOT_MENTION;
|
|
1817
|
+
const before = value[i - 1];
|
|
1818
|
+
if (before !== void 0 && !/\s/.test(before)) return NOT_MENTION;
|
|
1819
|
+
return {
|
|
1820
|
+
isMentionMode: true,
|
|
1821
|
+
query: value.slice(i + 1, cursor),
|
|
1822
|
+
start: i
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
/**
|
|
1826
|
+
* Filter and rank file candidates for a `@`-mention query.
|
|
1827
|
+
* @param candidates - the full file index, repo-relative paths.
|
|
1828
|
+
* @param query - text typed after `@` (case-insensitive substring match).
|
|
1829
|
+
* @param limit - max rows returned.
|
|
1830
|
+
* @returns matches ranked by path-prefix, then basename-prefix, then path length.
|
|
1831
|
+
*/
|
|
1832
|
+
function matchFileCandidates(candidates, query, limit = 10) {
|
|
1833
|
+
const needle = query.toLowerCase();
|
|
1834
|
+
const matches = candidates.filter((path) => path.toLowerCase().includes(needle));
|
|
1835
|
+
matches.sort((a, b) => rank(a, needle) - rank(b, needle) || a.length - b.length);
|
|
1836
|
+
return matches.slice(0, limit);
|
|
1837
|
+
}
|
|
1838
|
+
function rank(path, needle) {
|
|
1839
|
+
const lower = path.toLowerCase();
|
|
1840
|
+
if (lower.startsWith(needle)) return 0;
|
|
1841
|
+
if (lower.slice(lower.lastIndexOf("/") + 1).startsWith(needle)) return 1;
|
|
1842
|
+
return 2;
|
|
1843
|
+
}
|
|
1844
|
+
//#endregion
|
|
1845
|
+
//#region src/tui/promptAutocomplete.ts
|
|
1846
|
+
function offsetToLineCol(lines, offset) {
|
|
1847
|
+
let remaining = offset;
|
|
1848
|
+
for (let line = 0; line < lines.length; line++) {
|
|
1849
|
+
const len = lines[line].length;
|
|
1850
|
+
if (remaining <= len) return {
|
|
1851
|
+
line,
|
|
1852
|
+
col: remaining
|
|
1853
|
+
};
|
|
1854
|
+
remaining -= len + 1;
|
|
1855
|
+
}
|
|
1856
|
+
const lastLine = Math.max(0, lines.length - 1);
|
|
1857
|
+
return {
|
|
1858
|
+
line: lastLine,
|
|
1859
|
+
col: lines[lastLine]?.length ?? 0
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
function lineColToOffset(lines, line, col) {
|
|
1863
|
+
let offset = 0;
|
|
1864
|
+
for (let i = 0; i < line; i++) offset += lines[i].length + 1;
|
|
1865
|
+
return offset + col;
|
|
1866
|
+
}
|
|
1867
|
+
function splitWithCursor(text, offset) {
|
|
1868
|
+
const lines = text.split("\n");
|
|
1869
|
+
const { line, col } = offsetToLineCol(lines, offset);
|
|
1870
|
+
return {
|
|
1871
|
+
lines,
|
|
1872
|
+
cursorLine: line,
|
|
1873
|
+
cursorCol: col
|
|
1874
|
+
};
|
|
1875
|
+
}
|
|
1876
|
+
var PromptAutocompleteProvider = class {
|
|
1877
|
+
getFileCandidates;
|
|
1878
|
+
triggerCharacters = ["/", "@"];
|
|
1879
|
+
constructor(getFileCandidates) {
|
|
1880
|
+
this.getFileCandidates = getFileCandidates;
|
|
1881
|
+
}
|
|
1882
|
+
async getSuggestions(lines, cursorLine, cursorCol, { signal }) {
|
|
1883
|
+
const value = lines.join("\n");
|
|
1884
|
+
const offset = lineColToOffset(lines, cursorLine, cursorCol);
|
|
1885
|
+
const { isCommandMode, matches } = commandQuery(value);
|
|
1886
|
+
if (isCommandMode) {
|
|
1887
|
+
if (matches.length === 0) return null;
|
|
1888
|
+
return {
|
|
1889
|
+
items: matches.map((c) => ({
|
|
1890
|
+
value: c.command,
|
|
1891
|
+
label: c.command,
|
|
1892
|
+
description: c.description
|
|
1893
|
+
})),
|
|
1894
|
+
prefix: value.trim()
|
|
1895
|
+
};
|
|
1896
|
+
}
|
|
1897
|
+
const mention = mentionQuery(value, offset);
|
|
1898
|
+
if (!mention.isMentionMode) return null;
|
|
1899
|
+
const candidates = await this.getFileCandidates();
|
|
1900
|
+
if (signal.aborted) return null;
|
|
1901
|
+
const paths = matchFileCandidates(candidates, mention.query);
|
|
1902
|
+
if (paths.length === 0) return null;
|
|
1903
|
+
return {
|
|
1904
|
+
items: paths.map((path) => ({
|
|
1905
|
+
value: path,
|
|
1906
|
+
label: path
|
|
1907
|
+
})),
|
|
1908
|
+
prefix: mention.query
|
|
1909
|
+
};
|
|
1910
|
+
}
|
|
1911
|
+
applyCompletion(lines, cursorLine, cursorCol, item, _prefix) {
|
|
1912
|
+
const value = lines.join("\n");
|
|
1913
|
+
const offset = lineColToOffset(lines, cursorLine, cursorCol);
|
|
1914
|
+
const { isCommandMode } = commandQuery(value);
|
|
1915
|
+
if (isCommandMode) return splitWithCursor(item.value, item.value.length);
|
|
1916
|
+
const mention = mentionQuery(value, offset);
|
|
1917
|
+
if (mention.isMentionMode) {
|
|
1918
|
+
const start = mention.start + 1;
|
|
1919
|
+
const end = start + mention.query.length;
|
|
1920
|
+
const inserted = `${item.value} `;
|
|
1921
|
+
return splitWithCursor(value.slice(0, start) + inserted + value.slice(end), start + inserted.length);
|
|
1922
|
+
}
|
|
1923
|
+
return {
|
|
1924
|
+
lines,
|
|
1925
|
+
cursorLine,
|
|
1926
|
+
cursorCol
|
|
1927
|
+
};
|
|
1928
|
+
}
|
|
1929
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
1930
|
+
return mentionQuery(lines.join("\n"), lineColToOffset(lines, cursorLine, cursorCol)).isMentionMode;
|
|
1931
|
+
}
|
|
1932
|
+
};
|
|
1933
|
+
//#endregion
|
|
1934
|
+
//#region src/tui/CustomEditor.ts
|
|
1935
|
+
const EXIT_ARM_TIMEOUT_MS = 2e3;
|
|
1936
|
+
const armedHint = fg(theme.muted);
|
|
1937
|
+
const shellModeHint = fg(theme.warning);
|
|
1938
|
+
var CustomEditor = class extends Editor {
|
|
1939
|
+
actions;
|
|
1940
|
+
deps;
|
|
1941
|
+
shellMode = false;
|
|
1942
|
+
armedKey;
|
|
1943
|
+
armTimer;
|
|
1944
|
+
constructor(tui, actions, deps) {
|
|
1945
|
+
super(tui, editorTheme, {
|
|
1946
|
+
paddingX: 2,
|
|
1947
|
+
autocompleteMaxVisible: 20
|
|
1948
|
+
});
|
|
1949
|
+
this.actions = actions;
|
|
1950
|
+
this.deps = deps;
|
|
1951
|
+
this.setAutocompleteProvider(new PromptAutocompleteProvider(deps.getFileCandidates));
|
|
1952
|
+
this.onSubmit = (text) => this.handleSubmit(text);
|
|
1953
|
+
for (const line of deps.history.slice(-100)) this.addToHistory(line);
|
|
1954
|
+
}
|
|
1955
|
+
armOrConfirmExit(key) {
|
|
1956
|
+
if (this.armedKey === key) {
|
|
1957
|
+
if (this.armTimer !== void 0) clearTimeout(this.armTimer);
|
|
1958
|
+
this.armedKey = void 0;
|
|
1959
|
+
this.actions.shutdown();
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
if (this.armTimer !== void 0) clearTimeout(this.armTimer);
|
|
1963
|
+
this.armedKey = key;
|
|
1964
|
+
this.tui.requestRender();
|
|
1965
|
+
this.armTimer = setTimeout(() => {
|
|
1966
|
+
this.armTimer = void 0;
|
|
1967
|
+
this.armedKey = void 0;
|
|
1968
|
+
this.tui.requestRender();
|
|
1969
|
+
}, EXIT_ARM_TIMEOUT_MS);
|
|
1970
|
+
}
|
|
1971
|
+
clearArm() {
|
|
1972
|
+
if (this.armTimer !== void 0) clearTimeout(this.armTimer);
|
|
1973
|
+
this.armTimer = void 0;
|
|
1974
|
+
this.armedKey = void 0;
|
|
1975
|
+
}
|
|
1976
|
+
setShellMode(enabled) {
|
|
1977
|
+
this.shellMode = enabled;
|
|
1978
|
+
this.borderColor = enabled ? shellModeEditorBorderColor : editorTheme.borderColor;
|
|
1979
|
+
}
|
|
1980
|
+
handleSubmit(text) {
|
|
1981
|
+
const trimmed = text.trim();
|
|
1982
|
+
const shellMode = this.shellMode;
|
|
1983
|
+
this.setShellMode(false);
|
|
1984
|
+
if (trimmed === "") return;
|
|
1985
|
+
if (this.deps.history.at(-1) !== trimmed) {
|
|
1986
|
+
this.deps.history.push(trimmed);
|
|
1987
|
+
this.actions.recordHistory(trimmed);
|
|
1988
|
+
}
|
|
1989
|
+
this.addToHistory(trimmed);
|
|
1990
|
+
if (shellMode) {
|
|
1991
|
+
this.actions.runShell(trimmed);
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
const planArgs = parsePlanCommand(trimmed);
|
|
1995
|
+
if (planArgs !== void 0) {
|
|
1996
|
+
this.actions.plan(planArgs);
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
const goalCommand = parseGoalCommand(trimmed);
|
|
2000
|
+
if (goalCommand !== void 0) {
|
|
2001
|
+
this.actions.goal(goalCommand);
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
const matches = trimmed.startsWith("/") && !/\s/.test(trimmed) ? matchSlashCommands(trimmed) : [];
|
|
2005
|
+
if (matches.length > 0) {
|
|
2006
|
+
runSlashCommand(matches[0].command, this.actions);
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
this.actions.send(trimmed);
|
|
2010
|
+
}
|
|
2011
|
+
handleInput(data) {
|
|
2012
|
+
if (matchesKey(data, Key.ctrl("o"))) {
|
|
2013
|
+
this.actions.openToolCards();
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
if (!this.shellMode && data === "!" && this.getText() === "") {
|
|
2017
|
+
this.setShellMode(true);
|
|
2018
|
+
this.tui.requestRender();
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
if (this.shellMode && (matchesKey(data, Key.escape) || matchesKey(data, Key.backspace) && this.getText() === "")) {
|
|
2022
|
+
this.setShellMode(false);
|
|
2023
|
+
this.tui.requestRender();
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
if (matchesKey(data, "shift+tab")) {
|
|
2027
|
+
this.actions.cyclePermission();
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
if (matchesKey(data, Key.ctrl("c"))) {
|
|
2031
|
+
if (this.deps.getStatus() === "running") {
|
|
2032
|
+
this.actions.cancel();
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
if (this.getText() !== "") {
|
|
2036
|
+
this.setText("");
|
|
2037
|
+
this.setShellMode(false);
|
|
2038
|
+
this.clearArm();
|
|
2039
|
+
this.tui.requestRender();
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
this.armOrConfirmExit("c");
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
if (matchesKey(data, Key.ctrl("d"))) {
|
|
2046
|
+
if (this.deps.getStatus() === "running") return;
|
|
2047
|
+
if (this.getText() !== "") {
|
|
2048
|
+
super.handleInput(data);
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
this.armOrConfirmExit("d");
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
super.handleInput(data);
|
|
2055
|
+
}
|
|
2056
|
+
render(width) {
|
|
2057
|
+
const hints = [];
|
|
2058
|
+
if (this.armedKey !== void 0) hints.push(armedHint(`Press Ctrl+${this.armedKey.toUpperCase()} again to exit`));
|
|
2059
|
+
if (this.shellMode) hints.push(shellModeHint("! shell mode — Enter runs the command, Esc/Backspace exits"));
|
|
2060
|
+
return [...hints, ...this.withPromptPrefix(super.render(width), width)];
|
|
2061
|
+
}
|
|
2062
|
+
/**
|
|
2063
|
+
* Splices a `'› '`/`'! '` prompt marker into the editor box's first
|
|
2064
|
+
* content row (index 1 — index 0 is always the top border), replacing
|
|
2065
|
+
* that row's leading `paddingX` spaces. Skipped below `paddingX: 2`'s
|
|
2066
|
+
* width-clamped floor (mirrors `Editor.render`'s own `maxPadding` clamp)
|
|
2067
|
+
* rather than risk eating actual text in a pathologically narrow terminal.
|
|
2068
|
+
*/
|
|
2069
|
+
withPromptPrefix(lines, width) {
|
|
2070
|
+
if (lines.length < 2) return lines;
|
|
2071
|
+
if (Math.max(0, Math.floor((width - 1) / 2)) < 2) return lines;
|
|
2072
|
+
const prefix = this.borderColor(this.shellMode ? "! " : "› ");
|
|
2073
|
+
const next = [...lines];
|
|
2074
|
+
next[1] = prefix + next[1].slice(2);
|
|
2075
|
+
return next;
|
|
2076
|
+
}
|
|
2077
|
+
};
|
|
2078
|
+
//#endregion
|
|
2079
|
+
//#region src/tui/Spinner.ts
|
|
2080
|
+
const FRAMES = [
|
|
2081
|
+
"⠋",
|
|
2082
|
+
"⠙",
|
|
2083
|
+
"⠹",
|
|
2084
|
+
"⠸",
|
|
2085
|
+
"⠼",
|
|
2086
|
+
"⠴",
|
|
2087
|
+
"⠦",
|
|
2088
|
+
"⠧",
|
|
2089
|
+
"⠇",
|
|
2090
|
+
"⠏"
|
|
2091
|
+
];
|
|
2092
|
+
const INTERVAL_MS = 80;
|
|
2093
|
+
var Spinner = class {
|
|
2094
|
+
tui;
|
|
2095
|
+
frame = 0;
|
|
2096
|
+
timer;
|
|
2097
|
+
constructor(tui) {
|
|
2098
|
+
this.tui = tui;
|
|
2099
|
+
}
|
|
2100
|
+
current() {
|
|
2101
|
+
return FRAMES[this.frame];
|
|
2102
|
+
}
|
|
2103
|
+
start() {
|
|
2104
|
+
if (this.timer !== void 0) return;
|
|
2105
|
+
this.timer = setInterval(() => {
|
|
2106
|
+
this.frame = (this.frame + 1) % FRAMES.length;
|
|
2107
|
+
this.tui.requestRender();
|
|
2108
|
+
}, INTERVAL_MS);
|
|
2109
|
+
}
|
|
2110
|
+
stop() {
|
|
2111
|
+
if (this.timer === void 0) return;
|
|
2112
|
+
clearInterval(this.timer);
|
|
2113
|
+
this.timer = void 0;
|
|
2114
|
+
}
|
|
2115
|
+
};
|
|
2116
|
+
//#endregion
|
|
2117
|
+
//#region src/tui/miniTextField.ts
|
|
2118
|
+
/**
|
|
2119
|
+
* A tiny hand-rolled single-line text buffer (value + cursor, insert/
|
|
2120
|
+
* backspace/delete/left/right/home/end) shared by the handful of overlays
|
|
2121
|
+
* that need one embedded field inside otherwise-custom keyboard handling
|
|
2122
|
+
* (`QuestionOverlay`'s free-text answer, `TrajectoryOverlay`'s filter,
|
|
2123
|
+
* `ProviderForm`'s fields, `ModelListEditor`'s add-id field) — replacing
|
|
2124
|
+
* `ink-text-input` without wrestling with pi-tui's focus model, which only
|
|
2125
|
+
* tracks one focused `Component` at a time and has no built-in way to
|
|
2126
|
+
* delegate keystrokes to a field nested inside a larger custom overlay.
|
|
2127
|
+
* @module @tomowang/dsh-tui/tui/miniTextField
|
|
2128
|
+
*/
|
|
2129
|
+
function emptyMiniTextField(value = "") {
|
|
2130
|
+
return {
|
|
2131
|
+
value,
|
|
2132
|
+
cursor: value.length
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
/** Apply one keystroke, or return `undefined` if this field doesn't handle it (so the caller can fall through to its own bindings, e.g. Enter/Escape/Tab). */
|
|
2136
|
+
function miniTextFieldInput(state, data) {
|
|
2137
|
+
if (matchesKey(data, Key.left)) return {
|
|
2138
|
+
...state,
|
|
2139
|
+
cursor: Math.max(0, state.cursor - 1)
|
|
2140
|
+
};
|
|
2141
|
+
if (matchesKey(data, Key.right)) return {
|
|
2142
|
+
...state,
|
|
2143
|
+
cursor: Math.min(state.value.length, state.cursor + 1)
|
|
2144
|
+
};
|
|
2145
|
+
if (matchesKey(data, Key.home) || matchesKey(data, Key.ctrl("a"))) return {
|
|
2146
|
+
...state,
|
|
2147
|
+
cursor: 0
|
|
2148
|
+
};
|
|
2149
|
+
if (matchesKey(data, Key.end) || matchesKey(data, Key.ctrl("e"))) return {
|
|
2150
|
+
...state,
|
|
2151
|
+
cursor: state.value.length
|
|
2152
|
+
};
|
|
2153
|
+
if (matchesKey(data, Key.backspace)) {
|
|
2154
|
+
if (state.cursor === 0) return state;
|
|
2155
|
+
return {
|
|
2156
|
+
value: state.value.slice(0, state.cursor - 1) + state.value.slice(state.cursor),
|
|
2157
|
+
cursor: state.cursor - 1
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2160
|
+
if (matchesKey(data, Key.delete)) {
|
|
2161
|
+
if (state.cursor >= state.value.length) return state;
|
|
2162
|
+
return {
|
|
2163
|
+
value: state.value.slice(0, state.cursor) + state.value.slice(state.cursor + 1),
|
|
2164
|
+
cursor: state.cursor
|
|
2165
|
+
};
|
|
2166
|
+
}
|
|
2167
|
+
if (data.length > 0 && !data.startsWith("\x1B") && data !== "\r" && data !== "\n" && data !== " ") return {
|
|
2168
|
+
value: state.value.slice(0, state.cursor) + data + state.value.slice(state.cursor),
|
|
2169
|
+
cursor: state.cursor + data.length
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
/** Render the field's text, optionally with an inverse-video cursor block at the cursor position. */
|
|
2173
|
+
function renderMiniTextField(state, cursorVisible, mask) {
|
|
2174
|
+
const display = mask === void 0 ? state.value : mask.repeat(state.value.length);
|
|
2175
|
+
if (!cursorVisible) return display;
|
|
2176
|
+
return `${display.slice(0, state.cursor)}\x1b[7m${display[state.cursor] ?? " "}\x1b[0m${display.slice(state.cursor + 1)}`;
|
|
2177
|
+
}
|
|
2178
|
+
//#endregion
|
|
2179
|
+
//#region src/tui/modelProfile/ModelProfileOverlay.ts
|
|
2180
|
+
const bold$8 = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
2181
|
+
const secondary$8 = fg(theme.secondary);
|
|
2182
|
+
const muted$9 = fg(theme.muted);
|
|
2183
|
+
const errorColor$4 = fg(theme.error);
|
|
2184
|
+
const invert$4 = (s) => `\x1b[7m${s}\x1b[0m`;
|
|
2185
|
+
var ModelProfileOverlay = class {
|
|
2186
|
+
store;
|
|
2187
|
+
actions;
|
|
2188
|
+
confirmDelete;
|
|
2189
|
+
formKeySeen = -1;
|
|
2190
|
+
route = emptyMiniTextField();
|
|
2191
|
+
displayName = emptyMiniTextField();
|
|
2192
|
+
api = emptyMiniTextField();
|
|
2193
|
+
baseURL = emptyMiniTextField();
|
|
2194
|
+
apiKeyDraft = emptyMiniTextField();
|
|
2195
|
+
models = [];
|
|
2196
|
+
showModels = false;
|
|
2197
|
+
focused = 0;
|
|
2198
|
+
modelDraftId = emptyMiniTextField();
|
|
2199
|
+
modelSelected = 0;
|
|
2200
|
+
modelInputFocused = false;
|
|
2201
|
+
constructor(store, actions) {
|
|
2202
|
+
this.store = store;
|
|
2203
|
+
this.actions = actions;
|
|
2204
|
+
}
|
|
2205
|
+
invalidate() {}
|
|
2206
|
+
textFields(draft) {
|
|
2207
|
+
return draft.isNew ? [
|
|
2208
|
+
"route",
|
|
2209
|
+
"displayName",
|
|
2210
|
+
"api",
|
|
2211
|
+
"baseURL",
|
|
2212
|
+
"apiKey"
|
|
2213
|
+
] : [
|
|
2214
|
+
"displayName",
|
|
2215
|
+
"api",
|
|
2216
|
+
"baseURL",
|
|
2217
|
+
"apiKey"
|
|
2218
|
+
];
|
|
2219
|
+
}
|
|
2220
|
+
/** Reinitialize form-local state from the store's draft when `formKey` changes — the equivalent of the old `key={formKey}` remount. */
|
|
2221
|
+
syncFormState(mp) {
|
|
2222
|
+
if (mp.view !== "form" || mp.draft === void 0) return void 0;
|
|
2223
|
+
const draft = mp.draft;
|
|
2224
|
+
if (mp.formKey !== this.formKeySeen) {
|
|
2225
|
+
this.formKeySeen = mp.formKey;
|
|
2226
|
+
this.route = emptyMiniTextField(draft.route);
|
|
2227
|
+
this.displayName = emptyMiniTextField(draft.displayName);
|
|
2228
|
+
this.api = emptyMiniTextField(draft.api);
|
|
2229
|
+
this.baseURL = emptyMiniTextField(draft.baseURL);
|
|
2230
|
+
this.apiKeyDraft = emptyMiniTextField("");
|
|
2231
|
+
this.models = [...draft.models];
|
|
2232
|
+
this.showModels = false;
|
|
2233
|
+
this.focused = 0;
|
|
2234
|
+
this.modelDraftId = emptyMiniTextField();
|
|
2235
|
+
this.modelSelected = 0;
|
|
2236
|
+
this.modelInputFocused = false;
|
|
2237
|
+
}
|
|
2238
|
+
return draft;
|
|
2239
|
+
}
|
|
2240
|
+
buildDraft(draft) {
|
|
2241
|
+
return {
|
|
2242
|
+
...draft,
|
|
2243
|
+
route: draft.isNew ? this.route.value.trim() : draft.route,
|
|
2244
|
+
displayName: this.displayName.value,
|
|
2245
|
+
api: this.api.value,
|
|
2246
|
+
baseURL: this.baseURL.value,
|
|
2247
|
+
apiKeyDraft: this.apiKeyDraft.value,
|
|
2248
|
+
models: this.models
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
render(_width) {
|
|
2252
|
+
const overlay = this.store.getSnapshot().overlay;
|
|
2253
|
+
if (overlay.kind !== "modelProfile") return [];
|
|
2254
|
+
const mp = overlay.modelProfile;
|
|
2255
|
+
const draft = this.syncFormState(mp);
|
|
2256
|
+
if (draft !== void 0) return this.showModels ? this.renderModelListEditor(mp) : this.renderForm(draft, mp);
|
|
2257
|
+
return this.renderList(mp);
|
|
2258
|
+
}
|
|
2259
|
+
renderList(mp) {
|
|
2260
|
+
const { providers, selected, busy, error } = mp;
|
|
2261
|
+
const lines = [bold$8(secondary$8("Model providers"))];
|
|
2262
|
+
if (error !== void 0) lines.push(errorColor$4(error));
|
|
2263
|
+
if (busy && providers === void 0) lines.push(muted$9("Loading…"));
|
|
2264
|
+
providers?.forEach((row, index) => {
|
|
2265
|
+
const marker = row.configured ? "● " : "○ ";
|
|
2266
|
+
const active = row.live ? " (active)" : "";
|
|
2267
|
+
const noKey = row.apiKeyConfigured ? "" : " [no api key]";
|
|
2268
|
+
const confirm = this.confirmDelete === index ? " — press d again to delete" : "";
|
|
2269
|
+
const text = `${index === selected ? "› " : " "}${marker}${row.displayName}${active}${noKey}${confirm}`;
|
|
2270
|
+
lines.push(index === selected ? invert$4(text) : text);
|
|
2271
|
+
});
|
|
2272
|
+
if (providers?.length === 0) lines.push(muted$9("No providers configured yet — press a to add one."));
|
|
2273
|
+
lines.push(muted$9("↑↓ select · enter edit · a add · d delete · s set active model · esc close"));
|
|
2274
|
+
return lines;
|
|
2275
|
+
}
|
|
2276
|
+
handleListInput(data, mp) {
|
|
2277
|
+
const { providers, selected } = mp;
|
|
2278
|
+
if (matchesKey(data, Key.escape)) {
|
|
2279
|
+
this.actions.closeModelProfile();
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
2282
|
+
if (providers === void 0 || providers.length === 0) {
|
|
2283
|
+
if (data === "a") this.actions.createProvider();
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
if (matchesKey(data, Key.up)) {
|
|
2287
|
+
this.confirmDelete = void 0;
|
|
2288
|
+
this.actions.selectProvider(Math.max(0, selected - 1));
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
if (matchesKey(data, Key.down)) {
|
|
2292
|
+
this.confirmDelete = void 0;
|
|
2293
|
+
this.actions.selectProvider(Math.min(providers.length - 1, selected + 1));
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
if (matchesKey(data, Key.enter)) {
|
|
2297
|
+
this.actions.editProvider(providers[selected].route);
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
if (data === "a") {
|
|
2301
|
+
this.actions.createProvider();
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
if (data === "s") {
|
|
2305
|
+
const row = providers[selected];
|
|
2306
|
+
const model = row.models[0];
|
|
2307
|
+
if (model !== void 0) this.actions.setActiveModel(row.route, model.id);
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
if (data === "d") {
|
|
2311
|
+
if (this.confirmDelete === selected) {
|
|
2312
|
+
this.confirmDelete = void 0;
|
|
2313
|
+
this.actions.deleteProvider(providers[selected]);
|
|
2314
|
+
} else this.confirmDelete = selected;
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
this.confirmDelete = void 0;
|
|
2318
|
+
}
|
|
2319
|
+
renderForm(draft, mp) {
|
|
2320
|
+
const textFields = this.textFields(draft);
|
|
2321
|
+
const modelsRow = textFields.length;
|
|
2322
|
+
const saveRow = textFields.length + 1;
|
|
2323
|
+
const fieldState = {
|
|
2324
|
+
route: this.route,
|
|
2325
|
+
displayName: this.displayName,
|
|
2326
|
+
api: this.api,
|
|
2327
|
+
baseURL: this.baseURL,
|
|
2328
|
+
apiKey: this.apiKeyDraft
|
|
2329
|
+
};
|
|
2330
|
+
const labels = {
|
|
2331
|
+
route: "Route",
|
|
2332
|
+
displayName: "Name",
|
|
2333
|
+
api: "Protocol",
|
|
2334
|
+
baseURL: "Base URL",
|
|
2335
|
+
apiKey: draft.apiKeyConfigured ? "API key (set — leave blank to keep)" : "API key"
|
|
2336
|
+
};
|
|
2337
|
+
const lines = [bold$8(secondary$8(draft.isNew ? "Add provider" : `Edit ${draft.displayName || draft.route}`))];
|
|
2338
|
+
if (mp.error !== void 0) lines.push(errorColor$4(mp.error));
|
|
2339
|
+
textFields.forEach((field, index) => {
|
|
2340
|
+
const isFocused = this.focused === index;
|
|
2341
|
+
const mask = field === "apiKey" ? "*" : void 0;
|
|
2342
|
+
const prefix = `${isFocused ? "› " : " "}${labels[field]}: `;
|
|
2343
|
+
lines.push(`${prefix}${renderMiniTextField(fieldState[field], isFocused, mask)}`);
|
|
2344
|
+
});
|
|
2345
|
+
const modelsText = `${this.focused === modelsRow ? "› " : " "}Models (${this.models.length}) — enter to edit`;
|
|
2346
|
+
lines.push(this.focused === modelsRow ? invert$4(modelsText) : modelsText);
|
|
2347
|
+
const saveText = `${this.focused === saveRow ? "› " : " "}${mp.busy ? "Saving…" : "Save"}`;
|
|
2348
|
+
lines.push(this.focused === saveRow ? invert$4(saveText) : saveText);
|
|
2349
|
+
lines.push(muted$9("tab/shift+tab move · enter confirm field / activate row · esc cancel"));
|
|
2350
|
+
return lines;
|
|
2351
|
+
}
|
|
2352
|
+
handleFormInput(data, draft) {
|
|
2353
|
+
const textFields = this.textFields(draft);
|
|
2354
|
+
const modelsRow = textFields.length;
|
|
2355
|
+
const saveRow = textFields.length + 1;
|
|
2356
|
+
const rowCount = textFields.length + 2;
|
|
2357
|
+
if (matchesKey(data, Key.escape)) {
|
|
2358
|
+
this.actions.backToProviderList();
|
|
2359
|
+
return;
|
|
2360
|
+
}
|
|
2361
|
+
if (matchesKey(data, "shift+tab")) {
|
|
2362
|
+
this.focused = (this.focused - 1 + rowCount) % rowCount;
|
|
2363
|
+
return;
|
|
2364
|
+
}
|
|
2365
|
+
if (matchesKey(data, Key.tab)) {
|
|
2366
|
+
this.focused = (this.focused + 1) % rowCount;
|
|
2367
|
+
return;
|
|
2368
|
+
}
|
|
2369
|
+
if (matchesKey(data, Key.enter) && this.focused === modelsRow) {
|
|
2370
|
+
this.showModels = true;
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
if (matchesKey(data, Key.enter) && this.focused === saveRow) {
|
|
2374
|
+
this.actions.saveProvider(this.buildDraft(draft));
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
if (matchesKey(data, Key.enter) && this.focused < textFields.length) {
|
|
2378
|
+
this.focused = (this.focused + 1) % rowCount;
|
|
2379
|
+
return;
|
|
2380
|
+
}
|
|
2381
|
+
if (this.focused < textFields.length) {
|
|
2382
|
+
const field = textFields[this.focused];
|
|
2383
|
+
const next = miniTextFieldInput({
|
|
2384
|
+
route: this.route,
|
|
2385
|
+
displayName: this.displayName,
|
|
2386
|
+
api: this.api,
|
|
2387
|
+
baseURL: this.baseURL,
|
|
2388
|
+
apiKey: this.apiKeyDraft
|
|
2389
|
+
}[field], data);
|
|
2390
|
+
if (next === void 0) return;
|
|
2391
|
+
if (field === "route") this.route = next;
|
|
2392
|
+
else if (field === "displayName") this.displayName = next;
|
|
2393
|
+
else if (field === "api") this.api = next;
|
|
2394
|
+
else if (field === "baseURL") this.baseURL = next;
|
|
2395
|
+
else this.apiKeyDraft = next;
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
renderModelListEditor(mp) {
|
|
2399
|
+
const lines = [bold$8(secondary$8("Models"))];
|
|
2400
|
+
this.models.forEach((model, index) => {
|
|
2401
|
+
const isSelected = !this.modelInputFocused && index === this.modelSelected;
|
|
2402
|
+
const text = `${isSelected ? "› " : " "}${model.id}${model.name === void 0 ? "" : ` — ${model.name}`}`;
|
|
2403
|
+
lines.push(isSelected ? invert$4(text) : text);
|
|
2404
|
+
});
|
|
2405
|
+
if (this.models.length === 0) lines.push(muted$9("No models yet."));
|
|
2406
|
+
lines.push(`${this.modelInputFocused ? "› " : " "}Add id: ${renderMiniTextField(this.modelDraftId, this.modelInputFocused)}`);
|
|
2407
|
+
if (mp.busy) lines.push(muted$9("Discovering…"));
|
|
2408
|
+
if (mp.discovered !== void 0) if (mp.discovered.length === 0) lines.push(muted$9("No models discovered."));
|
|
2409
|
+
else {
|
|
2410
|
+
lines.push(muted$9("Discovered — tab to the id field and type one to adopt it:"));
|
|
2411
|
+
for (const model of mp.discovered) lines.push(muted$9(` ${model.id}${model.name === void 0 ? "" : ` — ${model.name}`}`));
|
|
2412
|
+
}
|
|
2413
|
+
lines.push(muted$9("tab toggle list/input · ↑↓ select · x remove · g discover · esc back"));
|
|
2414
|
+
return lines;
|
|
2415
|
+
}
|
|
2416
|
+
addModel(id) {
|
|
2417
|
+
const trimmed = id.trim();
|
|
2418
|
+
if (trimmed === "" || this.models.some((model) => model.id === trimmed)) return;
|
|
2419
|
+
const overlay = this.store.getSnapshot().overlay;
|
|
2420
|
+
const found = (overlay.kind === "modelProfile" ? overlay.modelProfile.discovered : void 0)?.find((model) => model.id === trimmed);
|
|
2421
|
+
this.models = [...this.models, found === void 0 ? { id: trimmed } : { ...found }];
|
|
2422
|
+
this.modelDraftId = emptyMiniTextField();
|
|
2423
|
+
}
|
|
2424
|
+
handleModelListEditorInput(data, draft) {
|
|
2425
|
+
if (matchesKey(data, Key.escape)) {
|
|
2426
|
+
this.showModels = false;
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
if (matchesKey(data, Key.tab)) {
|
|
2430
|
+
this.modelInputFocused = !this.modelInputFocused;
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
2433
|
+
if (this.modelInputFocused) {
|
|
2434
|
+
if (matchesKey(data, Key.enter)) {
|
|
2435
|
+
this.addModel(this.modelDraftId.value);
|
|
2436
|
+
return;
|
|
2437
|
+
}
|
|
2438
|
+
const next = miniTextFieldInput(this.modelDraftId, data);
|
|
2439
|
+
if (next !== void 0) this.modelDraftId = next;
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
if (data === "g") {
|
|
2443
|
+
this.actions.discoverModelsForDraft(this.buildDraft(draft));
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
if (this.models.length === 0) return;
|
|
2447
|
+
if (matchesKey(data, Key.up)) {
|
|
2448
|
+
this.modelSelected = Math.max(0, this.modelSelected - 1);
|
|
2449
|
+
return;
|
|
2450
|
+
}
|
|
2451
|
+
if (matchesKey(data, Key.down)) {
|
|
2452
|
+
this.modelSelected = Math.min(this.models.length - 1, this.modelSelected + 1);
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
if (data === "x") {
|
|
2456
|
+
this.models = this.models.filter((_, index) => index !== this.modelSelected);
|
|
2457
|
+
this.modelSelected = Math.max(0, Math.min(this.modelSelected, this.models.length - 1));
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
handleInput(data) {
|
|
2461
|
+
const overlay = this.store.getSnapshot().overlay;
|
|
2462
|
+
if (overlay.kind !== "modelProfile") return;
|
|
2463
|
+
const mp = overlay.modelProfile;
|
|
2464
|
+
const draft = this.syncFormState(mp);
|
|
2465
|
+
if (draft !== void 0) {
|
|
2466
|
+
if (this.showModels) this.handleModelListEditorInput(data, draft);
|
|
2467
|
+
else this.handleFormInput(data, draft);
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
this.handleListInput(data, mp);
|
|
2471
|
+
}
|
|
2472
|
+
};
|
|
2473
|
+
//#endregion
|
|
2474
|
+
//#region src/tui/trajectory/layout.ts
|
|
2475
|
+
const LABEL_LIMIT = 100;
|
|
2476
|
+
function prettyJson(raw) {
|
|
2477
|
+
try {
|
|
2478
|
+
return JSON.stringify(JSON.parse(raw), null, 2);
|
|
2479
|
+
} catch {
|
|
2480
|
+
return raw;
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
/** The row's kind tag (USER/CONTEXT) already names the source, so the label itself carries no redundant prefix. */
|
|
2484
|
+
function userLabel(data) {
|
|
2485
|
+
const { source } = data;
|
|
2486
|
+
if (source.kind === "user") return truncate(textOf(data.content), LABEL_LIMIT);
|
|
2487
|
+
if (source.kind === "plugin") {
|
|
2488
|
+
const summary = source.form === "notice" ? source.summary : void 0;
|
|
2489
|
+
return `${source.plugin}${summary === void 0 ? "" : ` · ${summary}`}`;
|
|
2490
|
+
}
|
|
2491
|
+
if (source.kind === "goal") return `goal · round ${source.round}`;
|
|
2492
|
+
return source.kind;
|
|
2493
|
+
}
|
|
2494
|
+
/**
|
|
2495
|
+
* Fold the session log into ledger rows, in seq order.
|
|
2496
|
+
* @param events - the session's durable event log (replay + live, already seq-deduped by `TuiStore`).
|
|
2497
|
+
* @param collapsedTurns - turns whose non-first content row should fold into one `'collapsed'` summary row.
|
|
2498
|
+
*/
|
|
2499
|
+
function buildTrajectoryRows(events, collapsedTurns) {
|
|
2500
|
+
const rows = [];
|
|
2501
|
+
const stepsByTurn = /* @__PURE__ */ new Map();
|
|
2502
|
+
const pendingCalls = /* @__PURE__ */ new Map();
|
|
2503
|
+
const openTurnRows = /* @__PURE__ */ new Map();
|
|
2504
|
+
let currentTurn = 0;
|
|
2505
|
+
let currentStep = 0;
|
|
2506
|
+
for (const event of events) switch (event.type) {
|
|
2507
|
+
case "turn/start": {
|
|
2508
|
+
currentTurn = event.data.turn;
|
|
2509
|
+
const draft = {
|
|
2510
|
+
kind: "turn",
|
|
2511
|
+
turn: currentTurn,
|
|
2512
|
+
aborted: void 0
|
|
2513
|
+
};
|
|
2514
|
+
openTurnRows.set(currentTurn, draft);
|
|
2515
|
+
rows.push(draft);
|
|
2516
|
+
break;
|
|
2517
|
+
}
|
|
2518
|
+
case "turn/end": {
|
|
2519
|
+
const draft = openTurnRows.get(event.data.turn);
|
|
2520
|
+
const { reason } = event.data;
|
|
2521
|
+
if (draft !== void 0 && reason.kind === "error") draft.aborted = `${reason.error.code}: ${reason.error.message}`;
|
|
2522
|
+
else if (draft !== void 0 && reason.kind === "aborted") draft.aborted = "turn canceled";
|
|
2523
|
+
break;
|
|
2524
|
+
}
|
|
2525
|
+
case "step/start": {
|
|
2526
|
+
currentStep = event.data.step;
|
|
2527
|
+
let steps = stepsByTurn.get(event.data.turn);
|
|
2528
|
+
if (steps === void 0) {
|
|
2529
|
+
steps = /* @__PURE__ */ new Set();
|
|
2530
|
+
stepsByTurn.set(event.data.turn, steps);
|
|
2531
|
+
}
|
|
2532
|
+
steps.add(event.data.step);
|
|
2533
|
+
rows.push({
|
|
2534
|
+
kind: "step",
|
|
2535
|
+
turn: event.data.turn,
|
|
2536
|
+
step: event.data.step
|
|
2537
|
+
});
|
|
2538
|
+
break;
|
|
2539
|
+
}
|
|
2540
|
+
case "user/message": {
|
|
2541
|
+
const label = userLabel(event.data);
|
|
2542
|
+
const text = textOf(event.data.content);
|
|
2543
|
+
const record = {
|
|
2544
|
+
id: `${event.seq}`,
|
|
2545
|
+
kind: event.data.source.kind === "user" ? "user" : "context",
|
|
2546
|
+
turn: currentTurn,
|
|
2547
|
+
step: currentStep,
|
|
2548
|
+
seq: event.seq,
|
|
2549
|
+
startedAt: event.time,
|
|
2550
|
+
completedAt: void 0,
|
|
2551
|
+
label,
|
|
2552
|
+
isError: false,
|
|
2553
|
+
summary: label,
|
|
2554
|
+
payload: text === "" ? void 0 : text,
|
|
2555
|
+
result: void 0,
|
|
2556
|
+
reasoning: void 0,
|
|
2557
|
+
source: event.data.source,
|
|
2558
|
+
toolName: void 0
|
|
2559
|
+
};
|
|
2560
|
+
rows.push({
|
|
2561
|
+
kind: "record",
|
|
2562
|
+
record
|
|
2563
|
+
});
|
|
2564
|
+
break;
|
|
2565
|
+
}
|
|
2566
|
+
case "assistant/message": {
|
|
2567
|
+
const content = event.data.message.content;
|
|
2568
|
+
const text = textOf(content);
|
|
2569
|
+
const reasoningText = reasoningOf(content);
|
|
2570
|
+
const displaySource = text === "" ? reasoningText : text;
|
|
2571
|
+
const label = displaySource === "" ? "(tool calls only)" : truncate(displaySource, LABEL_LIMIT);
|
|
2572
|
+
const record = {
|
|
2573
|
+
id: `${event.seq}`,
|
|
2574
|
+
kind: "assistant",
|
|
2575
|
+
turn: event.data.turn,
|
|
2576
|
+
step: event.data.step,
|
|
2577
|
+
seq: event.seq,
|
|
2578
|
+
startedAt: event.time,
|
|
2579
|
+
completedAt: void 0,
|
|
2580
|
+
label,
|
|
2581
|
+
isError: false,
|
|
2582
|
+
summary: label,
|
|
2583
|
+
payload: text === "" ? void 0 : text,
|
|
2584
|
+
result: void 0,
|
|
2585
|
+
reasoning: reasoningText === "" ? void 0 : reasoningText,
|
|
2586
|
+
source: void 0,
|
|
2587
|
+
toolName: void 0
|
|
2588
|
+
};
|
|
2589
|
+
rows.push({
|
|
2590
|
+
kind: "record",
|
|
2591
|
+
record
|
|
2592
|
+
});
|
|
2593
|
+
break;
|
|
2594
|
+
}
|
|
2595
|
+
case "tool/call": {
|
|
2596
|
+
const label = `${event.data.name} ${truncate(event.data.arguments, LABEL_LIMIT)}`;
|
|
2597
|
+
const record = {
|
|
2598
|
+
id: event.data.callId,
|
|
2599
|
+
kind: "tool",
|
|
2600
|
+
turn: event.data.turn,
|
|
2601
|
+
step: event.data.step,
|
|
2602
|
+
seq: event.seq,
|
|
2603
|
+
startedAt: event.time,
|
|
2604
|
+
completedAt: void 0,
|
|
2605
|
+
label,
|
|
2606
|
+
isError: false,
|
|
2607
|
+
summary: label,
|
|
2608
|
+
payload: prettyJson(event.data.arguments),
|
|
2609
|
+
result: void 0,
|
|
2610
|
+
reasoning: void 0,
|
|
2611
|
+
source: void 0,
|
|
2612
|
+
toolName: event.data.name
|
|
2613
|
+
};
|
|
2614
|
+
pendingCalls.set(event.data.callId, record);
|
|
2615
|
+
rows.push({
|
|
2616
|
+
kind: "record",
|
|
2617
|
+
record
|
|
2618
|
+
});
|
|
2619
|
+
break;
|
|
2620
|
+
}
|
|
2621
|
+
case "tool/result": {
|
|
2622
|
+
const [block] = event.data.message.content;
|
|
2623
|
+
const failed = event.data.error !== void 0 || block.isError === true;
|
|
2624
|
+
const resultText = event.data.error !== void 0 ? `${event.data.error.code}: ${event.data.error.name}` : textOf(block.content);
|
|
2625
|
+
const callId = event.data.message.source.callId;
|
|
2626
|
+
const pending = pendingCalls.get(callId);
|
|
2627
|
+
if (pending !== void 0) {
|
|
2628
|
+
pending.completedAt = event.time;
|
|
2629
|
+
pending.isError = failed;
|
|
2630
|
+
pending.result = resultText;
|
|
2631
|
+
pending.summary = `${pending.label} → ${failed ? "error" : "ok"}`;
|
|
2632
|
+
pendingCalls.delete(callId);
|
|
2633
|
+
} else {
|
|
2634
|
+
const label = `(unmatched result) ${truncate(resultText, LABEL_LIMIT)}`;
|
|
2635
|
+
const record = {
|
|
2636
|
+
id: `${event.seq}`,
|
|
2637
|
+
kind: "tool",
|
|
2638
|
+
turn: event.data.turn,
|
|
2639
|
+
step: event.data.step,
|
|
2640
|
+
seq: event.seq,
|
|
2641
|
+
startedAt: event.time,
|
|
2642
|
+
completedAt: event.time,
|
|
2643
|
+
label,
|
|
2644
|
+
isError: failed,
|
|
2645
|
+
summary: label,
|
|
2646
|
+
payload: void 0,
|
|
2647
|
+
result: resultText,
|
|
2648
|
+
reasoning: void 0,
|
|
2649
|
+
source: void 0,
|
|
2650
|
+
toolName: void 0
|
|
2651
|
+
};
|
|
2652
|
+
rows.push({
|
|
2653
|
+
kind: "record",
|
|
2654
|
+
record
|
|
2655
|
+
});
|
|
2656
|
+
}
|
|
2657
|
+
break;
|
|
2658
|
+
}
|
|
2659
|
+
case "request/header": {
|
|
2660
|
+
if (event.data.reason === "initial") break;
|
|
2661
|
+
const label = `config ${event.data.reason} updated`;
|
|
2662
|
+
const record = {
|
|
2663
|
+
id: `${event.seq}`,
|
|
2664
|
+
kind: "header",
|
|
2665
|
+
turn: currentTurn,
|
|
2666
|
+
step: currentStep,
|
|
2667
|
+
seq: event.seq,
|
|
2668
|
+
startedAt: event.time,
|
|
2669
|
+
completedAt: void 0,
|
|
2670
|
+
label,
|
|
2671
|
+
isError: false,
|
|
2672
|
+
summary: label,
|
|
2673
|
+
payload: JSON.stringify(event.data.header, null, 2),
|
|
2674
|
+
result: void 0,
|
|
2675
|
+
reasoning: void 0,
|
|
2676
|
+
source: void 0,
|
|
2677
|
+
toolName: void 0
|
|
2678
|
+
};
|
|
2679
|
+
rows.push({
|
|
2680
|
+
kind: "record",
|
|
2681
|
+
record
|
|
2682
|
+
});
|
|
2683
|
+
break;
|
|
2684
|
+
}
|
|
2685
|
+
default: break;
|
|
2686
|
+
}
|
|
2687
|
+
return collapseRows(rows.filter((row) => row.kind !== "step" || (stepsByTurn.get(row.turn)?.size ?? 0) > 1), collapsedTurns);
|
|
2688
|
+
}
|
|
2689
|
+
/** Fold every row of a collapsed turn after its first content row into one summary row. */
|
|
2690
|
+
function collapseRows(rows, collapsedTurns) {
|
|
2691
|
+
if (collapsedTurns.size === 0) return rows;
|
|
2692
|
+
const out = [];
|
|
2693
|
+
let seenFirstInTurn = false;
|
|
2694
|
+
let pendingCount = 0;
|
|
2695
|
+
let pendingTurn = -1;
|
|
2696
|
+
const flush = () => {
|
|
2697
|
+
if (pendingCount > 0) {
|
|
2698
|
+
out.push({
|
|
2699
|
+
kind: "collapsed",
|
|
2700
|
+
turn: pendingTurn,
|
|
2701
|
+
count: pendingCount
|
|
2702
|
+
});
|
|
2703
|
+
pendingCount = 0;
|
|
2704
|
+
}
|
|
2705
|
+
};
|
|
2706
|
+
for (const row of rows) {
|
|
2707
|
+
if (row.kind === "turn") {
|
|
2708
|
+
flush();
|
|
2709
|
+
seenFirstInTurn = false;
|
|
2710
|
+
pendingTurn = row.turn;
|
|
2711
|
+
out.push(row);
|
|
2712
|
+
continue;
|
|
2713
|
+
}
|
|
2714
|
+
const turn = row.kind === "step" ? row.turn : row.kind === "record" ? row.record.turn : pendingTurn;
|
|
2715
|
+
if (!collapsedTurns.has(turn)) {
|
|
2716
|
+
out.push(row);
|
|
2717
|
+
continue;
|
|
2718
|
+
}
|
|
2719
|
+
if (!seenFirstInTurn) {
|
|
2720
|
+
seenFirstInTurn = true;
|
|
2721
|
+
out.push(row);
|
|
2722
|
+
continue;
|
|
2723
|
+
}
|
|
2724
|
+
pendingCount += 1;
|
|
2725
|
+
}
|
|
2726
|
+
flush();
|
|
2727
|
+
return out;
|
|
2728
|
+
}
|
|
2729
|
+
//#endregion
|
|
2730
|
+
//#region src/tui/trajectory/TrajectoryLedger.ts
|
|
2731
|
+
const bold$7 = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
2732
|
+
const invert$3 = (s) => `\x1b[7m${s}\x1b[0m`;
|
|
2733
|
+
const secondary$7 = fg(theme.secondary);
|
|
2734
|
+
const muted$8 = fg(theme.muted);
|
|
2735
|
+
const errorColor$3 = fg(theme.error);
|
|
2736
|
+
function recordGlyph(record) {
|
|
2737
|
+
if (record.kind === "tool") return record.isError ? "✖" : "⚙";
|
|
2738
|
+
if (record.kind === "header") return "⊕";
|
|
2739
|
+
return " ";
|
|
2740
|
+
}
|
|
2741
|
+
/** Kind tags, matching the web ledger's USER/CONTEXT/ASSISTANT/TOOL wording exactly (`header` has no web counterpart). */
|
|
2742
|
+
const KIND_TAG = {
|
|
2743
|
+
user: "USER",
|
|
2744
|
+
context: "CONTEXT",
|
|
2745
|
+
assistant: "ASSISTANT",
|
|
2746
|
+
tool: "TOOL",
|
|
2747
|
+
header: "HEADER"
|
|
2748
|
+
};
|
|
2749
|
+
const KIND_TAG_WIDTH = Math.max(...Object.values(KIND_TAG).map((tag) => tag.length));
|
|
2750
|
+
/** Mirrors the web ledger's per-row kind tag coloring (assistant violet, tool amber, user brand blue, context mint, header neutral). */
|
|
2751
|
+
function kindColor(kind) {
|
|
2752
|
+
switch (kind) {
|
|
2753
|
+
case "user": return theme.primary;
|
|
2754
|
+
case "context": return theme.success;
|
|
2755
|
+
case "assistant": return theme.reasoning;
|
|
2756
|
+
case "tool": return theme.warning;
|
|
2757
|
+
case "header": return theme.muted;
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
function recordLine(record, selected) {
|
|
2761
|
+
const tag = bold$7(fg(kindColor(record.kind))(KIND_TAG[record.kind].padEnd(KIND_TAG_WIDTH)));
|
|
2762
|
+
const body = `${selected ? "› " : " "}${tag} ${recordGlyph(record)} ${record.label}`;
|
|
2763
|
+
const withColor = record.isError ? errorColor$3(body) : record.kind === "header" ? muted$8(body) : body;
|
|
2764
|
+
return selected ? invert$3(withColor) : withColor;
|
|
2765
|
+
}
|
|
2766
|
+
function buildLedgerLines(rows, selectedId) {
|
|
2767
|
+
return rows.map((row) => {
|
|
2768
|
+
switch (row.kind) {
|
|
2769
|
+
case "turn": return bold$7(secondary$7(`── Turn ${row.turn} ──${row.aborted === void 0 ? "" : ` ⚠ ${row.aborted}`}`));
|
|
2770
|
+
case "step": return muted$8(` Step ${row.step}`);
|
|
2771
|
+
case "collapsed": return muted$8(` … ${row.count} record${row.count === 1 ? "" : "s"} collapsed`);
|
|
2772
|
+
case "record": return recordLine(row.record, row.record.id === selectedId);
|
|
2773
|
+
}
|
|
2774
|
+
});
|
|
2775
|
+
}
|
|
2776
|
+
//#endregion
|
|
2777
|
+
//#region src/tui/trajectory/detail.ts
|
|
2778
|
+
const violet = fg(theme.reasoning);
|
|
2779
|
+
function formatTime(ms) {
|
|
2780
|
+
return new Date(ms).toLocaleTimeString();
|
|
2781
|
+
}
|
|
2782
|
+
function summaryText(record) {
|
|
2783
|
+
const lines = [
|
|
2784
|
+
`kind ${record.kind}`,
|
|
2785
|
+
`turn/step ${record.turn}/${record.step}`,
|
|
2786
|
+
`started ${formatTime(record.startedAt)}`
|
|
2787
|
+
];
|
|
2788
|
+
if (record.completedAt !== void 0) lines.push(`duration ${record.completedAt - record.startedAt}ms`);
|
|
2789
|
+
if (record.isError) lines.push("status error");
|
|
2790
|
+
return lines.join("\n");
|
|
2791
|
+
}
|
|
2792
|
+
function timingText(record) {
|
|
2793
|
+
if (record.completedAt === void 0) return `started ${formatTime(record.startedAt)}\n(no completion recorded)`;
|
|
2794
|
+
return [
|
|
2795
|
+
`started ${formatTime(record.startedAt)}`,
|
|
2796
|
+
`completed ${formatTime(record.completedAt)}`,
|
|
2797
|
+
`duration ${record.completedAt - record.startedAt}ms`
|
|
2798
|
+
].join("\n");
|
|
2799
|
+
}
|
|
2800
|
+
/** Rendered Preview tab: reasoning (if any) ahead of the visible text, mirroring the transcript's own reasoning-then-answer framing (`formatStreamingText` in `render.ts`). */
|
|
2801
|
+
function previewText(record) {
|
|
2802
|
+
const parts = [];
|
|
2803
|
+
if (record.reasoning !== void 0) parts.push(violet("✦ thinking"), violet(record.reasoning));
|
|
2804
|
+
if (record.payload !== void 0) parts.push(renderMarkdown(record.payload));
|
|
2805
|
+
return parts.length === 0 ? "(no content)" : parts.join("\n\n");
|
|
2806
|
+
}
|
|
2807
|
+
/** Raw tab: same content as `previewText`, unrendered — the markdown/plain source as the log carries it. */
|
|
2808
|
+
function rawText(record) {
|
|
2809
|
+
const parts = [];
|
|
2810
|
+
if (record.reasoning !== void 0) parts.push(`[thinking]\n${record.reasoning}`);
|
|
2811
|
+
if (record.payload !== void 0) parts.push(record.payload);
|
|
2812
|
+
return parts.length === 0 ? "(no content)" : parts.join("\n\n");
|
|
2813
|
+
}
|
|
2814
|
+
/** Source tab: the raw `user/message` event's `source` descriptor, pretty-printed. */
|
|
2815
|
+
function sourceText(record) {
|
|
2816
|
+
if (record.source === void 0) return "(no source)";
|
|
2817
|
+
try {
|
|
2818
|
+
return JSON.stringify(record.source, null, 2);
|
|
2819
|
+
} catch {
|
|
2820
|
+
return "(unserializable source)";
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
/** Schema tab: the tool's own declared `{name, description, parameters}` (`ToolSchema`, via `getTool`) — the live registry's schema, not a per-request snapshot, so it can drift from what an older call actually saw if the tool changed mid-session. */
|
|
2824
|
+
function schemaText(record, getTool) {
|
|
2825
|
+
const tool = record.toolName === void 0 ? void 0 : getTool?.(record.toolName);
|
|
2826
|
+
if (tool === void 0) return "Schema unavailable";
|
|
2827
|
+
return JSON.stringify({
|
|
2828
|
+
name: tool.name,
|
|
2829
|
+
description: tool.description,
|
|
2830
|
+
parameters: tool.parameters
|
|
2831
|
+
}, null, 2);
|
|
2832
|
+
}
|
|
2833
|
+
/** Render the content of one detail-pane tab for the selected record. */
|
|
2834
|
+
function buildDetail(record, tab, getTool) {
|
|
2835
|
+
switch (tab) {
|
|
2836
|
+
case "summary": return summaryText(record);
|
|
2837
|
+
case "payload": return record.payload ?? "(no payload)";
|
|
2838
|
+
case "result": return record.result ?? "—";
|
|
2839
|
+
case "timing": return timingText(record);
|
|
2840
|
+
case "preview": return previewText(record);
|
|
2841
|
+
case "raw": return rawText(record);
|
|
2842
|
+
case "source": return sourceText(record);
|
|
2843
|
+
case "schema": return schemaText(record, getTool);
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
//#endregion
|
|
2847
|
+
//#region src/tui/trajectory/types.ts
|
|
2848
|
+
/**
|
|
2849
|
+
* Which detail tabs apply to a record, mirroring the web ledger's per-kind
|
|
2850
|
+
* split: a markdown record (user/context/assistant) gets Summary/Preview/Raw
|
|
2851
|
+
* plus Source only when the underlying event actually carried one (user and
|
|
2852
|
+
* context always do; assistant never does — see `layout.ts`). Everything
|
|
2853
|
+
* else (tool, header) gets Summary plus whichever of Payload/Result the
|
|
2854
|
+
* record actually has, and — tool records only — Schema, always followed by
|
|
2855
|
+
* Timing.
|
|
2856
|
+
*/
|
|
2857
|
+
function detailTabsFor(record) {
|
|
2858
|
+
if (record.kind === "user" || record.kind === "context" || record.kind === "assistant") {
|
|
2859
|
+
const tabs = [
|
|
2860
|
+
"summary",
|
|
2861
|
+
"preview",
|
|
2862
|
+
"raw"
|
|
2863
|
+
];
|
|
2864
|
+
if (record.source !== void 0) tabs.push("source");
|
|
2865
|
+
return tabs;
|
|
2866
|
+
}
|
|
2867
|
+
const tabs = ["summary"];
|
|
2868
|
+
if (record.payload !== void 0) tabs.push("payload");
|
|
2869
|
+
if (record.result !== void 0) tabs.push("result");
|
|
2870
|
+
if (record.kind === "tool") tabs.push("schema");
|
|
2871
|
+
tabs.push("timing");
|
|
2872
|
+
return tabs;
|
|
2873
|
+
}
|
|
2874
|
+
//#endregion
|
|
2875
|
+
//#region src/tui/trajectory/TrajectoryDetail.ts
|
|
2876
|
+
/**
|
|
2877
|
+
* Pure line-builder for the `/trajectory` overlay's tabbed detail pane —
|
|
2878
|
+
* tabs vary by the selected record's kind (see `detailTabsFor`), content
|
|
2879
|
+
* word-wrapped to the pane's width and line-clamped to fit its height
|
|
2880
|
+
* budget. Wrapping matters here specifically because `buildDetail`'s
|
|
2881
|
+
* Preview/Raw/Payload/Schema content can be arbitrarily long prose or JSON
|
|
2882
|
+
* with no line breaks of its own — unlike a browser, the terminal won't wrap
|
|
2883
|
+
* it for us.
|
|
2884
|
+
* @module @tomowang/dsh-tui/tui/trajectory/TrajectoryDetail
|
|
2885
|
+
*/
|
|
2886
|
+
const muted$7 = fg(theme.muted);
|
|
2887
|
+
/** Left padding for the panel's body, under its flush-left tab-bar heading — mirrors the ledger's own "Turn" header / indented "Step" row convention (`TrajectoryLedger.ts`). */
|
|
2888
|
+
const DETAIL_INDENT = " ";
|
|
2889
|
+
/** Right padding, matching `DETAIL_INDENT`'s width — reserved purely by wrapping short of the pane's edge, since there's no trailing character to place there. */
|
|
2890
|
+
const DETAIL_RIGHT_PADDING = 2;
|
|
2891
|
+
function buildDetailLines(record, tab, maxLines, getTool, width) {
|
|
2892
|
+
const wrapWidth = Math.max(1, width - 2 - DETAIL_RIGHT_PADDING);
|
|
2893
|
+
const lines = record === void 0 ? [] : wrapTextWithAnsi(buildDetail(record, tab, getTool), wrapWidth);
|
|
2894
|
+
const shown = lines.slice(0, maxLines);
|
|
2895
|
+
const hidden = lines.length - shown.length;
|
|
2896
|
+
const out = [(record === void 0 ? [] : detailTabsFor(record)).map((candidate) => candidate === tab ? `[${candidate}]` : ` ${candidate} `).join(" ")];
|
|
2897
|
+
if (record === void 0) out.push(`${DETAIL_INDENT}${muted$7("(no record selected)")}`);
|
|
2898
|
+
else out.push(...shown.map((line) => `${DETAIL_INDENT}${line}`));
|
|
2899
|
+
if (hidden > 0) out.push(`${DETAIL_INDENT}${muted$7(`… ${hidden} more line${hidden === 1 ? "" : "s"}`)}`);
|
|
2900
|
+
return out;
|
|
2901
|
+
}
|
|
2902
|
+
//#endregion
|
|
2903
|
+
//#region src/tui/trajectory/TrajectoryOverlay.ts
|
|
2904
|
+
const bold$6 = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
2905
|
+
const secondary$6 = fg(theme.secondary);
|
|
2906
|
+
const muted$6 = fg(theme.muted);
|
|
2907
|
+
var TrajectoryOverlay = class {
|
|
2908
|
+
tui;
|
|
2909
|
+
store;
|
|
2910
|
+
actions;
|
|
2911
|
+
getTool;
|
|
2912
|
+
collapsedTurns = /* @__PURE__ */ new Set();
|
|
2913
|
+
filter = emptyMiniTextField();
|
|
2914
|
+
filterFocused = false;
|
|
2915
|
+
detailTab = "summary";
|
|
2916
|
+
selectedId;
|
|
2917
|
+
scrollOffset = 0;
|
|
2918
|
+
lastSelectedTurn;
|
|
2919
|
+
constructor(tui, store, actions, getTool) {
|
|
2920
|
+
this.tui = tui;
|
|
2921
|
+
this.store = store;
|
|
2922
|
+
this.actions = actions;
|
|
2923
|
+
this.getTool = getTool;
|
|
2924
|
+
}
|
|
2925
|
+
invalidate() {}
|
|
2926
|
+
heights() {
|
|
2927
|
+
const availableRows = Math.max(10, this.tui.terminal.rows - 1);
|
|
2928
|
+
const remaining = Math.max(6, availableRows - 4);
|
|
2929
|
+
const detailContentHeight = Math.max(2, Math.floor(remaining / 2));
|
|
2930
|
+
return {
|
|
2931
|
+
ledgerHeight: Math.max(3, remaining - detailContentHeight - 1),
|
|
2932
|
+
detailContentHeight
|
|
2933
|
+
};
|
|
2934
|
+
}
|
|
2935
|
+
computeRows() {
|
|
2936
|
+
const events = this.store.getSnapshot().events;
|
|
2937
|
+
const rows = buildTrajectoryRows(events, this.collapsedTurns);
|
|
2938
|
+
const query = this.filter.value.trim().toLowerCase();
|
|
2939
|
+
const filteredRows = query === "" ? rows : rows.filter((row) => row.kind === "record" && (row.record.label.toLowerCase().includes(query) || row.record.summary.toLowerCase().includes(query)));
|
|
2940
|
+
return {
|
|
2941
|
+
filteredRows,
|
|
2942
|
+
records: filteredRows.filter((row) => row.kind === "record")
|
|
2943
|
+
};
|
|
2944
|
+
}
|
|
2945
|
+
render(width) {
|
|
2946
|
+
const { filteredRows, records } = this.computeRows();
|
|
2947
|
+
const selectedIndex = this.selectedId === void 0 ? -1 : records.findIndex((row) => row.record.id === this.selectedId);
|
|
2948
|
+
const effectiveIndex = selectedIndex === -1 ? records.length - 1 : selectedIndex;
|
|
2949
|
+
const selectedRecord = records[effectiveIndex]?.record;
|
|
2950
|
+
if (selectedRecord !== void 0) this.lastSelectedTurn = selectedRecord.turn;
|
|
2951
|
+
if (selectedRecord !== void 0 && !detailTabsFor(selectedRecord).includes(this.detailTab)) this.detailTab = "summary";
|
|
2952
|
+
const selectedRowIndex = selectedRecord === void 0 ? -1 : filteredRows.findIndex((row) => row.kind === "record" && row.record.id === selectedRecord.id);
|
|
2953
|
+
const { ledgerHeight, detailContentHeight } = this.heights();
|
|
2954
|
+
const maxOffset = Math.max(0, filteredRows.length - ledgerHeight);
|
|
2955
|
+
if (selectedRowIndex < this.scrollOffset) this.scrollOffset = selectedRowIndex;
|
|
2956
|
+
else if (selectedRowIndex >= this.scrollOffset + ledgerHeight) this.scrollOffset = selectedRowIndex - ledgerHeight + 1;
|
|
2957
|
+
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxOffset));
|
|
2958
|
+
const windowedRows = filteredRows.slice(this.scrollOffset, this.scrollOffset + ledgerHeight);
|
|
2959
|
+
const lines = [
|
|
2960
|
+
bold$6(secondary$6(`Trajectory${this.filter.value === "" ? "" : ` — filter: ${this.filter.value}`}${records.length === 0 ? "" : ` (${effectiveIndex + 1}/${records.length})`}`)),
|
|
2961
|
+
...buildLedgerLines(windowedRows, selectedRecord?.id),
|
|
2962
|
+
"",
|
|
2963
|
+
...buildDetailLines(selectedRecord, this.detailTab, detailContentHeight, this.getTool, width),
|
|
2964
|
+
""
|
|
2965
|
+
];
|
|
2966
|
+
if (this.filterFocused) lines.push(`/ ${renderMiniTextField(this.filter, true)}`);
|
|
2967
|
+
else lines.push(muted$6("↑↓ select · tab detail · c collapse · / filter · esc close"));
|
|
2968
|
+
return lines;
|
|
2969
|
+
}
|
|
2970
|
+
moveSelection(delta) {
|
|
2971
|
+
const { records } = this.computeRows();
|
|
2972
|
+
if (records.length === 0) return;
|
|
2973
|
+
const selectedIndex = this.selectedId === void 0 ? -1 : records.findIndex((row) => row.record.id === this.selectedId);
|
|
2974
|
+
const effectiveIndex = selectedIndex === -1 ? records.length - 1 : selectedIndex;
|
|
2975
|
+
const next = Math.min(records.length - 1, Math.max(0, effectiveIndex + delta));
|
|
2976
|
+
this.selectedId = records[next].record.id;
|
|
2977
|
+
}
|
|
2978
|
+
selectedRecord() {
|
|
2979
|
+
const { records } = this.computeRows();
|
|
2980
|
+
const selectedIndex = this.selectedId === void 0 ? -1 : records.findIndex((row) => row.record.id === this.selectedId);
|
|
2981
|
+
return records[selectedIndex === -1 ? records.length - 1 : selectedIndex]?.record;
|
|
2982
|
+
}
|
|
2983
|
+
cycleTab(delta) {
|
|
2984
|
+
const record = this.selectedRecord();
|
|
2985
|
+
if (record === void 0) return;
|
|
2986
|
+
const tabs = detailTabsFor(record);
|
|
2987
|
+
const index = tabs.indexOf(this.detailTab);
|
|
2988
|
+
const next = ((index === -1 ? 0 : index) + delta + tabs.length) % tabs.length;
|
|
2989
|
+
this.detailTab = tabs[next];
|
|
2990
|
+
}
|
|
2991
|
+
toggleCollapse() {
|
|
2992
|
+
const turn = this.selectedRecord()?.turn ?? this.lastSelectedTurn;
|
|
2993
|
+
if (turn === void 0) return;
|
|
2994
|
+
if (this.collapsedTurns.has(turn)) this.collapsedTurns.delete(turn);
|
|
2995
|
+
else this.collapsedTurns.add(turn);
|
|
2996
|
+
}
|
|
2997
|
+
handleInput(data) {
|
|
2998
|
+
if (this.filterFocused) {
|
|
2999
|
+
if (matchesKey(data, Key.escape)) {
|
|
3000
|
+
this.filterFocused = false;
|
|
3001
|
+
return;
|
|
3002
|
+
}
|
|
3003
|
+
if (matchesKey(data, Key.enter)) {
|
|
3004
|
+
this.filterFocused = false;
|
|
3005
|
+
return;
|
|
3006
|
+
}
|
|
3007
|
+
const next = miniTextFieldInput(this.filter, data);
|
|
3008
|
+
if (next !== void 0) this.filter = next;
|
|
3009
|
+
return;
|
|
3010
|
+
}
|
|
3011
|
+
if (matchesKey(data, Key.escape)) {
|
|
3012
|
+
this.actions.closeTrajectory();
|
|
3013
|
+
return;
|
|
3014
|
+
}
|
|
3015
|
+
if (matchesKey(data, Key.up)) {
|
|
3016
|
+
this.moveSelection(-1);
|
|
3017
|
+
return;
|
|
3018
|
+
}
|
|
3019
|
+
if (matchesKey(data, Key.down)) {
|
|
3020
|
+
this.moveSelection(1);
|
|
3021
|
+
return;
|
|
3022
|
+
}
|
|
3023
|
+
if (matchesKey(data, "shift+tab")) {
|
|
3024
|
+
this.cycleTab(-1);
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
if (matchesKey(data, Key.tab)) {
|
|
3028
|
+
this.cycleTab(1);
|
|
3029
|
+
return;
|
|
3030
|
+
}
|
|
3031
|
+
if (data === "c") {
|
|
3032
|
+
this.toggleCollapse();
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
if (data === "/") this.filterFocused = true;
|
|
3036
|
+
}
|
|
3037
|
+
};
|
|
3038
|
+
//#endregion
|
|
3039
|
+
//#region src/tui/toolCards/ToolCardsOverlay.ts
|
|
3040
|
+
const bold$5 = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
3041
|
+
const secondary$5 = fg(theme.secondary);
|
|
3042
|
+
const muted$5 = fg(theme.muted);
|
|
3043
|
+
/** Stable identity for a row across its pending → resolved transition — the call's own `seq` when there is one, so `collapsed`/scroll state survives its result landing. */
|
|
3044
|
+
function rowKey(row) {
|
|
3045
|
+
return (row.call ?? row.result).seq;
|
|
3046
|
+
}
|
|
3047
|
+
function summaryOf(row, options) {
|
|
3048
|
+
if (row.result !== void 0) return formatToolCardSummary(row.result, options);
|
|
3049
|
+
if (row.call !== void 0) return formatToolCardSummary(row.call, options);
|
|
3050
|
+
return "";
|
|
3051
|
+
}
|
|
3052
|
+
/** Full detail for a row: the call's presentation, then the result's, blank-line separated when both are present. */
|
|
3053
|
+
function detailOf(row, options) {
|
|
3054
|
+
const callLines = row.call === void 0 ? [] : formatToolCardDetail(row.call, options);
|
|
3055
|
+
const resultLines = row.result === void 0 ? [] : formatToolCardDetail(row.result, options);
|
|
3056
|
+
if (callLines.length === 0) return resultLines;
|
|
3057
|
+
if (resultLines.length === 0) return callLines;
|
|
3058
|
+
return [
|
|
3059
|
+
...callLines,
|
|
3060
|
+
"",
|
|
3061
|
+
...resultLines
|
|
3062
|
+
];
|
|
3063
|
+
}
|
|
3064
|
+
var ToolCardsOverlay = class {
|
|
3065
|
+
tui;
|
|
3066
|
+
store;
|
|
3067
|
+
actions;
|
|
3068
|
+
getTool;
|
|
3069
|
+
getToolCall;
|
|
3070
|
+
selected;
|
|
3071
|
+
collapsed = /* @__PURE__ */ new Set();
|
|
3072
|
+
scrollOffset = 0;
|
|
3073
|
+
lastRowKey;
|
|
3074
|
+
lastOpen = false;
|
|
3075
|
+
constructor(tui, store, actions, getTool, getToolCall) {
|
|
3076
|
+
this.tui = tui;
|
|
3077
|
+
this.store = store;
|
|
3078
|
+
this.actions = actions;
|
|
3079
|
+
this.getTool = getTool;
|
|
3080
|
+
this.getToolCall = getToolCall;
|
|
3081
|
+
}
|
|
3082
|
+
invalidate() {}
|
|
3083
|
+
/** Pairs `tool/call`/`tool/result` events by `callId`, in call order; an orphaned result (no call in the log) gets its own trailing row. */
|
|
3084
|
+
cards() {
|
|
3085
|
+
const rows = [];
|
|
3086
|
+
const indexByCallId = /* @__PURE__ */ new Map();
|
|
3087
|
+
for (const event of this.store.getSnapshot().events) if (event.type === "tool/call") {
|
|
3088
|
+
indexByCallId.set(event.data.callId, rows.length);
|
|
3089
|
+
rows.push({
|
|
3090
|
+
call: event,
|
|
3091
|
+
result: void 0
|
|
3092
|
+
});
|
|
3093
|
+
} else if (event.type === "tool/result") {
|
|
3094
|
+
const index = indexByCallId.get(event.data.message.source.callId);
|
|
3095
|
+
if (index === void 0) rows.push({
|
|
3096
|
+
call: void 0,
|
|
3097
|
+
result: event
|
|
3098
|
+
});
|
|
3099
|
+
else rows[index] = {
|
|
3100
|
+
...rows[index],
|
|
3101
|
+
result: event
|
|
3102
|
+
};
|
|
3103
|
+
}
|
|
3104
|
+
return rows;
|
|
3105
|
+
}
|
|
3106
|
+
contentRows() {
|
|
3107
|
+
const availableRows = Math.max(6, this.tui.terminal.rows - 1);
|
|
3108
|
+
return Math.max(1, availableRows - 4);
|
|
3109
|
+
}
|
|
3110
|
+
render(_width) {
|
|
3111
|
+
const cards = this.cards();
|
|
3112
|
+
const index = cards.length === 0 ? 0 : Math.min(this.selected ?? cards.length - 1, cards.length - 1);
|
|
3113
|
+
const row = cards[index];
|
|
3114
|
+
const key = row === void 0 ? void 0 : rowKey(row);
|
|
3115
|
+
const open = key !== void 0 && !this.collapsed.has(key);
|
|
3116
|
+
if (key !== this.lastRowKey || open !== this.lastOpen) {
|
|
3117
|
+
this.scrollOffset = 0;
|
|
3118
|
+
this.lastRowKey = key;
|
|
3119
|
+
this.lastOpen = open;
|
|
3120
|
+
}
|
|
3121
|
+
const options = {
|
|
3122
|
+
replay: false,
|
|
3123
|
+
getTool: this.getTool,
|
|
3124
|
+
getToolCall: this.getToolCall
|
|
3125
|
+
};
|
|
3126
|
+
const contentRows = this.contentRows();
|
|
3127
|
+
const summary = row === void 0 ? void 0 : summaryOf(row, options);
|
|
3128
|
+
const detailLines = row === void 0 || !open ? void 0 : detailOf(row, options);
|
|
3129
|
+
const totalDetailLines = detailLines?.length ?? 0;
|
|
3130
|
+
const maxScrollOffset = Math.max(0, totalDetailLines - contentRows);
|
|
3131
|
+
const clampedOffset = Math.min(this.scrollOffset, maxScrollOffset);
|
|
3132
|
+
const visibleDetailLines = detailLines?.slice(clampedOffset, clampedOffset + contentRows);
|
|
3133
|
+
const scrollHint = totalDetailLines <= contentRows ? "" : ` · lines ${clampedOffset + 1}-${Math.min(totalDetailLines, clampedOffset + contentRows)} of ${totalDetailLines}`;
|
|
3134
|
+
const lines = [bold$5(secondary$5(`Tool Cards${cards.length === 0 ? "" : ` (${index + 1}/${cards.length})`}${open ? scrollHint : ""}`))];
|
|
3135
|
+
if (row === void 0) lines.push(muted$5("No tool cards in this session yet."));
|
|
3136
|
+
else if (open) lines.push(...visibleDetailLines ?? []);
|
|
3137
|
+
else lines.push(`▸ ${summary ?? ""}`);
|
|
3138
|
+
lines.push("");
|
|
3139
|
+
lines.push(muted$5(`↑↓ select · PgUp/PgDn/Home/End scroll · Enter/Space ${open ? "collapse" : "expand"} · Ctrl+O/Esc close`));
|
|
3140
|
+
return lines;
|
|
3141
|
+
}
|
|
3142
|
+
move(delta) {
|
|
3143
|
+
const cards = this.cards();
|
|
3144
|
+
if (cards.length === 0) return;
|
|
3145
|
+
const current = this.selected ?? cards.length - 1;
|
|
3146
|
+
this.selected = Math.max(0, Math.min(cards.length - 1, current + delta));
|
|
3147
|
+
}
|
|
3148
|
+
scroll(delta, maxScrollOffset) {
|
|
3149
|
+
this.scrollOffset = Math.max(0, Math.min(maxScrollOffset, this.scrollOffset + delta));
|
|
3150
|
+
}
|
|
3151
|
+
toggle() {
|
|
3152
|
+
const cards = this.cards();
|
|
3153
|
+
const row = cards[cards.length === 0 ? 0 : Math.min(this.selected ?? cards.length - 1, cards.length - 1)];
|
|
3154
|
+
if (row === void 0) return;
|
|
3155
|
+
const key = rowKey(row);
|
|
3156
|
+
if (this.collapsed.has(key)) this.collapsed.delete(key);
|
|
3157
|
+
else this.collapsed.add(key);
|
|
3158
|
+
}
|
|
3159
|
+
handleInput(data) {
|
|
3160
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("o")) || data === "q") {
|
|
3161
|
+
this.actions.closeToolCards();
|
|
3162
|
+
return;
|
|
3163
|
+
}
|
|
3164
|
+
const cards = this.cards();
|
|
3165
|
+
const row = cards[cards.length === 0 ? 0 : Math.min(this.selected ?? cards.length - 1, cards.length - 1)];
|
|
3166
|
+
const key = row === void 0 ? void 0 : rowKey(row);
|
|
3167
|
+
const open = key !== void 0 && !this.collapsed.has(key);
|
|
3168
|
+
const contentRows = this.contentRows();
|
|
3169
|
+
const options = {
|
|
3170
|
+
replay: false,
|
|
3171
|
+
getTool: this.getTool,
|
|
3172
|
+
getToolCall: this.getToolCall
|
|
3173
|
+
};
|
|
3174
|
+
const detailLines = row === void 0 || !open ? void 0 : detailOf(row, options);
|
|
3175
|
+
const maxScrollOffset = Math.max(0, (detailLines?.length ?? 0) - contentRows);
|
|
3176
|
+
if (matchesKey(data, Key.pageUp)) return this.scroll(-contentRows, maxScrollOffset);
|
|
3177
|
+
if (matchesKey(data, Key.pageDown)) return this.scroll(contentRows, maxScrollOffset);
|
|
3178
|
+
if (matchesKey(data, Key.home)) return this.scroll(-maxScrollOffset, maxScrollOffset);
|
|
3179
|
+
if (matchesKey(data, Key.end)) return this.scroll(maxScrollOffset, maxScrollOffset);
|
|
3180
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.ctrl("p"))) {
|
|
3181
|
+
this.move(-1);
|
|
3182
|
+
return;
|
|
3183
|
+
}
|
|
3184
|
+
if (matchesKey(data, Key.down) || matchesKey(data, Key.ctrl("n"))) {
|
|
3185
|
+
this.move(1);
|
|
3186
|
+
return;
|
|
3187
|
+
}
|
|
3188
|
+
if (matchesKey(data, Key.enter) || data === " ") this.toggle();
|
|
3189
|
+
}
|
|
3190
|
+
};
|
|
3191
|
+
//#endregion
|
|
3192
|
+
//#region src/tui/context/ContextOverlay.ts
|
|
3193
|
+
const bold$4 = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
3194
|
+
const secondary$4 = fg(theme.secondary);
|
|
3195
|
+
const muted$4 = fg(theme.muted);
|
|
3196
|
+
const BAR_WIDTH = 30;
|
|
3197
|
+
function bar(widthPercent) {
|
|
3198
|
+
const filled = Math.round(widthPercent / 100 * BAR_WIDTH);
|
|
3199
|
+
return "█".repeat(Math.max(0, Math.min(BAR_WIDTH, filled))).padEnd(BAR_WIDTH, "░");
|
|
3200
|
+
}
|
|
3201
|
+
var ContextOverlay = class {
|
|
3202
|
+
store;
|
|
3203
|
+
actions;
|
|
3204
|
+
constructor(store, actions) {
|
|
3205
|
+
this.store = store;
|
|
3206
|
+
this.actions = actions;
|
|
3207
|
+
}
|
|
3208
|
+
invalidate() {}
|
|
3209
|
+
render(_width) {
|
|
3210
|
+
const { contextPressure, contextBreakdown } = this.store.getSnapshot().stats;
|
|
3211
|
+
const occupancy = contextOccupancy(contextPressure);
|
|
3212
|
+
const rows = contextBreakdownRows(occupancy, contextBreakdown);
|
|
3213
|
+
const lines = [bold$4(secondary$4("Context usage"))];
|
|
3214
|
+
if (occupancy === null) lines.push(muted$4("No usage reported yet — send a message first."));
|
|
3215
|
+
else {
|
|
3216
|
+
lines.push(`${occupancy.percent}% of context used`);
|
|
3217
|
+
lines.push(muted$4(`~${formatTokens(occupancy.usedTokens)} / ${formatTokens(occupancy.contextWindow)}`));
|
|
3218
|
+
lines.push("");
|
|
3219
|
+
if (rows.length === 0) lines.push(muted$4("No composition breakdown yet."));
|
|
3220
|
+
else for (const row of rows) lines.push(`${row.label.padEnd(14)} ${secondary$4(bar(row.width))} ${formatTokens(row.tokens)}`);
|
|
3221
|
+
}
|
|
3222
|
+
lines.push("");
|
|
3223
|
+
lines.push(muted$4("esc close"));
|
|
3224
|
+
return lines;
|
|
3225
|
+
}
|
|
3226
|
+
handleInput(data) {
|
|
3227
|
+
if (matchesKey(data, Key.escape) || data === "q") {
|
|
3228
|
+
this.actions.closeContext();
|
|
3229
|
+
return;
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
};
|
|
3233
|
+
//#endregion
|
|
3234
|
+
//#region src/tui/plugins/PluginsOverlay.ts
|
|
3235
|
+
const bold$3 = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
3236
|
+
const secondary$3 = fg(theme.secondary);
|
|
3237
|
+
const muted$3 = fg(theme.muted);
|
|
3238
|
+
const errorColor$2 = fg(theme.error);
|
|
3239
|
+
const success$1 = fg(theme.success);
|
|
3240
|
+
const STATE_LABEL = {
|
|
3241
|
+
pending: "pending",
|
|
3242
|
+
loading: "loading",
|
|
3243
|
+
active: "active",
|
|
3244
|
+
failed: "failed",
|
|
3245
|
+
disposed: "disposed",
|
|
3246
|
+
unloading: "unloading"
|
|
3247
|
+
};
|
|
3248
|
+
function rowLabel(row) {
|
|
3249
|
+
if (row.state !== void 0) return STATE_LABEL[row.state];
|
|
3250
|
+
return row.disabled ? "off" : "···";
|
|
3251
|
+
}
|
|
3252
|
+
function rowColor(row) {
|
|
3253
|
+
if (row.disabled) return muted$3;
|
|
3254
|
+
if (row.state === "failed") return errorColor$2;
|
|
3255
|
+
if (row.state === "active") return success$1;
|
|
3256
|
+
}
|
|
3257
|
+
var PluginsOverlay = class {
|
|
3258
|
+
tui;
|
|
3259
|
+
rows;
|
|
3260
|
+
actions;
|
|
3261
|
+
scrollOffset = 0;
|
|
3262
|
+
constructor(tui, rows, actions) {
|
|
3263
|
+
this.tui = tui;
|
|
3264
|
+
this.rows = rows;
|
|
3265
|
+
this.actions = actions;
|
|
3266
|
+
}
|
|
3267
|
+
invalidate() {}
|
|
3268
|
+
listHeight() {
|
|
3269
|
+
const availableRows = Math.max(10, this.tui.terminal.rows - 1);
|
|
3270
|
+
return Math.max(3, availableRows - 2);
|
|
3271
|
+
}
|
|
3272
|
+
maxOffset() {
|
|
3273
|
+
return Math.max(0, this.rows.length - this.listHeight());
|
|
3274
|
+
}
|
|
3275
|
+
render(_width) {
|
|
3276
|
+
const listHeight = this.listHeight();
|
|
3277
|
+
const offset = Math.min(this.scrollOffset, this.maxOffset());
|
|
3278
|
+
const windowedRows = this.rows.slice(offset, offset + listHeight);
|
|
3279
|
+
const activeCount = this.rows.filter((row) => row.state === "active").length;
|
|
3280
|
+
const failedCount = this.rows.filter((row) => row.state === "failed").length;
|
|
3281
|
+
const lines = [bold$3(secondary$3(`Plugins (${this.rows.length}) — ${activeCount} active${failedCount === 0 ? "" : `, ${failedCount} failed`}`))];
|
|
3282
|
+
for (const row of windowedRows) {
|
|
3283
|
+
const color = rowColor(row);
|
|
3284
|
+
const label = color === void 0 ? rowLabel(row).padEnd(8) : color(rowLabel(row).padEnd(8));
|
|
3285
|
+
const id = row.disabled ? muted$3(` ${row.id}`) : ` ${row.id}`;
|
|
3286
|
+
lines.push(`${label}${id}${muted$3(` (${row.name})`)}`);
|
|
3287
|
+
}
|
|
3288
|
+
lines.push(muted$3("↑↓ scroll · esc close"));
|
|
3289
|
+
return lines;
|
|
3290
|
+
}
|
|
3291
|
+
handleInput(data) {
|
|
3292
|
+
if (matchesKey(data, Key.escape) || data === "q") {
|
|
3293
|
+
this.actions.closePlugins();
|
|
3294
|
+
return;
|
|
3295
|
+
}
|
|
3296
|
+
const listHeight = this.listHeight();
|
|
3297
|
+
const maxOffset = this.maxOffset();
|
|
3298
|
+
if (matchesKey(data, Key.up)) {
|
|
3299
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
3300
|
+
return;
|
|
3301
|
+
}
|
|
3302
|
+
if (matchesKey(data, Key.down)) {
|
|
3303
|
+
this.scrollOffset = Math.min(maxOffset, this.scrollOffset + 1);
|
|
3304
|
+
return;
|
|
3305
|
+
}
|
|
3306
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
3307
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - listHeight);
|
|
3308
|
+
return;
|
|
3309
|
+
}
|
|
3310
|
+
if (matchesKey(data, Key.pageDown)) this.scrollOffset = Math.min(maxOffset, this.scrollOffset + listHeight);
|
|
3311
|
+
}
|
|
3312
|
+
};
|
|
3313
|
+
//#endregion
|
|
3314
|
+
//#region src/tui/agentPresets/AgentPresetsOverlay.ts
|
|
3315
|
+
const bold$2 = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
3316
|
+
const secondary$2 = fg(theme.secondary);
|
|
3317
|
+
const muted$2 = fg(theme.muted);
|
|
3318
|
+
const errorColor$1 = fg(theme.error);
|
|
3319
|
+
const invert$2 = (s) => `\x1b[7m${s}\x1b[0m`;
|
|
3320
|
+
var AgentPresetsOverlay = class {
|
|
3321
|
+
store;
|
|
3322
|
+
actions;
|
|
3323
|
+
constructor(store, actions) {
|
|
3324
|
+
this.store = store;
|
|
3325
|
+
this.actions = actions;
|
|
3326
|
+
}
|
|
3327
|
+
invalidate() {}
|
|
3328
|
+
render(_width) {
|
|
3329
|
+
const overlay = this.store.getSnapshot().overlay;
|
|
3330
|
+
if (overlay.kind !== "agentPresets") return [];
|
|
3331
|
+
const { rows, selected, current, blank, busy, error } = overlay.agentPresets;
|
|
3332
|
+
const lines = [bold$2(secondary$2("Agent presets"))];
|
|
3333
|
+
if (error !== void 0) lines.push(errorColor$1(error));
|
|
3334
|
+
if (busy && rows.length === 0) lines.push(muted$2("Loading…"));
|
|
3335
|
+
rows.forEach((row, index) => {
|
|
3336
|
+
const marker = row.id === current ? "● " : "○ ";
|
|
3337
|
+
const trust = row.trust === "user" ? " (custom)" : "";
|
|
3338
|
+
const row0 = `${index === selected ? "› " : " "}${marker}${row.label}${trust}`;
|
|
3339
|
+
lines.push(index === selected ? invert$2(row0) : row0);
|
|
3340
|
+
if (row.broken !== void 0) lines.push(errorColor$1(` broken: ${row.broken}`));
|
|
3341
|
+
else if (row.description !== void 0) lines.push(muted$2(` ${row.description}`));
|
|
3342
|
+
});
|
|
3343
|
+
if (rows.length === 0 && !busy) lines.push(muted$2("No agent presets configured in this profile."));
|
|
3344
|
+
lines.push(muted$2(blank ? "↑↓ select · enter apply · esc close" : "session already started — preset is fixed · esc close"));
|
|
3345
|
+
return lines;
|
|
3346
|
+
}
|
|
3347
|
+
handleInput(data) {
|
|
3348
|
+
const overlay = this.store.getSnapshot().overlay;
|
|
3349
|
+
if (overlay.kind !== "agentPresets") return;
|
|
3350
|
+
const { rows, selected, blank } = overlay.agentPresets;
|
|
3351
|
+
if (matchesKey(data, Key.escape) || data === "q") {
|
|
3352
|
+
this.actions.closeAgentPresets();
|
|
3353
|
+
return;
|
|
3354
|
+
}
|
|
3355
|
+
if (rows.length === 0) return;
|
|
3356
|
+
if (matchesKey(data, Key.up)) {
|
|
3357
|
+
this.actions.selectAgentPresetRow(Math.max(0, selected - 1));
|
|
3358
|
+
return;
|
|
3359
|
+
}
|
|
3360
|
+
if (matchesKey(data, Key.down)) {
|
|
3361
|
+
this.actions.selectAgentPresetRow(Math.min(rows.length - 1, selected + 1));
|
|
3362
|
+
return;
|
|
3363
|
+
}
|
|
3364
|
+
if (matchesKey(data, Key.enter) && blank) {
|
|
3365
|
+
const row = rows[selected];
|
|
3366
|
+
if (row.broken === void 0) this.actions.applyAgentPreset(row.id);
|
|
3367
|
+
}
|
|
3368
|
+
}
|
|
3369
|
+
};
|
|
3370
|
+
//#endregion
|
|
3371
|
+
//#region src/tui/interaction/ApprovalOverlay.ts
|
|
3372
|
+
const bold$1 = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
3373
|
+
const warning = fg(theme.warning);
|
|
3374
|
+
const muted$1 = fg(theme.muted);
|
|
3375
|
+
const success = fg(theme.success);
|
|
3376
|
+
const errorColor = fg(theme.error);
|
|
3377
|
+
const invert$1 = (s) => `\x1b[7m${s}\x1b[0m`;
|
|
3378
|
+
const CHOICES = [{
|
|
3379
|
+
outcome: "allowed-once",
|
|
3380
|
+
label: "Allow once"
|
|
3381
|
+
}, {
|
|
3382
|
+
outcome: "rejected",
|
|
3383
|
+
label: "Reject"
|
|
3384
|
+
}];
|
|
3385
|
+
var ApprovalOverlay = class {
|
|
3386
|
+
approval;
|
|
3387
|
+
actions;
|
|
3388
|
+
selected = 0;
|
|
3389
|
+
constructor(approval, actions) {
|
|
3390
|
+
this.approval = approval;
|
|
3391
|
+
this.actions = actions;
|
|
3392
|
+
}
|
|
3393
|
+
invalidate() {}
|
|
3394
|
+
render(_width) {
|
|
3395
|
+
const lines = [bold$1(warning("Approval requested"))];
|
|
3396
|
+
const idSuffix = this.approval.callId === void 0 ? "" : muted$1(` (${this.approval.callId})`);
|
|
3397
|
+
lines.push(`Tool: ${bold$1(this.approval.toolName)}${idSuffix}`);
|
|
3398
|
+
if (this.approval.reason !== void 0) lines.push(muted$1(this.approval.reason));
|
|
3399
|
+
CHOICES.forEach((choice, index) => {
|
|
3400
|
+
const color = choice.outcome === "rejected" ? errorColor : success;
|
|
3401
|
+
const text = `${index === this.selected ? "› " : " "}${choice.label}`;
|
|
3402
|
+
lines.push(color(index === this.selected ? invert$1(text) : text));
|
|
3403
|
+
});
|
|
3404
|
+
lines.push(muted$1("↑↓ select · enter confirm · y allow · n/esc reject"));
|
|
3405
|
+
return lines;
|
|
3406
|
+
}
|
|
3407
|
+
handleInput(data) {
|
|
3408
|
+
if (data === "y") {
|
|
3409
|
+
this.actions.answerApproval("allowed-once");
|
|
3410
|
+
return;
|
|
3411
|
+
}
|
|
3412
|
+
if (data === "n" || matchesKey(data, Key.escape)) {
|
|
3413
|
+
this.actions.answerApproval("rejected");
|
|
3414
|
+
return;
|
|
3415
|
+
}
|
|
3416
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.left)) {
|
|
3417
|
+
this.selected = (this.selected - 1 + CHOICES.length) % CHOICES.length;
|
|
3418
|
+
return;
|
|
3419
|
+
}
|
|
3420
|
+
if (matchesKey(data, Key.down) || matchesKey(data, Key.right)) {
|
|
3421
|
+
this.selected = (this.selected + 1) % CHOICES.length;
|
|
3422
|
+
return;
|
|
3423
|
+
}
|
|
3424
|
+
if (matchesKey(data, Key.enter)) this.actions.answerApproval(CHOICES[this.selected].outcome);
|
|
3425
|
+
}
|
|
3426
|
+
};
|
|
3427
|
+
//#endregion
|
|
3428
|
+
//#region src/tui/interaction/QuestionOverlay.ts
|
|
3429
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
3430
|
+
const secondary$1 = fg(theme.secondary);
|
|
3431
|
+
const muted = fg(theme.muted);
|
|
3432
|
+
const invert = (s) => `\x1b[7m${s}\x1b[0m`;
|
|
3433
|
+
/** Detail (e.g. a plan-review's plan markdown) line cap. */
|
|
3434
|
+
const MAX_DETAIL_LINES = 60;
|
|
3435
|
+
function capDetailLines(detail) {
|
|
3436
|
+
const lines = detail.split("\n");
|
|
3437
|
+
if (lines.length <= MAX_DETAIL_LINES) return lines;
|
|
3438
|
+
const omitted = lines.length - MAX_DETAIL_LINES;
|
|
3439
|
+
return [...lines.slice(0, MAX_DETAIL_LINES), `… +${omitted} more line${omitted === 1 ? "" : "s"}`];
|
|
3440
|
+
}
|
|
3441
|
+
var QuestionOverlay = class {
|
|
3442
|
+
question;
|
|
3443
|
+
actions;
|
|
3444
|
+
cursor = 0;
|
|
3445
|
+
toggled = /* @__PURE__ */ new Set();
|
|
3446
|
+
customMode;
|
|
3447
|
+
customField = emptyMiniTextField();
|
|
3448
|
+
constructor(question, actions) {
|
|
3449
|
+
this.question = question;
|
|
3450
|
+
this.actions = actions;
|
|
3451
|
+
this.customMode = question.options.length === 0;
|
|
3452
|
+
}
|
|
3453
|
+
invalidate() {}
|
|
3454
|
+
submit() {
|
|
3455
|
+
const custom = this.customField.value.trim();
|
|
3456
|
+
if (this.question.multiSelect) {
|
|
3457
|
+
const selected = [...this.toggled].sort((a, b) => a - b).map((index) => this.question.options[index].label);
|
|
3458
|
+
this.actions.answerQuestion({
|
|
3459
|
+
selected,
|
|
3460
|
+
custom: custom === "" ? void 0 : custom
|
|
3461
|
+
});
|
|
3462
|
+
return;
|
|
3463
|
+
}
|
|
3464
|
+
if (this.customMode) {
|
|
3465
|
+
this.actions.answerQuestion({
|
|
3466
|
+
selected: [],
|
|
3467
|
+
custom
|
|
3468
|
+
});
|
|
3469
|
+
return;
|
|
3470
|
+
}
|
|
3471
|
+
this.actions.answerQuestion({
|
|
3472
|
+
selected: [this.question.options[this.cursor].label],
|
|
3473
|
+
custom: void 0
|
|
3474
|
+
});
|
|
3475
|
+
}
|
|
3476
|
+
render(_width) {
|
|
3477
|
+
const { header, question: text, detail, options, multiSelect, approveLabel, progress } = this.question;
|
|
3478
|
+
const otherIndex = options.length;
|
|
3479
|
+
const lines = [];
|
|
3480
|
+
lines.push(bold(secondary$1(`${header ?? "Question"}${progress === void 0 ? "" : ` — ${progress}`}`)));
|
|
3481
|
+
lines.push(text);
|
|
3482
|
+
if (detail !== void 0) {
|
|
3483
|
+
lines.push("");
|
|
3484
|
+
for (const line of capDetailLines(detail)) lines.push(muted(line));
|
|
3485
|
+
lines.push("");
|
|
3486
|
+
}
|
|
3487
|
+
options.forEach((option, index) => {
|
|
3488
|
+
const isSelected = !this.customMode && this.cursor === index;
|
|
3489
|
+
const box = multiSelect ? this.toggled.has(index) ? "[x] " : "[ ] " : "";
|
|
3490
|
+
const approve = approveLabel === option.label ? " (approve)" : "";
|
|
3491
|
+
const row = `${isSelected ? "› " : " "}${box}${option.label}${approve}`;
|
|
3492
|
+
lines.push(isSelected ? invert(row) : row);
|
|
3493
|
+
if (option.description !== void 0) lines.push(muted(` ${option.description}`));
|
|
3494
|
+
});
|
|
3495
|
+
if (options.length > 0) {
|
|
3496
|
+
const isSelected = !this.customMode && this.cursor === otherIndex;
|
|
3497
|
+
const row = `${isSelected ? "› " : " "}Other…`;
|
|
3498
|
+
lines.push(isSelected ? invert(row) : row);
|
|
3499
|
+
}
|
|
3500
|
+
if (this.customMode) lines.push(`> ${renderMiniTextField(this.customField, true)}`);
|
|
3501
|
+
const hint = [
|
|
3502
|
+
multiSelect ? "↑↓ move · space toggle · enter submit" : "↑↓ move · enter select",
|
|
3503
|
+
options.length === 0 ? "" : "\"Other…\" for free text",
|
|
3504
|
+
"esc skip"
|
|
3505
|
+
].filter((s) => s !== "").join(" · ");
|
|
3506
|
+
lines.push(muted(hint));
|
|
3507
|
+
return lines;
|
|
3508
|
+
}
|
|
3509
|
+
handleInput(data) {
|
|
3510
|
+
const { options, multiSelect } = this.question;
|
|
3511
|
+
const otherIndex = options.length;
|
|
3512
|
+
if (this.customMode) {
|
|
3513
|
+
if (matchesKey(data, Key.escape)) {
|
|
3514
|
+
if (options.length > 0) this.customMode = false;
|
|
3515
|
+
else this.actions.answerQuestion({
|
|
3516
|
+
selected: [],
|
|
3517
|
+
custom: void 0
|
|
3518
|
+
});
|
|
3519
|
+
return;
|
|
3520
|
+
}
|
|
3521
|
+
if (matchesKey(data, Key.enter)) {
|
|
3522
|
+
this.submit();
|
|
3523
|
+
return;
|
|
3524
|
+
}
|
|
3525
|
+
const next = miniTextFieldInput(this.customField, data);
|
|
3526
|
+
if (next !== void 0) this.customField = next;
|
|
3527
|
+
return;
|
|
3528
|
+
}
|
|
3529
|
+
if (matchesKey(data, Key.escape)) {
|
|
3530
|
+
this.actions.answerQuestion({
|
|
3531
|
+
selected: [],
|
|
3532
|
+
custom: void 0
|
|
3533
|
+
});
|
|
3534
|
+
return;
|
|
3535
|
+
}
|
|
3536
|
+
if (options.length === 0) return;
|
|
3537
|
+
if (matchesKey(data, Key.up)) {
|
|
3538
|
+
this.cursor = (this.cursor - 1 + options.length + 1) % (options.length + 1);
|
|
3539
|
+
return;
|
|
3540
|
+
}
|
|
3541
|
+
if (matchesKey(data, Key.down)) {
|
|
3542
|
+
this.cursor = (this.cursor + 1) % (options.length + 1);
|
|
3543
|
+
return;
|
|
3544
|
+
}
|
|
3545
|
+
if (data === " " && multiSelect && this.cursor < options.length) {
|
|
3546
|
+
if (this.toggled.has(this.cursor)) this.toggled.delete(this.cursor);
|
|
3547
|
+
else this.toggled.add(this.cursor);
|
|
3548
|
+
return;
|
|
3549
|
+
}
|
|
3550
|
+
if (matchesKey(data, Key.enter)) {
|
|
3551
|
+
if (this.cursor === otherIndex) {
|
|
3552
|
+
this.customMode = true;
|
|
3553
|
+
return;
|
|
3554
|
+
}
|
|
3555
|
+
this.submit();
|
|
3556
|
+
}
|
|
3557
|
+
}
|
|
3558
|
+
};
|
|
3559
|
+
//#endregion
|
|
3560
|
+
//#region src/tui/TuiApp.ts
|
|
3561
|
+
/**
|
|
3562
|
+
* Root orchestrator: builds the pi-tui component tree once, then patches it
|
|
3563
|
+
* imperatively from `TuiStore` change notifications — the pi-tui equivalent
|
|
3564
|
+
* of `App.tsx` (root component) + `mount.tsx` (the `render()` call site)
|
|
3565
|
+
* combined, since pi-tui has no JSX/reconciler to split those across.
|
|
3566
|
+
*
|
|
3567
|
+
* Two different update strategies are used, deliberately:
|
|
3568
|
+
*
|
|
3569
|
+
* - The live region (notice, queued preview, streaming text, status bar,
|
|
3570
|
+
* stats line, permission indicator, update hint) is a `DynamicText`/`Spinner` per row,
|
|
3571
|
+
* each pulling straight from `store.getSnapshot()` at render time. There is
|
|
3572
|
+
* no manual `setText` bookkeeping to keep in sync — every repaint just
|
|
3573
|
+
* reflects whatever the store currently holds. The approve/reject panel
|
|
3574
|
+
* (`ApprovalSlot`) is a live-region row too, but an interactive one: it
|
|
3575
|
+
* delegates render/input to whichever `ApprovalOverlay` is currently
|
|
3576
|
+
* active and takes focus while one is, rather than covering the screen.
|
|
3577
|
+
* - The transcript is append-only: `appendNewTranscriptItems` diffs the
|
|
3578
|
+
* store's `events`/`shellHistory` arrays against how much has already been
|
|
3579
|
+
* turned into a `createTranscriptLine` child of `documentContainer`,
|
|
3580
|
+
* appending only the new tail. Re-formatting the whole transcript on every
|
|
3581
|
+
* store change would be wasteful for a long session — `ScrollView`'s own
|
|
3582
|
+
* viewport culling (confirmed in pi-tui's own test suite: painting a huge
|
|
3583
|
+
* scroll child is O(viewport), not O(content)) is what makes this safe to
|
|
3584
|
+
* grow without bound.
|
|
3585
|
+
*
|
|
3586
|
+
* Overlays (`/model`, `/trajectory`, Ctrl+O tool cards, `/context`,
|
|
3587
|
+
* `/plugins`, `/presets`, question) are `tui.showOverlay(...)` calls keyed
|
|
3588
|
+
* off `store.getSnapshot().overlay.kind` — see `updateOverlay`. Approval is
|
|
3589
|
+
* the one exception: it renders inline in the dock instead (see above).
|
|
3590
|
+
* @module @tomowang/dsh-tui/tui/TuiApp
|
|
3591
|
+
*/
|
|
3592
|
+
const secondary = fg(theme.secondary);
|
|
3593
|
+
/** Full-screen panel anchored at the top — every overlay's uniform placement. */
|
|
3594
|
+
const OVERLAY_OPTIONS = {
|
|
3595
|
+
anchor: "top-left",
|
|
3596
|
+
row: 0,
|
|
3597
|
+
col: 0,
|
|
3598
|
+
width: "100%",
|
|
3599
|
+
maxHeight: "100%"
|
|
3600
|
+
};
|
|
3601
|
+
/**
|
|
3602
|
+
* Wraps an overlay `Component` so it always paints every cell of the
|
|
3603
|
+
* terminal, not just however many lines its own content happens to need.
|
|
3604
|
+
* `tui.showOverlay` composites exactly what `render()` returns onto the base
|
|
3605
|
+
* frame at the requested position/size — it does not clear or pad the rest
|
|
3606
|
+
* of that box — so a short overlay (e.g. a "No tool cards yet" one-liner)
|
|
3607
|
+
* otherwise leaves the transcript/dock's last-painted content visible
|
|
3608
|
+
* underneath it, which reads as a rendering bug (old messages "bleeding
|
|
3609
|
+
* through" around a top-anchored panel) rather than an intentional
|
|
3610
|
+
* takeover. Padding to the full terminal height/width here, once, means no
|
|
3611
|
+
* individual overlay has to reimplement this.
|
|
3612
|
+
*/
|
|
3613
|
+
var FullScreenOverlay = class {
|
|
3614
|
+
inner;
|
|
3615
|
+
tui;
|
|
3616
|
+
constructor(inner, tui) {
|
|
3617
|
+
this.inner = inner;
|
|
3618
|
+
this.tui = tui;
|
|
3619
|
+
}
|
|
3620
|
+
get wantsKeyRelease() {
|
|
3621
|
+
return this.inner.wantsKeyRelease ?? false;
|
|
3622
|
+
}
|
|
3623
|
+
invalidate() {
|
|
3624
|
+
this.inner.invalidate();
|
|
3625
|
+
}
|
|
3626
|
+
handleInput(data) {
|
|
3627
|
+
this.inner.handleInput?.(data);
|
|
3628
|
+
}
|
|
3629
|
+
render(width) {
|
|
3630
|
+
const lines = this.inner.render(width);
|
|
3631
|
+
const height = Math.max(lines.length, this.tui.terminal.rows);
|
|
3632
|
+
const padded = [];
|
|
3633
|
+
for (let i = 0; i < height; i++) {
|
|
3634
|
+
const line = lines[i] ?? "";
|
|
3635
|
+
const pad = width - visibleWidth(line);
|
|
3636
|
+
padded.push(pad > 0 ? line + " ".repeat(pad) : line);
|
|
3637
|
+
}
|
|
3638
|
+
return padded;
|
|
3639
|
+
}
|
|
3640
|
+
};
|
|
3641
|
+
/**
|
|
3642
|
+
* Dock row that delegates to whichever `ApprovalOverlay` is currently
|
|
3643
|
+
* active, or renders nothing between approvals. Unlike the other dock rows
|
|
3644
|
+
* (`DynamicText`, pulling read-only text from the store each render), this
|
|
3645
|
+
* one also takes focus and forwards keystrokes — it's how the approve/reject
|
|
3646
|
+
* panel gets shown inline, in the live region, instead of as a
|
|
3647
|
+
* full-screen `showOverlay` panel covering the transcript.
|
|
3648
|
+
*/
|
|
3649
|
+
var ApprovalSlot = class {
|
|
3650
|
+
current;
|
|
3651
|
+
set(component) {
|
|
3652
|
+
this.current = component;
|
|
3653
|
+
}
|
|
3654
|
+
invalidate() {
|
|
3655
|
+
this.current?.invalidate();
|
|
3656
|
+
}
|
|
3657
|
+
render(width) {
|
|
3658
|
+
return this.current?.render(width) ?? [];
|
|
3659
|
+
}
|
|
3660
|
+
handleInput(data) {
|
|
3661
|
+
this.current?.handleInput(data);
|
|
3662
|
+
}
|
|
3663
|
+
};
|
|
3664
|
+
let activeTui;
|
|
3665
|
+
let keybindingsConfigured = false;
|
|
3666
|
+
/**
|
|
3667
|
+
* Emacs-style Ctrl+P/Ctrl+N aliases, and history recall on up/down —
|
|
3668
|
+
* matching the old hand-rolled `PromptInput` exactly. Also frees `Home`/`End`
|
|
3669
|
+
* from `TuiAltScreen`'s default viewport-jump-to-top/bottom bindings: the
|
|
3670
|
+
* alt-screen's own viewport navigation intercepts input *before* it reaches
|
|
3671
|
+
* the focused component (confirmed empirically — an unmodified `Home`
|
|
3672
|
+
* scrolled the transcript instead of moving the prompt's cursor to line
|
|
3673
|
+
* start), which would otherwise silently break `Editor`'s own
|
|
3674
|
+
* `cursorLineStart`/`cursorLineEnd` (`Home`/`End`/Ctrl+A/Ctrl+E) whenever the
|
|
3675
|
+
* prompt has focus, which is effectively always. `Ctrl+A`/`Ctrl+E` still
|
|
3676
|
+
* give line motion and `PageUp`/`PageDown`/mouse wheel still give transcript
|
|
3677
|
+
* scroll, so unbinding the dedicated top/bottom jump is a reasonable trade.
|
|
3678
|
+
* Configured once, globally (pi-tui's keybinding registry is module-global,
|
|
3679
|
+
* not per-instance).
|
|
3680
|
+
*/
|
|
3681
|
+
function ensureKeybindings() {
|
|
3682
|
+
if (keybindingsConfigured) return;
|
|
3683
|
+
keybindingsConfigured = true;
|
|
3684
|
+
setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS, {
|
|
3685
|
+
"tui.editor.cursorUp": ["up", "ctrl+p"],
|
|
3686
|
+
"tui.editor.cursorDown": ["down", "ctrl+n"],
|
|
3687
|
+
"tui.editor.historyPrevious": ["up", "ctrl+p"],
|
|
3688
|
+
"tui.editor.historyNext": ["down", "ctrl+n"],
|
|
3689
|
+
"tui.altScreen.top": [],
|
|
3690
|
+
"tui.altScreen.bottom": []
|
|
3691
|
+
}));
|
|
3692
|
+
}
|
|
3693
|
+
var TuiApp = class {
|
|
3694
|
+
options;
|
|
3695
|
+
tui;
|
|
3696
|
+
documentContainer = new Container();
|
|
3697
|
+
editor;
|
|
3698
|
+
spinner;
|
|
3699
|
+
appendedEventsCount = 0;
|
|
3700
|
+
appendedShellCount = 0;
|
|
3701
|
+
currentOverlayKind = "none";
|
|
3702
|
+
overlayHandle;
|
|
3703
|
+
approvalSlot = new ApprovalSlot();
|
|
3704
|
+
wasRunning = false;
|
|
3705
|
+
stopped = false;
|
|
3706
|
+
/** Last title string sent to the terminal, so an unrelated store change doesn't re-issue the same OSC 0 write every render. */
|
|
3707
|
+
lastTerminalTitle;
|
|
3708
|
+
constructor(options) {
|
|
3709
|
+
this.options = options;
|
|
3710
|
+
const { store, actions } = options;
|
|
3711
|
+
const terminal = new ProcessTerminal();
|
|
3712
|
+
this.tui = new TuiAltScreen(terminal, true, void 0, { mouse: true });
|
|
3713
|
+
this.spinner = new Spinner(this.tui);
|
|
3714
|
+
this.documentContainer.addChild(new DynamicText((width) => buildBannerText({
|
|
3715
|
+
version: options.version,
|
|
3716
|
+
provider: options.provider,
|
|
3717
|
+
model: options.model,
|
|
3718
|
+
cwd: options.cwd
|
|
3719
|
+
}, width)));
|
|
3720
|
+
const transcriptScrollView = new ScrollView(this.documentContainer, {
|
|
3721
|
+
follow: "end",
|
|
3722
|
+
primary: true,
|
|
3723
|
+
overscroll: "chain"
|
|
3724
|
+
});
|
|
3725
|
+
this.editor = new CustomEditor(this.tui, actions, {
|
|
3726
|
+
getStatus: () => store.getSnapshot().status,
|
|
3727
|
+
history: options.promptHistory,
|
|
3728
|
+
getFileCandidates: () => this.waitForFileIndex()
|
|
3729
|
+
});
|
|
3730
|
+
const noticeText = new DynamicText(() => {
|
|
3731
|
+
const notice = store.getSnapshot().notice;
|
|
3732
|
+
return notice === void 0 ? "" : secondary(notice);
|
|
3733
|
+
});
|
|
3734
|
+
const goalText = new DynamicText(() => buildGoalBarText(store.getSnapshot().goal));
|
|
3735
|
+
const queuedText = new DynamicText(() => buildQueuedText(store.getSnapshot().queued));
|
|
3736
|
+
const streamingText = new DynamicText((width) => {
|
|
3737
|
+
const streaming = store.getSnapshot().streaming;
|
|
3738
|
+
if (streaming === void 0) return "";
|
|
3739
|
+
return padTranscriptText(formatStreamingText(streaming.text, streaming.reasoningText, this.spinner.current()) ?? "", width).join("\n");
|
|
3740
|
+
});
|
|
3741
|
+
const pendingToolCallsText = new DynamicText((width) => {
|
|
3742
|
+
const { pendingToolCalls } = store.getSnapshot();
|
|
3743
|
+
return padTranscriptText(formatPendingToolCalls(pendingToolCalls, this.spinner.current(), options.getTool), width).join("\n");
|
|
3744
|
+
});
|
|
3745
|
+
const shellRunLiveText = new DynamicText((width) => {
|
|
3746
|
+
const run = store.getSnapshot().shellRun;
|
|
3747
|
+
if (run === void 0) return "";
|
|
3748
|
+
return padTranscriptText(formatShellRunLive(run.command, run.output), width).join("\n");
|
|
3749
|
+
});
|
|
3750
|
+
const statusBarText = new DynamicText(() => {
|
|
3751
|
+
const state = store.getSnapshot();
|
|
3752
|
+
return buildStatusBarText({
|
|
3753
|
+
sessionId: options.sessionId,
|
|
3754
|
+
provider: options.provider,
|
|
3755
|
+
model: options.model,
|
|
3756
|
+
status: state.status,
|
|
3757
|
+
queuedCount: state.queued.length,
|
|
3758
|
+
presetLabel: state.preset?.current,
|
|
3759
|
+
eventCount: state.events.length,
|
|
3760
|
+
spinnerChar: this.spinner.current()
|
|
3761
|
+
});
|
|
3762
|
+
});
|
|
3763
|
+
const permissionText = new DynamicText(() => buildPermissionText(store.getSnapshot().permission));
|
|
3764
|
+
const updateHintText = new DynamicText(() => buildUpdateHintText(options.version, store.getSnapshot().updateHint));
|
|
3765
|
+
const statsLineText = new DynamicText(() => {
|
|
3766
|
+
const stats = store.getSnapshot().stats;
|
|
3767
|
+
return [buildStatsLine(stats.sessionStats, stats.tokenUsage), buildContextLine(stats.contextPressure)].filter((group) => group !== "").join("| ");
|
|
3768
|
+
});
|
|
3769
|
+
const dock = new VStack([
|
|
3770
|
+
noticeText,
|
|
3771
|
+
goalText,
|
|
3772
|
+
queuedText,
|
|
3773
|
+
streamingText,
|
|
3774
|
+
pendingToolCallsText,
|
|
3775
|
+
shellRunLiveText,
|
|
3776
|
+
statusBarText,
|
|
3777
|
+
this.approvalSlot,
|
|
3778
|
+
this.editor,
|
|
3779
|
+
permissionText,
|
|
3780
|
+
updateHintText,
|
|
3781
|
+
statsLineText
|
|
3782
|
+
], { gap: 0 });
|
|
3783
|
+
const layoutRoot = new VStack([{
|
|
3784
|
+
component: transcriptScrollView,
|
|
3785
|
+
basis: 0,
|
|
3786
|
+
grow: 1,
|
|
3787
|
+
minSize: 1
|
|
3788
|
+
}, {
|
|
3789
|
+
component: dock,
|
|
3790
|
+
basis: "auto",
|
|
3791
|
+
shrink: 1,
|
|
3792
|
+
minSize: 1
|
|
3793
|
+
}], { gap: 0 });
|
|
3794
|
+
this.tui.setLayoutRoot(layoutRoot);
|
|
3795
|
+
this.tui.setFocus(this.editor);
|
|
3796
|
+
this.appendNewTranscriptItems(store.getSnapshot());
|
|
3797
|
+
this.updateTerminalTitle(store.getSnapshot().title);
|
|
3798
|
+
store.subscribe(() => {
|
|
3799
|
+
const state = store.getSnapshot();
|
|
3800
|
+
this.appendNewTranscriptItems(state);
|
|
3801
|
+
this.updateOverlay(state.overlay);
|
|
3802
|
+
this.updateTerminalTitle(state.title);
|
|
3803
|
+
const running = state.status === "running";
|
|
3804
|
+
if (running !== this.wasRunning) {
|
|
3805
|
+
this.wasRunning = running;
|
|
3806
|
+
if (running) this.spinner.start();
|
|
3807
|
+
else this.spinner.stop();
|
|
3808
|
+
}
|
|
3809
|
+
this.tui.requestRender();
|
|
3810
|
+
});
|
|
3811
|
+
}
|
|
3812
|
+
/** Push the terminal window/tab title (OSC 0) when the session's title projection changes; a no-op once already reflecting the current value. */
|
|
3813
|
+
updateTerminalTitle(title) {
|
|
3814
|
+
const text = buildTerminalTitle(title);
|
|
3815
|
+
if (text === this.lastTerminalTitle) return;
|
|
3816
|
+
this.lastTerminalTitle = text;
|
|
3817
|
+
this.tui.terminal.setTitle(text);
|
|
3818
|
+
}
|
|
3819
|
+
start() {
|
|
3820
|
+
activeTui = this.tui;
|
|
3821
|
+
this.tui.start();
|
|
3822
|
+
}
|
|
3823
|
+
appendNewTranscriptItems(state) {
|
|
3824
|
+
const { getTool, getToolCall } = this.options;
|
|
3825
|
+
if (state.events.length > this.appendedEventsCount) {
|
|
3826
|
+
for (let i = this.appendedEventsCount; i < state.events.length; i++) {
|
|
3827
|
+
const event = state.events[i];
|
|
3828
|
+
const formatted = formatEvent(event, {
|
|
3829
|
+
replay: event.seq <= state.replayThrough,
|
|
3830
|
+
getTool,
|
|
3831
|
+
getToolCall
|
|
3832
|
+
});
|
|
3833
|
+
if (formatted !== void 0 && formatted !== "") this.documentContainer.addChild(createTranscriptLine(formatted));
|
|
3834
|
+
}
|
|
3835
|
+
this.appendedEventsCount = state.events.length;
|
|
3836
|
+
}
|
|
3837
|
+
if (state.shellHistory.length > this.appendedShellCount) {
|
|
3838
|
+
for (let i = this.appendedShellCount; i < state.shellHistory.length; i++) {
|
|
3839
|
+
const run = state.shellHistory[i];
|
|
3840
|
+
this.documentContainer.addChild(createTranscriptLine(formatShellRun(run.command, run.output, run.exitCode)));
|
|
3841
|
+
}
|
|
3842
|
+
this.appendedShellCount = state.shellHistory.length;
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
/** Loads (once, cached in the store) and resolves with the `@`-mention file index, for `CustomEditor`'s autocomplete provider. */
|
|
3846
|
+
waitForFileIndex() {
|
|
3847
|
+
const { store, actions } = this.options;
|
|
3848
|
+
actions.ensureFileIndex();
|
|
3849
|
+
const snapshot = store.getSnapshot().fileIndex;
|
|
3850
|
+
if (snapshot.candidates !== void 0) return Promise.resolve(snapshot.candidates);
|
|
3851
|
+
return new Promise((resolve) => {
|
|
3852
|
+
const unsubscribe = store.subscribe(() => {
|
|
3853
|
+
const current = store.getSnapshot().fileIndex;
|
|
3854
|
+
if (current.candidates !== void 0) {
|
|
3855
|
+
unsubscribe();
|
|
3856
|
+
resolve(current.candidates);
|
|
3857
|
+
}
|
|
3858
|
+
});
|
|
3859
|
+
});
|
|
3860
|
+
}
|
|
3861
|
+
buildOverlayComponent(overlay) {
|
|
3862
|
+
const { store, actions, getTool, getToolCall } = this.options;
|
|
3863
|
+
switch (overlay.kind) {
|
|
3864
|
+
case "none": return;
|
|
3865
|
+
case "modelProfile": return new ModelProfileOverlay(store, actions);
|
|
3866
|
+
case "trajectory": return new TrajectoryOverlay(this.tui, store, actions, getTool);
|
|
3867
|
+
case "toolCards": return new ToolCardsOverlay(this.tui, store, actions, getTool, getToolCall);
|
|
3868
|
+
case "context": return new ContextOverlay(store, actions);
|
|
3869
|
+
case "plugins": return new PluginsOverlay(this.tui, overlay.rows, actions);
|
|
3870
|
+
case "agentPresets": return new AgentPresetsOverlay(store, actions);
|
|
3871
|
+
case "approval": return;
|
|
3872
|
+
case "userQuestion": return new QuestionOverlay(overlay.userQuestion, actions);
|
|
3873
|
+
}
|
|
3874
|
+
}
|
|
3875
|
+
updateOverlay(overlay) {
|
|
3876
|
+
if (overlay.kind === this.currentOverlayKind) return;
|
|
3877
|
+
const previousKind = this.currentOverlayKind;
|
|
3878
|
+
this.currentOverlayKind = overlay.kind;
|
|
3879
|
+
if ((overlay.kind === "approval" || overlay.kind === "userQuestion") && previousKind !== "approval" && previousKind !== "userQuestion") {
|
|
3880
|
+
const message = overlay.kind === "approval" ? "dsh-tui is waiting for your approval" : "dsh-tui is waiting for your answer";
|
|
3881
|
+
this.tui.terminal.write(`\x1b]9;${message}\x07`);
|
|
3882
|
+
}
|
|
3883
|
+
if (previousKind === "approval") this.approvalSlot.set(void 0);
|
|
3884
|
+
if (this.overlayHandle !== void 0) {
|
|
3885
|
+
this.overlayHandle.hide();
|
|
3886
|
+
this.overlayHandle = void 0;
|
|
3887
|
+
}
|
|
3888
|
+
if (overlay.kind === "approval") {
|
|
3889
|
+
this.approvalSlot.set(new ApprovalOverlay(overlay.approval, this.options.actions));
|
|
3890
|
+
this.tui.setFocus(this.approvalSlot);
|
|
3891
|
+
return;
|
|
3892
|
+
}
|
|
3893
|
+
this.tui.setFocus(this.editor);
|
|
3894
|
+
const component = this.buildOverlayComponent(overlay);
|
|
3895
|
+
if (component === void 0) return;
|
|
3896
|
+
this.overlayHandle = this.tui.showOverlay(new FullScreenOverlay(component, this.tui), OVERLAY_OPTIONS);
|
|
3897
|
+
}
|
|
3898
|
+
unmount(options) {
|
|
3899
|
+
if (this.stopped) return;
|
|
3900
|
+
this.stopped = true;
|
|
3901
|
+
this.spinner.stop();
|
|
3902
|
+
this.tui.stop(options);
|
|
3903
|
+
if (activeTui === this.tui) activeTui = void 0;
|
|
3904
|
+
}
|
|
3905
|
+
waitUntilExit() {
|
|
3906
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
3907
|
+
}
|
|
3908
|
+
};
|
|
3909
|
+
/** Mount the interactive front door. */
|
|
3910
|
+
function mountTui(options) {
|
|
3911
|
+
ensureKeybindings();
|
|
3912
|
+
const app = new TuiApp(options);
|
|
3913
|
+
app.start();
|
|
3914
|
+
return app;
|
|
3915
|
+
}
|
|
3916
|
+
/** Directory names the fallback walk never descends into. */
|
|
3917
|
+
const WALK_EXCLUDES = /* @__PURE__ */ new Set([".git", "node_modules"]);
|
|
3918
|
+
/**
|
|
3919
|
+
* List candidate file paths under `cwd`, relative to `cwd`.
|
|
3920
|
+
* @param cwd - root to list from.
|
|
3921
|
+
* @returns tracked and untracked-but-not-gitignored paths via `git ls-files`
|
|
3922
|
+
* when `cwd` is inside a git repo; otherwise a bounded recursive walk.
|
|
3923
|
+
*/
|
|
3924
|
+
async function loadFileIndex(cwd) {
|
|
3925
|
+
const fromGit = await listGitFiles(cwd);
|
|
3926
|
+
if (fromGit !== void 0) return fromGit;
|
|
3927
|
+
return walkDirectory(cwd);
|
|
3928
|
+
}
|
|
3929
|
+
function listGitFiles(cwd) {
|
|
3930
|
+
return new Promise((resolve) => {
|
|
3931
|
+
let out = "";
|
|
3932
|
+
let child;
|
|
3933
|
+
try {
|
|
3934
|
+
child = spawn("git", [
|
|
3935
|
+
"ls-files",
|
|
3936
|
+
"--cached",
|
|
3937
|
+
"--others",
|
|
3938
|
+
"--exclude-standard"
|
|
3939
|
+
], {
|
|
3940
|
+
cwd,
|
|
3941
|
+
stdio: [
|
|
3942
|
+
"ignore",
|
|
3943
|
+
"pipe",
|
|
3944
|
+
"ignore"
|
|
3945
|
+
]
|
|
3946
|
+
});
|
|
3947
|
+
} catch {
|
|
3948
|
+
resolve(void 0);
|
|
3949
|
+
return;
|
|
3950
|
+
}
|
|
3951
|
+
child.stdout.on("data", (chunk) => {
|
|
3952
|
+
out += chunk.toString();
|
|
3953
|
+
});
|
|
3954
|
+
child.on("error", () => resolve(void 0));
|
|
3955
|
+
child.on("close", (code) => {
|
|
3956
|
+
if (code !== 0) {
|
|
3957
|
+
resolve(void 0);
|
|
3958
|
+
return;
|
|
3959
|
+
}
|
|
3960
|
+
resolve(out.split("\n").filter((line) => line.length > 0));
|
|
3961
|
+
});
|
|
3962
|
+
});
|
|
3963
|
+
}
|
|
3964
|
+
async function walkDirectory(cwd) {
|
|
3965
|
+
const results = [];
|
|
3966
|
+
const queue = [cwd];
|
|
3967
|
+
while (queue.length > 0 && results.length < 5e3) {
|
|
3968
|
+
const dir = queue.shift();
|
|
3969
|
+
let entries;
|
|
3970
|
+
try {
|
|
3971
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
3972
|
+
} catch {
|
|
3973
|
+
continue;
|
|
3974
|
+
}
|
|
3975
|
+
for (const entry of entries) {
|
|
3976
|
+
if (results.length >= 5e3) break;
|
|
3977
|
+
if (entry.isDirectory()) {
|
|
3978
|
+
if (WALK_EXCLUDES.has(entry.name)) continue;
|
|
3979
|
+
queue.push(join(dir, entry.name));
|
|
3980
|
+
continue;
|
|
3981
|
+
}
|
|
3982
|
+
if (entry.isFile()) results.push(relative(cwd, join(dir, entry.name)));
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
3985
|
+
return results;
|
|
3986
|
+
}
|
|
3987
|
+
//#endregion
|
|
3988
|
+
//#region src/tui-app/session.ts
|
|
3989
|
+
/**
|
|
3990
|
+
* ACRYL terminal host adapter: brings up one normal local runtime, opens or
|
|
3991
|
+
* resumes one native durable DSH session through the runtime-owned bridge,
|
|
3992
|
+
* projects the durable event log into `TuiStore`, and drives the pi-tui shell
|
|
3993
|
+
* (ported from `tomowang/dsh-tui`) through `TuiActions`. The adapter owns the
|
|
3994
|
+
* mount/dispose order only; it reuses ACRYL runtime ownership and never
|
|
3995
|
+
* constructs a Cordis tree or touches DSH agent internals directly.
|
|
3996
|
+
*
|
|
3997
|
+
* First slice scope: create/resume -> prompt -> stream -> tool state -> cancel
|
|
3998
|
+
* -> clean dispose. Overlay, approval/question, model/preset and shell-mode
|
|
3999
|
+
* surfaces are intentionally stubbed (later increments).
|
|
4000
|
+
*/
|
|
4001
|
+
const TUI_VERSION = "0.1.0-dev.0";
|
|
4002
|
+
const PROMPT_HISTORY_LIMIT = 200;
|
|
4003
|
+
const NOT_AVAILABLE = "not available in this build yet";
|
|
4004
|
+
function failUnknown(status) {
|
|
4005
|
+
return status === "running" ? "running" : "idle";
|
|
4006
|
+
}
|
|
4007
|
+
function toolPreview(ctx) {
|
|
4008
|
+
return (name) => ctx.get("tools")?.get(name);
|
|
4009
|
+
}
|
|
4010
|
+
/** Mount one interactive pi-tui session over the bridge. Resolves when the shell exits. */
|
|
4011
|
+
async function runAcrylTui(options) {
|
|
4012
|
+
const host = await startDirectHost({ profile: options.profile });
|
|
4013
|
+
let bridge;
|
|
4014
|
+
let instance;
|
|
4015
|
+
let settled = false;
|
|
4016
|
+
try {
|
|
4017
|
+
const created = createAcrylSessionBridge(host.ctx, {
|
|
4018
|
+
profile: options.profile,
|
|
4019
|
+
generationId: randomUUID(),
|
|
4020
|
+
attachment: "owner",
|
|
4021
|
+
cwd: process.cwd()
|
|
4022
|
+
});
|
|
4023
|
+
bridge = created;
|
|
4024
|
+
const sessionId = await created.open(options.resumeSessionId);
|
|
4025
|
+
const store = new TuiStore({ events: created.events(sessionId) });
|
|
4026
|
+
const initial = await created.snapshot(sessionId);
|
|
4027
|
+
store.setStatus(failUnknown(initial.agentStatus));
|
|
4028
|
+
created.subscribeEvents(sessionId, (event) => {
|
|
4029
|
+
store.appendEvent(event);
|
|
4030
|
+
created.snapshot(sessionId).then((next) => store.setStatus(failUnknown(next.agentStatus)));
|
|
4031
|
+
});
|
|
4032
|
+
const history = [];
|
|
4033
|
+
let exitResolve = () => {};
|
|
4034
|
+
const exited = new Promise((resolve) => {
|
|
4035
|
+
exitResolve = resolve;
|
|
4036
|
+
});
|
|
4037
|
+
const actions = {
|
|
4038
|
+
send(text) {
|
|
4039
|
+
store.setNotice(void 0);
|
|
4040
|
+
created.submitPrompt({
|
|
4041
|
+
sessionId,
|
|
4042
|
+
text
|
|
4043
|
+
}).catch((error) => {
|
|
4044
|
+
store.setNotice(error instanceof Error ? error.message : String(error));
|
|
4045
|
+
});
|
|
4046
|
+
},
|
|
4047
|
+
cancel() {
|
|
4048
|
+
created.cancel(sessionId).catch(() => {});
|
|
4049
|
+
},
|
|
4050
|
+
shutdown() {
|
|
4051
|
+
exitResolve();
|
|
4052
|
+
},
|
|
4053
|
+
help() {
|
|
4054
|
+
store.setNotice("available: any text submits, Ctrl+C cancels, Ctrl+D exits, /help shows commands");
|
|
4055
|
+
},
|
|
4056
|
+
recordHistory(line) {
|
|
4057
|
+
history.push(line);
|
|
4058
|
+
if (history.length > PROMPT_HISTORY_LIMIT) history.shift();
|
|
4059
|
+
},
|
|
4060
|
+
clear() {
|
|
4061
|
+
store.setNotice(`/clear ${NOT_AVAILABLE}`);
|
|
4062
|
+
},
|
|
4063
|
+
cyclePermission() {
|
|
4064
|
+
store.setNotice(`permission cycling ${NOT_AVAILABLE}`);
|
|
4065
|
+
},
|
|
4066
|
+
compact() {
|
|
4067
|
+
store.setNotice(`/compact ${NOT_AVAILABLE}`);
|
|
4068
|
+
},
|
|
4069
|
+
plan() {
|
|
4070
|
+
store.setNotice(`plan mode ${NOT_AVAILABLE}`);
|
|
4071
|
+
},
|
|
4072
|
+
goal() {
|
|
4073
|
+
store.setNotice(`goal mode ${NOT_AVAILABLE}`);
|
|
4074
|
+
},
|
|
4075
|
+
runShell() {
|
|
4076
|
+
store.setNotice(`shell mode ${NOT_AVAILABLE}`);
|
|
4077
|
+
},
|
|
4078
|
+
ensureFileIndex() {
|
|
4079
|
+
if (store.getSnapshot().fileIndex.candidates !== void 0) return;
|
|
4080
|
+
loadFileIndex(process.cwd()).then((candidates) => store.setFileIndex(candidates));
|
|
4081
|
+
},
|
|
4082
|
+
openModelProfile() {
|
|
4083
|
+
store.setNotice(`/model ${NOT_AVAILABLE}`);
|
|
4084
|
+
},
|
|
4085
|
+
openTrajectory() {
|
|
4086
|
+
store.setNotice(`/trajectory ${NOT_AVAILABLE}`);
|
|
4087
|
+
},
|
|
4088
|
+
openToolCards() {
|
|
4089
|
+
store.setNotice(`/tools ${NOT_AVAILABLE}`);
|
|
4090
|
+
},
|
|
4091
|
+
openContext() {
|
|
4092
|
+
store.setNotice(`/context ${NOT_AVAILABLE}`);
|
|
4093
|
+
},
|
|
4094
|
+
openPlugins() {
|
|
4095
|
+
store.setNotice(`/plugins ${NOT_AVAILABLE}`);
|
|
4096
|
+
},
|
|
4097
|
+
openAgentPresets() {
|
|
4098
|
+
store.setNotice(`/presets ${NOT_AVAILABLE}`);
|
|
4099
|
+
},
|
|
4100
|
+
closeModelProfile() {},
|
|
4101
|
+
backToProviderList() {},
|
|
4102
|
+
selectProvider() {},
|
|
4103
|
+
createProvider() {},
|
|
4104
|
+
editProvider() {},
|
|
4105
|
+
saveProvider() {},
|
|
4106
|
+
deleteProvider() {},
|
|
4107
|
+
discoverModelsForDraft() {},
|
|
4108
|
+
setActiveModel() {},
|
|
4109
|
+
closeTrajectory() {},
|
|
4110
|
+
closeToolCards() {},
|
|
4111
|
+
closeContext() {},
|
|
4112
|
+
closePlugins() {},
|
|
4113
|
+
closeAgentPresets() {},
|
|
4114
|
+
selectAgentPresetRow() {},
|
|
4115
|
+
applyAgentPreset() {},
|
|
4116
|
+
answerApproval() {},
|
|
4117
|
+
answerQuestion() {}
|
|
4118
|
+
};
|
|
4119
|
+
const selection = host.ctx.get("agentDefaultModel")?.currentSelection();
|
|
4120
|
+
instance = mountTui({
|
|
4121
|
+
store,
|
|
4122
|
+
actions,
|
|
4123
|
+
sessionId,
|
|
4124
|
+
provider: selection?.provider ?? "",
|
|
4125
|
+
model: selection?.model ?? "",
|
|
4126
|
+
version: TUI_VERSION,
|
|
4127
|
+
cwd: process.cwd(),
|
|
4128
|
+
promptHistory: history,
|
|
4129
|
+
getTool: toolPreview(host.ctx),
|
|
4130
|
+
getToolCall: store.getToolCall
|
|
4131
|
+
});
|
|
4132
|
+
await exited;
|
|
4133
|
+
instance.unmount();
|
|
4134
|
+
await created.dispose();
|
|
4135
|
+
await host.dispose();
|
|
4136
|
+
const resumeHint = stripSessionIdPrefix(sessionId);
|
|
4137
|
+
return Object.freeze({
|
|
4138
|
+
sessionId,
|
|
4139
|
+
resumeHint,
|
|
4140
|
+
async dispose() {
|
|
4141
|
+
if (settled) return;
|
|
4142
|
+
settled = true;
|
|
4143
|
+
instance?.unmount();
|
|
4144
|
+
await bridge?.dispose();
|
|
4145
|
+
await host.dispose();
|
|
4146
|
+
}
|
|
4147
|
+
});
|
|
4148
|
+
} catch (error) {
|
|
4149
|
+
instance?.unmount();
|
|
4150
|
+
await bridge?.dispose().catch(() => {});
|
|
4151
|
+
await host.dispose();
|
|
4152
|
+
throw error;
|
|
4153
|
+
}
|
|
4154
|
+
}
|
|
4155
|
+
//#endregion
|
|
4156
|
+
//#region src/version.ts
|
|
4157
|
+
/** Canonical ACRYL version string used by the CLI and the release smoke checks. */
|
|
4158
|
+
const ACRYL_VERSION = "0.1.0-dev.0";
|
|
4159
|
+
//#endregion
|
|
4160
|
+
//#region src/cli/grammar.ts
|
|
4161
|
+
const HOST_COMMANDS = /* @__PURE__ */ new Set([
|
|
4162
|
+
"tui",
|
|
4163
|
+
"gui",
|
|
4164
|
+
"web"
|
|
4165
|
+
]);
|
|
4166
|
+
function hostCommand(value) {
|
|
4167
|
+
return HOST_COMMANDS.has(value) ? value : void 0;
|
|
4168
|
+
}
|
|
4169
|
+
function parseAcrylArgs(args) {
|
|
4170
|
+
let command;
|
|
4171
|
+
let profile;
|
|
4172
|
+
let resumeSessionId;
|
|
4173
|
+
let json = false;
|
|
4174
|
+
let version = false;
|
|
4175
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
4176
|
+
const argument = args[index];
|
|
4177
|
+
if (argument === void 0) continue;
|
|
4178
|
+
if (argument === "--version" || argument === "-v") {
|
|
4179
|
+
if (version) throw new Error("--version may be provided only once");
|
|
4180
|
+
version = true;
|
|
4181
|
+
continue;
|
|
4182
|
+
}
|
|
4183
|
+
if (argument === "--profile") {
|
|
4184
|
+
if (profile !== void 0) throw new Error("--profile may be provided only once");
|
|
4185
|
+
const value = args[index + 1];
|
|
4186
|
+
if (value === void 0 || value.startsWith("--") || value.trim() === "") throw new Error("--profile requires a value");
|
|
4187
|
+
profile = value;
|
|
4188
|
+
index += 1;
|
|
4189
|
+
continue;
|
|
4190
|
+
}
|
|
4191
|
+
if (argument === "--resume") {
|
|
4192
|
+
if (resumeSessionId !== void 0) throw new Error("--resume may be provided only once");
|
|
4193
|
+
const value = args[index + 1];
|
|
4194
|
+
if (value === void 0 || value.startsWith("--") || value.trim() === "") throw new Error("--resume requires a session id");
|
|
4195
|
+
resumeSessionId = value;
|
|
4196
|
+
index += 1;
|
|
4197
|
+
continue;
|
|
4198
|
+
}
|
|
4199
|
+
if (argument === "--json") {
|
|
4200
|
+
if (json) throw new Error("--json may be provided only once");
|
|
4201
|
+
json = true;
|
|
4202
|
+
continue;
|
|
4203
|
+
}
|
|
4204
|
+
if (argument.startsWith("-")) throw new Error(`unknown option: ${argument}`);
|
|
4205
|
+
const parsed = hostCommand(argument);
|
|
4206
|
+
if (command === void 0) {
|
|
4207
|
+
if (parsed === void 0) throw new Error(`unknown command: ${argument}`);
|
|
4208
|
+
command = parsed;
|
|
4209
|
+
continue;
|
|
4210
|
+
}
|
|
4211
|
+
throw new Error(`unexpected argument for ${command}: ${argument}`);
|
|
4212
|
+
}
|
|
4213
|
+
const resolvedCommand = command ?? "tui";
|
|
4214
|
+
if (!version && profile === void 0 && resumeSessionId === void 0) return {
|
|
4215
|
+
command: resolvedCommand,
|
|
4216
|
+
json,
|
|
4217
|
+
version
|
|
4218
|
+
};
|
|
4219
|
+
return {
|
|
4220
|
+
command: resolvedCommand,
|
|
4221
|
+
json,
|
|
4222
|
+
version,
|
|
4223
|
+
...profile === void 0 ? {} : { profile },
|
|
4224
|
+
...resumeSessionId === void 0 ? {} : { resumeSessionId }
|
|
4225
|
+
};
|
|
4226
|
+
}
|
|
4227
|
+
//#endregion
|
|
4228
|
+
//#region src/cli/run.ts
|
|
4229
|
+
const defaults = {
|
|
4230
|
+
startDirectHost,
|
|
4231
|
+
runTui: runAcrylTui,
|
|
4232
|
+
exit: (code) => {
|
|
4233
|
+
process.exitCode = code;
|
|
4234
|
+
},
|
|
4235
|
+
write: (line) => {
|
|
4236
|
+
process.stdout.write(`${line}\n`);
|
|
4237
|
+
}
|
|
4238
|
+
};
|
|
4239
|
+
function statusLine(host) {
|
|
4240
|
+
return JSON.stringify({
|
|
4241
|
+
mode: "direct",
|
|
4242
|
+
profile: host.profile,
|
|
4243
|
+
generationId: host.generationId
|
|
4244
|
+
});
|
|
4245
|
+
}
|
|
4246
|
+
/**
|
|
4247
|
+
* Run the direct ACRYL terminal host. `--json` is a short-lived, scriptable
|
|
4248
|
+
* headless readiness probe; interactive mode mounts the pi-tui session via the
|
|
4249
|
+
* runtime bridge until a normal exit, then prints a resumable session id.
|
|
4250
|
+
*/
|
|
4251
|
+
async function runAcryl(args, supplied = {}) {
|
|
4252
|
+
const dependencies = {
|
|
4253
|
+
...defaults,
|
|
4254
|
+
...supplied
|
|
4255
|
+
};
|
|
4256
|
+
const invocation = parseAcrylArgs(args);
|
|
4257
|
+
if (invocation.command !== "tui") throw new Error(`ACRYL ${invocation.command} host is not implemented; use "acryl tui"`);
|
|
4258
|
+
if (invocation.version) {
|
|
4259
|
+
dependencies.write(ACRYL_VERSION);
|
|
4260
|
+
return;
|
|
4261
|
+
}
|
|
4262
|
+
if (invocation.json) {
|
|
4263
|
+
const host = await dependencies.startDirectHost({ profile: invocation.profile ?? "acryl" });
|
|
4264
|
+
try {
|
|
4265
|
+
dependencies.write(statusLine(host));
|
|
4266
|
+
} finally {
|
|
4267
|
+
await host.dispose();
|
|
4268
|
+
}
|
|
4269
|
+
return;
|
|
4270
|
+
}
|
|
4271
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
4272
|
+
dependencies.write("acryl-tui: stdin and stdout must both be TTYs; use `acryl tui --json` for a headless probe");
|
|
4273
|
+
dependencies.exit(1);
|
|
4274
|
+
return;
|
|
4275
|
+
}
|
|
4276
|
+
const result = await dependencies.runTui({
|
|
4277
|
+
profile: invocation.profile ?? "acryl",
|
|
4278
|
+
resumeSessionId: invocation.resumeSessionId
|
|
4279
|
+
});
|
|
4280
|
+
dependencies.write(`resume with: acryl tui --resume ${result.resumeHint}`);
|
|
4281
|
+
}
|
|
4282
|
+
//#endregion
|
|
4283
|
+
//#region src/bin.ts
|
|
4284
|
+
function isEntrypoint() {
|
|
4285
|
+
const entrypoint = process.argv[1];
|
|
4286
|
+
return entrypoint !== void 0 && resolve(entrypoint) === fileURLToPath(import.meta.url);
|
|
4287
|
+
}
|
|
4288
|
+
if (isEntrypoint()) (async () => {
|
|
4289
|
+
const script = process.argv[1];
|
|
4290
|
+
if (script === void 0) throw new Error("ACRYL Node entrypoint is unavailable");
|
|
4291
|
+
if (await relaunchWithExposedInternals({
|
|
4292
|
+
execArgv: process.execArgv,
|
|
4293
|
+
script,
|
|
4294
|
+
args: process.argv.slice(2)
|
|
4295
|
+
})) return;
|
|
4296
|
+
await runAcryl(process.argv.slice(2));
|
|
4297
|
+
})().catch((cause) => {
|
|
4298
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
4299
|
+
process.stderr.write(`acryl: ${message}\n`);
|
|
4300
|
+
process.exitCode = 1;
|
|
4301
|
+
});
|
|
4302
|
+
//#endregion
|
|
4303
|
+
export { parseAcrylArgs, runAcryl };
|