@alisio/alisio-code 0.1.0-alpha.9 → 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/README.md +95 -16
- package/dist/analysis.d.ts +27 -0
- package/dist/analysis.js +145 -0
- package/dist/banner.js +2 -1
- package/dist/builtin.d.ts +3 -1
- package/dist/builtin.js +0 -24
- package/dist/main.js +133 -6
- package/dist/serve.d.ts +15 -0
- package/dist/serve.js +99 -0
- package/dist/tui/agent-manager.d.ts +105 -0
- package/dist/tui/agent-manager.js +269 -0
- package/dist/tui/agents.d.ts +5 -0
- package/dist/tui/agents.js +5 -0
- package/dist/tui/app.d.ts +42 -1
- package/dist/tui/app.js +1895 -97
- package/dist/tui/artifact-preview.d.ts +52 -0
- package/dist/tui/artifact-preview.js +212 -0
- package/dist/tui/artifacts.d.ts +54 -0
- package/dist/tui/artifacts.js +207 -0
- package/dist/tui/btw.d.ts +57 -0
- package/dist/tui/btw.js +76 -0
- package/dist/tui/components.d.ts +174 -12
- package/dist/tui/components.js +962 -66
- package/dist/tui/connect-input.d.ts +2 -0
- package/dist/tui/connect-input.js +1 -1
- package/dist/tui/exit.d.ts +10 -0
- package/dist/tui/exit.js +15 -0
- package/dist/tui/git-branch.d.ts +48 -0
- package/dist/tui/git-branch.js +99 -0
- package/dist/tui/goal.d.ts +75 -0
- package/dist/tui/goal.js +171 -0
- package/dist/tui/modes.d.ts +71 -0
- package/dist/tui/modes.js +90 -0
- package/dist/tui/open-path.d.ts +38 -0
- package/dist/tui/open-path.js +80 -0
- package/dist/tui/plan-review.d.ts +48 -0
- package/dist/tui/plan-review.js +76 -0
- package/dist/tui/queue.d.ts +2 -0
- package/dist/tui/queue.js +5 -1
- package/dist/tui/scroll.d.ts +8 -0
- package/dist/tui/scroll.js +12 -0
- package/dist/tui/settings-menu.d.ts +40 -0
- package/dist/tui/settings-menu.js +132 -0
- package/dist/tui/settings.d.ts +194 -0
- package/dist/tui/settings.js +482 -0
- package/dist/tui/state.d.ts +343 -7
- package/dist/tui/state.js +823 -54
- package/dist/tui/tasks.d.ts +123 -0
- package/dist/tui/tasks.js +369 -0
- package/dist/tui/theme.d.ts +42 -1
- package/dist/tui/theme.js +516 -1
- package/dist/tui/viewer-state.d.ts +7 -0
- package/dist/tui/viewer-state.js +36 -0
- package/dist/version.d.ts +7 -5
- package/dist/version.js +44 -8
- package/package.json +7 -9
package/dist/tui/state.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure presentation logic for the TUI: formatting, command parsing and the reduction of
|
|
3
|
+
* versioned runner events into a view model. No terminal or pi-tui imports here.
|
|
4
|
+
*/
|
|
5
|
+
import { CommandCatalog, describeOutputLimitSource } from "@alisio/core";
|
|
1
6
|
const trimZero = (value) => value.replace(/\.0$/, "");
|
|
2
7
|
export function formatTokens(n) {
|
|
3
8
|
if (!Number.isFinite(n) || n < 0)
|
|
@@ -24,6 +29,55 @@ export function providerModelItems(provider, models, current) {
|
|
|
24
29
|
}
|
|
25
30
|
/** Text markers remain meaningful without color: [x] active, [ ] inactive, [!] failed, [*] pending. */
|
|
26
31
|
/** Group headings are derived from the primary category (or "General") of each plugin. */
|
|
32
|
+
/**
|
|
33
|
+
* Reserved value prefix marking a non-selectable group-header row in picker item lists. It can
|
|
34
|
+
* never collide with real entry ids (plugin ids, server names), which never start with "_".
|
|
35
|
+
*/
|
|
36
|
+
export const GROUP_HEADER_PREFIX = "__group:";
|
|
37
|
+
/** True when the item is a group header emitted under GROUP_HEADER_PREFIX (never selectable). */
|
|
38
|
+
export const isGroupHeader = (item) => item.value.startsWith(GROUP_HEADER_PREFIX);
|
|
39
|
+
/**
|
|
40
|
+
* Nearest non-header index from `index` stepping `dir` (1 = down, -1 = up), wrapping at the
|
|
41
|
+
* edges exactly like a plain list would, so navigation skips header rows in both directions and
|
|
42
|
+
* never stops on one. Returns -1 for an empty list and `index` unchanged when every item is a
|
|
43
|
+
* header (callers then treat it as "no move").
|
|
44
|
+
*/
|
|
45
|
+
export function skipGroupHeaders(items, index, dir) {
|
|
46
|
+
const count = items.length;
|
|
47
|
+
if (!count)
|
|
48
|
+
return -1;
|
|
49
|
+
let next = (index + dir + count) % count;
|
|
50
|
+
for (let steps = 0; steps < count && next !== index; steps++) {
|
|
51
|
+
if (!isGroupHeader(items[next]))
|
|
52
|
+
return next;
|
|
53
|
+
next = (next + dir + count) % count;
|
|
54
|
+
}
|
|
55
|
+
return index;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The display sequence a picker should render for a filter: rows matching SelectList's own
|
|
59
|
+
* prefix semantics, plus the group header of every group that still has at least one visible
|
|
60
|
+
* row, in original order. Groups with no match emit no header.
|
|
61
|
+
*/
|
|
62
|
+
export function visibleGroupedItems(items, filter) {
|
|
63
|
+
const query = filter.toLowerCase();
|
|
64
|
+
const visible = [];
|
|
65
|
+
let header;
|
|
66
|
+
for (const item of items) {
|
|
67
|
+
if (isGroupHeader(item)) {
|
|
68
|
+
header = item;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (item.value.toLowerCase().startsWith(query)) {
|
|
72
|
+
if (header) {
|
|
73
|
+
visible.push(header);
|
|
74
|
+
header = undefined;
|
|
75
|
+
}
|
|
76
|
+
visible.push(item);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return visible;
|
|
80
|
+
}
|
|
27
81
|
export function pluginCatalogItems(entries) {
|
|
28
82
|
const marker = (entry) => entry.status === "active"
|
|
29
83
|
? "[x]"
|
|
@@ -37,11 +91,15 @@ export function pluginCatalogItems(entries) {
|
|
|
37
91
|
const primary = entry.categories[0] ?? "General";
|
|
38
92
|
grouped.set(primary, [...(grouped.get(primary) ?? []), entry]);
|
|
39
93
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
94
|
+
// Every group opens with its own non-selectable header row; plugin rows are indented under it.
|
|
95
|
+
return [...grouped].flatMap(([title, group]) => [
|
|
96
|
+
{ value: `${GROUP_HEADER_PREFIX}${title}`, label: `${title} ›` },
|
|
97
|
+
...group.map((entry) => ({
|
|
98
|
+
value: entry.id,
|
|
99
|
+
label: ` ${marker(entry)} ${entry.name} · ${entry.builtin ? "built-in" : entry.source}`,
|
|
100
|
+
description: `${entry.status}${entry.categories.length ? ` · ${entry.categories.join(", ")}` : ""} · ${entry.description}`,
|
|
101
|
+
})),
|
|
102
|
+
]);
|
|
45
103
|
}
|
|
46
104
|
export const pluginToggleNeedsConfirmation = (entry) => !entry.builtin;
|
|
47
105
|
const mcpSourceTitle = (kind) => ({
|
|
@@ -198,46 +256,27 @@ export function fitSegments(segments, width, separator) {
|
|
|
198
256
|
kept[0] = { ...only, text: truncatePlain(only.text, width) };
|
|
199
257
|
return kept;
|
|
200
258
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
{
|
|
216
|
-
name: "plugins",
|
|
217
|
-
description: "Browse and manage project plugins",
|
|
218
|
-
aliases: ["plugin"],
|
|
219
|
-
},
|
|
220
|
-
{
|
|
221
|
-
name: "skills",
|
|
222
|
-
description: "Browse and manage effective skills",
|
|
223
|
-
aliases: ["skill"],
|
|
224
|
-
},
|
|
225
|
-
{ name: "mcp", description: "Browse and manage MCP servers" },
|
|
226
|
-
{ name: "copy", description: "Copy the last assistant response to the clipboard" },
|
|
227
|
-
{
|
|
228
|
-
name: "ask",
|
|
229
|
-
description: "Ask the agent to turn your question into a multiple-choice ask_user_question",
|
|
230
|
-
argumentHint: "<question>",
|
|
231
|
-
},
|
|
232
|
-
{ name: "exit", description: "Exit Alisio", aliases: ["quit"] },
|
|
233
|
-
];
|
|
259
|
+
/** Built-ins only: plugin, template and skill commands are resolved against the live app. */
|
|
260
|
+
const BUILTIN_CATALOG = new CommandCatalog();
|
|
261
|
+
/**
|
|
262
|
+
* Built-in slash commands the TUI offers, derived from the core `CommandCatalog` (the TUI
|
|
263
|
+
* surface of `BUILTIN_COMMANDS`). Kept as `COMMANDS` for compatibility.
|
|
264
|
+
*/
|
|
265
|
+
export const COMMANDS = BUILTIN_CATALOG.list("tui")
|
|
266
|
+
.filter((c) => c.source === "builtin")
|
|
267
|
+
.map((c) => ({
|
|
268
|
+
name: c.name,
|
|
269
|
+
description: c.description,
|
|
270
|
+
...(c.argumentHint ? { argumentHint: c.argumentHint } : {}),
|
|
271
|
+
...(c.aliases ? { aliases: c.aliases } : {}),
|
|
272
|
+
}));
|
|
234
273
|
/** Every TUI slash name (commands, aliases and routing prefixes); templates cannot take them. */
|
|
235
274
|
export function reservedCommandNames() {
|
|
236
275
|
return [...COMMANDS.flatMap((c) => [c.name, ...(c.aliases ?? [])]), "command"];
|
|
237
276
|
}
|
|
238
277
|
export function resolveCommand(name) {
|
|
239
|
-
const
|
|
240
|
-
return
|
|
278
|
+
const found = BUILTIN_CATALOG.resolve(name);
|
|
279
|
+
return found?.source === "builtin" && found.surfaces.includes("tui") ? found.name : undefined;
|
|
241
280
|
}
|
|
242
281
|
export function parseCommand(input) {
|
|
243
282
|
const match = /^\/([A-Za-z][\w:.-]*)(?:\s+([\s\S]*))?$/.exec(input.trim());
|
|
@@ -245,6 +284,151 @@ export function parseCommand(input) {
|
|
|
245
284
|
return undefined;
|
|
246
285
|
return { name: match[1], args: (match[2] ?? "").trim() };
|
|
247
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* The effective reasoning effort to send for a model: the persisted level when the model
|
|
289
|
+
* supports it, otherwise the model's default level (silent fallback), and nothing when the
|
|
290
|
+
* model advertises no effort levels at all.
|
|
291
|
+
*/
|
|
292
|
+
export function effectiveEffort(persisted, capability) {
|
|
293
|
+
if (!capability?.supportedLevels?.length)
|
|
294
|
+
return undefined;
|
|
295
|
+
if (persisted && capability.supportedLevels.includes(persisted))
|
|
296
|
+
return persisted;
|
|
297
|
+
return capability.defaultLevel;
|
|
298
|
+
}
|
|
299
|
+
/** Validates a `/effort <level>` argument against the active model; returns an error or nothing. */
|
|
300
|
+
export function validateEffortLevel(level, capability) {
|
|
301
|
+
if (!capability?.supportedLevels?.length)
|
|
302
|
+
return "This model does not advertise reasoning effort levels, so no effort is sent to it.";
|
|
303
|
+
if (!capability.supportedLevels.includes(level))
|
|
304
|
+
return `Unsupported effort level "${level}". Supported levels: ${capability.supportedLevels.join(", ")}.`;
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
export function effortPickerItems(capability, active) {
|
|
308
|
+
const items = capability.supportedLevels.map((level) => ({
|
|
309
|
+
value: level,
|
|
310
|
+
label: [
|
|
311
|
+
level,
|
|
312
|
+
level === active ? "(current)" : undefined,
|
|
313
|
+
level === capability.defaultLevel ? "(default)" : undefined,
|
|
314
|
+
]
|
|
315
|
+
.filter(Boolean)
|
|
316
|
+
.join(" · "),
|
|
317
|
+
...(level === capability.defaultLevel ? { description: "The model's own default" } : {}),
|
|
318
|
+
}));
|
|
319
|
+
return [
|
|
320
|
+
...items,
|
|
321
|
+
{
|
|
322
|
+
value: "!clear",
|
|
323
|
+
label: "Auto · provider default",
|
|
324
|
+
description: "Clear the saved effort; the provider uses its own default",
|
|
325
|
+
},
|
|
326
|
+
];
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* The ordered identity parts for the status line below the editor:
|
|
330
|
+
* `agent: <name> · <model> · <provider> · <effort>`, with the effort part omitted when the
|
|
331
|
+
* active model advertises no supported levels (`effort === undefined`). Colors are applied by
|
|
332
|
+
* the renderer per role; this module stays terminal-free and testable.
|
|
333
|
+
*/
|
|
334
|
+
export function identityParts(input) {
|
|
335
|
+
const parts = [
|
|
336
|
+
{ text: `agent: ${input.agent}`, priority: 10, role: "agent" },
|
|
337
|
+
// Between the agent and the model: it drops after the effort and the provider, before the model.
|
|
338
|
+
...(input.mode ? [{ text: `mode: ${input.mode}`, priority: 8.5, role: "mode" }] : []),
|
|
339
|
+
{ text: input.model || "not connected", priority: 9, role: "model" },
|
|
340
|
+
];
|
|
341
|
+
if (input.provider)
|
|
342
|
+
parts.push({ text: input.provider, priority: 8, role: "provider" });
|
|
343
|
+
if (input.effort)
|
|
344
|
+
parts.push({ text: input.effort, priority: 7, role: "effort" });
|
|
345
|
+
return parts;
|
|
346
|
+
}
|
|
347
|
+
/** Drops the lowest-priority identity parts until the line fits `width`; truncates the survivor. */
|
|
348
|
+
export function fitIdentityParts(input, width) {
|
|
349
|
+
return fitSegments(identityParts(input), width, " · ");
|
|
350
|
+
}
|
|
351
|
+
export const SKILL_COMPLETION_DESCRIPTION_LIMIT = 80;
|
|
352
|
+
/** Status hint prefixes (no color): shadowed < disabled < locked states shown by /skills. */
|
|
353
|
+
const skillStatus = (skill) => !skill.effective
|
|
354
|
+
? "shadowed"
|
|
355
|
+
: !skill.enabled
|
|
356
|
+
? "disabled"
|
|
357
|
+
: skill.locked
|
|
358
|
+
? "locked by plugin"
|
|
359
|
+
: undefined;
|
|
360
|
+
/**
|
|
361
|
+
* OpenCode-style scope marker for a skill's slash entry, mirroring the catalog scopes the /skills
|
|
362
|
+
* manager shows: user, project, config and plugin (plugin skills are locked by their owner plugin).
|
|
363
|
+
*/
|
|
364
|
+
export const skillScopeMarker = (skill) => {
|
|
365
|
+
const marker = { user: "[u]", project: "[p]", config: "[c]", plugin: "[l]" }[skill.scope];
|
|
366
|
+
return marker ?? (skill.scope ? `[${skill.scope}]` : undefined);
|
|
367
|
+
};
|
|
368
|
+
/**
|
|
369
|
+
* Description for a skill's first-class `skill:<id>` slash entry: scope marker, status hint, then
|
|
370
|
+
* the skill description truncated to the shared limit. The marker and status are prepended before
|
|
371
|
+
* truncating, so they survive the cap just like the /skills argument completions.
|
|
372
|
+
*/
|
|
373
|
+
export function skillSlashDescription(skill) {
|
|
374
|
+
const marker = skillScopeMarker(skill);
|
|
375
|
+
const status = skillStatus(skill);
|
|
376
|
+
if (!marker && !status)
|
|
377
|
+
return truncatePlain(skill.description, SKILL_COMPLETION_DESCRIPTION_LIMIT);
|
|
378
|
+
return truncatePlain(`${[marker, status].filter(Boolean).join(" ")}${status ? " · " : " "}${skill.description}`, SKILL_COMPLETION_DESCRIPTION_LIMIT);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Suggested completions for `/skills <prefix>`: every entry the /skills manager shows, filtered by
|
|
382
|
+
* prefix (case-insensitive) on name or description. Selecting a suggestion only fills the argument;
|
|
383
|
+
* submitting still opens the skills manager.
|
|
384
|
+
*/
|
|
385
|
+
export function skillCompletions(catalog, prefix, limit = 20) {
|
|
386
|
+
const query = prefix.trim().toLowerCase();
|
|
387
|
+
const items = query
|
|
388
|
+
? catalog.filter((skill) => [skill.displayId, skill.name, skill.description].join(" ").toLowerCase().includes(query))
|
|
389
|
+
: catalog;
|
|
390
|
+
return items.slice(0, limit).map((skill) => {
|
|
391
|
+
const status = skillStatus(skill);
|
|
392
|
+
return {
|
|
393
|
+
value: skill.id,
|
|
394
|
+
label: skill.displayId,
|
|
395
|
+
description: truncatePlain(status ? `${status} · ${skill.description}` : skill.description, SKILL_COMPLETION_DESCRIPTION_LIMIT),
|
|
396
|
+
};
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
function argumentCompletionsFor(source, context) {
|
|
400
|
+
const sessions = context.sessions;
|
|
401
|
+
if (source.name === "resume" && sessions)
|
|
402
|
+
return {
|
|
403
|
+
getArgumentCompletions: (prefix) => sessions(prefix).slice(0, 20),
|
|
404
|
+
};
|
|
405
|
+
if (source.name === "skills")
|
|
406
|
+
return {
|
|
407
|
+
getArgumentCompletions: (prefix) => skillCompletions(context.skills, prefix, 20),
|
|
408
|
+
};
|
|
409
|
+
return {};
|
|
410
|
+
}
|
|
411
|
+
export function slashCompletionCommands(sources, context, options = {}) {
|
|
412
|
+
const entries = sources.flatMap((source) => {
|
|
413
|
+
const entry = (name) => ({
|
|
414
|
+
name,
|
|
415
|
+
description: source.description,
|
|
416
|
+
...(source.argumentHint ? { argumentHint: source.argumentHint } : {}),
|
|
417
|
+
...argumentCompletionsFor(source, context),
|
|
418
|
+
});
|
|
419
|
+
return [
|
|
420
|
+
entry(source.name),
|
|
421
|
+
...(source.aliases ?? []).filter((a) => a !== source.name).map(entry),
|
|
422
|
+
];
|
|
423
|
+
});
|
|
424
|
+
if (options.skillEntries !== false)
|
|
425
|
+
for (const skill of context.skills)
|
|
426
|
+
entries.push({
|
|
427
|
+
name: `skill:${skill.id}`,
|
|
428
|
+
description: skillSlashDescription(skill),
|
|
429
|
+
});
|
|
430
|
+
return entries;
|
|
431
|
+
}
|
|
248
432
|
function parseArgs(args) {
|
|
249
433
|
try {
|
|
250
434
|
const value = JSON.parse(args);
|
|
@@ -350,19 +534,40 @@ export function addItem(state, item) {
|
|
|
350
534
|
const withModel = (stats, model) => typeof model === "string" && model && !stats.models.includes(model)
|
|
351
535
|
? { ...stats, models: [...stats.models, model] }
|
|
352
536
|
: stats;
|
|
353
|
-
|
|
537
|
+
/**
|
|
538
|
+
* Streaming assistant items stamp the thinking interval: the first reasoning delta records
|
|
539
|
+
* `reasoningStartedAt` and every later one refreshes `reasoningDoneAt`, so a completed thought
|
|
540
|
+
* section can show a `Thought · 2.9s` duration. The stamp stops once text arrives (the model
|
|
541
|
+
* moved on to the answer), so the duration measures the visible reasoning span, never the turn.
|
|
542
|
+
*/
|
|
543
|
+
const withReasoningStamp = (item, field, at) => {
|
|
544
|
+
if (field !== "reasoning" || item.text)
|
|
545
|
+
return item;
|
|
546
|
+
return { ...item, reasoningStartedAt: item.reasoningStartedAt ?? at, reasoningDoneAt: at };
|
|
547
|
+
};
|
|
548
|
+
function appendAssistant(state, field, delta, at = Date.now()) {
|
|
354
549
|
const last = state.items.at(-1);
|
|
355
550
|
if (last?.kind === "assistant" && !last.done)
|
|
356
551
|
return {
|
|
357
552
|
...state,
|
|
358
|
-
items: [
|
|
553
|
+
items: [
|
|
554
|
+
...state.items.slice(0, -1),
|
|
555
|
+
withReasoningStamp({ ...last, [field]: last[field] + delta }, field, at),
|
|
556
|
+
],
|
|
359
557
|
};
|
|
360
|
-
return addItem(state, {
|
|
558
|
+
return addItem(state, withReasoningStamp({
|
|
361
559
|
kind: "assistant",
|
|
362
560
|
text: field === "text" ? delta : "",
|
|
363
561
|
reasoning: field === "reasoning" ? delta : "",
|
|
364
562
|
done: false,
|
|
365
|
-
});
|
|
563
|
+
}, field, at));
|
|
564
|
+
}
|
|
565
|
+
/** Approximated thinking interval of a completed reasoning section, when the events carried timestamps. */
|
|
566
|
+
export function reasoningDurationMs(item) {
|
|
567
|
+
const { reasoningStartedAt, reasoningDoneAt } = item;
|
|
568
|
+
if (reasoningStartedAt === undefined || reasoningDoneAt === undefined)
|
|
569
|
+
return undefined;
|
|
570
|
+
return Math.max(0, reasoningDoneAt - reasoningStartedAt);
|
|
366
571
|
}
|
|
367
572
|
function updateTool(state, id, update) {
|
|
368
573
|
const index = state.items.findLastIndex((i) => i.kind === "tool" && i.id === id);
|
|
@@ -374,6 +579,98 @@ function updateTool(state, id, update) {
|
|
|
374
579
|
return { ...state, items };
|
|
375
580
|
}
|
|
376
581
|
export function reduceEvent(state, event) {
|
|
582
|
+
const next = reduceEventItems(state, event);
|
|
583
|
+
const terminal = event.type === "run_completed" ||
|
|
584
|
+
event.type === "run_failed" ||
|
|
585
|
+
event.type === "run_cancelled" ||
|
|
586
|
+
event.type === "run_turns_exceeded";
|
|
587
|
+
if (terminal) {
|
|
588
|
+
const { lastEventAt: _ended, retry: _retry, recovery: _recovery, ...rest } = next;
|
|
589
|
+
return rest;
|
|
590
|
+
}
|
|
591
|
+
return { ...next, lastEventAt: Date.parse(event.timestamp) || Date.now() };
|
|
592
|
+
}
|
|
593
|
+
/** No event for this long: the footer says how long the run has been quiet. */
|
|
594
|
+
export const QUIET_MS = 15_000;
|
|
595
|
+
/** No event for this long: the footer marks the run as possibly stuck. */
|
|
596
|
+
export const STALLED_MS = 60_000;
|
|
597
|
+
/** A short, safe label from tool arguments: a title or a file name, never commands or URLs. */
|
|
598
|
+
function shortArg(args) {
|
|
599
|
+
const input = parseArgs(args);
|
|
600
|
+
if (!input)
|
|
601
|
+
return "";
|
|
602
|
+
const first = Array.isArray(input.inputs) ? input.inputs[0] : {};
|
|
603
|
+
const value = [input.title, input.fileName, input.path, input.file_path, first?.path].find((v) => typeof v === "string" && v.trim() !== "");
|
|
604
|
+
if (!value)
|
|
605
|
+
return "";
|
|
606
|
+
return truncatePlain(oneLine(value.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? ""), 40);
|
|
607
|
+
}
|
|
608
|
+
/** What the live run is doing, from the transcript items and flags the events already update. */
|
|
609
|
+
export function runPhase(view) {
|
|
610
|
+
if (view.compacting)
|
|
611
|
+
return { label: "compacting context", silentByDesign: false };
|
|
612
|
+
if (view.retry)
|
|
613
|
+
return {
|
|
614
|
+
label: `the model did not respond; retrying (${view.retry.attempt}/${view.retry.of})`,
|
|
615
|
+
silentByDesign: false,
|
|
616
|
+
};
|
|
617
|
+
if (view.recovery)
|
|
618
|
+
return {
|
|
619
|
+
label: `the response was cut off; retrying in smaller steps (${view.recovery.attempt}/${view.recovery.of})`,
|
|
620
|
+
silentByDesign: false,
|
|
621
|
+
};
|
|
622
|
+
const tool = view.items.findLast((i) => i.kind === "tool" && i.status !== "ok" && i.status !== "error");
|
|
623
|
+
if (tool?.kind === "tool") {
|
|
624
|
+
const name = tool.name.replace(/^p_[0-9a-f]{10}_/, "");
|
|
625
|
+
const arg = shortArg(tool.args);
|
|
626
|
+
const detail = arg ? ` (${arg})` : "";
|
|
627
|
+
if (tool.status === "approval")
|
|
628
|
+
return {
|
|
629
|
+
label: `waiting for your approval: ${humanizeToolName(name)}`,
|
|
630
|
+
silentByDesign: true,
|
|
631
|
+
};
|
|
632
|
+
if (name === "ask_user_question")
|
|
633
|
+
return { label: "waiting for your answer", silentByDesign: true };
|
|
634
|
+
if (name === "exit_plan")
|
|
635
|
+
return { label: "waiting for your plan decision", silentByDesign: true };
|
|
636
|
+
if (name === "task" || name === "task_wait")
|
|
637
|
+
return { label: "a sub-agent is working", silentByDesign: true };
|
|
638
|
+
if (name === "python_run")
|
|
639
|
+
return { label: `running Python${detail}`, silentByDesign: false };
|
|
640
|
+
if (name === "data_inspect" || name === "data_query")
|
|
641
|
+
return { label: `reading your data${detail}`, silentByDesign: false };
|
|
642
|
+
if (name === "artifact_create")
|
|
643
|
+
return { label: `publishing the artifact${detail}`, silentByDesign: false };
|
|
644
|
+
return { label: `running ${humanizeToolName(name)}${detail}`, silentByDesign: false };
|
|
645
|
+
}
|
|
646
|
+
const last = view.items.at(-1);
|
|
647
|
+
if (last?.kind === "assistant" && !last.done)
|
|
648
|
+
return {
|
|
649
|
+
label: last.text ? "writing the answer" : "thinking",
|
|
650
|
+
silentByDesign: false,
|
|
651
|
+
};
|
|
652
|
+
return { label: "waiting for the model", silentByDesign: false };
|
|
653
|
+
}
|
|
654
|
+
/** Milliseconds of silence worth reporting (0 when the run is busy, or silent by design). */
|
|
655
|
+
export function quietFor(view, now) {
|
|
656
|
+
if (view.lastEventAt === undefined || runPhase(view).silentByDesign)
|
|
657
|
+
return 0;
|
|
658
|
+
const idle = Math.max(0, now - view.lastEventAt);
|
|
659
|
+
return idle >= QUIET_MS ? idle : 0;
|
|
660
|
+
}
|
|
661
|
+
/** What the retried request produced (or the end of the turn) ends the retry label. */
|
|
662
|
+
const RETRY_ENDERS = new Set([
|
|
663
|
+
"text_delta",
|
|
664
|
+
"reasoning_delta",
|
|
665
|
+
"turn_completed",
|
|
666
|
+
"tool_started",
|
|
667
|
+
"compaction_started",
|
|
668
|
+
]);
|
|
669
|
+
function reduceEventItems(state, event) {
|
|
670
|
+
if ((state.retry || state.recovery) && RETRY_ENDERS.has(event.type)) {
|
|
671
|
+
const { retry: _retry, recovery: _recovery, ...rest } = state;
|
|
672
|
+
return reduceEventItems(rest, event);
|
|
673
|
+
}
|
|
377
674
|
const d = (event.data ?? {});
|
|
378
675
|
const at = Date.parse(event.timestamp);
|
|
379
676
|
switch (event.type) {
|
|
@@ -385,9 +682,9 @@ export function reduceEvent(state, event) {
|
|
|
385
682
|
stats: withModel({ ...state.stats, runs: state.stats.runs + 1 }, d.model),
|
|
386
683
|
};
|
|
387
684
|
case "text_delta":
|
|
388
|
-
return appendAssistant(state, "text", String(d.delta ?? ""));
|
|
685
|
+
return appendAssistant(state, "text", String(d.delta ?? ""), at);
|
|
389
686
|
case "reasoning_delta":
|
|
390
|
-
return appendAssistant(state, "reasoning", String(d.delta ?? ""));
|
|
687
|
+
return appendAssistant(state, "reasoning", String(d.delta ?? ""), at);
|
|
391
688
|
case "turn_completed": {
|
|
392
689
|
const usage = d.usage;
|
|
393
690
|
const last = state.items.at(-1);
|
|
@@ -409,15 +706,18 @@ export function reduceEvent(state, event) {
|
|
|
409
706
|
},
|
|
410
707
|
};
|
|
411
708
|
}
|
|
412
|
-
case "tool_started":
|
|
709
|
+
case "tool_started": {
|
|
710
|
+
const name = String(d.name ?? "tool");
|
|
413
711
|
return addItem(state, {
|
|
414
712
|
kind: "tool",
|
|
415
713
|
id: String(d.id ?? ""),
|
|
416
|
-
name
|
|
714
|
+
name,
|
|
417
715
|
args: typeof d.arguments === "string" ? d.arguments : "",
|
|
418
716
|
summary: summarizeToolArgs(String(d.name ?? ""), typeof d.arguments === "string" ? d.arguments : ""),
|
|
419
717
|
status: "running",
|
|
718
|
+
toolKind: toolKindOf(name, typeof d.effect === "string" ? d.effect : undefined),
|
|
420
719
|
});
|
|
720
|
+
}
|
|
421
721
|
case "approval_requested":
|
|
422
722
|
return updateTool(state, d.id, { status: "approval" });
|
|
423
723
|
case "approval_resolved":
|
|
@@ -429,6 +729,9 @@ export function reduceEvent(state, event) {
|
|
|
429
729
|
status: d.isError ? "error" : "ok",
|
|
430
730
|
...(typeof d.durationMs === "number" ? { durationMs: d.durationMs } : {}),
|
|
431
731
|
...(typeof d.preview === "string" ? { preview: d.preview } : {}),
|
|
732
|
+
...exitCodePart(d.preview),
|
|
733
|
+
...(isUiBlock(d.ui) ? { ui: d.ui } : {}),
|
|
734
|
+
...(isImagePart(d.image) ? { image: d.image } : {}),
|
|
432
735
|
});
|
|
433
736
|
return {
|
|
434
737
|
...next,
|
|
@@ -443,7 +746,8 @@ export function reduceEvent(state, event) {
|
|
|
443
746
|
}
|
|
444
747
|
case "run_completed":
|
|
445
748
|
case "run_failed":
|
|
446
|
-
case "run_cancelled":
|
|
749
|
+
case "run_cancelled":
|
|
750
|
+
case "run_turns_exceeded": {
|
|
447
751
|
const ended = {
|
|
448
752
|
...state,
|
|
449
753
|
streaming: false,
|
|
@@ -455,6 +759,11 @@ export function reduceEvent(state, event) {
|
|
|
455
759
|
};
|
|
456
760
|
if (event.type === "run_failed")
|
|
457
761
|
return addItem(ended, { kind: "error", text: String(d.error ?? "Run failed") });
|
|
762
|
+
if (event.type === "run_turns_exceeded")
|
|
763
|
+
return addItem(ended, {
|
|
764
|
+
kind: "notice",
|
|
765
|
+
text: "Turn limit reached — the answer may be incomplete. Continue with another prompt or raise limits.maxTurns (/settings → Max turns).",
|
|
766
|
+
});
|
|
458
767
|
if (event.type === "run_cancelled") {
|
|
459
768
|
const reason = String(d.error ?? "cancelled");
|
|
460
769
|
return addItem(ended, {
|
|
@@ -464,6 +773,10 @@ export function reduceEvent(state, event) {
|
|
|
464
773
|
}
|
|
465
774
|
return ended;
|
|
466
775
|
}
|
|
776
|
+
case "truncation_recovery":
|
|
777
|
+
return { ...state, recovery: { attempt: Number(d.attempt) || 1, of: Number(d.of) || 1 } };
|
|
778
|
+
case "request_retry":
|
|
779
|
+
return { ...state, retry: { attempt: Number(d.attempt) || 1, of: Number(d.of) || 1 } };
|
|
467
780
|
case "compaction_started":
|
|
468
781
|
return { ...state, compacting: true };
|
|
469
782
|
case "compaction_completed": {
|
|
@@ -505,7 +818,9 @@ export function reduceEvent(state, event) {
|
|
|
505
818
|
case "response_truncated":
|
|
506
819
|
return addItem(state, {
|
|
507
820
|
kind: "notice",
|
|
508
|
-
text:
|
|
821
|
+
text: `Response cut by max output tokens${typeof d.maxOutputTokens === "number"
|
|
822
|
+
? ` (${d.maxOutputTokens} tokens, ${describeOutputLimitSource(d.source)})`
|
|
823
|
+
: ""} — the answer may be incomplete. Raise limits.maxOutputTokens (/settings → Agent max output tokens) to allow longer answers.`,
|
|
509
824
|
});
|
|
510
825
|
case "model_changed":
|
|
511
826
|
return {
|
|
@@ -532,6 +847,455 @@ export function lastAssistantText(items) {
|
|
|
532
847
|
}
|
|
533
848
|
return undefined;
|
|
534
849
|
}
|
|
850
|
+
/**
|
|
851
|
+
* True when a printable key should copy the last assistant response instead of typing: bound to
|
|
852
|
+
* `c`/`y` (and uppercase) on an EMPTY editor line, while no autocomplete is showing and no turn
|
|
853
|
+
* is running — the same "empty input" convention Enter and Ctrl+D already use. A non-empty input
|
|
854
|
+
* never triggers, so typing `c` or `y` mid-message is unaffected.
|
|
855
|
+
*/
|
|
856
|
+
export function editorCopyKey(data, input) {
|
|
857
|
+
return ((data === "c" || data === "C" || data === "y" || data === "Y") &&
|
|
858
|
+
input.text === "" &&
|
|
859
|
+
!input.autocomplete &&
|
|
860
|
+
!input.busy);
|
|
861
|
+
}
|
|
862
|
+
/** True when an unknown event value is a `Node` of a tree UI block. */
|
|
863
|
+
export function isTreeNode(value) {
|
|
864
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
865
|
+
return false;
|
|
866
|
+
const record = value;
|
|
867
|
+
if (typeof record.label !== "string")
|
|
868
|
+
return false;
|
|
869
|
+
if (record.meta !== undefined && typeof record.meta !== "string")
|
|
870
|
+
return false;
|
|
871
|
+
if (record.children !== undefined && !Array.isArray(record.children))
|
|
872
|
+
return false;
|
|
873
|
+
return record.children === undefined || record.children.every(isTreeNode);
|
|
874
|
+
}
|
|
875
|
+
/** True when an unknown event value is an Alisio `UiBlock` (validated defensively). */
|
|
876
|
+
export function isUiBlock(value) {
|
|
877
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
878
|
+
return false;
|
|
879
|
+
const block = value;
|
|
880
|
+
switch (block.kind) {
|
|
881
|
+
case "artifact": {
|
|
882
|
+
const a = block.artifact;
|
|
883
|
+
return (!!a &&
|
|
884
|
+
typeof a === "object" &&
|
|
885
|
+
typeof a.id === "string" &&
|
|
886
|
+
typeof a.fileName === "string" &&
|
|
887
|
+
typeof a.kind === "string" &&
|
|
888
|
+
typeof a.bytes === "number" &&
|
|
889
|
+
typeof a.status === "string");
|
|
890
|
+
}
|
|
891
|
+
case "table":
|
|
892
|
+
return (Array.isArray(block.columns) &&
|
|
893
|
+
block.columns.every((c) => typeof c === "string") &&
|
|
894
|
+
Array.isArray(block.rows) &&
|
|
895
|
+
block.rows.every((r) => Array.isArray(r) && r.every((c) => typeof c === "string")) &&
|
|
896
|
+
(block.caption === undefined || typeof block.caption === "string"));
|
|
897
|
+
case "key-value":
|
|
898
|
+
return (Array.isArray(block.entries) &&
|
|
899
|
+
block.entries.every((e) => Array.isArray(e) &&
|
|
900
|
+
e.length === 2 &&
|
|
901
|
+
typeof e[0] === "string" &&
|
|
902
|
+
typeof e[1] === "string") &&
|
|
903
|
+
(block.caption === undefined || typeof block.caption === "string"));
|
|
904
|
+
case "tree":
|
|
905
|
+
return Array.isArray(block.nodes) && block.nodes.every(isTreeNode);
|
|
906
|
+
case "code":
|
|
907
|
+
return (typeof block.code === "string" &&
|
|
908
|
+
(block.lang === undefined || typeof block.lang === "string") &&
|
|
909
|
+
(block.caption === undefined || typeof block.caption === "string"));
|
|
910
|
+
case "markdown":
|
|
911
|
+
return typeof block.text === "string";
|
|
912
|
+
case "diff":
|
|
913
|
+
return optionalStrings(block, ["path", "patch", "before", "after", "lang", "caption"]);
|
|
914
|
+
case "terminal":
|
|
915
|
+
return (typeof block.output === "string" &&
|
|
916
|
+
optionalStrings(block, ["command", "cwd"]) &&
|
|
917
|
+
optionalNumbers(block, ["exitCode", "durationMs"]) &&
|
|
918
|
+
(block.truncated === undefined || typeof block.truncated === "boolean"));
|
|
919
|
+
case "mermaid":
|
|
920
|
+
return typeof block.source === "string" && optionalStrings(block, ["title"]);
|
|
921
|
+
case "math":
|
|
922
|
+
return (typeof block.latex === "string" &&
|
|
923
|
+
(block.display === undefined || typeof block.display === "boolean"));
|
|
924
|
+
case "json":
|
|
925
|
+
return ("value" in block &&
|
|
926
|
+
optionalNumbers(block, ["collapsedDepth"]) &&
|
|
927
|
+
optionalStrings(block, ["caption"]));
|
|
928
|
+
case "test-results":
|
|
929
|
+
return (optionalStrings(block, ["framework"]) &&
|
|
930
|
+
optionalNumbers(block, ["durationMs"]) &&
|
|
931
|
+
Array.isArray(block.suites) &&
|
|
932
|
+
block.suites.every((suite) => isRecord(suite) &&
|
|
933
|
+
typeof suite.name === "string" &&
|
|
934
|
+
optionalStrings(suite, ["file"]) &&
|
|
935
|
+
Array.isArray(suite.cases) &&
|
|
936
|
+
suite.cases.every((c) => isRecord(c) &&
|
|
937
|
+
typeof c.name === "string" &&
|
|
938
|
+
TEST_STATUSES.has(c.status) &&
|
|
939
|
+
optionalStrings(c, ["error"]) &&
|
|
940
|
+
optionalNumbers(c, ["durationMs", "line"]))));
|
|
941
|
+
case "progress":
|
|
942
|
+
return (optionalStrings(block, ["title"]) &&
|
|
943
|
+
Array.isArray(block.steps) &&
|
|
944
|
+
block.steps.every((step) => isRecord(step) &&
|
|
945
|
+
typeof step.label === "string" &&
|
|
946
|
+
PROGRESS_STATUSES.has(step.status) &&
|
|
947
|
+
optionalStrings(step, ["detail"])));
|
|
948
|
+
default:
|
|
949
|
+
return false;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
const TEST_STATUSES = new Set(["passed", "failed", "skipped", "todo"]);
|
|
953
|
+
const PROGRESS_STATUSES = new Set(["pending", "running", "completed", "failed", "cancelled"]);
|
|
954
|
+
const isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
955
|
+
const optionalStrings = (record, keys) => keys.every((key) => record[key] === undefined || typeof record[key] === "string");
|
|
956
|
+
const optionalNumbers = (record, keys) => keys.every((key) => record[key] === undefined || typeof record[key] === "number");
|
|
957
|
+
/** True when an unknown event value is a `{type:"image"}`-style part (mime + base64 data). */
|
|
958
|
+
export function isImagePart(value) {
|
|
959
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
960
|
+
return false;
|
|
961
|
+
const record = value;
|
|
962
|
+
return typeof record.mimeType === "string" && typeof record.data === "string";
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Display blocks the standard tools attach for the web UI only (spec §8.6): the TUI already
|
|
966
|
+
* shows the same content from the text (output rows, exit line), so rendering them would repeat it.
|
|
967
|
+
*/
|
|
968
|
+
const WEB_ONLY_UI = {
|
|
969
|
+
write_file: "diff",
|
|
970
|
+
edit_file: "diff",
|
|
971
|
+
shell: "terminal",
|
|
972
|
+
run_process: "terminal",
|
|
973
|
+
};
|
|
974
|
+
/** Rich (ui/image) parts of a persisted tool result, if any. */
|
|
975
|
+
export function richPartsOf(result, toolName) {
|
|
976
|
+
if (!result)
|
|
977
|
+
return {};
|
|
978
|
+
const skip = toolName !== undefined && Object.hasOwn(WEB_ONLY_UI, toolName)
|
|
979
|
+
? WEB_ONLY_UI[toolName]
|
|
980
|
+
: undefined;
|
|
981
|
+
const ui = result.content.find((part) => part.type === "ui" && part.block.kind !== skip);
|
|
982
|
+
const image = result.content.find((part) => part.type === "image");
|
|
983
|
+
return {
|
|
984
|
+
...(ui?.type === "ui" ? { ui: ui.block } : {}),
|
|
985
|
+
...(image?.type === "image" ? { image: { mimeType: image.mimeType, data: image.data } } : {}),
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
// ============================================================================
|
|
989
|
+
// Tool presentation model — the pure extension point for transcript display.
|
|
990
|
+
// Everything the TUI renders beyond a plain `TranscriptItem` is a
|
|
991
|
+
// `TranscriptEntry`, and anything collapsible declares a `FoldCandidate` here,
|
|
992
|
+
// so the renderer, the keybinding and (optionally) mouse clicks stay consistent
|
|
993
|
+
// without importing pi-tui. Future block kinds hook in by extending these
|
|
994
|
+
// shapes, never by special-casing components.
|
|
995
|
+
// ============================================================================
|
|
996
|
+
/** Words keyed by their canonical upper-case spelling that stay acronyms when humanized. */
|
|
997
|
+
const ACRONYM_WORDS = new Set([
|
|
998
|
+
"MCP",
|
|
999
|
+
"HTTP",
|
|
1000
|
+
"HTTPS",
|
|
1001
|
+
"URL",
|
|
1002
|
+
"API",
|
|
1003
|
+
"CLI",
|
|
1004
|
+
"JSON",
|
|
1005
|
+
"CSV",
|
|
1006
|
+
"XML",
|
|
1007
|
+
"HTML",
|
|
1008
|
+
"SQL",
|
|
1009
|
+
"SQLITE",
|
|
1010
|
+
"DNS",
|
|
1011
|
+
"SSH",
|
|
1012
|
+
"IP",
|
|
1013
|
+
"ID",
|
|
1014
|
+
"DB",
|
|
1015
|
+
"UI",
|
|
1016
|
+
"OS",
|
|
1017
|
+
"IDE",
|
|
1018
|
+
"TUI",
|
|
1019
|
+
"PNG",
|
|
1020
|
+
"SVG",
|
|
1021
|
+
"PDF",
|
|
1022
|
+
"YAML",
|
|
1023
|
+
"TOML",
|
|
1024
|
+
"2FA",
|
|
1025
|
+
"V2",
|
|
1026
|
+
]);
|
|
1027
|
+
/**
|
|
1028
|
+
* `read_file` → `Read File`, `search_text` → `Search Text`, `mcp_devforge_time_diff` →
|
|
1029
|
+
* `MCP · Devforge Time Diff`. Splits on `_`/`-`/`.`/spaces, keeps known acronyms (MCP, HTTP…)
|
|
1030
|
+
* and bare numeric suffixes (`read_file_2` → `Read File 2`). The machine name stays available
|
|
1031
|
+
* dimmed in grouped detail rows and in the `/tools` and `/stats` reports.
|
|
1032
|
+
*/
|
|
1033
|
+
export function humanizeToolName(name) {
|
|
1034
|
+
const trimmed = name.trim();
|
|
1035
|
+
let rest = trimmed;
|
|
1036
|
+
let prefix = "";
|
|
1037
|
+
const mcp = /^mcp_/i.exec(rest);
|
|
1038
|
+
if (mcp) {
|
|
1039
|
+
prefix = "MCP · ";
|
|
1040
|
+
rest = rest.slice(mcp[0].length);
|
|
1041
|
+
}
|
|
1042
|
+
const words = rest
|
|
1043
|
+
.split(/[_\-. /]+/)
|
|
1044
|
+
.filter(Boolean)
|
|
1045
|
+
.map((word) => {
|
|
1046
|
+
const upper = word.toUpperCase();
|
|
1047
|
+
if (ACRONYM_WORDS.has(upper))
|
|
1048
|
+
return upper;
|
|
1049
|
+
if (/^\d+$/.test(upper))
|
|
1050
|
+
return upper;
|
|
1051
|
+
return `${word[0]?.toUpperCase() ?? ""}${word.slice(1).toLowerCase()}`;
|
|
1052
|
+
});
|
|
1053
|
+
return words.length ? `${prefix}${words.join(" ")}` : trimmed;
|
|
1054
|
+
}
|
|
1055
|
+
/** Verb and count noun used by grouped batch rows per tool kind. */
|
|
1056
|
+
export const TOOL_KIND_VERB = {
|
|
1057
|
+
read: "Explored",
|
|
1058
|
+
write: "Edited",
|
|
1059
|
+
process: "Ran",
|
|
1060
|
+
mcp: "Queried",
|
|
1061
|
+
internal: "Ran",
|
|
1062
|
+
other: "Called",
|
|
1063
|
+
};
|
|
1064
|
+
export const TOOL_KIND_WORD = {
|
|
1065
|
+
read: "reads",
|
|
1066
|
+
write: "files",
|
|
1067
|
+
process: "commands",
|
|
1068
|
+
mcp: "calls",
|
|
1069
|
+
internal: "tasks",
|
|
1070
|
+
other: "calls",
|
|
1071
|
+
};
|
|
1072
|
+
/**
|
|
1073
|
+
* Capability kind of a tool call. Live events carry the registry `effect` (exact); replayed
|
|
1074
|
+
* history falls back to conservative name heuristics. `mcp_*` is always the mcp kind.
|
|
1075
|
+
*/
|
|
1076
|
+
export function toolKindOf(name, effect) {
|
|
1077
|
+
if (name.startsWith("mcp_"))
|
|
1078
|
+
return "mcp";
|
|
1079
|
+
switch (effect) {
|
|
1080
|
+
case "read":
|
|
1081
|
+
case "write":
|
|
1082
|
+
case "process":
|
|
1083
|
+
case "internal":
|
|
1084
|
+
return effect;
|
|
1085
|
+
default:
|
|
1086
|
+
break;
|
|
1087
|
+
}
|
|
1088
|
+
if (/^(read|search|list|git|context|ask|web|fetch)_/.test(name))
|
|
1089
|
+
return "read";
|
|
1090
|
+
if (/^(write|edit|append|delete|rename|move|mkdir|patch)_/.test(name))
|
|
1091
|
+
return "write";
|
|
1092
|
+
if (/^(run|shell|execute)/.test(name))
|
|
1093
|
+
return "process";
|
|
1094
|
+
if (name === "task" || name.startsWith("task_"))
|
|
1095
|
+
return "internal";
|
|
1096
|
+
return "other";
|
|
1097
|
+
}
|
|
1098
|
+
/** Terminal-preview caps for collapsed rows (same values the renderer always used). */
|
|
1099
|
+
export const OUTPUT_PREVIEW_LINES_OK = 3;
|
|
1100
|
+
export const OUTPUT_PREVIEW_LINES_ERROR = 6;
|
|
1101
|
+
export const previewLinesFor = (status) => status === "error" ? OUTPUT_PREVIEW_LINES_ERROR : OUTPUT_PREVIEW_LINES_OK;
|
|
1102
|
+
/** Preview rows as the renderer shows them: blank lines dropped, tabs kept for the caller to expand. */
|
|
1103
|
+
export const previewRows = (preview) => preview.split("\n").filter((line) => line.trim());
|
|
1104
|
+
/** True when the preview exceeds the collapsed cap (the row becomes a collapsible "output"). */
|
|
1105
|
+
export function previewTruncated(preview, status) {
|
|
1106
|
+
return !!preview && previewRows(preview).length > previewLinesFor(status);
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Exit code derived safely from a text preview. `run_process`/`shell`/`search_text` return
|
|
1110
|
+
* `{stdout, stderr, exitCode, truncated}` JSON; read-effect tools may carry an appended
|
|
1111
|
+
* `\n<instructions>…` block, retried on the JSON prefix. Any other shape yields nothing.
|
|
1112
|
+
*/
|
|
1113
|
+
export function exitCodeOf(preview) {
|
|
1114
|
+
if (!preview)
|
|
1115
|
+
return undefined;
|
|
1116
|
+
const codeOf = (text) => {
|
|
1117
|
+
try {
|
|
1118
|
+
const value = JSON.parse(text);
|
|
1119
|
+
const code = value && typeof value === "object" && !Array.isArray(value)
|
|
1120
|
+
? value.exitCode
|
|
1121
|
+
: undefined;
|
|
1122
|
+
return typeof code === "number" ? code : undefined;
|
|
1123
|
+
}
|
|
1124
|
+
catch {
|
|
1125
|
+
return undefined;
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
const direct = codeOf(preview);
|
|
1129
|
+
if (direct !== undefined)
|
|
1130
|
+
return direct;
|
|
1131
|
+
const cut = preview.indexOf("\n<instructions>");
|
|
1132
|
+
if (cut > 0)
|
|
1133
|
+
return codeOf(preview.slice(0, cut));
|
|
1134
|
+
return undefined;
|
|
1135
|
+
}
|
|
1136
|
+
const exitCodePart = (preview) => {
|
|
1137
|
+
if (typeof preview !== "string")
|
|
1138
|
+
return {};
|
|
1139
|
+
const code = exitCodeOf(preview);
|
|
1140
|
+
return code === undefined ? {} : { exitCode: code };
|
|
1141
|
+
};
|
|
1142
|
+
export function toolItemView(item) {
|
|
1143
|
+
return {
|
|
1144
|
+
id: item.id,
|
|
1145
|
+
name: item.name,
|
|
1146
|
+
humanName: humanizeToolName(item.name),
|
|
1147
|
+
kind: item.toolKind ?? toolKindOf(item.name),
|
|
1148
|
+
summary: item.summary,
|
|
1149
|
+
status: item.status,
|
|
1150
|
+
...(item.durationMs !== undefined ? { durationMs: item.durationMs } : {}),
|
|
1151
|
+
...(item.preview !== undefined ? { preview: item.preview } : {}),
|
|
1152
|
+
...(item.ui ? { ui: item.ui } : {}),
|
|
1153
|
+
...(item.image ? { image: item.image } : {}),
|
|
1154
|
+
...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}),
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Consecutive tool calls of the same kind whose statuses are ALL terminal (ok/error) merge into
|
|
1159
|
+
* one `group` entry; running/approval calls always stay singleton so their spinner and live state
|
|
1160
|
+
* stay visible. A kind change splits the run (`read` never merges with `shell`). Order is
|
|
1161
|
+
* preserved; every entry carries its index within `items` (fold keys for assistant items need
|
|
1162
|
+
* it). One linear pass — called on state changes only, never per frame.
|
|
1163
|
+
*/
|
|
1164
|
+
export function groupToolEntries(items) {
|
|
1165
|
+
const out = [];
|
|
1166
|
+
let run = [];
|
|
1167
|
+
let runKind;
|
|
1168
|
+
const flush = () => {
|
|
1169
|
+
if (!run.length)
|
|
1170
|
+
return;
|
|
1171
|
+
const views = run.map((r) => toolItemView(r.item));
|
|
1172
|
+
const first = views[0];
|
|
1173
|
+
const groupable = views.length > 1 &&
|
|
1174
|
+
first !== undefined &&
|
|
1175
|
+
views.every((v) => v.kind === runKind) &&
|
|
1176
|
+
views.every((v) => v.status === "ok" || v.status === "error");
|
|
1177
|
+
if (groupable) {
|
|
1178
|
+
const sameName = views.every((v) => v.name === first.name);
|
|
1179
|
+
out.push({
|
|
1180
|
+
entry: {
|
|
1181
|
+
kind: "group",
|
|
1182
|
+
group: {
|
|
1183
|
+
id: first.id,
|
|
1184
|
+
members: views,
|
|
1185
|
+
kind: first.kind,
|
|
1186
|
+
status: views.some((v) => v.status === "error") ? "error" : "ok",
|
|
1187
|
+
totalMs: views.reduce((sum, v) => sum + (v.durationMs ?? 0), 0),
|
|
1188
|
+
label: sameName ? first.humanName : TOOL_KIND_VERB[first.kind],
|
|
1189
|
+
word: TOOL_KIND_WORD[first.kind],
|
|
1190
|
+
},
|
|
1191
|
+
},
|
|
1192
|
+
at: run[0]?.at ?? 0,
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
else {
|
|
1196
|
+
for (const r of run)
|
|
1197
|
+
out.push({ entry: r.item, at: r.at });
|
|
1198
|
+
}
|
|
1199
|
+
run = [];
|
|
1200
|
+
runKind = undefined;
|
|
1201
|
+
};
|
|
1202
|
+
items.forEach((item, at) => {
|
|
1203
|
+
if (item.kind === "tool") {
|
|
1204
|
+
const kind = item.toolKind ?? toolKindOf(item.name);
|
|
1205
|
+
if (runKind === undefined) {
|
|
1206
|
+
runKind = kind;
|
|
1207
|
+
run.push({ item, at });
|
|
1208
|
+
}
|
|
1209
|
+
else if (runKind === kind) {
|
|
1210
|
+
run.push({ item, at });
|
|
1211
|
+
}
|
|
1212
|
+
else {
|
|
1213
|
+
flush();
|
|
1214
|
+
runKind = kind;
|
|
1215
|
+
run.push({ item, at });
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
else {
|
|
1219
|
+
flush();
|
|
1220
|
+
out.push({ entry: item, at });
|
|
1221
|
+
}
|
|
1222
|
+
});
|
|
1223
|
+
flush();
|
|
1224
|
+
return out;
|
|
1225
|
+
}
|
|
1226
|
+
/** Content identity of a group (fold/diff purposes): member ids, statuses and durations. */
|
|
1227
|
+
export function groupIdentity(group) {
|
|
1228
|
+
return `${group.id}|${group.members.length}|${group.members
|
|
1229
|
+
.map((m) => `${m.id}:${m.status}:${m.durationMs ?? ""}`)
|
|
1230
|
+
.join(",")}`;
|
|
1231
|
+
}
|
|
1232
|
+
/** Cap for the reasoning text when expanded; longer sections show a truncation note. */
|
|
1233
|
+
export const REASONING_EXPAND_MAX_LINES = 40;
|
|
1234
|
+
/**
|
|
1235
|
+
* The fold contract of one transcript entry: nothing (not collapsible), or a candidate with its
|
|
1236
|
+
* default state. `at` is the entry's index within the ORIGINAL items array (assistant fold keys
|
|
1237
|
+
* are stable because transcript items are append-only).
|
|
1238
|
+
*/
|
|
1239
|
+
export function foldCandidateOf(entry, at) {
|
|
1240
|
+
if (entry.kind === "assistant") {
|
|
1241
|
+
const reasoning = entry.reasoning.trim();
|
|
1242
|
+
if (!reasoning || !(entry.done || entry.text))
|
|
1243
|
+
return undefined;
|
|
1244
|
+
return { key: `a:${at}`, kind: "reasoning", defaultExpanded: false };
|
|
1245
|
+
}
|
|
1246
|
+
if (entry.kind === "group")
|
|
1247
|
+
return { key: `g:${entry.group.id}`, kind: "group", defaultExpanded: false };
|
|
1248
|
+
if (entry.kind === "tool") {
|
|
1249
|
+
if (entry.status === "running" || entry.status === "approval")
|
|
1250
|
+
return undefined;
|
|
1251
|
+
// Rich blocks keep their native rendering (tables/trees/images are already bounded);
|
|
1252
|
+
// only plain text output folds when it exceeds the collapsed cap.
|
|
1253
|
+
if (entry.ui || entry.image)
|
|
1254
|
+
return undefined;
|
|
1255
|
+
if (previewTruncated(entry.preview, entry.status))
|
|
1256
|
+
return { key: `t:${entry.id}`, kind: "output", defaultExpanded: false };
|
|
1257
|
+
return undefined;
|
|
1258
|
+
}
|
|
1259
|
+
return undefined;
|
|
1260
|
+
}
|
|
1261
|
+
/** Stable key of a transcript entry (used by the component sync to reuse row instances). */
|
|
1262
|
+
export function entryKeyOf(entry, at) {
|
|
1263
|
+
switch (entry.kind) {
|
|
1264
|
+
case "assistant":
|
|
1265
|
+
return `a:${at}`;
|
|
1266
|
+
case "group":
|
|
1267
|
+
return `g:${entry.group.id}`;
|
|
1268
|
+
case "tool":
|
|
1269
|
+
return `t:${entry.id}`;
|
|
1270
|
+
case "user":
|
|
1271
|
+
return `u:${at}`;
|
|
1272
|
+
case "notice":
|
|
1273
|
+
return `n:${at}`;
|
|
1274
|
+
case "info":
|
|
1275
|
+
return `i:${at}`;
|
|
1276
|
+
case "error":
|
|
1277
|
+
return `e:${at}`;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
/** Every fold candidate of the transcript in display order; the keybinding targets the last one. */
|
|
1281
|
+
export function foldCandidates(items) {
|
|
1282
|
+
const candidates = [];
|
|
1283
|
+
for (const { entry, at } of groupToolEntries(items)) {
|
|
1284
|
+
const candidate = foldCandidateOf(entry, at);
|
|
1285
|
+
if (candidate)
|
|
1286
|
+
candidates.push(candidate);
|
|
1287
|
+
}
|
|
1288
|
+
return candidates;
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* True when a printable key should toggle the nearest collapsible transcript row instead of
|
|
1292
|
+
* typing: bound to `x`/`X` on an EMPTY editor line (no autocomplete), the same "empty input"
|
|
1293
|
+
* convention Enter, Ctrl+D and `c`/`y` already use. Unlike copy, it also works while a turn runs
|
|
1294
|
+
* (the nearest candidate may still exist mid-stream). A non-empty input never triggers.
|
|
1295
|
+
*/
|
|
1296
|
+
export function foldToggleKey(data, input) {
|
|
1297
|
+
return (data === "x" || data === "X") && input.text === "" && !input.autocomplete;
|
|
1298
|
+
}
|
|
535
1299
|
/** Rebuilds transcript items from persisted history (used by /resume). */
|
|
536
1300
|
export function itemsFromHistory(messages) {
|
|
537
1301
|
const items = [];
|
|
@@ -547,6 +1311,11 @@ export function itemsFromHistory(messages) {
|
|
|
547
1311
|
items.push({ kind: "assistant", text: m.text, reasoning: "", done: true });
|
|
548
1312
|
for (const c of m.calls) {
|
|
549
1313
|
const r = results.get(c.id);
|
|
1314
|
+
const preview = r?.result.content
|
|
1315
|
+
.filter((p) => p.type === "text")
|
|
1316
|
+
.map((x) => x.text)
|
|
1317
|
+
.join("\n")
|
|
1318
|
+
.slice(0, 2_000);
|
|
550
1319
|
items.push({
|
|
551
1320
|
kind: "tool",
|
|
552
1321
|
id: c.id,
|
|
@@ -554,10 +1323,10 @@ export function itemsFromHistory(messages) {
|
|
|
554
1323
|
args: c.arguments,
|
|
555
1324
|
summary: summarizeToolArgs(c.name, c.arguments),
|
|
556
1325
|
status: r?.result.isError ? "error" : "ok",
|
|
557
|
-
preview
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
1326
|
+
preview,
|
|
1327
|
+
toolKind: toolKindOf(c.name),
|
|
1328
|
+
...exitCodePart(preview),
|
|
1329
|
+
...richPartsOf(r?.result, c.name),
|
|
561
1330
|
});
|
|
562
1331
|
}
|
|
563
1332
|
}
|