@xmoon76/dsh-pi-tui 0.5.0 → 0.5.2-rc.1
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.en.md +135 -16
- package/README.md +99 -15
- package/cordis.patch.yml +30 -1
- package/dist/builtins.mjs +12 -16
- package/dist/context-summary-CxQnkDEN.mjs +2478 -0
- package/dist/extension/advanced.d.mts +2 -2
- package/dist/extension/unstable.d.mts +1 -1
- package/dist/extensions-DzpBiiYb.d.mts +139 -0
- package/dist/extensions-VuoIE_Jc.mjs +18827 -0
- package/dist/extensions.d.mts +2 -137
- package/dist/extensions.mjs +1 -1
- package/dist/index.d.mts +489 -433
- package/dist/index.mjs +55356 -19666
- package/dist/performance-host-BJT6scGF.mjs +266 -0
- package/dist/performance-host.d.mts +36 -0
- package/dist/performance-host.mjs +2 -0
- package/dist/performance-view-jvvd8Jn7.mjs +476 -0
- package/dist/{public-types-YbB5Rhku.d.mts → public-types-gZi3iFNh.d.mts} +40 -6
- package/dist/rolldown-runtime-8H4AJuhK.mjs +14 -0
- package/dist/runtime-Dq6HoyjZ.mjs +6187 -0
- package/dist/session-DTauqYx6.mjs +5664 -0
- package/dist/{startup-DM-xxzcX.mjs → startup-CWQl0-0n.mjs} +14 -8
- package/dist/startup.d.mts +5 -4
- package/dist/startup.mjs +1 -1
- package/dist/tasks-D_z5Yjjr.mjs +267 -0
- package/dist/terminal-progress-source-Dpwz3csm.mjs +2133 -0
- package/dist/version-a-eF-QER.mjs +80 -0
- package/package.json +149 -130
- package/dist/dsh-version-V0exEv9O.mjs +0 -76
- package/dist/extensions-Dg1qhjUi.mjs +0 -39850
- package/dist/keybinding-registry-CjbtRmhd.d.mts +0 -1
|
@@ -0,0 +1,2478 @@
|
|
|
1
|
+
import { ut as stripTerminalSequences } from "./extensions-VuoIE_Jc.mjs";
|
|
2
|
+
import { normalize } from "node:path";
|
|
3
|
+
//#region src/domain/transcript/content-blocks.ts
|
|
4
|
+
/** Match the Web JsonBlock serialized-payload bound. */
|
|
5
|
+
const FINALIZED_BLOCK_PAYLOAD_MAX_CHARS = 2e4;
|
|
6
|
+
/** The flat marker for one known attachment occurrence. */
|
|
7
|
+
function attachmentMarker(block) {
|
|
8
|
+
if (block.type === "image") return `🖼️ ${block.attachment.name ?? "image"}`;
|
|
9
|
+
if (block.type === "file") return `📄 ${block.attachment.name}`;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Build the lightweight ordered text projection used by user transcript
|
|
13
|
+
* search and loader-less rendering. Known attachments retain their positions;
|
|
14
|
+
* other non-text blocks remain in their structured content only.
|
|
15
|
+
*/
|
|
16
|
+
function textWithAttachmentMarkers(blocks) {
|
|
17
|
+
let text = "";
|
|
18
|
+
let boundary = false;
|
|
19
|
+
for (const block of blocks) {
|
|
20
|
+
if (block.type === "text") {
|
|
21
|
+
if (boundary && text !== "" && !/\s$/.test(text) && !/^\s/.test(block.text)) text += " ";
|
|
22
|
+
boundary = false;
|
|
23
|
+
text += block.text;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const marker = attachmentMarker(block);
|
|
27
|
+
if (marker === void 0) continue;
|
|
28
|
+
if (text !== "" && !/\s$/.test(text)) text += " ";
|
|
29
|
+
text += marker;
|
|
30
|
+
boundary = true;
|
|
31
|
+
}
|
|
32
|
+
return text;
|
|
33
|
+
}
|
|
34
|
+
/** Sanitize and bound a provider-supplied block type for a heading. */
|
|
35
|
+
function safeBlockTypeLabel(type) {
|
|
36
|
+
if (typeof type !== "string") return "unknown";
|
|
37
|
+
const safeType = type.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
|
|
38
|
+
if (safeType === "") return "unknown";
|
|
39
|
+
return safeType.length <= 120 ? safeType : `${safeType.slice(0, 119)}…`;
|
|
40
|
+
}
|
|
41
|
+
/** Read and bound the type tag for the explicit unknown-block heading. */
|
|
42
|
+
function blockType(block) {
|
|
43
|
+
if (typeof block !== "object" || block === null) return "unknown";
|
|
44
|
+
return safeBlockTypeLabel(block.type);
|
|
45
|
+
}
|
|
46
|
+
/** Serialize one finalized block and keep its payload bounded. */
|
|
47
|
+
function boundedSerializedBlock(block) {
|
|
48
|
+
const serialized = JSON.stringify(block, null, 2) ?? String(block);
|
|
49
|
+
if (serialized.length <= 2e4) return serialized;
|
|
50
|
+
return `${serialized.slice(0, FINALIZED_BLOCK_PAYLOAD_MAX_CHARS)}\n… block payload truncated (${serialized.length} characters)`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Explicit bounded fallback for a finalized block that has no first-class
|
|
54
|
+
* renderer. Callers should route known text/image/file/reasoning/tool-call
|
|
55
|
+
* blocks through their existing semantics before using this fallback.
|
|
56
|
+
*/
|
|
57
|
+
function finalizedBlockFallbackText(block) {
|
|
58
|
+
return `Unknown block: ${blockType(block)}\n${boundedSerializedBlock(block)}`;
|
|
59
|
+
}
|
|
60
|
+
/** Render an open opaque block without inventing an unfinished payload. */
|
|
61
|
+
function openOpaqueBlockFallbackText(blockType) {
|
|
62
|
+
return `Unknown block: ${safeBlockTypeLabel(blockType)}\nnull`;
|
|
63
|
+
}
|
|
64
|
+
/** Whether a user message contains content the transcript should retain. */
|
|
65
|
+
function userBlocksVisibleNow(blocks) {
|
|
66
|
+
return blocks.some((block) => block.type !== "text" || block.text.trim() !== "");
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/domain/transcript/semantics.ts
|
|
70
|
+
/** Classify one current TranscriptMessage without consulting display text. */
|
|
71
|
+
function classifyTranscriptMessage(message) {
|
|
72
|
+
switch (message.kind) {
|
|
73
|
+
case "user":
|
|
74
|
+
case "assistant": return { class: "conversation" };
|
|
75
|
+
case "thinking": return {
|
|
76
|
+
class: "process",
|
|
77
|
+
origin: "thinking"
|
|
78
|
+
};
|
|
79
|
+
case "tool":
|
|
80
|
+
if (message.origin === "turn-error" || message.origin === "turn-interrupted" || message.origin === "tool-not-started") return {
|
|
81
|
+
class: "attention",
|
|
82
|
+
origin: message.origin
|
|
83
|
+
};
|
|
84
|
+
return message.origin === void 0 ? { class: "process" } : {
|
|
85
|
+
class: "process",
|
|
86
|
+
origin: message.origin
|
|
87
|
+
};
|
|
88
|
+
case "system":
|
|
89
|
+
if (isSurfacedContext(message)) return {
|
|
90
|
+
class: "context",
|
|
91
|
+
origin: "injected-context"
|
|
92
|
+
};
|
|
93
|
+
if (message.origin === "turn-max-tokens") return {
|
|
94
|
+
class: "attention",
|
|
95
|
+
origin: message.origin
|
|
96
|
+
};
|
|
97
|
+
if (message.origin === "llm-retry") return {
|
|
98
|
+
class: "process",
|
|
99
|
+
origin: message.origin
|
|
100
|
+
};
|
|
101
|
+
return { class: "context" };
|
|
102
|
+
case "workflow": return {
|
|
103
|
+
class: "context",
|
|
104
|
+
origin: "workflow"
|
|
105
|
+
};
|
|
106
|
+
case "compaction": return {
|
|
107
|
+
class: "context",
|
|
108
|
+
origin: "compaction"
|
|
109
|
+
};
|
|
110
|
+
case "command": return {
|
|
111
|
+
class: "control",
|
|
112
|
+
origin: "command"
|
|
113
|
+
};
|
|
114
|
+
case "summary": return {
|
|
115
|
+
class: "context",
|
|
116
|
+
origin: "window-summary"
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Whether a system row is an injected context boundary that Focus keeps
|
|
122
|
+
* visible when its Thought is collapsed. The fold's authoritative `context`
|
|
123
|
+
* marker intentionally covers unknown/future injection producers too.
|
|
124
|
+
*/
|
|
125
|
+
function isSurfacedContext(message) {
|
|
126
|
+
return message.kind === "system" && message.context === true;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The tool names whose SETTLED card is surfaced human-interaction evidence —
|
|
130
|
+
* durable human decisions, not ordinary work. PR4's authoritative set is
|
|
131
|
+
* exactly these two: `ask_user_question` (the user's answers) and
|
|
132
|
+
* `exit_plan_mode` (the user's Plan review / approval result). Every other
|
|
133
|
+
* tool stays Process regardless of how rich its card looks or whether its
|
|
134
|
+
* execution happened to require an approval.
|
|
135
|
+
*/
|
|
136
|
+
const SURFACED_INTERACTION_TOOL_NAMES = /* @__PURE__ */ new Set(["ask_user_question", "exit_plan_mode"]);
|
|
137
|
+
/** Whether one tool NAME belongs to the surfaced-interaction set, regardless of
|
|
138
|
+
* settled state. The fold uses this to keep such calls out of the turn's work
|
|
139
|
+
* accounting even while they are running. */
|
|
140
|
+
function isSurfacedInteractionToolName(name) {
|
|
141
|
+
return SURFACED_INTERACTION_TOOL_NAMES.has(name);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Whether one tool row is SETTLED human-interaction evidence rather than
|
|
145
|
+
* ordinary work-process evidence. The decision is source/tool identity only
|
|
146
|
+
* (kind + a name in {@link SURFACED_INTERACTION_TOOL_NAMES} + settled status) —
|
|
147
|
+
* never the display title or the result wording. A RUNNING question / plan
|
|
148
|
+
* review is owned by its active interaction panel and must NOT become a
|
|
149
|
+
* duplicate surfaced card. This orthogonal projection role deliberately does
|
|
150
|
+
* NOT add a fifth semantic class, and unknown tools stay Process.
|
|
151
|
+
*/
|
|
152
|
+
function isSurfacedInteractionTool(message) {
|
|
153
|
+
return message.kind === "tool" && isSurfacedInteractionToolName(message.name) && message.status !== "running";
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Post-turn replay evidence: the rows that MATERIALIZED after their owning
|
|
157
|
+
* turn's authoritative `turn/end` (weakly held sidecar — the fact dies with
|
|
158
|
+
* its row). The fold is the ONLY authority that can know this: a row's
|
|
159
|
+
* `turn`/`kind` alone cannot distinguish a durable row of a settled turn from
|
|
160
|
+
* a replay artifact that arrived afterwards.
|
|
161
|
+
*
|
|
162
|
+
* The provenance is exactly "this row was NEWLY created after the turn
|
|
163
|
+
* completed" — NEVER "this row was touched by a post-`turn/end` event". A
|
|
164
|
+
* `tool/result` that finds its own pending/running card still settles that
|
|
165
|
+
* card normally and leaves it fully legal Action evidence; only a newly
|
|
166
|
+
* created row (an orphan result or a fresh synthetic call card) earns the
|
|
167
|
+
* mark.
|
|
168
|
+
*
|
|
169
|
+
* Consumers share this ONE predicate so no surface invents its own fence:
|
|
170
|
+
* - the transcript keeps the row (search / Full / expanded Focus);
|
|
171
|
+
* - it is never Process aggregation evidence: it is excluded from the Action
|
|
172
|
+
* classifier, from Work-span membership, and from consecutive-read
|
|
173
|
+
* grouping (a group card is a synthesized object that could otherwise
|
|
174
|
+
* launder the provenance back into an aggregate).
|
|
175
|
+
*/
|
|
176
|
+
const postTurnReplayEvidence = /* @__PURE__ */ new WeakSet();
|
|
177
|
+
/** Whether one row materialized after its owning turn's `turn/end` (see
|
|
178
|
+
* {@link postTurnReplayEvidence}). Presentation/persistence consumers use
|
|
179
|
+
* this to keep the row as transcript evidence while excluding it from the
|
|
180
|
+
* settled turn's Process/Action aggregates. */
|
|
181
|
+
function isPostTurnReplayEvidence(message) {
|
|
182
|
+
return postTurnReplayEvidence.has(message);
|
|
183
|
+
}
|
|
184
|
+
/** Mark one newly materialized row as post-turn replay evidence
|
|
185
|
+
* (fold-internal authority — called ONLY where a row is created while its
|
|
186
|
+
* owning turn is already `completed`). */
|
|
187
|
+
function markPostTurnReplayEvidence(message) {
|
|
188
|
+
postTurnReplayEvidence.add(message);
|
|
189
|
+
}
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/domain/transcript/context-semantics.ts
|
|
192
|
+
/** One durable source narrowed to the readable-record shape; null for anything else. */
|
|
193
|
+
function asRecord(value) {
|
|
194
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
/** A record field read as a non-empty string, or null. */
|
|
198
|
+
function readString(record, key) {
|
|
199
|
+
const value = record[key];
|
|
200
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
201
|
+
}
|
|
202
|
+
/** Distinct non-empty `field` values of an array-valued source member, in first-seen order.
|
|
203
|
+
* The `seen` Set keeps a foreign/legacy log with a very large member array
|
|
204
|
+
* linear; an `includes()` scan here is an accidental O(n^2). */
|
|
205
|
+
function collect(source, member, field) {
|
|
206
|
+
const list = source[member];
|
|
207
|
+
if (!Array.isArray(list)) return [];
|
|
208
|
+
const seen = /* @__PURE__ */ new Set();
|
|
209
|
+
const names = [];
|
|
210
|
+
for (const entry of list) {
|
|
211
|
+
const record = asRecord(entry);
|
|
212
|
+
const value = record === null ? null : readString(record, field);
|
|
213
|
+
if (value !== null && !seen.has(value)) {
|
|
214
|
+
seen.add(value);
|
|
215
|
+
names.push(value);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return names;
|
|
219
|
+
}
|
|
220
|
+
/** A collected name list rendered as one label; null when the list is empty. */
|
|
221
|
+
function joined(names) {
|
|
222
|
+
return names.length > 0 ? names.join(", ") : null;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Project one durable message source onto its transcript role and producer
|
|
226
|
+
* name, exactly like the Web's contextProvenance: agent-instructions name
|
|
227
|
+
* their changed file paths, plugins their plugin id, skill invocations their
|
|
228
|
+
* skill name, session references their recalled labels; unknown kinds carry
|
|
229
|
+
* the kind itself.
|
|
230
|
+
* @param source - the logged user/message source, exactly as recorded.
|
|
231
|
+
* @returns the role and producer name to present for this context.
|
|
232
|
+
*/
|
|
233
|
+
function contextProvenance(source) {
|
|
234
|
+
const record = asRecord(source);
|
|
235
|
+
const kind = record === null ? null : readString(record, "kind");
|
|
236
|
+
if (record === null || kind === null) return {
|
|
237
|
+
role: "inject",
|
|
238
|
+
label: null
|
|
239
|
+
};
|
|
240
|
+
switch (kind) {
|
|
241
|
+
case "session-reference": return {
|
|
242
|
+
role: "recall",
|
|
243
|
+
label: joined(collect(record, "references", "label")) ?? kind
|
|
244
|
+
};
|
|
245
|
+
case "agent-instructions": return {
|
|
246
|
+
role: "inject",
|
|
247
|
+
label: joined(collect(record, "changes", "path")) ?? kind
|
|
248
|
+
};
|
|
249
|
+
case "plugin": return {
|
|
250
|
+
role: "inject",
|
|
251
|
+
label: readString(record, "plugin") ?? kind
|
|
252
|
+
};
|
|
253
|
+
case "skill-invocation": return {
|
|
254
|
+
role: "inject",
|
|
255
|
+
label: readString(record, "name") ?? kind
|
|
256
|
+
};
|
|
257
|
+
default: return {
|
|
258
|
+
role: "inject",
|
|
259
|
+
label: kind
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/** Whether a raw value is one of the declared context forms. */
|
|
264
|
+
function isTranscriptContextForm(value) {
|
|
265
|
+
return value === "instructions" || value === "catalog" || value === "snapshot" || value === "notice" || value === "relay" || value === "recall";
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Project one logged context source onto the presentation-only metadata the
|
|
269
|
+
* transcript fold retains: the producer-declared `form` (the authority for
|
|
270
|
+
* presentation role and ambient clustering), the raw `kind`, a
|
|
271
|
+
* relay/notice sender, and the inject/recall role. This is the SINGLE
|
|
272
|
+
* parser of raw context sources; the fold and every projection consume its
|
|
273
|
+
* result. An absent/unknown form stays undefined — the caller must present
|
|
274
|
+
* the row as standalone generic Context.
|
|
275
|
+
* @param source - the logged user/message source, exactly as recorded.
|
|
276
|
+
*/
|
|
277
|
+
function contextPresentation(source) {
|
|
278
|
+
const record = asRecord(source);
|
|
279
|
+
const role = contextProvenance(source).role;
|
|
280
|
+
if (record === null) return { role };
|
|
281
|
+
const kind = readString(record, "kind");
|
|
282
|
+
const form = record["form"];
|
|
283
|
+
const senderSessionId = readString(record, "senderSessionId");
|
|
284
|
+
return {
|
|
285
|
+
...isTranscriptContextForm(form) ? { form } : {},
|
|
286
|
+
...kind === null ? {} : { sourceKind: kind },
|
|
287
|
+
...senderSessionId === null ? {} : { senderSessionId },
|
|
288
|
+
role
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/** The ambient foundation forms: their adjacent rows may cluster. */
|
|
292
|
+
const AMBIENT_CONTEXT_FORMS = [
|
|
293
|
+
"instructions",
|
|
294
|
+
"catalog",
|
|
295
|
+
"snapshot"
|
|
296
|
+
];
|
|
297
|
+
/** One injected Context row's presentation provenance, or undefined. */
|
|
298
|
+
function presentationOf(message) {
|
|
299
|
+
return message.kind === "system" ? message.contextPresentation : void 0;
|
|
300
|
+
}
|
|
301
|
+
/** The producer-declared form of one row, when recorded. */
|
|
302
|
+
function contextFormOf(message) {
|
|
303
|
+
return presentationOf(message)?.form;
|
|
304
|
+
}
|
|
305
|
+
/** Whether one row is ambient foundation (instructions/catalog/snapshot). */
|
|
306
|
+
function isAmbientContext(message) {
|
|
307
|
+
const form = contextFormOf(message);
|
|
308
|
+
return isSurfacedContext(message) && form !== void 0 && AMBIENT_CONTEXT_FORMS.includes(form);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* The card-header icon SEMANTIC for one context injection, keyed by source
|
|
312
|
+
* kind so a reader can tell an instruction file from a skill catalog or a
|
|
313
|
+
* recalled session at a glance (the Web renders one browse icon for all of
|
|
314
|
+
* them). The glyph itself resolves through the concrete TUI palette at render
|
|
315
|
+
* time — fold state stores the semantic, never a concrete emoji/symbol, so an
|
|
316
|
+
* icon-style switch repaints ALREADY-FOLDED context cards immediately.
|
|
317
|
+
* @param source - the logged user/message source.
|
|
318
|
+
*/
|
|
319
|
+
function contextIconSemantic(source) {
|
|
320
|
+
const record = asRecord(source);
|
|
321
|
+
switch (record === null ? null : readString(record, "kind")) {
|
|
322
|
+
case "agent-instructions": return "context-file";
|
|
323
|
+
case "skill-invocation": return "context-skill";
|
|
324
|
+
case "plugin": return readString(record ?? {}, "form") === "notice" ? "context-notice" : "context-plugin";
|
|
325
|
+
case "subagent-settled": return "context-notice";
|
|
326
|
+
case "session-reference": return "context-recall";
|
|
327
|
+
default: return "context-generic";
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/** The producer's one-line notice summary, or null when absent. */
|
|
331
|
+
function contextSummary(source) {
|
|
332
|
+
const record = asRecord(source);
|
|
333
|
+
if (record === null) return null;
|
|
334
|
+
const summary = record["summary"];
|
|
335
|
+
return typeof summary === "string" && summary !== "" ? summary : null;
|
|
336
|
+
}
|
|
337
|
+
//#endregion
|
|
338
|
+
//#region src/runtime/process/disposal.ts
|
|
339
|
+
/**
|
|
340
|
+
* The ONE synchronous disposal primitive (M3-6 PR3 plan D1): attempt every
|
|
341
|
+
* cleanup step in caller-provided order, then surface the collected failure.
|
|
342
|
+
*
|
|
343
|
+
* The helper answers exactly one question — "attempt every synchronous
|
|
344
|
+
* cleanup step in this order, then surface collected failure(s)" — and owns
|
|
345
|
+
* nothing else: no async support, no ownership state, no logging, no retries
|
|
346
|
+
* and no fallback. Ownership slots stay inside the owning module; every
|
|
347
|
+
* orchestrator (bootstrap, TuiApp, SurfaceRuntime, the per-owner dispose
|
|
348
|
+
* methods) keeps its own order and passes it here.
|
|
349
|
+
*
|
|
350
|
+
* Failure contract:
|
|
351
|
+
* - zero failures: return;
|
|
352
|
+
* - exactly one failure: rethrow the EXACT original thrown value;
|
|
353
|
+
* - two or more failures: throw `AggregateError(failures, label)`.
|
|
354
|
+
*
|
|
355
|
+
* A throwing step never truncates its siblings.
|
|
356
|
+
* @module @xmoon76/dsh-pi-tui/runtime/process/disposal
|
|
357
|
+
*/
|
|
358
|
+
/** Attempt every synchronous disposal step in order, surfacing failures. */
|
|
359
|
+
function runSyncDisposalSteps(label, steps) {
|
|
360
|
+
const failures = [];
|
|
361
|
+
for (const step of steps) try {
|
|
362
|
+
step();
|
|
363
|
+
} catch (error) {
|
|
364
|
+
failures.push(error);
|
|
365
|
+
}
|
|
366
|
+
if (failures.length === 1) throw failures[0];
|
|
367
|
+
if (failures.length > 1) throw new AggregateError(failures, label);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* The same non-truncating attempt, for a batch that rolls back a PRIMARY failure:
|
|
371
|
+
* every rollback step is attempted in caller-provided order, then the PRIMARY
|
|
372
|
+
* value is thrown — aggregated by IDENTITY with the collected rollback failures,
|
|
373
|
+
* the primary first and the rollback values FLAT, in step order
|
|
374
|
+
* (`AggregateError([primary, r1, r2])`). One rollback obligation must never be
|
|
375
|
+
* skipped because an earlier one threw, and every value a step threw stays ONE
|
|
376
|
+
* entry — a user `AggregateError` is never flattened into its `.errors`. The step
|
|
377
|
+
* loop is inlined (not delegated to {@link runSyncDisposalSteps}, which would
|
|
378
|
+
* nest its own aggregate one level down).
|
|
379
|
+
*/
|
|
380
|
+
function runSyncDisposalStepsWithPrimary(label, primary, steps) {
|
|
381
|
+
const failures = [];
|
|
382
|
+
for (const step of steps) try {
|
|
383
|
+
step();
|
|
384
|
+
} catch (error) {
|
|
385
|
+
failures.push(error);
|
|
386
|
+
}
|
|
387
|
+
if (failures.length === 0) throw primary;
|
|
388
|
+
throw new AggregateError([primary, ...failures], label);
|
|
389
|
+
}
|
|
390
|
+
//#endregion
|
|
391
|
+
//#region src/domain/media/format.ts
|
|
392
|
+
/**
|
|
393
|
+
* Human byte-size formatting for media limit messages (1024-based).
|
|
394
|
+
* @module @xmoon76/dsh-pi-tui/domain/media/format
|
|
395
|
+
*/
|
|
396
|
+
function formatBytes(bytes) {
|
|
397
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
398
|
+
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KiB`;
|
|
399
|
+
return `${(bytes / 1048576).toFixed(1)} MiB`;
|
|
400
|
+
}
|
|
401
|
+
//#endregion
|
|
402
|
+
//#region src/tui/pickers/model-directory.ts
|
|
403
|
+
const IDENTITY_SEP = "\0";
|
|
404
|
+
const PROVIDER_DEFAULT = `${IDENTITY_SEP}provider-default`;
|
|
405
|
+
/** Full logical identity of a model row. Model ids are not globally unique. */
|
|
406
|
+
function modelIdentity(providerId, modelId) {
|
|
407
|
+
return `${providerId}${IDENTITY_SEP}${modelId}`;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Project the Host directory into identity-complete presentation rows. Pure:
|
|
411
|
+
* no terminal/component state, so the current/default/effort rules are unit
|
|
412
|
+
* testable without a rendering snapshot.
|
|
413
|
+
*/
|
|
414
|
+
function projectModelDirectory(directory, current, sessionless) {
|
|
415
|
+
const models = [];
|
|
416
|
+
for (const group of directory.groups) for (const model of group.models) {
|
|
417
|
+
const matchesConfigured = current !== void 0 && current.provider === group.id && current.model === model.id;
|
|
418
|
+
const isCurrentModel = !sessionless && matchesConfigured;
|
|
419
|
+
const isDefault = directory.default.provider === group.id && directory.default.model === model.id;
|
|
420
|
+
models.push({
|
|
421
|
+
providerId: group.id,
|
|
422
|
+
providerName: group.name,
|
|
423
|
+
modelId: model.id,
|
|
424
|
+
modelName: model.name,
|
|
425
|
+
efforts: model.reasoning?.efforts ?? [],
|
|
426
|
+
...model.reasoning?.defaultEffort === void 0 ? {} : { defaultEffort: model.reasoning.defaultEffort },
|
|
427
|
+
isCurrent: isCurrentModel,
|
|
428
|
+
isDefault,
|
|
429
|
+
...isCurrentModel && current?.reasoningEffort !== void 0 ? { currentEffort: current.reasoningEffort } : {},
|
|
430
|
+
...matchesConfigured && current?.reasoningEffort !== void 0 ? { configuredEffort: current.reasoningEffort } : {}
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
models,
|
|
435
|
+
failures: directory.failures.map((failure) => ({
|
|
436
|
+
providerId: failure.id,
|
|
437
|
+
providerName: failure.name,
|
|
438
|
+
message: failure.message
|
|
439
|
+
}))
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
/** The picker-local effort CHOICES for one model, in cycle order. A model with
|
|
443
|
+
* NO effort metadata has none — the effort keys must be a true no-op there.
|
|
444
|
+
* The synthetic `provider default` joins the cycle only when the model
|
|
445
|
+
* declares efforts but no concrete default. */
|
|
446
|
+
function effortChoicesOf(row) {
|
|
447
|
+
if (row.efforts.length === 0) return [];
|
|
448
|
+
const efforts = row.efforts.map((effort) => ({
|
|
449
|
+
value: effort.id,
|
|
450
|
+
label: effort.name
|
|
451
|
+
}));
|
|
452
|
+
return row.defaultEffort === void 0 ? [{
|
|
453
|
+
value: PROVIDER_DEFAULT,
|
|
454
|
+
label: "Default"
|
|
455
|
+
}, ...efforts] : efforts;
|
|
456
|
+
}
|
|
457
|
+
/** The initial picker-local effort VALUE for one model: the configured/current
|
|
458
|
+
* explicit effort when advertised, else the model default, else the first
|
|
459
|
+
* candidate (provider default); `undefined` for a model with no effort
|
|
460
|
+
* metadata at all. */
|
|
461
|
+
function initialEffortOf(row, choices) {
|
|
462
|
+
if (choices.length === 0) return void 0;
|
|
463
|
+
const advertised = (value) => value !== void 0 && choices.some((choice) => choice.value === value) ? value : void 0;
|
|
464
|
+
return advertised(row.configuredEffort) ?? advertised(row.defaultEffort) ?? choices[0].value;
|
|
465
|
+
}
|
|
466
|
+
//#endregion
|
|
467
|
+
//#region src/tui/pickers/preset-display.ts
|
|
468
|
+
/** The shipped-preset display copy, shared by every preset picker surface.
|
|
469
|
+
*
|
|
470
|
+
* Fixed in English — the web surface's `BUILT_IN_PRESET_KEYS` mapping
|
|
471
|
+
* (`dsh-agent-preset-registry` display helpers), TUI-side. The shipped
|
|
472
|
+
* declarations publish no `name` (the official built-in classification), and
|
|
473
|
+
* their metadata language is not ours to control: mapping the known ids keeps
|
|
474
|
+
* the pickers English regardless of what the declarations say; everything else
|
|
475
|
+
* renders the declaration's own metadata. Names follow the upstream English
|
|
476
|
+
* locale.
|
|
477
|
+
* @module tui/pickers/preset-display
|
|
478
|
+
*/
|
|
479
|
+
const BUILT_IN_PRESET_COPY = {
|
|
480
|
+
standard: {
|
|
481
|
+
name: "Standard mode",
|
|
482
|
+
description: "Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows."
|
|
483
|
+
},
|
|
484
|
+
ptc: {
|
|
485
|
+
name: "PTC mode",
|
|
486
|
+
description: "All Standard mode capabilities, with tools exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program."
|
|
487
|
+
},
|
|
488
|
+
minimal: {
|
|
489
|
+
name: "Minimal mode",
|
|
490
|
+
description: "Minimal coding agent with a persistent shell."
|
|
491
|
+
},
|
|
492
|
+
cordis: {
|
|
493
|
+
name: "Creator mode",
|
|
494
|
+
description: "Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance."
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
/** Whether one roster row is a SHIPPED preset under the official 0.1.7
|
|
498
|
+
* classification: a known shipped id that publishes no `name` of its own.
|
|
499
|
+
* A declaration that names itself — even with a shipped id — owns its
|
|
500
|
+
* copy (the same rule as `isBuiltInPreset` upstream; kept local so the
|
|
501
|
+
* display layer carries no runtime import). */
|
|
502
|
+
function isBuiltInPresetRow(preset) {
|
|
503
|
+
return preset.name === void 0 && BUILT_IN_PRESET_COPY[preset.id] !== void 0;
|
|
504
|
+
}
|
|
505
|
+
/** Resolve one roster row's display copy: the TUI's fixed English copy for
|
|
506
|
+
* a shipped preset (official classification above), otherwise the preset's
|
|
507
|
+
* own published metadata — a user-authored copy is never translated. */
|
|
508
|
+
function presetDisplayText(preset) {
|
|
509
|
+
const builtIn = isBuiltInPresetRow(preset) ? BUILT_IN_PRESET_COPY[preset.id] : void 0;
|
|
510
|
+
if (builtIn !== void 0) return {
|
|
511
|
+
name: builtIn.name,
|
|
512
|
+
description: builtIn.description
|
|
513
|
+
};
|
|
514
|
+
return {
|
|
515
|
+
name: preset.name ?? preset.id,
|
|
516
|
+
...preset.description === void 0 ? {} : { description: preset.description }
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
//#endregion
|
|
520
|
+
//#region src/tui/keybindings/hints.ts
|
|
521
|
+
/**
|
|
522
|
+
* The TSP renderer's FIXED parked-Question inspection chord (PR3-B B3 §3.8).
|
|
523
|
+
*
|
|
524
|
+
* The TSP interaction seat is not a Host command and owns no keymap action —
|
|
525
|
+
* `Alt+Q` is dispatched from the raw SDK key stream — so this label can never
|
|
526
|
+
* come from `keyHint`/`keysFor`. It also cannot go stale under a remap,
|
|
527
|
+
* because no remap exists for it. It lives HERE, with the key-label
|
|
528
|
+
* vocabulary, because the host-keybindings gate only sanctions key labels
|
|
529
|
+
* owned by this tree: a renderer module must not hard-code the chord itself.
|
|
530
|
+
*/
|
|
531
|
+
const TSP_CONTINUED_INSPECTION_LABEL = "Alt+Q";
|
|
532
|
+
/**
|
|
533
|
+
* The History editor-seat tenant's ONE fixed-chord table (Phase 3B / B3-B).
|
|
534
|
+
*
|
|
535
|
+
* The seat is a per-surface COMPONENT contract, not a Host command: it owns no
|
|
536
|
+
* keymap action and no remap exists, so both the handler's KeyIds and the
|
|
537
|
+
* footer / key-badge labels are fixed. Keeping them in ONE table (and in this
|
|
538
|
+
* key-label authority, which is the sanctioned owner of fixed chords) means a
|
|
539
|
+
* hint and the handler it names can never drift into two string authorities.
|
|
540
|
+
*/
|
|
541
|
+
const HISTORY_CHORDS = {
|
|
542
|
+
accept: {
|
|
543
|
+
keys: ["enter", "ctrl+j"],
|
|
544
|
+
label: "Enter"
|
|
545
|
+
},
|
|
546
|
+
scope: {
|
|
547
|
+
keys: ["tab"],
|
|
548
|
+
label: "Tab"
|
|
549
|
+
},
|
|
550
|
+
results: {
|
|
551
|
+
keys: [
|
|
552
|
+
"up",
|
|
553
|
+
"down",
|
|
554
|
+
"pageUp",
|
|
555
|
+
"pageDown"
|
|
556
|
+
],
|
|
557
|
+
label: "↑/↓"
|
|
558
|
+
},
|
|
559
|
+
preview: {
|
|
560
|
+
keys: [
|
|
561
|
+
"alt+up",
|
|
562
|
+
"alt+down",
|
|
563
|
+
"alt+pageUp",
|
|
564
|
+
"alt+pageDown",
|
|
565
|
+
"alt+home",
|
|
566
|
+
"alt+end"
|
|
567
|
+
],
|
|
568
|
+
label: "Alt+↑/↓"
|
|
569
|
+
},
|
|
570
|
+
copy: {
|
|
571
|
+
keys: ["alt+c"],
|
|
572
|
+
label: "Alt+C"
|
|
573
|
+
},
|
|
574
|
+
expand: {
|
|
575
|
+
keys: ["f2"],
|
|
576
|
+
label: "F2"
|
|
577
|
+
},
|
|
578
|
+
cancel: {
|
|
579
|
+
keys: ["escape", "ctrl+c"],
|
|
580
|
+
label: "Esc"
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
/** The display name of a bare (unmodified) key id. */
|
|
584
|
+
const BASE_KEY_LABELS = {
|
|
585
|
+
enter: "Enter",
|
|
586
|
+
escape: "Esc",
|
|
587
|
+
tab: "Tab",
|
|
588
|
+
space: "Space",
|
|
589
|
+
backspace: "Backspace",
|
|
590
|
+
delete: "Delete",
|
|
591
|
+
insert: "Insert",
|
|
592
|
+
home: "Home",
|
|
593
|
+
end: "End",
|
|
594
|
+
pageUp: "PageUp",
|
|
595
|
+
pageDown: "PageDown",
|
|
596
|
+
pageup: "PageUp",
|
|
597
|
+
pagedown: "PageDown",
|
|
598
|
+
up: "Up",
|
|
599
|
+
down: "Down",
|
|
600
|
+
left: "Left",
|
|
601
|
+
right: "Right",
|
|
602
|
+
"ctrl+[": "Ctrl+["
|
|
603
|
+
};
|
|
604
|
+
/** Format one KeyId as a human label (`ctrl+shift+f` → `Ctrl+Shift+F`). */
|
|
605
|
+
function formatKeyId(key) {
|
|
606
|
+
const parts = key.split("+");
|
|
607
|
+
const mods = [];
|
|
608
|
+
let base = "";
|
|
609
|
+
for (const part of parts) if (part === "ctrl") mods.push("Ctrl");
|
|
610
|
+
else if (part === "alt") mods.push("Alt");
|
|
611
|
+
else if (part === "shift") mods.push("Shift");
|
|
612
|
+
else if (part === "super") mods.push("Super");
|
|
613
|
+
else base = part;
|
|
614
|
+
const baseLabel = BASE_KEY_LABELS[base] ?? (base.length === 1 ? base.toUpperCase() : base);
|
|
615
|
+
return [...mods, baseLabel].join("+");
|
|
616
|
+
}
|
|
617
|
+
/** Format a `<leader>X` sequence label (`<leader>t` → `Leader T`). */
|
|
618
|
+
function formatLeaderSequence(completingKey) {
|
|
619
|
+
return `Leader ${formatKeyId(completingKey)}`;
|
|
620
|
+
}
|
|
621
|
+
/** The Job workspace's FIXED local keys (B1-C/B1-D, the same category as the
|
|
622
|
+
* History editor seat's labels below): the workspace owns NO keymap action for
|
|
623
|
+
* them — the page toggle and the current page's exact copy are screen-local,
|
|
624
|
+
* deliberately non-rebindable semantics — so they cannot come from `keyHint`,
|
|
625
|
+
* and they cannot lie either because no remap exists for them. The **key ids**
|
|
626
|
+
* live in this authority and the panel imports them for BOTH the handler and
|
|
627
|
+
* the hint, so an advertised key can never drift from the key that really acts
|
|
628
|
+
* (design language §7.1). */
|
|
629
|
+
const JOB_LOCAL_KEYS = {
|
|
630
|
+
/** Switch between the Output and Command pages, in both directions. */
|
|
631
|
+
switchPage: "tab",
|
|
632
|
+
/** Copy the current page's exact original. A BARE letter — the same key the
|
|
633
|
+
* Settings workspace's copy uses and the one the `[C]` affordance badges name —
|
|
634
|
+
* so the bar hint reads `c copy …` and badge and key agree (§4.1, §7.1). */
|
|
635
|
+
copy: "c"
|
|
636
|
+
};
|
|
637
|
+
/** Format one already-labelled key list for an ACTION-BAR HINT: a BARE
|
|
638
|
+
* unmodified letter stays lowercase (`s`, never `S`) because `S` reads as ⇧S in
|
|
639
|
+
* prose — the design language's key rule (§4.1). A modified chord keeps its
|
|
640
|
+
* formatted form (`Shift+S`), and a list keeps its OWN separator spacing
|
|
641
|
+
* (`s / Shift+S`, the same shape {@link formatKeyList} paints). */
|
|
642
|
+
function formatHintKeys(keys) {
|
|
643
|
+
return keys.split("/").map((part) => {
|
|
644
|
+
const core = part.trim();
|
|
645
|
+
const lowered = /^[A-Za-z]$/u.test(core) ? core.toLowerCase() : core;
|
|
646
|
+
return part.replace(core, lowered);
|
|
647
|
+
}).join("/");
|
|
648
|
+
}
|
|
649
|
+
/** Canonicalize one user query for both metadata matching and Host search. */
|
|
650
|
+
function sanitizeSessionSearchInput(value) {
|
|
651
|
+
const trimmed = value.replace(/\0/g, "").trim();
|
|
652
|
+
if (trimmed.length <= 500) return trimmed;
|
|
653
|
+
const truncated = trimmed.slice(0, 500);
|
|
654
|
+
const last = truncated.charCodeAt(499);
|
|
655
|
+
return last >= 55296 && last <= 56319 ? truncated.slice(0, 499) : truncated;
|
|
656
|
+
}
|
|
657
|
+
/** Metadata-only membership; content membership belongs to SessionReader.search(). */
|
|
658
|
+
function sessionRowMatchesQuery(row, query) {
|
|
659
|
+
const needle = query.toLowerCase();
|
|
660
|
+
return row.id.toLowerCase().includes(needle) || (row.title ?? "").toLowerCase().includes(needle) || (row.cwd ?? "").toLowerCase().includes(needle) || (row.preset ?? "").toLowerCase().includes(needle);
|
|
661
|
+
}
|
|
662
|
+
/** Lexical workspace equality; absent and empty roots match nothing. */
|
|
663
|
+
function sameWorkspace(a, b) {
|
|
664
|
+
if (a === void 0 || b === void 0 || a === "" || b === "") return false;
|
|
665
|
+
const strip = (path) => {
|
|
666
|
+
let end = path.length;
|
|
667
|
+
while (end > 1 && (path[end - 1] === "/" || path[end - 1] === "\\")) end -= 1;
|
|
668
|
+
return path.slice(0, end);
|
|
669
|
+
};
|
|
670
|
+
return normalize(strip(a)) === normalize(strip(b));
|
|
671
|
+
}
|
|
672
|
+
/** Parent-first lineage, preserving newest-first order at every level. */
|
|
673
|
+
function buildSessionTree(rows) {
|
|
674
|
+
const known = new Set(rows.map((row) => row.id));
|
|
675
|
+
const children = /* @__PURE__ */ new Map();
|
|
676
|
+
const roots = [];
|
|
677
|
+
for (const row of rows) if (row.parentSession !== void 0 && known.has(row.parentSession)) {
|
|
678
|
+
const list = children.get(row.parentSession);
|
|
679
|
+
if (list === void 0) children.set(row.parentSession, [row]);
|
|
680
|
+
else list.push(row);
|
|
681
|
+
} else roots.push(row);
|
|
682
|
+
const result = [];
|
|
683
|
+
const placed = /* @__PURE__ */ new Set();
|
|
684
|
+
const place = (row, depth) => {
|
|
685
|
+
if (placed.has(row.id)) return;
|
|
686
|
+
placed.add(row.id);
|
|
687
|
+
result.push({
|
|
688
|
+
row,
|
|
689
|
+
depth
|
|
690
|
+
});
|
|
691
|
+
for (const child of children.get(row.id) ?? []) place(child, depth + 1);
|
|
692
|
+
};
|
|
693
|
+
for (const root of roots) place(root, 0);
|
|
694
|
+
for (const row of rows) if (!placed.has(row.id)) place(row, 0);
|
|
695
|
+
return result;
|
|
696
|
+
}
|
|
697
|
+
//#endregion
|
|
698
|
+
//#region src/tui/pickers/sessions.ts
|
|
699
|
+
/**
|
|
700
|
+
* Session-picker support for `/sessions`: pure row assembly and
|
|
701
|
+
* presentation. The row model mirrors the kimicode web session rail — a
|
|
702
|
+
* short id, a relative age, an optional title, and a workspace group —
|
|
703
|
+
* rendered as one line per session in the TUI picker.
|
|
704
|
+
*
|
|
705
|
+
* This module is PURE presentation: it never touches `$DSH_HOME`, never
|
|
706
|
+
* stats or reads session logs, and never folds a title — Host-owned session
|
|
707
|
+
* derived state (titles, presets) arrives through the `SessionReader`
|
|
708
|
+
* projection port (`src/runtime/session-reader-port.ts`); the Direct
|
|
709
|
+
* adapter in `src/runtime/direct/` owns the official DSH projection,
|
|
710
|
+
* cache, and observation semantics.
|
|
711
|
+
* @module @xmoon76/dsh-pi-tui/tui/pickers/sessions
|
|
712
|
+
*/
|
|
713
|
+
/** Strip the `session-` prefix and keep the first 8 characters, like the
|
|
714
|
+
* kimicode card's short id. */
|
|
715
|
+
function shortSessionId(id) {
|
|
716
|
+
return id.replace(/^session[-_]/i, "").slice(0, 8);
|
|
717
|
+
}
|
|
718
|
+
/** kimicode-style workspace key: the last two path segments, or a placeholder.
|
|
719
|
+
* The cwd is a Host/persistence boundary value — the group header is
|
|
720
|
+
* rendered straight to the terminal, so the output is sanitized here (the
|
|
721
|
+
* single group-derivation point covers the browse AND search views). */
|
|
722
|
+
function workspaceKey(cwd) {
|
|
723
|
+
if (cwd === void 0 || cwd === "") return "(no workspace)";
|
|
724
|
+
return sanitizeTerminalText(cwd.split("/").slice(-2).join("/"));
|
|
725
|
+
}
|
|
726
|
+
/** Compact relative age of a session ("now", "2m", "3h", "5d", "3mo", "1y"). */
|
|
727
|
+
function formatSessionAge(createdAt, now = Date.now()) {
|
|
728
|
+
const diff = now - createdAt;
|
|
729
|
+
if (diff < 0) return "now";
|
|
730
|
+
const seconds = Math.floor(diff / 1e3);
|
|
731
|
+
if (seconds < 60) return "now";
|
|
732
|
+
const minutes = Math.floor(seconds / 60);
|
|
733
|
+
if (minutes < 60) return `${minutes}m`;
|
|
734
|
+
const hours = Math.floor(minutes / 60);
|
|
735
|
+
if (hours < 24) return `${hours}h`;
|
|
736
|
+
const days = Math.floor(hours / 24);
|
|
737
|
+
if (days < 30) return `${days}d`;
|
|
738
|
+
const months = Math.floor(days / 30);
|
|
739
|
+
if (months < 12) return `${months}mo`;
|
|
740
|
+
return `${Math.floor(months / 12)}y`;
|
|
741
|
+
}
|
|
742
|
+
/** Strip terminal control characters from untrusted text before it enters
|
|
743
|
+
* the picker presentation. Complete ANSI/OSC/APC sequences are removed
|
|
744
|
+
* first (the fork's parser), then every remaining C0 control (including a
|
|
745
|
+
* lone ESC), DEL, and C1 control (U+0080–U+009F — CSI/OSC/APC in 8-bit
|
|
746
|
+
* form) is dropped: the picker writes descriptions straight to the
|
|
747
|
+
* terminal, and a terminal decoding UTF-8 interprets C1 code points as
|
|
748
|
+
* control sequences, not text. Host snippets may carry persisted control
|
|
749
|
+
* bytes (message/tool output) — a raw ESC/OSC would be interpreted, not
|
|
750
|
+
* displayed. */
|
|
751
|
+
function sanitizeTerminalText(value) {
|
|
752
|
+
return stripTerminalSequences(value).replace(/[\u0000-\u001f\u007f-\u009f]/gu, "");
|
|
753
|
+
}
|
|
754
|
+
/** Assemble one session row for the picker, marking the current session.
|
|
755
|
+
* @param indent - tree depth for the "All" category: rows hang under their
|
|
756
|
+
* parent with a `└─` prefix so subagent lineage reads at a glance.
|
|
757
|
+
* @param contentHit - optional Host content-search hit for this row: the
|
|
758
|
+
* snippet joins the description so the local filter (which matches
|
|
759
|
+
* descriptions) surfaces content-only hits.
|
|
760
|
+
*/
|
|
761
|
+
function sessionPickerItem(row, currentId, indent = 0, contentHit) {
|
|
762
|
+
const marker = row.id === currentId ? "● " : "";
|
|
763
|
+
const treePrefix = indent <= 0 ? "" : `${" ".repeat(indent)}└─ `;
|
|
764
|
+
const meta = [shortSessionId(row.id), formatSessionAge(row.createdAt ?? row.updatedAt)];
|
|
765
|
+
if (row.origin === "subagent") meta.push("sub");
|
|
766
|
+
if (row.parentSession !== void 0) meta.push("fork");
|
|
767
|
+
if (row.preset !== void 0) meta.push(`preset:${row.preset}`);
|
|
768
|
+
if (row.live) meta.push("live");
|
|
769
|
+
if (contentHit !== void 0) meta.push(`…${sanitizeTerminalText(contentHit.snippet)}…`);
|
|
770
|
+
return {
|
|
771
|
+
value: row.id,
|
|
772
|
+
label: `${treePrefix}${marker}${row.title ?? shortSessionId(row.id)}`,
|
|
773
|
+
description: meta.join(" · "),
|
|
774
|
+
group: workspaceKey(row.cwd)
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
/** Assemble one SEARCH-projection row: the browse presentation plus the
|
|
778
|
+
* workspace in the searchable description. The search projection's
|
|
779
|
+
* membership considers cwd, and the SelectList's substring filter searches
|
|
780
|
+
* value/label/description only (never the group header) — so the search
|
|
781
|
+
* view must carry the cwd in the description, or a cwd query would match
|
|
782
|
+
* the union yet stay invisible. */
|
|
783
|
+
function sessionSearchItem(row, currentId, indent = 0, contentHit) {
|
|
784
|
+
const item = sessionPickerItem(row, currentId, indent, contentHit);
|
|
785
|
+
if (row.cwd !== void 0 && row.cwd !== "") item.description = `${item.description} · ${sanitizeTerminalText(row.cwd)}`;
|
|
786
|
+
return item;
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Split a session picker label into its FIXED presentation prefix (tree
|
|
790
|
+
* connector + current-session marker) and the marqueeable title (plan
|
|
791
|
+
* §7.7): the selected-row marquee scrolls only the title — the lineage
|
|
792
|
+
* prefix and the `●` marker are layout regions that never move. The
|
|
793
|
+
* derivation mirrors {@link sessionPickerItem}'s construction (never
|
|
794
|
+
* guesses arbitrary prefixes).
|
|
795
|
+
*/
|
|
796
|
+
function sessionLabelParts(label) {
|
|
797
|
+
const tree = /^((?: )+└─ )/.exec(label)?.[1] ?? "";
|
|
798
|
+
const rest = label.slice(tree.length);
|
|
799
|
+
const marker = rest.startsWith("● ") ? "● " : "";
|
|
800
|
+
return {
|
|
801
|
+
prefix: tree + marker,
|
|
802
|
+
title: rest.slice(marker.length)
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Resolve one session row for a resume query: an exact id, a
|
|
807
|
+
* `session-`-prefixed prefix, or a short-id prefix. Rows are newest-first,
|
|
808
|
+
* so the first match is the most recent candidate.
|
|
809
|
+
* @param rows - the picker rows, newest first.
|
|
810
|
+
* @param query - the resume query (trimmed).
|
|
811
|
+
* @returns the matching row, or undefined.
|
|
812
|
+
*/
|
|
813
|
+
function findSessionMatch(rows, query) {
|
|
814
|
+
const bare = query.replace(/^session[-_]/i, "");
|
|
815
|
+
return rows.find((row) => row.id === query || row.id.startsWith(`session-${bare}`) || shortSessionId(row.id).startsWith(bare));
|
|
816
|
+
}
|
|
817
|
+
//#endregion
|
|
818
|
+
//#region src/domain/media/file-summary.ts
|
|
819
|
+
/**
|
|
820
|
+
* Transport/UI-neutral textual summary of a durable file attachment (TS8-D
|
|
821
|
+
* move out of the legacy root `src/content-block-presentation.ts`).
|
|
822
|
+
*
|
|
823
|
+
* The summary is media vocabulary, not transcript semantics, so it lives in
|
|
824
|
+
* `domain/media/**` — that keeps `domain/transcript/**` from importing another
|
|
825
|
+
* domain subtree while preserving ONE file-summary formatter for the PiTui
|
|
826
|
+
* attachment card, pending presentation, tool presentation and the Markdown
|
|
827
|
+
* exporter.
|
|
828
|
+
* @module @xmoon76/dsh-pi-tui/domain/media/file-summary
|
|
829
|
+
*/
|
|
830
|
+
/** Render a FileBlock attachment without exposing its opaque storage id. */
|
|
831
|
+
function fileAttachmentSummary(attachment) {
|
|
832
|
+
return `📄 ${attachment.name} · ${formatBytes(attachment.bytes)}`;
|
|
833
|
+
}
|
|
834
|
+
//#endregion
|
|
835
|
+
//#region src/tui/transcript/tool-presentation.ts
|
|
836
|
+
/** Figma row titles per variant (design literals, not translatable copy). */
|
|
837
|
+
const VARIANT_TITLES = {
|
|
838
|
+
search: "Search",
|
|
839
|
+
read: "Read",
|
|
840
|
+
bash: "Bash",
|
|
841
|
+
write: "Write",
|
|
842
|
+
edit: "Edit",
|
|
843
|
+
code: "Code",
|
|
844
|
+
others: "Tool call"
|
|
845
|
+
};
|
|
846
|
+
/**
|
|
847
|
+
* Known tool name -> variant (the Web's TOOL_VARIANTS; cordis_define is
|
|
848
|
+
* deliberately absent there too - a keyed toolview replaces the generic row).
|
|
849
|
+
*/
|
|
850
|
+
const TOOL_VARIANTS = {
|
|
851
|
+
bash: "bash",
|
|
852
|
+
pwsh: "bash",
|
|
853
|
+
read: "read",
|
|
854
|
+
web_fetch: "read",
|
|
855
|
+
web_search: "search",
|
|
856
|
+
grep: "search",
|
|
857
|
+
glob: "search",
|
|
858
|
+
write: "write",
|
|
859
|
+
edit: "edit",
|
|
860
|
+
run_code: "code",
|
|
861
|
+
skill: "read",
|
|
862
|
+
cordis_package_inspect: "read",
|
|
863
|
+
cordis_runtime_inspect: "read",
|
|
864
|
+
cordis_run: "others",
|
|
865
|
+
cordis_stop: "others",
|
|
866
|
+
cordis_undefine: "others"
|
|
867
|
+
};
|
|
868
|
+
/** Tool-owned titles that refine a generic row variant without replacing it. */
|
|
869
|
+
const TOOL_TITLES = {
|
|
870
|
+
cordis_package_inspect: "Inspect",
|
|
871
|
+
cordis_runtime_inspect: "Inspect",
|
|
872
|
+
cordis_run: "Run Cordis Plugin",
|
|
873
|
+
cordis_stop: "Stop Cordis Plugin",
|
|
874
|
+
cordis_undefine: "Remove Cordis Plugin",
|
|
875
|
+
pwsh: "Pwsh",
|
|
876
|
+
skill: "Load skill"
|
|
877
|
+
};
|
|
878
|
+
/**
|
|
879
|
+
* TUI-local titles for synthetic cards the session log produces without a
|
|
880
|
+
* registry definition (local `!` shell runs, workflow bookkeeping, failure
|
|
881
|
+
* lines). Same refinement mechanism as TOOL_TITLES; without these the cards
|
|
882
|
+
* would fall into the generic Tool call title and lose their identity.
|
|
883
|
+
*/
|
|
884
|
+
const TUI_TOOL_TITLES = {
|
|
885
|
+
shell: "Shell",
|
|
886
|
+
subagent: "Subagent",
|
|
887
|
+
workflow: "Workflow",
|
|
888
|
+
"workflow-member": "Workflow Agent",
|
|
889
|
+
error: "Error",
|
|
890
|
+
interrupted: "Interrupted",
|
|
891
|
+
ask_user_question: "Question",
|
|
892
|
+
get_goal: "Read Goal",
|
|
893
|
+
create_goal: "Create Goal",
|
|
894
|
+
update_goal: "Update Goal"
|
|
895
|
+
};
|
|
896
|
+
/** The goal tool family (all three share the `{"goal":…}` result shape). */
|
|
897
|
+
const GOAL_TOOL_NAMES = /* @__PURE__ */ new Set([
|
|
898
|
+
"get_goal",
|
|
899
|
+
"create_goal",
|
|
900
|
+
"update_goal"
|
|
901
|
+
]);
|
|
902
|
+
/**
|
|
903
|
+
* Tools whose folded result preview must never leak the raw result JSON
|
|
904
|
+
* (web parity: the web's folded row shows only the args summary, never the
|
|
905
|
+
* result). The TUI keeps its result-preview row but shows a parsed summary
|
|
906
|
+
* instead — and nothing at all when the result cannot be parsed.
|
|
907
|
+
*
|
|
908
|
+
* `ralph`'s render already leads with a friendly line ("Ralph worker
|
|
909
|
+
* reported completion after N rounds.") before the JSON report, so its
|
|
910
|
+
* summary IS that first line. The agent-team family is deliberately NOT in
|
|
911
|
+
* the set: it is experimental (not in the production bundle) and two of its
|
|
912
|
+
* tools (`send_message`, `interrupt_agent`) share names with subagent tools
|
|
913
|
+
* whose render text is friendly — a name-keyed set cannot tell them apart.
|
|
914
|
+
*/
|
|
915
|
+
const FOLDED_JSON_RESULT_TOOLS = /* @__PURE__ */ new Set([
|
|
916
|
+
"ralph",
|
|
917
|
+
"schedule_create",
|
|
918
|
+
"schedule_list",
|
|
919
|
+
"schedule_delete",
|
|
920
|
+
"cordis_inspect_list",
|
|
921
|
+
"cordis_inspect_query",
|
|
922
|
+
"cordis_inspect_self"
|
|
923
|
+
]);
|
|
924
|
+
/** Parse a result text as JSON, tolerating a friendly prefix line before
|
|
925
|
+
* the JSON document (e.g. ralph's "…\nFinal report:\n{…}"). */
|
|
926
|
+
function parseJsonValue(text) {
|
|
927
|
+
try {
|
|
928
|
+
return JSON.parse(text);
|
|
929
|
+
} catch {}
|
|
930
|
+
const brace = text.indexOf("{");
|
|
931
|
+
const bracket = text.indexOf("[");
|
|
932
|
+
const start = bracket !== -1 && (brace === -1 || bracket < brace) ? bracket : brace;
|
|
933
|
+
if (start > 0) try {
|
|
934
|
+
return JSON.parse(text.slice(start));
|
|
935
|
+
} catch {
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
/** A non-empty string field, or undefined. */
|
|
940
|
+
function stringField(value) {
|
|
941
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* The folded preview summary for one of {@link FOLDED_JSON_RESULT_TOOLS}:
|
|
945
|
+
* a short human phrase derived from the parsed result JSON. Returns
|
|
946
|
+
* undefined when nothing useful can be derived — the caller then shows NO
|
|
947
|
+
* folded result preview (web parity), never the raw JSON.
|
|
948
|
+
* @param name - the tool name.
|
|
949
|
+
* @param text - the settled result text.
|
|
950
|
+
*/
|
|
951
|
+
function foldedResultSummaryFor(name, text) {
|
|
952
|
+
if (name === "ralph") {
|
|
953
|
+
const line = firstLine(text);
|
|
954
|
+
if (line !== "" && !line.startsWith("{")) return line;
|
|
955
|
+
const parsed = parseJsonValue(text);
|
|
956
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
957
|
+
const record = parsed;
|
|
958
|
+
const status = stringField(record.status);
|
|
959
|
+
const rounds = typeof record.roundsStarted === "number" ? String(record.roundsStarted) : void 0;
|
|
960
|
+
const parts = [status, rounds === void 0 ? void 0 : `${rounds} rounds`].filter((part) => part !== void 0);
|
|
961
|
+
return parts.length === 0 ? void 0 : parts.join(" · ");
|
|
962
|
+
}
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const parsed = parseJsonValue(text);
|
|
966
|
+
if (name === "schedule_list") {
|
|
967
|
+
if (Array.isArray(parsed)) return parsed.length === 0 ? "no scheduled jobs" : `${parsed.length} scheduled`;
|
|
968
|
+
if (typeof parsed === "object" && parsed !== null) return stringField(parsed.code);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
if (name === "schedule_create") {
|
|
972
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
973
|
+
const record = parsed;
|
|
974
|
+
const parts = [stringField(record.kind), stringField(record.state)].filter((part) => part !== void 0);
|
|
975
|
+
return parts.length === 0 ? void 0 : parts.join(" · ");
|
|
976
|
+
}
|
|
977
|
+
if (name === "schedule_delete") {
|
|
978
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
979
|
+
const record = parsed;
|
|
980
|
+
if (record.deleted === true) return "deleted";
|
|
981
|
+
if (record.deleted === false) return "not found";
|
|
982
|
+
return stringField(record.code);
|
|
983
|
+
}
|
|
984
|
+
if (name === "cordis_inspect_list") {
|
|
985
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
986
|
+
const providers = parsed.providers;
|
|
987
|
+
if (Array.isArray(providers)) return providers.length === 0 ? "no providers" : `${providers.length} providers`;
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (name === "cordis_inspect_query") {
|
|
991
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
992
|
+
const record = parsed;
|
|
993
|
+
const parts = [
|
|
994
|
+
stringField(record.platform),
|
|
995
|
+
stringField(record.provider),
|
|
996
|
+
stringField(record.method)
|
|
997
|
+
].filter((part) => part !== void 0);
|
|
998
|
+
return parts.length === 0 ? void 0 : parts.join(" · ");
|
|
999
|
+
}
|
|
1000
|
+
if (name === "cordis_inspect_self") {
|
|
1001
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
1002
|
+
const mode = stringField(parsed.mode);
|
|
1003
|
+
return mode === void 0 ? void 0 : `mode ${mode}`;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
function nonBlankString(value) {
|
|
1007
|
+
return typeof value === "string" && value.trim() !== "" ? value : void 0;
|
|
1008
|
+
}
|
|
1009
|
+
/** Parse standard and Agent Teams-compatible send_message arguments. */
|
|
1010
|
+
function sendMessageCallPresentation(argsRaw) {
|
|
1011
|
+
const parsed = parseArgs(argsRaw);
|
|
1012
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
1013
|
+
const args = parsed;
|
|
1014
|
+
const target = nonBlankString(args.agent_id) ?? nonBlankString(args.target);
|
|
1015
|
+
if (target === void 0 || typeof args.message !== "string") return void 0;
|
|
1016
|
+
return {
|
|
1017
|
+
target,
|
|
1018
|
+
message: args.message
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
/** Parse standard and Agent Teams-compatible interrupt_agent arguments. */
|
|
1022
|
+
function interruptAgentCallPresentation(argsRaw) {
|
|
1023
|
+
const parsed = parseArgs(argsRaw);
|
|
1024
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
1025
|
+
const args = parsed;
|
|
1026
|
+
const target = nonBlankString(args.agent_id) ?? nonBlankString(args.target);
|
|
1027
|
+
return target === void 0 ? void 0 : { target };
|
|
1028
|
+
}
|
|
1029
|
+
/** Parse the terminal_send identity and payload. */
|
|
1030
|
+
function terminalSendCallPresentation(argsRaw) {
|
|
1031
|
+
const parsed = parseArgs(argsRaw);
|
|
1032
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
1033
|
+
const args = parsed;
|
|
1034
|
+
const sessionId = nonBlankString(args.sessionId);
|
|
1035
|
+
if (sessionId === void 0 || typeof args.text !== "string") return void 0;
|
|
1036
|
+
if (args.submit !== void 0 && typeof args.submit !== "boolean") return void 0;
|
|
1037
|
+
if (args.run_in_background !== void 0 && typeof args.run_in_background !== "boolean") return void 0;
|
|
1038
|
+
return {
|
|
1039
|
+
sessionId,
|
|
1040
|
+
text: args.text
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
/** Parse list_agents scope without consulting any live registry. */
|
|
1044
|
+
function listAgentsCallPresentation(argsRaw) {
|
|
1045
|
+
const parsed = argsRaw.trim() === "" ? {} : parseArgs(argsRaw);
|
|
1046
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
1047
|
+
const scope = parsed.scope;
|
|
1048
|
+
if (scope === void 0) return { scope: "children" };
|
|
1049
|
+
return scope === "children" || scope === "descendants" ? { scope } : void 0;
|
|
1050
|
+
}
|
|
1051
|
+
/** Parse the display-only file declarations from a `present` call. */
|
|
1052
|
+
function presentCallPresentation(argsRaw) {
|
|
1053
|
+
const parsed = parseArgs(argsRaw);
|
|
1054
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
1055
|
+
const files = parsed.files;
|
|
1056
|
+
if (!Array.isArray(files)) return void 0;
|
|
1057
|
+
const presented = [];
|
|
1058
|
+
for (const file of files) {
|
|
1059
|
+
if (typeof file !== "object" || file === null || Array.isArray(file)) return void 0;
|
|
1060
|
+
const value = file;
|
|
1061
|
+
if (typeof value.path !== "string" || value.path.trim() === "") return void 0;
|
|
1062
|
+
if (value.description !== void 0 && typeof value.description !== "string") return void 0;
|
|
1063
|
+
presented.push({
|
|
1064
|
+
path: value.path,
|
|
1065
|
+
...value.description === void 0 ? {} : { description: value.description }
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
return { files: presented };
|
|
1069
|
+
}
|
|
1070
|
+
/** Count one structured list_agents snapshot. */
|
|
1071
|
+
function summarizeAgentEntries(entries) {
|
|
1072
|
+
let running = 0;
|
|
1073
|
+
let idle = 0;
|
|
1074
|
+
let ready = 0;
|
|
1075
|
+
let diagnostics = 0;
|
|
1076
|
+
for (const entry of entries) {
|
|
1077
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
1078
|
+
diagnostics += 1;
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
const value = entry;
|
|
1082
|
+
if (value.kind === "diagnostic") {
|
|
1083
|
+
diagnostics += 1;
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1086
|
+
if (value.kind !== void 0 && value.kind !== "child" && value.kind !== "agent") {
|
|
1087
|
+
diagnostics += 1;
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
switch (value.status) {
|
|
1091
|
+
case "running":
|
|
1092
|
+
case "provisioning":
|
|
1093
|
+
running += 1;
|
|
1094
|
+
break;
|
|
1095
|
+
case "idle":
|
|
1096
|
+
idle += 1;
|
|
1097
|
+
break;
|
|
1098
|
+
case "ready":
|
|
1099
|
+
case "inactive":
|
|
1100
|
+
ready += 1;
|
|
1101
|
+
break;
|
|
1102
|
+
default: diagnostics += 1;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
return {
|
|
1106
|
+
total: running + idle + ready + diagnostics,
|
|
1107
|
+
running,
|
|
1108
|
+
idle,
|
|
1109
|
+
ready,
|
|
1110
|
+
...diagnostics === 0 ? {} : { diagnostics }
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
/** Parse both the structured snapshot and the standard rendered text. */
|
|
1114
|
+
function summarizeAgentListResult(text) {
|
|
1115
|
+
const parsed = parseJsonValue(text);
|
|
1116
|
+
if (Array.isArray(parsed)) return summarizeAgentEntries(parsed);
|
|
1117
|
+
const trimmed = text.trim();
|
|
1118
|
+
if (trimmed === "(no subagents)") return {
|
|
1119
|
+
total: 0,
|
|
1120
|
+
running: 0,
|
|
1121
|
+
idle: 0,
|
|
1122
|
+
ready: 0
|
|
1123
|
+
};
|
|
1124
|
+
if (trimmed === "") return void 0;
|
|
1125
|
+
let running = 0;
|
|
1126
|
+
let idle = 0;
|
|
1127
|
+
let ready = 0;
|
|
1128
|
+
let diagnostics = 0;
|
|
1129
|
+
let matched = 0;
|
|
1130
|
+
for (const line of trimmed.split(/\r\n|\r|\n/)) {
|
|
1131
|
+
if (/\[running\](?:\s|$)/.test(line)) {
|
|
1132
|
+
running += 1;
|
|
1133
|
+
matched += 1;
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
if (/\[idle\](?:\s|$)/.test(line)) {
|
|
1137
|
+
idle += 1;
|
|
1138
|
+
matched += 1;
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
if (/\[ready\](?:\s|$)/.test(line)) {
|
|
1142
|
+
ready += 1;
|
|
1143
|
+
matched += 1;
|
|
1144
|
+
continue;
|
|
1145
|
+
}
|
|
1146
|
+
if (/\[diagnostic:[^\]]+\]/.test(line)) {
|
|
1147
|
+
diagnostics += 1;
|
|
1148
|
+
matched += 1;
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
if (/\[[a-z][^\]]*\]/.test(line)) {
|
|
1152
|
+
diagnostics += 1;
|
|
1153
|
+
matched += 1;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
if (matched === 0) return void 0;
|
|
1157
|
+
return {
|
|
1158
|
+
total: running + idle + ready + diagnostics,
|
|
1159
|
+
running,
|
|
1160
|
+
idle,
|
|
1161
|
+
ready,
|
|
1162
|
+
...diagnostics === 0 ? {} : { diagnostics }
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
/** Format a list snapshot without treating diagnostics as agents. */
|
|
1166
|
+
function formatAgentListSummary(summary) {
|
|
1167
|
+
if (summary.total === 0) return "no subagents";
|
|
1168
|
+
const agents = summary.running + summary.idle + summary.ready;
|
|
1169
|
+
const diagnostics = summary.diagnostics ?? 0;
|
|
1170
|
+
const parts = [diagnostics > 0 ? `${summary.total} ${summary.total === 1 ? "entry" : "entries"}` : `${agents} ${agents === 1 ? "agent" : "agents"}`];
|
|
1171
|
+
if (summary.running > 0) parts.push(`${summary.running} running`);
|
|
1172
|
+
if (summary.idle > 0) parts.push(`${summary.idle} idle`);
|
|
1173
|
+
if (summary.ready > 0) parts.push(`${summary.ready} ready`);
|
|
1174
|
+
if (diagnostics > 0) parts.push(`${diagnostics} ${diagnostics === 1 ? "diagnostic" : "diagnostics"}`);
|
|
1175
|
+
return parts.join(" · ");
|
|
1176
|
+
}
|
|
1177
|
+
/** A compact result line for a non-JSON action acknowledgement/error. */
|
|
1178
|
+
function firstActionResultDetail(text) {
|
|
1179
|
+
const trimmed = text.trim();
|
|
1180
|
+
if (trimmed === "") return void 0;
|
|
1181
|
+
const parsed = parseJsonValue(text);
|
|
1182
|
+
if (typeof parsed === "string") return firstLine(parsed);
|
|
1183
|
+
if (parsed !== void 0 || /^[\[{]/.test(trimmed)) return void 0;
|
|
1184
|
+
return firstLine(trimmed);
|
|
1185
|
+
}
|
|
1186
|
+
/** Pull a human detail out of a structured action error without raw JSON. */
|
|
1187
|
+
function actionErrorDetail(text) {
|
|
1188
|
+
const parsed = parseJsonValue(text);
|
|
1189
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
1190
|
+
const value = parsed;
|
|
1191
|
+
const code = nonBlankString(value.code);
|
|
1192
|
+
const message = nonBlankString(value.message ?? value.error ?? value.detail ?? value.reason);
|
|
1193
|
+
if (code !== void 0 && message !== void 0) return `${code}: ${message}`;
|
|
1194
|
+
return message ?? code;
|
|
1195
|
+
}
|
|
1196
|
+
return firstActionResultDetail(text) ?? (text.trim() === "" ? void 0 : firstLine(text.trim()));
|
|
1197
|
+
}
|
|
1198
|
+
/** Combine a structured error identity with any safe human detail. */
|
|
1199
|
+
function actionErrorResult(text, options) {
|
|
1200
|
+
const identity = options.error === void 0 ? void 0 : `${options.error.name}: ${options.error.code}`;
|
|
1201
|
+
const detail = actionErrorDetail(text);
|
|
1202
|
+
return identity === void 0 ? detail : detail === void 0 ? identity : `${identity}: ${detail}`;
|
|
1203
|
+
}
|
|
1204
|
+
const ACKNOWLEDGEMENT_STATUSES = /* @__PURE__ */ new Set([
|
|
1205
|
+
"accepted",
|
|
1206
|
+
"completed",
|
|
1207
|
+
"delivered",
|
|
1208
|
+
"ok",
|
|
1209
|
+
"sent",
|
|
1210
|
+
"success"
|
|
1211
|
+
]);
|
|
1212
|
+
/** Derive a meaningful result for one action tool; receipt-only results vanish. */
|
|
1213
|
+
function actionResultSummary(kind, text) {
|
|
1214
|
+
const trimmed = text.trim();
|
|
1215
|
+
if (trimmed === "") return void 0;
|
|
1216
|
+
const parsed = parseJsonValue(text);
|
|
1217
|
+
if (kind === "terminal_send") {
|
|
1218
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
1219
|
+
const value = parsed;
|
|
1220
|
+
if (nonBlankString(value.status)?.toLowerCase() === "queued") return "queued";
|
|
1221
|
+
if (value.kind === "background") {
|
|
1222
|
+
const jobId = nonBlankString(value.jobId);
|
|
1223
|
+
return jobId === void 0 ? void 0 : `started background job ${jobId}`;
|
|
1224
|
+
}
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
const value = typeof parsed === "string" ? firstLine(parsed) : firstLine(trimmed);
|
|
1228
|
+
if (/^started background job\b/i.test(value)) return value;
|
|
1229
|
+
return /\bqueued\b/i.test(value) ? "queued" : void 0;
|
|
1230
|
+
}
|
|
1231
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
1232
|
+
const value = parsed;
|
|
1233
|
+
const status = nonBlankString(value.status)?.toLowerCase();
|
|
1234
|
+
if (status === "queued") return "queued";
|
|
1235
|
+
if (status !== void 0 && !ACKNOWLEDGEMENT_STATUSES.has(status)) return status;
|
|
1236
|
+
if (kind === "interrupt_agent") {
|
|
1237
|
+
const previous = nonBlankString(value.previousStatus);
|
|
1238
|
+
if (previous !== void 0) return `was ${previous}`;
|
|
1239
|
+
}
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
const value = typeof parsed === "string" ? firstLine(parsed) : firstLine(trimmed);
|
|
1243
|
+
if (value === "") return void 0;
|
|
1244
|
+
if (kind === "send_message" && /^message\s+(?:delivered|sent|accepted)\b/i.test(value)) return /\bqueued\b/i.test(value) ? "queued" : void 0;
|
|
1245
|
+
if (kind === "interrupt_agent" && /^interrupt\s+requested\s+for\s+agent\b/i.test(value)) return void 0;
|
|
1246
|
+
if (/^(?:ok|accepted|done|success|succeeded|sent|delivered)$/i.test(value)) return void 0;
|
|
1247
|
+
return firstActionResultDetail(value);
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Derive action semantics from the recorded call/result only. This is the
|
|
1251
|
+
* host fallback boundary: extension-owned renderers still take precedence in
|
|
1252
|
+
* the TUI, while replay remains deterministic and registry-independent.
|
|
1253
|
+
*/
|
|
1254
|
+
function compactToolPresentation(name, argsRaw, result = "", options = {}) {
|
|
1255
|
+
if (name === "present") {
|
|
1256
|
+
const call = presentCallPresentation(argsRaw);
|
|
1257
|
+
const resultText = options.isError === true || options.error !== void 0 ? actionErrorResult(result, options) : void 0;
|
|
1258
|
+
const count = call === void 0 ? void 0 : `${call.files.length} ${call.files.length === 1 ? "file" : "files"}`;
|
|
1259
|
+
return {
|
|
1260
|
+
title: "Present files",
|
|
1261
|
+
...count === void 0 ? {} : { summary: count },
|
|
1262
|
+
...resultText === void 0 ? {} : { result: resultText }
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
const send = name === "send_message" ? sendMessageCallPresentation(argsRaw) : void 0;
|
|
1266
|
+
if (send !== void 0) {
|
|
1267
|
+
const resultText = options.isError === true || options.error !== void 0 ? actionErrorResult(result, options) : actionResultSummary("send_message", result);
|
|
1268
|
+
return {
|
|
1269
|
+
title: "Send message",
|
|
1270
|
+
summary: send.target,
|
|
1271
|
+
payload: send.message,
|
|
1272
|
+
...resultText === void 0 ? {} : { result: resultText }
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
const terminal = name === "terminal_send" ? terminalSendCallPresentation(argsRaw) : void 0;
|
|
1276
|
+
if (terminal !== void 0) {
|
|
1277
|
+
const resultText = options.isError === true || options.error !== void 0 ? actionErrorResult(result, options) : actionResultSummary("terminal_send", result);
|
|
1278
|
+
return {
|
|
1279
|
+
title: "Terminal",
|
|
1280
|
+
summary: terminal.sessionId,
|
|
1281
|
+
payload: terminal.text,
|
|
1282
|
+
...resultText === void 0 ? {} : { result: resultText }
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
const interrupt = name === "interrupt_agent" ? interruptAgentCallPresentation(argsRaw) : void 0;
|
|
1286
|
+
if (interrupt !== void 0) {
|
|
1287
|
+
const resultText = options.isError === true || options.error !== void 0 ? actionErrorResult(result, options) : actionResultSummary("interrupt_agent", result);
|
|
1288
|
+
return {
|
|
1289
|
+
title: "Interrupt agent",
|
|
1290
|
+
summary: interrupt.target,
|
|
1291
|
+
...resultText === void 0 ? {} : { result: resultText }
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
const list = name === "list_agents" ? listAgentsCallPresentation(argsRaw) : void 0;
|
|
1295
|
+
if (list !== void 0) {
|
|
1296
|
+
const summary = options.isError === true || options.error !== void 0 ? actionErrorResult(result, options) : (() => {
|
|
1297
|
+
const snapshot = summarizeAgentListResult(result);
|
|
1298
|
+
return snapshot === void 0 ? void 0 : formatAgentListSummary(snapshot);
|
|
1299
|
+
})();
|
|
1300
|
+
return {
|
|
1301
|
+
title: "List agents",
|
|
1302
|
+
summary: list.scope,
|
|
1303
|
+
...summary === void 0 ? {} : { result: summary }
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
/** Whether a valid action-tool call uses the compact semantic fallback. */
|
|
1308
|
+
function isCompactActionTool(name, argsRaw) {
|
|
1309
|
+
return compactToolPresentation(name, argsRaw) !== void 0;
|
|
1310
|
+
}
|
|
1311
|
+
/** Expanded semantic lines for action cards; list_agents keeps its history snapshot. */
|
|
1312
|
+
function compactToolExpandedLines(name, argsRaw, result = "", options = {}) {
|
|
1313
|
+
const presentation = compactToolPresentation(name, argsRaw, result, options);
|
|
1314
|
+
if (presentation === void 0) return void 0;
|
|
1315
|
+
if (name === "present") {
|
|
1316
|
+
const lines = presentCallPresentation(argsRaw)?.files.map((file) => file.description === void 0 || file.description === "" ? file.path : `${file.path} — ${file.description}`) ?? [];
|
|
1317
|
+
if (presentation.result !== void 0) lines.push(presentation.result);
|
|
1318
|
+
return lines;
|
|
1319
|
+
}
|
|
1320
|
+
if (name === "list_agents" && options.isError !== true && options.error === void 0) {
|
|
1321
|
+
const parsed = parseJsonValue(result);
|
|
1322
|
+
if (Array.isArray(parsed)) return JSON.stringify(parsed, null, 2).split("\n");
|
|
1323
|
+
if (result.trim() !== "") return result.split(/\r\n|\r|\n/);
|
|
1324
|
+
}
|
|
1325
|
+
const lines = presentation.payload === void 0 ? [] : presentation.payload.split(/\r\n|\r|\n/);
|
|
1326
|
+
if (presentation.result !== void 0) lines.push(presentation.result);
|
|
1327
|
+
return lines;
|
|
1328
|
+
}
|
|
1329
|
+
/** Summary key preference per variant (args-derived). */
|
|
1330
|
+
const SUMMARY_KEYS = {
|
|
1331
|
+
bash: ["description", "command"],
|
|
1332
|
+
read: [
|
|
1333
|
+
"path",
|
|
1334
|
+
"file_path",
|
|
1335
|
+
"url"
|
|
1336
|
+
],
|
|
1337
|
+
search: [
|
|
1338
|
+
"query",
|
|
1339
|
+
"pattern",
|
|
1340
|
+
"url"
|
|
1341
|
+
],
|
|
1342
|
+
write: ["path", "file_path"],
|
|
1343
|
+
edit: ["path", "file_path"],
|
|
1344
|
+
code: ["description"],
|
|
1345
|
+
others: []
|
|
1346
|
+
};
|
|
1347
|
+
/** Return the formal Tool card's preferred argument summary keys. Preparing
|
|
1348
|
+
* extraction treats this order as the best available choice at extraction time. */
|
|
1349
|
+
function toolSummaryKeys(name) {
|
|
1350
|
+
return SUMMARY_KEYS[classifyTool(name)] ?? [];
|
|
1351
|
+
}
|
|
1352
|
+
/** The first line of a text (the Web's ReasoningRow summary for settled rows). */
|
|
1353
|
+
function firstLine(text) {
|
|
1354
|
+
const newline = text.indexOf("\n");
|
|
1355
|
+
return newline === -1 ? text : text.slice(0, newline);
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Strip the workspace root from a workspace-rooted absolute path (display
|
|
1359
|
+
* only), exactly like the Web's relativizeToCwd: only the workspace-root
|
|
1360
|
+
* prefix is peeled, both `/` and `\` separators are handled, and a path
|
|
1361
|
+
* outside the root is returned unchanged.
|
|
1362
|
+
* @param text - the path to shorten.
|
|
1363
|
+
* @param cwd - session workspace root; absent or empty leaves the path unchanged.
|
|
1364
|
+
* @returns the path relative to the workspace root, or unchanged when it is not rooted there.
|
|
1365
|
+
*/
|
|
1366
|
+
function relativizeToCwd(text, cwd) {
|
|
1367
|
+
if (cwd === void 0 || cwd === "") return text;
|
|
1368
|
+
const root = cwd.replace(/[/\\]+$/, "");
|
|
1369
|
+
if (text.startsWith(root + "/") || text.startsWith(root + "\\")) return text.slice(root.length + 1);
|
|
1370
|
+
return text;
|
|
1371
|
+
}
|
|
1372
|
+
function parseArgs(argsRaw) {
|
|
1373
|
+
try {
|
|
1374
|
+
return JSON.parse(argsRaw);
|
|
1375
|
+
} catch {
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
function pickString(args, keys) {
|
|
1380
|
+
for (const key of keys) {
|
|
1381
|
+
const value = args[key];
|
|
1382
|
+
if (typeof value === "string" && value !== "") return value;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
/** The summary key preference for one row variant, falling back to the first
|
|
1386
|
+
* string arg value, then to the raw args text (Web deriveSummary). */
|
|
1387
|
+
function deriveSummary(name, argsRaw) {
|
|
1388
|
+
const parsed = parseArgs(argsRaw);
|
|
1389
|
+
if (typeof parsed !== "object" || parsed === null) return firstLine(argsRaw);
|
|
1390
|
+
const args = parsed;
|
|
1391
|
+
const picked = pickString(args, toolSummaryKeys(name));
|
|
1392
|
+
if (picked !== void 0) return firstLine(picked);
|
|
1393
|
+
for (const value of Object.values(args)) if (typeof value === "string" && value !== "") return firstLine(value);
|
|
1394
|
+
return firstLine(argsRaw);
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Tool-specific args summaries for tools whose natural one-line identity is
|
|
1398
|
+
* not a raw arg value (Web TodoRow/AskQuestionRow parity): `todo_write`
|
|
1399
|
+
* reads `done/total done` plus the first active item, `ask_user_question`
|
|
1400
|
+
* reads the first question text. Returns undefined for every other tool,
|
|
1401
|
+
* letting the generic derivation own the summary.
|
|
1402
|
+
* @param name - the tool name.
|
|
1403
|
+
* @param argsRaw - the raw arguments JSON.
|
|
1404
|
+
* @returns the tool-specific summary, or undefined when none applies.
|
|
1405
|
+
*/
|
|
1406
|
+
function summarizeToolArgs(name, argsRaw) {
|
|
1407
|
+
const parsed = parseArgs(argsRaw);
|
|
1408
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
1409
|
+
const args = parsed;
|
|
1410
|
+
if (name === "ask_user_question") {
|
|
1411
|
+
const questions = args.questions;
|
|
1412
|
+
if (!Array.isArray(questions) || questions.length === 0) return void 0;
|
|
1413
|
+
const first = questions[0];
|
|
1414
|
+
if (typeof first !== "object" || first === null) return void 0;
|
|
1415
|
+
const question = first.question;
|
|
1416
|
+
if (typeof question !== "string" || question === "") return void 0;
|
|
1417
|
+
return firstLine(question);
|
|
1418
|
+
}
|
|
1419
|
+
if (name === "create_goal") {
|
|
1420
|
+
const objective = args.objective;
|
|
1421
|
+
return typeof objective === "string" && objective !== "" ? firstLine(objective) : void 0;
|
|
1422
|
+
}
|
|
1423
|
+
if (name === "update_goal") {
|
|
1424
|
+
const action = args.action;
|
|
1425
|
+
return typeof action === "string" && action !== "" ? action : void 0;
|
|
1426
|
+
}
|
|
1427
|
+
if (name === "get_goal") return "";
|
|
1428
|
+
if (name === "skill") {
|
|
1429
|
+
const skillName = args.name;
|
|
1430
|
+
return typeof skillName === "string" && skillName !== "" ? firstLine(skillName) : void 0;
|
|
1431
|
+
}
|
|
1432
|
+
if (name !== "todo_write") return void 0;
|
|
1433
|
+
const todos = args.todos;
|
|
1434
|
+
if (!Array.isArray(todos)) return void 0;
|
|
1435
|
+
const done = todos.filter((todo) => typeof todo === "object" && todo !== null && todo.status === "completed").length;
|
|
1436
|
+
const active = todos.find((todo) => typeof todo === "object" && todo !== null && todo.status !== "completed");
|
|
1437
|
+
const content = active === void 0 ? "" : String(active.content ?? "");
|
|
1438
|
+
return `${done}/${todos.length} done${content === "" ? "" : ` · ${firstLine(content)}`}`;
|
|
1439
|
+
}
|
|
1440
|
+
/** Classify a tool name into its row variant; unknown names are `others`. */
|
|
1441
|
+
function classifyTool(name) {
|
|
1442
|
+
return Object.prototype.hasOwnProperty.call(TOOL_VARIANTS, name) ? TOOL_VARIANTS[name] : "others";
|
|
1443
|
+
}
|
|
1444
|
+
/** The structural icon semantic per exact tool name, for synthetic cards
|
|
1445
|
+
* without a registry entry (the glyph itself resolves through
|
|
1446
|
+
* src/tui/icons.ts — fold state never stores a concrete emoji/symbol). */
|
|
1447
|
+
const TOOL_SEMANTICS = {
|
|
1448
|
+
shell: "tool-shell",
|
|
1449
|
+
subagent: "subagent",
|
|
1450
|
+
workflow: "workflow",
|
|
1451
|
+
"workflow-member": "subagent",
|
|
1452
|
+
error: "error",
|
|
1453
|
+
interrupted: "interrupted",
|
|
1454
|
+
ask_user_question: "question"
|
|
1455
|
+
};
|
|
1456
|
+
/** The icon semantic per row variant, applied to every registered tool of
|
|
1457
|
+
* that class. */
|
|
1458
|
+
const VARIANT_SEMANTICS = {
|
|
1459
|
+
read: "tool-read",
|
|
1460
|
+
search: "tool-search",
|
|
1461
|
+
bash: "tool-shell",
|
|
1462
|
+
write: "tool-write",
|
|
1463
|
+
edit: "tool-edit",
|
|
1464
|
+
code: "tool-code",
|
|
1465
|
+
others: "tool-generic"
|
|
1466
|
+
};
|
|
1467
|
+
/**
|
|
1468
|
+
* The card header's leading icon semantic: exact-name entries first
|
|
1469
|
+
* (synthetic cards), then slash commands (a control action, not a tool),
|
|
1470
|
+
* then the tool's row-variant semantic, then the generic tool. The renderer
|
|
1471
|
+
* resolves the glyph through `iconFor(semantic, iconStyle)`.
|
|
1472
|
+
* @param name - the tool name.
|
|
1473
|
+
*/
|
|
1474
|
+
function toolIconSemantic(name) {
|
|
1475
|
+
const exact = Object.prototype.hasOwnProperty.call(TOOL_SEMANTICS, name) ? TOOL_SEMANTICS[name] : void 0;
|
|
1476
|
+
if (exact !== void 0) return exact;
|
|
1477
|
+
if (name.startsWith("/")) return "slash-command";
|
|
1478
|
+
return VARIANT_SEMANTICS[classifyTool(name)];
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* The short title used by live tool-call presentations. Known names reuse the
|
|
1482
|
+
* same exact-title and variant tables as formal tool cards; an unknown tool is
|
|
1483
|
+
* intentionally generic rather than exposing its raw model-facing name.
|
|
1484
|
+
*/
|
|
1485
|
+
function toolTitle(name) {
|
|
1486
|
+
if (name === "") return "tool";
|
|
1487
|
+
const exact = Object.prototype.hasOwnProperty.call(TOOL_TITLES, name) ? TOOL_TITLES[name] : Object.prototype.hasOwnProperty.call(TUI_TOOL_TITLES, name) ? TUI_TOOL_TITLES[name] : void 0;
|
|
1488
|
+
if (exact !== void 0) return exact;
|
|
1489
|
+
const variant = classifyTool(name);
|
|
1490
|
+
return variant === "others" ? "Tool" : VARIANT_TITLES[variant];
|
|
1491
|
+
}
|
|
1492
|
+
/** Derive the folded preview from a tool name and its raw args JSON. */
|
|
1493
|
+
function parseCallPreview(name, argsRaw) {
|
|
1494
|
+
const parsed = parseArgs(argsRaw);
|
|
1495
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
1496
|
+
const args = parsed;
|
|
1497
|
+
if (name === "bash" || name === "pwsh") {
|
|
1498
|
+
const command = args.command;
|
|
1499
|
+
if (typeof command !== "string" || command === "") return void 0;
|
|
1500
|
+
const workdir = args.workdir;
|
|
1501
|
+
return {
|
|
1502
|
+
kind: "bash",
|
|
1503
|
+
command,
|
|
1504
|
+
...typeof workdir === "string" && workdir !== "" ? { workdir } : {}
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
if (name === "edit") {
|
|
1508
|
+
const oldText = args.old_string;
|
|
1509
|
+
const newText = args.new_string;
|
|
1510
|
+
if (typeof oldText !== "string" || typeof newText !== "string") return void 0;
|
|
1511
|
+
const path = typeof args.file_path === "string" && args.file_path !== "" ? args.file_path : args.path;
|
|
1512
|
+
return {
|
|
1513
|
+
kind: "diff",
|
|
1514
|
+
diffs: [{
|
|
1515
|
+
path: typeof path === "string" ? path : "",
|
|
1516
|
+
oldText,
|
|
1517
|
+
newText
|
|
1518
|
+
}]
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
if (name === "write") {
|
|
1522
|
+
const content = args.content;
|
|
1523
|
+
if (typeof content !== "string" || content === "") return void 0;
|
|
1524
|
+
const path = typeof args.file_path === "string" && args.file_path !== "" ? args.file_path : args.path;
|
|
1525
|
+
return {
|
|
1526
|
+
kind: "diff",
|
|
1527
|
+
diffs: [{
|
|
1528
|
+
path: typeof path === "string" ? path : "",
|
|
1529
|
+
oldText: null,
|
|
1530
|
+
newText: content
|
|
1531
|
+
}]
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Parse the read tool's model-facing envelopes from a result: one
|
|
1537
|
+
* `<path>…</path> <type>…</type> <content>…numbered lines…</content>` block
|
|
1538
|
+
* per file read. A merged group card (groupConsecutiveReads) carries several
|
|
1539
|
+
* consecutive envelopes, hence the plural form; the single-envelope helper
|
|
1540
|
+
* below is the common-case convenience.
|
|
1541
|
+
* @param result - the tool result text.
|
|
1542
|
+
* @returns every parsed envelope; empty when the result is not a read envelope.
|
|
1543
|
+
*/
|
|
1544
|
+
function parseReadEnvelopes(result) {
|
|
1545
|
+
const out = [];
|
|
1546
|
+
const block = /<path>([\s\S]*?)<\/path>\s*<type>[^<]*<\/type>\s*<content>([\s\S]*?)<\/content>/g;
|
|
1547
|
+
let match;
|
|
1548
|
+
while ((match = block.exec(result)) !== null) {
|
|
1549
|
+
const path = match[1] ?? "";
|
|
1550
|
+
const body = match[2] ?? "";
|
|
1551
|
+
const lines = [];
|
|
1552
|
+
let totalLines;
|
|
1553
|
+
for (const raw of body.split("\n")) {
|
|
1554
|
+
const numbered = /^(\d+): (.*)$/.exec(raw);
|
|
1555
|
+
if (numbered !== null) {
|
|
1556
|
+
lines.push({
|
|
1557
|
+
number: Number(numbered[1]),
|
|
1558
|
+
text: numbered[2] ?? ""
|
|
1559
|
+
});
|
|
1560
|
+
continue;
|
|
1561
|
+
}
|
|
1562
|
+
const end = /^\(End of file - total (\d+) lines\)$/.exec(raw);
|
|
1563
|
+
if (end !== null) {
|
|
1564
|
+
totalLines = Number(end[1]);
|
|
1565
|
+
continue;
|
|
1566
|
+
}
|
|
1567
|
+
const showing = /^\(Showing lines \d+-\d+ of (\d+) lines/.exec(raw);
|
|
1568
|
+
if (showing !== null) totalLines = Number(showing[1]);
|
|
1569
|
+
}
|
|
1570
|
+
out.push({
|
|
1571
|
+
path,
|
|
1572
|
+
lines,
|
|
1573
|
+
...totalLines === void 0 ? {} : { totalLines }
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
return out;
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* Parse the write tool's XML confirmation envelope (`<path>…</path> <type>
|
|
1580
|
+
* …</type> <content>Created file</content>` — no file content is echoed).
|
|
1581
|
+
* The verb + path are the only material the envelope carries. A malformed
|
|
1582
|
+
* envelope — including a blank `<path>` — yields undefined, never a
|
|
1583
|
+
* partial parse (a whitespace path would surface as an invalid card row).
|
|
1584
|
+
* @param result - the tool result text.
|
|
1585
|
+
* @returns the envelope's verb and path, or undefined when the result is
|
|
1586
|
+
* not a well-formed write envelope.
|
|
1587
|
+
*/
|
|
1588
|
+
function parseWriteEnvelope(result) {
|
|
1589
|
+
const match = /<path>([\s\S]*?)<\/path>\s*<type>[^<]*<\/type>\s*<content>([\s\S]*?)<\/content>/.exec(result);
|
|
1590
|
+
if (match === null) return void 0;
|
|
1591
|
+
const verb = /^\s*(Created|Updated)\s+file\s*$/.exec(match[2] ?? "")?.[1];
|
|
1592
|
+
if (verb !== "Created" && verb !== "Updated") return void 0;
|
|
1593
|
+
const path = (match[1] ?? "").trim();
|
|
1594
|
+
if (path === "") return void 0;
|
|
1595
|
+
return {
|
|
1596
|
+
verb,
|
|
1597
|
+
path
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* The folded preview suffix for a write card: ` — Created` / ` — Updated`
|
|
1602
|
+
* when the result is the tool's XML confirmation envelope, empty otherwise.
|
|
1603
|
+
* The folded row must NEVER dump the raw envelope (the read-card no-XML
|
|
1604
|
+
* rule; a malformed envelope yields no preview at all, not the raw text).
|
|
1605
|
+
* @param result - the tool result text.
|
|
1606
|
+
* @returns the verb suffix, or '' when the result is not a write envelope.
|
|
1607
|
+
*/
|
|
1608
|
+
function writeFoldedPreview(result) {
|
|
1609
|
+
const envelope = parseWriteEnvelope(result);
|
|
1610
|
+
return envelope === void 0 ? "" : ` — ${envelope.verb}`;
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* Parse the skill tool's XML instruction envelope (`<skill_content
|
|
1614
|
+
* name="…"> <skill_resources>…</skill_resources> <skill_instructions>…
|
|
1615
|
+
* </skill_instructions> </skill_content>`). The name attribute is XML-
|
|
1616
|
+
* escaped by the producer (escapeAttr: `&`/`"`/`<`) and is
|
|
1617
|
+
* decoded here; the instructions body is embedded VERBATIM by the producer
|
|
1618
|
+
* (skills are trusted local content), so it is returned unmodified.
|
|
1619
|
+
*
|
|
1620
|
+
* The boundaries are OUTER-envelope-aware: the opener is the first
|
|
1621
|
+
* `<skill_content name="…">`, the instructions opener the first
|
|
1622
|
+
* `<skill_instructions>` after it, and the closers the last
|
|
1623
|
+
* `</skill_instructions>` / `</skill_content>` — so a body that itself
|
|
1624
|
+
* documents envelope-shaped XML (a complete nested block, or an unclosed
|
|
1625
|
+
* tag) never truncates the outer body. A MALFORMED first envelope never
|
|
1626
|
+
* borrows a later envelope's boundaries: a second `<skill_content name="…">`
|
|
1627
|
+
* opener between the first opener and its instructions opener means the
|
|
1628
|
+
* first envelope is incomplete, and the selected instructions body must
|
|
1629
|
+
* close every `<skill_content name="…">` opener it contains (unmatched
|
|
1630
|
+
* closing tags are tolerated) — without those guards, a first envelope
|
|
1631
|
+
* whose own boundaries are unclosed would cross-pair with a later
|
|
1632
|
+
* envelope's. An unclosed `<skill_content name="…">` example inside a body
|
|
1633
|
+
* is indistinguishable from such a malformed concatenation and degrades to
|
|
1634
|
+
* a header-only card, never raw tags.
|
|
1635
|
+
* @param result - the tool result text.
|
|
1636
|
+
* @returns the envelope's name and instruction body, or undefined when the
|
|
1637
|
+
* result is not a well-formed skill envelope (a blank name is malformed).
|
|
1638
|
+
*/
|
|
1639
|
+
function parseSkillEnvelope(result) {
|
|
1640
|
+
const open = result.indexOf("<skill_content name=\"");
|
|
1641
|
+
if (open < 0) return void 0;
|
|
1642
|
+
const nameStart = open + 21;
|
|
1643
|
+
const nameEnd = result.indexOf("\"", nameStart);
|
|
1644
|
+
if (nameEnd < 0) return void 0;
|
|
1645
|
+
const instrOpen = result.indexOf("<skill_instructions>", nameEnd);
|
|
1646
|
+
if (instrOpen < 0) return void 0;
|
|
1647
|
+
const nestedOpen = result.indexOf("<skill_content name=\"", nameEnd);
|
|
1648
|
+
if (nestedOpen >= 0 && nestedOpen < instrOpen) return void 0;
|
|
1649
|
+
const outerClose = result.lastIndexOf("</skill_content>");
|
|
1650
|
+
if (outerClose < instrOpen) return void 0;
|
|
1651
|
+
const instrClose = result.lastIndexOf("</skill_instructions>", outerClose);
|
|
1652
|
+
if (instrClose <= instrOpen) return void 0;
|
|
1653
|
+
const bodyRegion = result.slice(instrOpen, instrClose);
|
|
1654
|
+
let contentDepth = 0;
|
|
1655
|
+
for (const token of bodyRegion.matchAll(/<skill_content name="|<\/skill_content>/g)) if (token[0] === "<skill_content name=\"") contentDepth += 1;
|
|
1656
|
+
else contentDepth = Math.max(0, contentDepth - 1);
|
|
1657
|
+
if (contentDepth > 0) return void 0;
|
|
1658
|
+
const rawName = result.slice(nameStart, nameEnd).trim();
|
|
1659
|
+
if (rawName === "") return void 0;
|
|
1660
|
+
const name = rawName.replaceAll(""", "\"").replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
|
1661
|
+
let body = result.slice(instrOpen + 20, instrClose);
|
|
1662
|
+
if (body.startsWith("\n")) body = body.slice(1);
|
|
1663
|
+
if (body.endsWith("\n")) body = body.slice(0, -1);
|
|
1664
|
+
return {
|
|
1665
|
+
name,
|
|
1666
|
+
instructions: body
|
|
1667
|
+
};
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* The folded preview suffix for a skill card: ` — N lines` (the instruction
|
|
1671
|
+
* body's line count — the header already carries `skill · <name>`), empty
|
|
1672
|
+
* otherwise. The folded row never dumps the raw `<skill_content>` block.
|
|
1673
|
+
* @param result - the tool result text.
|
|
1674
|
+
* @returns the line-count suffix, or '' when the result is not a skill
|
|
1675
|
+
* envelope.
|
|
1676
|
+
*/
|
|
1677
|
+
function skillFoldedPreview(result) {
|
|
1678
|
+
const envelope = parseSkillEnvelope(result);
|
|
1679
|
+
if (envelope === void 0) return "";
|
|
1680
|
+
const lines = envelope.instructions.split("\n").filter((line) => line !== "").length;
|
|
1681
|
+
return lines === 0 ? "" : ` — ${lines} lines of instructions`;
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* The human-facing body lines of one injected context row, derived from the
|
|
1685
|
+
* model-facing text so the expanded row never leaks the envelope's raw XML
|
|
1686
|
+
* (the same no-XML rule as the read/write/skill tool cards):
|
|
1687
|
+
*
|
|
1688
|
+
* - a well-formed skill envelope (`<skill_content …>` — the skill loader
|
|
1689
|
+
* tool result and the user-explicit invocation injection share this
|
|
1690
|
+
* shape) renders its instructions body;
|
|
1691
|
+
* - a `<system-reminder>`-wrapped producer (the skill catalog, workspace
|
|
1692
|
+
* instructions — both bake their complete frame into the content,
|
|
1693
|
+
* harness caller-owned framing) renders its content with the wrapper tag
|
|
1694
|
+
* lines stripped; `<available_skills>` markers are stripped only when the
|
|
1695
|
+
* pair is present, so a lone marker inside real content survives;
|
|
1696
|
+
* - any OTHER text returns undefined and the caller keeps its raw-body
|
|
1697
|
+
* behavior (plain context rows are unchanged).
|
|
1698
|
+
*
|
|
1699
|
+
* A malformed skill envelope yields an EMPTY body, never the raw tags: the
|
|
1700
|
+
* header still names the producer, and the model-facing bytes are untouched
|
|
1701
|
+
* (presentation only).
|
|
1702
|
+
* @param text - the injected context message text, exactly as logged.
|
|
1703
|
+
* @returns the body lines to render, or undefined when no envelope shape
|
|
1704
|
+
* applies.
|
|
1705
|
+
*/
|
|
1706
|
+
function systemContextBody(text) {
|
|
1707
|
+
const skill = parseSkillEnvelope(text);
|
|
1708
|
+
if (skill !== void 0) return skill.instructions.split("\n");
|
|
1709
|
+
if (text.trimStart().startsWith("<skill_content")) return [];
|
|
1710
|
+
const lines = text.split("\n");
|
|
1711
|
+
const first = lines[0]?.trim();
|
|
1712
|
+
const last = lines[lines.length - 1]?.trim();
|
|
1713
|
+
if (first !== "<system-reminder>" || last !== "</system-reminder>") return void 0;
|
|
1714
|
+
const body = lines.slice(1, -1);
|
|
1715
|
+
if (!(body.some((line) => line.trim() === "<available_skills>") && body.some((line) => line.trim() === "</available_skills>"))) return body;
|
|
1716
|
+
return body.filter((line) => line.trim() !== "<available_skills>" && line.trim() !== "</available_skills>");
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* Parse the read_image tool's XML confirmation envelope (`<path>…</path>
|
|
1720
|
+
* <type>image</type> <content>PNG image, 800x600 px, … bytes</content>`).
|
|
1721
|
+
* @param result - the tool result text.
|
|
1722
|
+
* @returns the envelope's path and the content summary line, or undefined
|
|
1723
|
+
* when the result is not a well-formed image envelope (blank path or blank
|
|
1724
|
+
* summary are malformed).
|
|
1725
|
+
*/
|
|
1726
|
+
function parseImageEnvelope(result) {
|
|
1727
|
+
const match = /<path>([\s\S]*?)<\/path>\s*<type>image<\/type>\s*<content>\s*\n?\s*([\s\S]*?)\s*\n?\s*<\/content>/.exec(result);
|
|
1728
|
+
if (match === null) return void 0;
|
|
1729
|
+
const path = (match[1] ?? "").trim();
|
|
1730
|
+
const summary = (match[2] ?? "").trim();
|
|
1731
|
+
if (path === "" || summary === "") return void 0;
|
|
1732
|
+
return {
|
|
1733
|
+
path,
|
|
1734
|
+
summary
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* The folded preview suffix for a read_image card: ` — <summary>` (e.g.
|
|
1739
|
+
* `PNG image · 800x600 px`), empty otherwise. The folded row never dumps
|
|
1740
|
+
* the raw envelope (the read-card no-XML rule).
|
|
1741
|
+
* @param result - the tool result text.
|
|
1742
|
+
* @returns the summary suffix, or '' when the result is not an image
|
|
1743
|
+
* envelope.
|
|
1744
|
+
*/
|
|
1745
|
+
function imageFoldedPreview(result) {
|
|
1746
|
+
const envelope = parseImageEnvelope(result);
|
|
1747
|
+
return envelope === void 0 ? "" : ` — ${firstLine(envelope.summary)}`;
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* The folded preview suffix for a read card: `— {N} lines` when the envelope
|
|
1751
|
+
* reports a total (or a line count), empty otherwise. A merged group card
|
|
1752
|
+
* (multiple envelopes) yields nothing — its head already carries "N files".
|
|
1753
|
+
*/
|
|
1754
|
+
function readFoldedPreview(result) {
|
|
1755
|
+
const envelopes = parseReadEnvelopes(result);
|
|
1756
|
+
if (envelopes.length !== 1) return "";
|
|
1757
|
+
const envelope = envelopes[0];
|
|
1758
|
+
const total = envelope.totalLines ?? (envelope.lines.length > 0 ? envelope.lines.length : void 0);
|
|
1759
|
+
return total === void 0 ? "" : ` — ${total} lines`;
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* The Web row-model header for one tool card: title = tool-owned title or the
|
|
1763
|
+
* variant design title; summary = SUMMARY_KEYS-derived args summary relativized
|
|
1764
|
+
* to the workspace root. Unknown variants carry the tool name alongside the
|
|
1765
|
+
* summary (the Web's `toolName · base` rule), with the separator dropped when
|
|
1766
|
+
* there is no summary text. Slash-command names render without their slash.
|
|
1767
|
+
* @param name - the tool name.
|
|
1768
|
+
* @param argsRaw - the raw arguments JSON string ('' when absent).
|
|
1769
|
+
* @param cwd - workspace root for relativization; optional.
|
|
1770
|
+
*/
|
|
1771
|
+
function toolCardHeader(name, argsRaw, cwd) {
|
|
1772
|
+
const action = compactToolPresentation(name, argsRaw);
|
|
1773
|
+
if (action !== void 0) return {
|
|
1774
|
+
title: action.title,
|
|
1775
|
+
summary: action.summary ?? ""
|
|
1776
|
+
};
|
|
1777
|
+
const variant = classifyTool(name);
|
|
1778
|
+
const toolTitle = TOOL_TITLES[name] ?? TUI_TOOL_TITLES[name];
|
|
1779
|
+
const toolSummary = summarizeToolArgs(name, argsRaw);
|
|
1780
|
+
const base = argsRaw === "" ? "" : toolSummary ?? relativizeToCwd(deriveSummary(name, argsRaw), cwd);
|
|
1781
|
+
const summary = variant === "others" && toolTitle === void 0 ? base === "" ? "" : name + " · " + base : base;
|
|
1782
|
+
return {
|
|
1783
|
+
title: toolTitle ?? (name.startsWith("/") ? name.slice(1) : VARIANT_TITLES[variant]),
|
|
1784
|
+
summary
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
/**
|
|
1788
|
+
* Render a completed web retrieval as display lines (Web WebBlock parity):
|
|
1789
|
+
* a `search` shows the provider answer and the source list (title — url,
|
|
1790
|
+
* snippet under each), a `fetch` shows the URL and HTTP status. Truncation
|
|
1791
|
+
* is marked in the same place the Web marks it: under the list/status.
|
|
1792
|
+
* Colors are the caller's (the render layer owns the palette).
|
|
1793
|
+
* @param view - the web result view from presentResult.
|
|
1794
|
+
* @returns the display lines; empty when the view carries nothing to show.
|
|
1795
|
+
*/
|
|
1796
|
+
function webCardLines(view) {
|
|
1797
|
+
if (view.kind === "search") {
|
|
1798
|
+
const lines = [];
|
|
1799
|
+
if (view.answer !== void 0 && view.answer !== "") lines.push(view.answer);
|
|
1800
|
+
for (const source of view.sources) {
|
|
1801
|
+
const head = source.title === void 0 || source.title === "" ? source.url : `${source.title} — ${source.url}`;
|
|
1802
|
+
lines.push(`• ${head}`);
|
|
1803
|
+
if (source.snippet !== void 0 && source.snippet !== "") lines.push(` ${source.snippet}`);
|
|
1804
|
+
}
|
|
1805
|
+
if (view.truncated) lines.push("… truncated — more sources omitted");
|
|
1806
|
+
return lines;
|
|
1807
|
+
}
|
|
1808
|
+
const lines = [`${view.url} — HTTP ${view.statusCode}`];
|
|
1809
|
+
if (view.truncated) lines.push("… truncated — content capped");
|
|
1810
|
+
return lines;
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Render a generic card's rawInput as display lines, structured per tool
|
|
1814
|
+
* where the payload has a natural one-line shape (web TodoRow parity for
|
|
1815
|
+
* `todo_write`, a session/terminal target line for the rest). Unknown
|
|
1816
|
+
* object payloads fall back to pretty JSON — the same fallback the Web's
|
|
1817
|
+
* generic body uses. A string rawInput renders verbatim.
|
|
1818
|
+
* @param name - the tool name.
|
|
1819
|
+
* @param rawInput - the presenter's salient raw input.
|
|
1820
|
+
* @returns the display lines.
|
|
1821
|
+
*/
|
|
1822
|
+
/** Checklist display lines for a todo list: `●` in progress, `○` pending,
|
|
1823
|
+
* `✓` completed (Web TodoRow parity). Malformed items are skipped. */
|
|
1824
|
+
function todoChecklistLines(todos) {
|
|
1825
|
+
const lines = [];
|
|
1826
|
+
for (const todo of todos) {
|
|
1827
|
+
if (typeof todo !== "object" || todo === null) continue;
|
|
1828
|
+
const item = todo;
|
|
1829
|
+
const content = typeof item.content === "string" ? item.content : String(item.content ?? "");
|
|
1830
|
+
const mark = item.status === "completed" ? "✓" : item.status === "in_progress" ? "●" : "○";
|
|
1831
|
+
lines.push(`${mark} ${content}`);
|
|
1832
|
+
}
|
|
1833
|
+
return lines;
|
|
1834
|
+
}
|
|
1835
|
+
function genericRawInputLines(name, rawInput) {
|
|
1836
|
+
if (typeof rawInput === "string") return rawInput === "" ? [] : [rawInput];
|
|
1837
|
+
if (typeof rawInput !== "object" || rawInput === null) return rawInput === void 0 ? [] : [String(rawInput)];
|
|
1838
|
+
if (name === "todo_write" && Array.isArray(rawInput)) return todoChecklistLines(rawInput);
|
|
1839
|
+
const args = rawInput;
|
|
1840
|
+
if (name === "todo_write") {
|
|
1841
|
+
const todos = args.todos;
|
|
1842
|
+
if (Array.isArray(todos)) return todoChecklistLines(todos);
|
|
1843
|
+
}
|
|
1844
|
+
if ((name === "terminal_read" || name === "terminal_signal" || name === "terminal_send") && typeof args.sessionId === "string") {
|
|
1845
|
+
const line = `session ${args.sessionId}`;
|
|
1846
|
+
return name === "terminal_send" && typeof args.text === "string" ? [`${line}: ${args.text}`] : [line];
|
|
1847
|
+
}
|
|
1848
|
+
if ((name === "session_event_trace" || name === "session_event_read" || name === "session_trace") && (args.seq !== void 0 || args.session_id !== void 0)) {
|
|
1849
|
+
const parts = [];
|
|
1850
|
+
if (args.session_id !== void 0) parts.push(String(args.session_id));
|
|
1851
|
+
if (args.seq !== void 0) parts.push(`seq ${String(args.seq)}`);
|
|
1852
|
+
return [parts.join(" · ")];
|
|
1853
|
+
}
|
|
1854
|
+
return JSON.stringify(rawInput, null, 2).split("\n");
|
|
1855
|
+
}
|
|
1856
|
+
/**
|
|
1857
|
+
* Flatten a settled result's content blocks to display lines, with the Web's
|
|
1858
|
+
* `resultText` semantics: text blocks verbatim, known file blocks as a
|
|
1859
|
+
* metadata-only row, images as a compact placeholder, and unknown finalized
|
|
1860
|
+
* blocks as an explicit bounded fallback. Empty content on a failed call
|
|
1861
|
+
* falls back to the structured error's `name: code` line.
|
|
1862
|
+
* @param blocks - the result content blocks.
|
|
1863
|
+
* @param error - the structured error, when the call failed.
|
|
1864
|
+
* @returns the display lines (may be empty).
|
|
1865
|
+
*/
|
|
1866
|
+
function resultTextLines(blocks, error) {
|
|
1867
|
+
const lines = [];
|
|
1868
|
+
for (const block of blocks) if (block.type === "text") lines.push(...block.text.split("\n"));
|
|
1869
|
+
else if (block.type === "image") lines.push("[image]");
|
|
1870
|
+
else if (block.type === "file") lines.push(fileAttachmentSummary(block.attachment));
|
|
1871
|
+
else if (block.type === "reasoning" || block.type === "tool-call") lines.push(JSON.stringify(block, null, 2));
|
|
1872
|
+
else lines.push(finalizedBlockFallbackText(block));
|
|
1873
|
+
if (lines.length === 0 && error !== void 0) lines.push(`${error.name}: ${error.code}`);
|
|
1874
|
+
return lines;
|
|
1875
|
+
}
|
|
1876
|
+
/**
|
|
1877
|
+
* Parse and normalize the tool's `{"answers":[…]}` render text. A
|
|
1878
|
+
* malformed entry (non-object) invalidates the WHOLE text — the Web's
|
|
1879
|
+
* `answeredSummary` checks `answers.every(isAnswer)` the same way, and a
|
|
1880
|
+
* half-valid set would make the count and the rendered rows disagree.
|
|
1881
|
+
* Non-string `selected` members are dropped; missing id/custom normalize
|
|
1882
|
+
* to empty.
|
|
1883
|
+
* @param text - the settled result text.
|
|
1884
|
+
* @returns the normalized entries, or undefined when unparseable/malformed.
|
|
1885
|
+
*/
|
|
1886
|
+
function parseAnswerEntries(text) {
|
|
1887
|
+
let parsed;
|
|
1888
|
+
try {
|
|
1889
|
+
parsed = JSON.parse(text);
|
|
1890
|
+
} catch {
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
1894
|
+
const answers = parsed.answers;
|
|
1895
|
+
if (!Array.isArray(answers)) return void 0;
|
|
1896
|
+
if (!answers.every((entry) => typeof entry === "object" && entry !== null)) return void 0;
|
|
1897
|
+
return answers.map((entry) => {
|
|
1898
|
+
const candidate = entry;
|
|
1899
|
+
return {
|
|
1900
|
+
id: typeof candidate.id === "string" ? candidate.id : "",
|
|
1901
|
+
selected: Array.isArray(candidate.selected) ? candidate.selected.filter((item) => typeof item === "string") : [],
|
|
1902
|
+
custom: typeof candidate.custom === "string" ? candidate.custom : ""
|
|
1903
|
+
};
|
|
1904
|
+
});
|
|
1905
|
+
}
|
|
1906
|
+
/**
|
|
1907
|
+
* The answered-count summary for a settled `ask_user_question` result
|
|
1908
|
+
* (Web AskQuestionRow parity): parses the tool's `{"answers":[…]}` render
|
|
1909
|
+
* text and counts the entries that actually carry an answer — a non-empty
|
|
1910
|
+
* `selected` list or a non-empty `custom` string (a skipped question has
|
|
1911
|
+
* neither and stays out of the count). Returns `N/M answered`, or undefined
|
|
1912
|
+
* when the text is not the expected answer JSON (the caller then falls back
|
|
1913
|
+
* to the generic presentation instead of inventing a count).
|
|
1914
|
+
* @param text - the settled result text.
|
|
1915
|
+
* @returns the answered-count summary, or undefined when unparseable.
|
|
1916
|
+
*/
|
|
1917
|
+
function askAnswersSummary(text) {
|
|
1918
|
+
const entries = parseAnswerEntries(text);
|
|
1919
|
+
if (entries === void 0) return void 0;
|
|
1920
|
+
return `${entries.filter((entry) => entry.selected.length > 0 || entry.custom !== "").length}/${entries.length} answered`;
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* The per-question display lines for a settled `ask_user_question` result
|
|
1924
|
+
* (the expanded card's body, complementing {@link askAnswersSummary}'s
|
|
1925
|
+
* count). Parses the tool's `{"answers":[…]}` render text and renders one
|
|
1926
|
+
* line per entry: `● <id> → <selected, custom>` for an answered question,
|
|
1927
|
+
* `○ <id> — skipped` when neither is present. Shares the normalization with
|
|
1928
|
+
* {@link askAnswersSummary}, so the rendered rows and the count always
|
|
1929
|
+
* agree; an unparseable/malformed text returns undefined.
|
|
1930
|
+
* @param text - the settled result text.
|
|
1931
|
+
* @returns the display lines, or undefined when unparseable/malformed.
|
|
1932
|
+
*/
|
|
1933
|
+
function askAnswersLines(text) {
|
|
1934
|
+
const entries = parseAnswerEntries(text);
|
|
1935
|
+
if (entries === void 0) return void 0;
|
|
1936
|
+
return entries.map((entry) => {
|
|
1937
|
+
if (entry.selected.length > 0) return {
|
|
1938
|
+
text: `● ${entry.id} → ${entry.selected.join(", ")}`,
|
|
1939
|
+
skipped: false
|
|
1940
|
+
};
|
|
1941
|
+
if (entry.custom !== "") return {
|
|
1942
|
+
text: `● ${entry.id} → ${entry.custom}`,
|
|
1943
|
+
skipped: false
|
|
1944
|
+
};
|
|
1945
|
+
return {
|
|
1946
|
+
text: `○ ${entry.id} — skipped`,
|
|
1947
|
+
skipped: true
|
|
1948
|
+
};
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
/** Parse a settled goal-tool result text, or undefined when it is not the
|
|
1952
|
+
* expected `{"goal":…}` JSON (the caller then falls back to the generic
|
|
1953
|
+
* presentation). */
|
|
1954
|
+
function parseGoalResult(text) {
|
|
1955
|
+
let parsed;
|
|
1956
|
+
try {
|
|
1957
|
+
parsed = JSON.parse(text);
|
|
1958
|
+
} catch {
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
1962
|
+
const value = parsed;
|
|
1963
|
+
if (value.goal === null) return { goal: null };
|
|
1964
|
+
if (typeof value.goal !== "object" || value.goal === null) return void 0;
|
|
1965
|
+
const goal = value.goal;
|
|
1966
|
+
const blocked = typeof goal.blockedReason === "object" && goal.blockedReason !== null ? goal.blockedReason : void 0;
|
|
1967
|
+
return {
|
|
1968
|
+
goal: {
|
|
1969
|
+
...typeof goal.id === "string" ? { id: goal.id } : {},
|
|
1970
|
+
...typeof goal.revision === "number" ? { revision: goal.revision } : {},
|
|
1971
|
+
...typeof goal.objective === "string" ? { objective: goal.objective } : {},
|
|
1972
|
+
...typeof goal.phase === "string" ? { phase: goal.phase } : {},
|
|
1973
|
+
...typeof goal.roundsStarted === "number" ? { roundsStarted: goal.roundsStarted } : {},
|
|
1974
|
+
...typeof goal.maxGoalRounds === "number" ? { maxGoalRounds: goal.maxGoalRounds } : {},
|
|
1975
|
+
...blocked !== void 0 ? { blockedReason: {
|
|
1976
|
+
...typeof blocked.code === "string" ? { code: blocked.code } : {},
|
|
1977
|
+
...typeof blocked.message === "string" ? { message: blocked.message } : {}
|
|
1978
|
+
} } : {}
|
|
1979
|
+
},
|
|
1980
|
+
...typeof value.activation === "string" ? { activation: value.activation } : {}
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
/**
|
|
1984
|
+
* The one-line folded summary for a settled goal-tool result: `phase … ·
|
|
1985
|
+
* revision N · N/M rounds`, or `no goal set` for `{"goal":null}`.
|
|
1986
|
+
* Returns undefined when the text is not the expected result JSON (the
|
|
1987
|
+
* caller then falls back to the generic preview instead of inventing one).
|
|
1988
|
+
* @param text - the settled result text.
|
|
1989
|
+
*/
|
|
1990
|
+
function goalResultSummary(text) {
|
|
1991
|
+
const value = parseGoalResult(text);
|
|
1992
|
+
if (value === void 0) return void 0;
|
|
1993
|
+
const goal = value.goal;
|
|
1994
|
+
if (goal === null) return "no goal set";
|
|
1995
|
+
const parts = [];
|
|
1996
|
+
if (goal.phase !== void 0) parts.push(`phase ${goal.phase}`);
|
|
1997
|
+
if (goal.revision !== void 0) parts.push(`revision ${goal.revision}`);
|
|
1998
|
+
if (goal.roundsStarted !== void 0) parts.push(goal.maxGoalRounds === void 0 ? `${goal.roundsStarted} rounds` : `${goal.roundsStarted}/${goal.maxGoalRounds} rounds`);
|
|
1999
|
+
return parts.length === 0 ? "goal" : parts.join(" · ");
|
|
2000
|
+
}
|
|
2001
|
+
/**
|
|
2002
|
+
* The expanded-card display lines for a settled goal-tool result: one field
|
|
2003
|
+
* per line (`● objective: …`, `● phase: … · revision N`, `● rounds: N/M`,
|
|
2004
|
+
* `● activation: …`, `● blocked: code — message`), or a single `no goal
|
|
2005
|
+
* set` line for `{"goal":null}`. Returns undefined when the text is not the
|
|
2006
|
+
* expected result JSON (the caller falls back to the generic presentation).
|
|
2007
|
+
* @param text - the settled result text.
|
|
2008
|
+
*/
|
|
2009
|
+
function goalResultLines(text) {
|
|
2010
|
+
const value = parseGoalResult(text);
|
|
2011
|
+
if (value === void 0) return void 0;
|
|
2012
|
+
const goal = value.goal;
|
|
2013
|
+
if (goal === null) return ["no goal set"];
|
|
2014
|
+
const lines = [];
|
|
2015
|
+
if (goal.objective !== void 0 && goal.objective !== "") lines.push(`● objective: ${goal.objective}`);
|
|
2016
|
+
const identity = [];
|
|
2017
|
+
if (typeof goal.phase === "string" && goal.phase !== "") identity.push(goal.phase);
|
|
2018
|
+
if (goal.revision !== void 0) identity.push(`revision ${goal.revision}`);
|
|
2019
|
+
if (identity.length > 0) lines.push(`● ${identity.join(" · ")}`);
|
|
2020
|
+
if (goal.roundsStarted !== void 0) lines.push(goal.maxGoalRounds === void 0 ? `● rounds: ${goal.roundsStarted}` : `● rounds: ${goal.roundsStarted}/${goal.maxGoalRounds}`);
|
|
2021
|
+
const blocked = goal.blockedReason;
|
|
2022
|
+
if (blocked !== void 0 && (blocked.code !== void 0 || blocked.message !== void 0)) {
|
|
2023
|
+
const detail = [blocked.code, blocked.message].filter((part) => typeof part === "string" && part !== "").join(" — ");
|
|
2024
|
+
lines.push(`● blocked: ${detail}`);
|
|
2025
|
+
}
|
|
2026
|
+
if (value.activation !== void 0) lines.push(`● activation: ${value.activation}`);
|
|
2027
|
+
return lines;
|
|
2028
|
+
}
|
|
2029
|
+
/**
|
|
2030
|
+
* The folded-card call preview for tools whose args carry a one-line
|
|
2031
|
+
* identity (Web TodoRow/WebRow folded parity): `todo_write` summarizes
|
|
2032
|
+
* `done/total` plus the first active item, `web_search`/`web_fetch` show
|
|
2033
|
+
* the query/URL. Empty for every other tool (their folded row keeps the
|
|
2034
|
+
* header + result preview).
|
|
2035
|
+
* @param name - the tool name.
|
|
2036
|
+
* @param argsRaw - the raw arguments JSON.
|
|
2037
|
+
* @returns the preview line, or '' when the tool has none.
|
|
2038
|
+
*/
|
|
2039
|
+
function foldedCallPreview(name, argsRaw) {
|
|
2040
|
+
const parsed = parseArgs(argsRaw);
|
|
2041
|
+
if (typeof parsed !== "object" || parsed === null) return "";
|
|
2042
|
+
const args = parsed;
|
|
2043
|
+
if (name === "todo_write") {
|
|
2044
|
+
const summary = summarizeToolArgs(name, argsRaw);
|
|
2045
|
+
return summary === void 0 ? "" : ` — ${summary}`;
|
|
2046
|
+
}
|
|
2047
|
+
if (name === "web_search") {
|
|
2048
|
+
const query = args.query;
|
|
2049
|
+
return typeof query === "string" && query !== "" ? ` — ${firstLine(query)}` : "";
|
|
2050
|
+
}
|
|
2051
|
+
if (name === "web_fetch") {
|
|
2052
|
+
const url = args.url;
|
|
2053
|
+
return typeof url === "string" && url !== "" ? ` — ${firstLine(url)}` : "";
|
|
2054
|
+
}
|
|
2055
|
+
if (name === "skill") {
|
|
2056
|
+
const skill = args.name;
|
|
2057
|
+
return typeof skill === "string" && skill !== "" ? ` — ${firstLine(skill)}` : "";
|
|
2058
|
+
}
|
|
2059
|
+
return "";
|
|
2060
|
+
}
|
|
2061
|
+
/**
|
|
2062
|
+
* The Focus compact Tool line for one raw tool call: presenter-first,
|
|
2063
|
+
* static fallback second (plan §9.1/§9.4). The tool-owned presentCall
|
|
2064
|
+
* view wins when the live registry has one; otherwise the Web row-model
|
|
2065
|
+
* header (toolCardHeader) renders the same semantic. The fold stores the
|
|
2066
|
+
* RAW call facts — this helper is the ONLY place the compact line is
|
|
2067
|
+
* formatted, so the TranscriptFolder never bakes presentation strings.
|
|
2068
|
+
* @param tool - the raw call facts (name + raw arguments JSON).
|
|
2069
|
+
* @param options - the live presenter bridge and the workspace root for
|
|
2070
|
+
* path relativization (both optional — replay degrades to the fallback).
|
|
2071
|
+
* @returns the one-line display, e.g. `Load skill session-review`,
|
|
2072
|
+
* `Read src/index.ts`, `vendor_probe cache-01`.
|
|
2073
|
+
*/
|
|
2074
|
+
function focusToolDisplay(tool, options = {}) {
|
|
2075
|
+
const owned = options.presenter?.call(tool.name, tool.args);
|
|
2076
|
+
if (owned !== void 0) return formatOwnedCallForCompactFocus(tool.name, owned);
|
|
2077
|
+
return focusToolFallbackDisplay(tool.name, tool.args, options.cwd);
|
|
2078
|
+
}
|
|
2079
|
+
/** The compact line from a tool-owned presentCall view. The official shell
|
|
2080
|
+
* presenters split the COMMAND (`title` / `rawInput`) from the human
|
|
2081
|
+
* `description` that is "shown in the UI"; the Focus Tool slot is a
|
|
2082
|
+
* description surface, so it prefers that description and never re-derives
|
|
2083
|
+
* the command from args. Because a description alone loses the tool's
|
|
2084
|
+
* identity, the two description-backed paths (foreground terminal and
|
|
2085
|
+
* background generic execute) prefix it with the human-facing tool title —
|
|
2086
|
+
* `Bash · Show working tree status`. Every other path keeps the previous
|
|
2087
|
+
* title / rawInput behavior, and the terminal title's FULL command still
|
|
2088
|
+
* renders in the expanded tool card — the compact row is exactly ONE
|
|
2089
|
+
* physical framebuffer row, so only a first logical line may surface here. */
|
|
2090
|
+
function formatOwnedCallForCompactFocus(name, view) {
|
|
2091
|
+
if (view.card === "terminal") {
|
|
2092
|
+
const description = firstLine((view.description ?? "").replace(/\r\n|\r/g, "\n")).trim();
|
|
2093
|
+
if (description !== "") return `${compactFocusToolIdentity(name)} · ${description}`;
|
|
2094
|
+
return firstLine(view.title.replace(/\r\n|\r/g, "\n"));
|
|
2095
|
+
}
|
|
2096
|
+
if (view.card === "diff") return firstLine(view.title.replace(/\r\n|\r/g, "\n"));
|
|
2097
|
+
if (view.kind === "execute") {
|
|
2098
|
+
const description = firstContentText(view);
|
|
2099
|
+
if (description !== void 0) return `${compactFocusToolIdentity(name)} · ${description}`;
|
|
2100
|
+
}
|
|
2101
|
+
const raw = typeof view.rawInput === "string" ? view.rawInput.trim() : void 0;
|
|
2102
|
+
if (raw === void 0 || raw === "") return view.title;
|
|
2103
|
+
return view.title.endsWith(raw) ? view.title : `${view.title} ${firstLine(raw)}`;
|
|
2104
|
+
}
|
|
2105
|
+
/** The human-facing tool identity for a description-backed compact row.
|
|
2106
|
+
* Reuses the formal title mapping; a name it resolves only to the generic
|
|
2107
|
+
* `Tool`/`tool` keeps its raw name so a custom tool is never flattened to a
|
|
2108
|
+
* meaningless `Tool · description` (same unknown-tool identity principle as
|
|
2109
|
+
* the static fallback). */
|
|
2110
|
+
function compactFocusToolIdentity(name) {
|
|
2111
|
+
const title = toolTitle(name);
|
|
2112
|
+
return title === "Tool" || title === "tool" ? name : title;
|
|
2113
|
+
}
|
|
2114
|
+
/** The first meaningful text block of a call view's content as one logical
|
|
2115
|
+
* line — the official Bash background presenter puts its human description
|
|
2116
|
+
* in `content[0]`. Undefined when no non-blank text block exists. */
|
|
2117
|
+
function firstContentText(view) {
|
|
2118
|
+
for (const block of view.content ?? []) {
|
|
2119
|
+
if (block.type !== "text") continue;
|
|
2120
|
+
const line = firstLine(block.text.replace(/\r\n|\r/g, "\n")).trim();
|
|
2121
|
+
if (line !== "") return line;
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
/** The compact line from the static Web row-model header (replay /
|
|
2125
|
+
* registry-unavailable fallback). An unknown tool's header title is the
|
|
2126
|
+
* generic "Tool call" — the compact line names the actual tool instead
|
|
2127
|
+
* (plan §9.3: an unknown custom tool is still a Tool). */
|
|
2128
|
+
function focusToolFallbackDisplay(name, argsRaw, cwd) {
|
|
2129
|
+
const action = compactToolPresentation(name, argsRaw);
|
|
2130
|
+
if (action !== void 0) return `${action.summary === void 0 ? action.title : `${action.title} ${action.summary}`}${action.payload === void 0 || action.payload === "" ? "" : ` · ${firstLine(action.payload)}`}`;
|
|
2131
|
+
const header = toolCardHeader(name, argsRaw, cwd);
|
|
2132
|
+
if (!(classifyTool(name) !== "others" || TOOL_TITLES[name] !== void 0 || TUI_TOOL_TITLES[name] !== void 0)) return header.summary === "" ? name : header.summary.replace(" · ", " ");
|
|
2133
|
+
return header.summary === "" ? header.title : `${header.title} ${header.summary}`;
|
|
2134
|
+
}
|
|
2135
|
+
/**
|
|
2136
|
+
* Build the presentation bridge over a tool-definition lookup (the runner
|
|
2137
|
+
* passes a scoped registry read, e.g. `ctx.get('tools')?.get(name, scope)`).
|
|
2138
|
+
* Mirrors the host apiproxy's presenter invocations: args are JSON-parsed and
|
|
2139
|
+
* the callbacks are guarded so a throwing tool presenter degrades to the
|
|
2140
|
+
* generic card. An absent registry yields no views, so the cards fall back to
|
|
2141
|
+
* the generic presentation instead of failing.
|
|
2142
|
+
* @param get - resolve one tool definition by name (scope already applied).
|
|
2143
|
+
*/
|
|
2144
|
+
function toolPresenterFrom(get) {
|
|
2145
|
+
return {
|
|
2146
|
+
call(name, argsRaw) {
|
|
2147
|
+
const definition = get(name);
|
|
2148
|
+
if (definition?.presentCall === void 0) return void 0;
|
|
2149
|
+
const parsed = parseArgs(argsRaw);
|
|
2150
|
+
if (parsed === void 0) return void 0;
|
|
2151
|
+
try {
|
|
2152
|
+
return definition.presentCall(parsed);
|
|
2153
|
+
} catch {
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
},
|
|
2157
|
+
result(name, argsRaw, result) {
|
|
2158
|
+
const definition = get(name);
|
|
2159
|
+
if (definition?.presentResult === void 0) return void 0;
|
|
2160
|
+
const parsed = parseArgs(argsRaw);
|
|
2161
|
+
if (parsed === void 0) return void 0;
|
|
2162
|
+
try {
|
|
2163
|
+
return definition.presentResult(parsed, {
|
|
2164
|
+
content: [...result.content],
|
|
2165
|
+
isError: result.isError,
|
|
2166
|
+
...result.meta === void 0 ? {} : { meta: result.meta }
|
|
2167
|
+
});
|
|
2168
|
+
} catch {
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
/**
|
|
2175
|
+
* Subagent-family tool names whose call args may carry an explicit
|
|
2176
|
+
* model/provider override. Matched by REGISTERED name; unknown names are
|
|
2177
|
+
* ignored so third-party subagent-like tools keep their default rendering.
|
|
2178
|
+
*/
|
|
2179
|
+
const SUBAGENT_TOOL_NAMES = /* @__PURE__ */ new Set([
|
|
2180
|
+
"subagent",
|
|
2181
|
+
"subagent_route",
|
|
2182
|
+
"subagent_router",
|
|
2183
|
+
"subagent_fork"
|
|
2184
|
+
]);
|
|
2185
|
+
/**
|
|
2186
|
+
* Extract the model · provider display line for a subagent-family tool call.
|
|
2187
|
+
*
|
|
2188
|
+
* COMPATIBILITY CONTRACT: the line renders ONLY when the call args actually
|
|
2189
|
+
* carry a model/provider — the top-level `model`/`provider` shape
|
|
2190
|
+
* (subagent_route / subagent_router) or the `agentOptions.model/provider`
|
|
2191
|
+
* shape. Every other call returns `undefined` and the card renders exactly
|
|
2192
|
+
* as before; unknown tool names and unparseable args are ignored. The
|
|
2193
|
+
* official `subagent` tool's model lives in deployment config (never in
|
|
2194
|
+
* the call args), so it naturally renders nothing.
|
|
2195
|
+
* @param name - the tool's registered name.
|
|
2196
|
+
* @param argsRaw - the raw tool-call arguments JSON ('' or malformed → none).
|
|
2197
|
+
* @returns `model · provider` (either part alone when only one is present),
|
|
2198
|
+
* or undefined when the call carries no model/provider.
|
|
2199
|
+
*/
|
|
2200
|
+
function subagentModelDisplay(name, argsRaw) {
|
|
2201
|
+
if (!SUBAGENT_TOOL_NAMES.has(name)) return void 0;
|
|
2202
|
+
const parsed = parseArgs(argsRaw);
|
|
2203
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
2204
|
+
const args = parsed;
|
|
2205
|
+
const opts = typeof args.agentOptions === "object" && args.agentOptions !== null ? args.agentOptions : void 0;
|
|
2206
|
+
const parts = [typeof args.model === "string" && args.model !== "" ? args.model : typeof opts?.model === "string" && opts.model !== "" ? opts.model : void 0, typeof args.provider === "string" && args.provider !== "" ? args.provider : typeof opts?.provider === "string" && opts.provider !== "" ? opts.provider : void 0].filter((part) => part !== void 0);
|
|
2207
|
+
return parts.length === 0 ? void 0 : parts.join(" · ");
|
|
2208
|
+
}
|
|
2209
|
+
//#endregion
|
|
2210
|
+
//#region src/tui/transcript/context-structure.ts
|
|
2211
|
+
/**
|
|
2212
|
+
* Renderer-neutral ambient Context presentation structure.
|
|
2213
|
+
*
|
|
2214
|
+
* The semantic question "is this row an ambient Context fact?" belongs to the
|
|
2215
|
+
* canonical semantic source (`domain/transcript/context-semantics.ts`). THIS module owns the presentation question: "which
|
|
2216
|
+
* presentation role does one surfaced Context row take, and where do adjacent
|
|
2217
|
+
* ambient facts form ONE presentation container with ONE owner?".
|
|
2218
|
+
*
|
|
2219
|
+
* Clustering is computed from RAW transcript chronology. Hiding Process rows in
|
|
2220
|
+
* a projection must never merge two Context rows that were not adjacent in the
|
|
2221
|
+
* raw transcript. The module is deliberately renderer-free: it never reads
|
|
2222
|
+
* width, theme, icons, preset, disclosure state, search state or a component.
|
|
2223
|
+
* @module @xmoon76/dsh-pi-tui/tui/transcript/context-structure
|
|
2224
|
+
*/
|
|
2225
|
+
/** Whether one row is a standalone producer notice. */
|
|
2226
|
+
function isNoticeContext(message) {
|
|
2227
|
+
return isSurfacedContext(message) && contextFormOf(message) === "notice";
|
|
2228
|
+
}
|
|
2229
|
+
/** Whether one row is another Agent's relayed message. */
|
|
2230
|
+
function isRelayContext(message) {
|
|
2231
|
+
return isSurfacedContext(message) && contextFormOf(message) === "relay";
|
|
2232
|
+
}
|
|
2233
|
+
/** Whether one row is recalled material from another session. The legacy
|
|
2234
|
+
* session-reference role counts even when its producer did not declare the
|
|
2235
|
+
* form, so a recalled row is never clustered as ambient. The A-layer provenance
|
|
2236
|
+
* accessor is private to the transitional semantic source, so the legacy role
|
|
2237
|
+
* is read from the row's own field here. */
|
|
2238
|
+
function isRecallContext(message) {
|
|
2239
|
+
if (!isSurfacedContext(message)) return false;
|
|
2240
|
+
if (contextFormOf(message) === "recall") return true;
|
|
2241
|
+
return message.kind === "system" && message.contextPresentation?.role === "recall";
|
|
2242
|
+
}
|
|
2243
|
+
/** The presentation role of one injected Context row; undefined when the row
|
|
2244
|
+
* is not surfaced Context. Unknown/missing forms fail open to `generic`
|
|
2245
|
+
* (standalone), never to ambient. */
|
|
2246
|
+
function contextPresentationKind(message) {
|
|
2247
|
+
if (!isSurfacedContext(message)) return void 0;
|
|
2248
|
+
if (isAmbientContext(message)) return "ambient";
|
|
2249
|
+
if (isNoticeContext(message)) return "notice";
|
|
2250
|
+
if (isRelayContext(message)) return "relay";
|
|
2251
|
+
if (isRecallContext(message)) return "recall";
|
|
2252
|
+
return "generic";
|
|
2253
|
+
}
|
|
2254
|
+
/**
|
|
2255
|
+
* Group raw-adjacent same-turn ambient Context rows into clusters.
|
|
2256
|
+
*
|
|
2257
|
+
* A cluster forms ONLY when every requirement holds: same turn, raw-adjacent
|
|
2258
|
+
* TranscriptMessage rows, each row surfaced Context, each row ambient form.
|
|
2259
|
+
* A single eligible row stays an ordinary ambient row (no cluster). Every
|
|
2260
|
+
* non-ambient boundary (Conversation, Process, Attention, notice, relay,
|
|
2261
|
+
* recall, unknown Context, workflow, compaction, window summary, turn
|
|
2262
|
+
* boundary) ends the current run.
|
|
2263
|
+
* @param messages - the transcript window in raw chronology.
|
|
2264
|
+
*/
|
|
2265
|
+
function clusterAdjacentAmbientContext(messages) {
|
|
2266
|
+
const clusters = [];
|
|
2267
|
+
let run = [];
|
|
2268
|
+
const flush = () => {
|
|
2269
|
+
const first = run[0];
|
|
2270
|
+
if (first !== void 0 && run.length >= 2) clusters.push({
|
|
2271
|
+
kind: "context-cluster",
|
|
2272
|
+
turn: first.turn,
|
|
2273
|
+
members: run,
|
|
2274
|
+
owner: first
|
|
2275
|
+
});
|
|
2276
|
+
run = [];
|
|
2277
|
+
};
|
|
2278
|
+
for (const message of messages) {
|
|
2279
|
+
if (isAmbientContext(message)) {
|
|
2280
|
+
const first = run[0];
|
|
2281
|
+
if (first !== void 0 && first.turn !== message.turn) flush();
|
|
2282
|
+
run.push(message);
|
|
2283
|
+
continue;
|
|
2284
|
+
}
|
|
2285
|
+
flush();
|
|
2286
|
+
}
|
|
2287
|
+
flush();
|
|
2288
|
+
const byMember = /* @__PURE__ */ new Map();
|
|
2289
|
+
for (const cluster of clusters) for (const member of cluster.members) byMember.set(member, cluster);
|
|
2290
|
+
return {
|
|
2291
|
+
clusters,
|
|
2292
|
+
byMember
|
|
2293
|
+
};
|
|
2294
|
+
}
|
|
2295
|
+
//#endregion
|
|
2296
|
+
//#region src/tui/transcript/structure.ts
|
|
2297
|
+
/**
|
|
2298
|
+
* Canonical preset-neutral transcript structure projection.
|
|
2299
|
+
*
|
|
2300
|
+
* This module owns the ONE semantic segmentation of a raw transcript window:
|
|
2301
|
+
* maximal contiguous same-turn Process runs become {@link TranscriptWorkSpan}s
|
|
2302
|
+
* and raw-adjacent same-turn ambient Context runs become
|
|
2303
|
+
* {@link ContextCluster}s (every other semantic boundary ends a run). It is
|
|
2304
|
+
* deliberately blind to the active preset, the surface, disclosure state,
|
|
2305
|
+
* search state, render width and the renderer registry: it answers only
|
|
2306
|
+
* "where does the raw chronology form Work, a Context cluster, or a
|
|
2307
|
+
* standalone row?".
|
|
2308
|
+
*
|
|
2309
|
+
* `projectCompact()` is the Compact materialization adapter over this
|
|
2310
|
+
* structure, Full materializes it with expanded-flat Work and the shared
|
|
2311
|
+
* cluster presentation, and expanded Focus consumes it for its process tail.
|
|
2312
|
+
* The same boundary identity is therefore never re-derived per preset.
|
|
2313
|
+
*
|
|
2314
|
+
* The projector is a single forward pass plus the linear ambient-clustering
|
|
2315
|
+
* pass (O(n)); wrappers are freshly allocated each call, but their
|
|
2316
|
+
* `owner`/`members` always reference the original `TranscriptMessage`
|
|
2317
|
+
* objects, so presentation caches can compare owner + member identity/order.
|
|
2318
|
+
* @module @xmoon76/dsh-pi-tui/tui/transcript/structure
|
|
2319
|
+
*/
|
|
2320
|
+
/**
|
|
2321
|
+
* Whether one row continues the current Work run. Process rows with a turn
|
|
2322
|
+
* number extend the run only while the turn is unchanged; turn-less rows
|
|
2323
|
+
* (window summaries, standalone control-plane commands) never enter Work by
|
|
2324
|
+
* construction — a `kind: 'command'` row has no turn and a `control` class.
|
|
2325
|
+
* A settled surfaced-interaction card (question / Plan review) is
|
|
2326
|
+
* human-decision evidence, not Process work — it never joins a span (and so
|
|
2327
|
+
* never counts toward its tool count/preview); it flushes the run and
|
|
2328
|
+
* renders standalone. Exported so the live Preparing ownership consumes the
|
|
2329
|
+
* SAME boundary authority as every projection: a settled interaction closes
|
|
2330
|
+
* the trailing run.
|
|
2331
|
+
*/
|
|
2332
|
+
function isTranscriptWorkMember(message) {
|
|
2333
|
+
return "turn" in message && classifyTranscriptMessage(message).class === "process" && !isSurfacedInteractionTool(message) && !isPostTurnReplayEvidence(message);
|
|
2334
|
+
}
|
|
2335
|
+
/**
|
|
2336
|
+
* Segment one raw transcript window into canonical structural blocks.
|
|
2337
|
+
*
|
|
2338
|
+
* Ambient Context clusters are computed FIRST from raw adjacency, so a hidden
|
|
2339
|
+
* Process row can never merge two Context rows that were not adjacent, and a
|
|
2340
|
+
* cluster is a Work boundary exactly like a standalone Context row. A settled
|
|
2341
|
+
* surfaced-interaction card is its own boundary and belongs to neither the
|
|
2342
|
+
* preceding nor the following span. The result partitions `messages` exactly:
|
|
2343
|
+
* every input row appears once, in raw order, either as a `message` block, a
|
|
2344
|
+
* Work member, or a cluster member.
|
|
2345
|
+
* @param messages - the transcript window in raw chronology.
|
|
2346
|
+
*/
|
|
2347
|
+
function projectTranscriptStructure(messages) {
|
|
2348
|
+
const clustering = clusterAdjacentAmbientContext(messages);
|
|
2349
|
+
const out = [];
|
|
2350
|
+
let run = [];
|
|
2351
|
+
let runTurn;
|
|
2352
|
+
const flush = () => {
|
|
2353
|
+
const owner = run[0];
|
|
2354
|
+
if (owner !== void 0 && runTurn !== void 0) out.push({
|
|
2355
|
+
kind: "work",
|
|
2356
|
+
span: {
|
|
2357
|
+
kind: "work",
|
|
2358
|
+
turn: runTurn,
|
|
2359
|
+
members: run,
|
|
2360
|
+
owner
|
|
2361
|
+
}
|
|
2362
|
+
});
|
|
2363
|
+
run = [];
|
|
2364
|
+
runTurn = void 0;
|
|
2365
|
+
};
|
|
2366
|
+
for (const message of messages) {
|
|
2367
|
+
const cluster = clustering.byMember.get(message);
|
|
2368
|
+
if (cluster !== void 0) {
|
|
2369
|
+
flush();
|
|
2370
|
+
if (message === cluster.owner) out.push({
|
|
2371
|
+
kind: "context-cluster",
|
|
2372
|
+
cluster
|
|
2373
|
+
});
|
|
2374
|
+
continue;
|
|
2375
|
+
}
|
|
2376
|
+
if (isSurfacedInteractionTool(message)) {
|
|
2377
|
+
flush();
|
|
2378
|
+
out.push({
|
|
2379
|
+
kind: "message",
|
|
2380
|
+
message
|
|
2381
|
+
});
|
|
2382
|
+
continue;
|
|
2383
|
+
}
|
|
2384
|
+
if (isTranscriptWorkMember(message)) {
|
|
2385
|
+
if (run.length > 0 && runTurn !== message.turn) flush();
|
|
2386
|
+
run.push(message);
|
|
2387
|
+
runTurn = message.turn;
|
|
2388
|
+
continue;
|
|
2389
|
+
}
|
|
2390
|
+
flush();
|
|
2391
|
+
out.push({
|
|
2392
|
+
kind: "message",
|
|
2393
|
+
message
|
|
2394
|
+
});
|
|
2395
|
+
}
|
|
2396
|
+
flush();
|
|
2397
|
+
return out;
|
|
2398
|
+
}
|
|
2399
|
+
/**
|
|
2400
|
+
* Map every Work member to its canonical span in one linear pass. Disclosure,
|
|
2401
|
+
* search and pruning read Work ownership through THIS index instead of
|
|
2402
|
+
* scanning a preset materialization (or re-projecting Compact) merely to ask
|
|
2403
|
+
* "which Work span owns this row?".
|
|
2404
|
+
* @param structure - the canonical structural blocks for one window.
|
|
2405
|
+
*/
|
|
2406
|
+
function workByMemberOf(structure) {
|
|
2407
|
+
const byMember = /* @__PURE__ */ new Map();
|
|
2408
|
+
for (const block of structure) {
|
|
2409
|
+
if (block.kind !== "work") continue;
|
|
2410
|
+
for (const member of block.span.members) byMember.set(member, block.span);
|
|
2411
|
+
}
|
|
2412
|
+
return byMember;
|
|
2413
|
+
}
|
|
2414
|
+
/**
|
|
2415
|
+
* Map every Context-cluster member to its canonical cluster. Presentation
|
|
2416
|
+
* layers that reorder or hoist rows (collapsed/expanded Focus) consume THIS
|
|
2417
|
+
* identity instead of re-clustering the reordered sequence, so canonical
|
|
2418
|
+
* membership stays the only cluster authority.
|
|
2419
|
+
* @param structure - the canonical structural blocks for one window.
|
|
2420
|
+
*/
|
|
2421
|
+
function clusterByMemberOf(structure) {
|
|
2422
|
+
const byMember = /* @__PURE__ */ new Map();
|
|
2423
|
+
for (const block of structure) {
|
|
2424
|
+
if (block.kind !== "context-cluster") continue;
|
|
2425
|
+
for (const member of block.cluster.members) byMember.set(member, block.cluster);
|
|
2426
|
+
}
|
|
2427
|
+
return byMember;
|
|
2428
|
+
}
|
|
2429
|
+
//#endregion
|
|
2430
|
+
//#region src/tui/transcript/context-summary.ts
|
|
2431
|
+
/**
|
|
2432
|
+
* Renderer-neutral structured summary of one ambient Context cluster.
|
|
2433
|
+
*
|
|
2434
|
+
* The collapsed cluster header summarizes its members from STRUCTURED
|
|
2435
|
+
* provenance only — each row's producer label or its declared form, never
|
|
2436
|
+
* arbitrary payload text. Duplicate labels compress to `label ×N` for display
|
|
2437
|
+
* only: the underlying transcript rows are never deduplicated, reordered or
|
|
2438
|
+
* removed.
|
|
2439
|
+
*
|
|
2440
|
+
* This module derives strings only; the header chrome (disclosure marker,
|
|
2441
|
+
* identity icon, theme, width) belongs to the PiTui component in
|
|
2442
|
+
* `tui/components/transcript/context-cluster.ts`.
|
|
2443
|
+
* @module @xmoon76/dsh-pi-tui/tui/transcript/context-summary
|
|
2444
|
+
*/
|
|
2445
|
+
/** The structured display name of one ambient Context member: its producer
|
|
2446
|
+
* label, else its declared form, else the generic role name. Never payload
|
|
2447
|
+
* text. */
|
|
2448
|
+
function memberDisplayName(member) {
|
|
2449
|
+
if (member.kind !== "system") return "Context";
|
|
2450
|
+
if (member.label !== void 0 && member.label !== "") return member.label;
|
|
2451
|
+
const form = contextFormOf(member);
|
|
2452
|
+
return form === void 0 ? "Context" : form;
|
|
2453
|
+
}
|
|
2454
|
+
/**
|
|
2455
|
+
* The compressed summary parts of one cluster, in first-seen member order:
|
|
2456
|
+
* consecutive runs of the same display name become `name ×N`. Display-only —
|
|
2457
|
+
* the caller must not treat the parts as the member list.
|
|
2458
|
+
*/
|
|
2459
|
+
function contextClusterSummaryParts(cluster) {
|
|
2460
|
+
const parts = [];
|
|
2461
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2462
|
+
const order = [];
|
|
2463
|
+
for (const member of cluster.members) {
|
|
2464
|
+
const name = memberDisplayName(member);
|
|
2465
|
+
const seen = counts.get(name);
|
|
2466
|
+
if (seen === void 0) {
|
|
2467
|
+
counts.set(name, 1);
|
|
2468
|
+
order.push(name);
|
|
2469
|
+
} else counts.set(name, seen + 1);
|
|
2470
|
+
}
|
|
2471
|
+
for (const name of order) {
|
|
2472
|
+
const count = counts.get(name) ?? 1;
|
|
2473
|
+
parts.push(count > 1 ? `${name} ×${count}` : name);
|
|
2474
|
+
}
|
|
2475
|
+
return parts;
|
|
2476
|
+
}
|
|
2477
|
+
//#endregion
|
|
2478
|
+
export { sessionRowMatchesQuery as $, resultTextLines as A, writeFoldedPreview as B, parseCallPreview as C, isSurfacedInteractionTool as Ct, parseWriteEnvelope as D, openOpaqueBlockFallbackText as Dt, parseSkillEnvelope as E, finalizedBlockFallbackText as Et, toolIconSemantic as F, sessionLabelParts as G, findSessionMatch as H, toolPresenterFrom as I, shortSessionId as J, sessionPickerItem as K, toolSummaryKeys as L, subagentModelDisplay as M, systemContextBody as N, readFoldedPreview as O, textWithAttachmentMarkers as Ot, toolCardHeader as P, sanitizeSessionSearchInput as Q, toolTitle as R, isCompactActionTool as S, isSurfacedContext as St, parseReadEnvelopes as T, markPostTurnReplayEvidence as Tt, formatSessionAge as U, fileAttachmentSummary as V, sanitizeTerminalText as W, buildSessionTree as X, workspaceKey as Y, sameWorkspace as Z, foldedResultSummaryFor as _, contextIconSemantic as _t, workByMemberOf as a, formatLeaderSequence as at, goalResultSummary as b, contextSummary as bt, FOLDED_JSON_RESULT_TOOLS as c, PROVIDER_DEFAULT as ct, askAnswersSummary as d, modelIdentity as dt, HISTORY_CHORDS as et, compactToolExpandedLines as f, projectModelDirectory as ft, foldedCallPreview as g, contextFormOf as gt, focusToolDisplay as h, runSyncDisposalStepsWithPrimary as ht, projectTranscriptStructure as i, formatKeyId as it, skillFoldedPreview as j, relativizeToCwd as k, userBlocksVisibleNow as kt, GOAL_TOOL_NAMES as l, effortChoicesOf as lt, firstLine as m, runSyncDisposalSteps as mt, clusterByMemberOf as n, TSP_CONTINUED_INSPECTION_LABEL as nt, contextPresentationKind as o, isBuiltInPresetRow as ot, compactToolPresentation as p, formatBytes as pt, sessionSearchItem as q, isTranscriptWorkMember as r, formatHintKeys as rt, isNoticeContext as s, presetDisplayText as st, contextClusterSummaryParts as t, JOB_LOCAL_KEYS as tt, askAnswersLines as u, initialEffortOf as ut, genericRawInputLines as v, contextPresentation as vt, parseImageEnvelope as w, isSurfacedInteractionToolName as wt, imageFoldedPreview as x, isPostTurnReplayEvidence as xt, goalResultLines as y, contextProvenance as yt, webCardLines as z };
|