@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.
@@ -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: `&amp;`/`&quot;`/`&lt;`) 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("&quot;", "\"").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
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 };