@pi-archimedes/subagent 2.10.4 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +1 -1
  2. package/package.json +10 -10
  3. package/src/agent-panel.mcp.test.ts +76 -0
  4. package/src/agent-panel.tools.test.ts +162 -0
  5. package/src/agent-panel.ts +37 -8
  6. package/src/agent-panel.width-audit.test.ts +258 -0
  7. package/src/agents-parse.test.ts +252 -0
  8. package/src/agents.ts +72 -5
  9. package/src/compact.test.ts +136 -57
  10. package/src/compact.ts +57 -37
  11. package/src/dispatch.test.ts +73 -1
  12. package/src/dispatch.ts +20 -2
  13. package/src/execute.test.ts +9 -1
  14. package/src/execute.ts +26 -29
  15. package/src/expanded.test.ts +61 -0
  16. package/src/expanded.ts +96 -80
  17. package/src/format.test.ts +71 -29
  18. package/src/format.ts +71 -28
  19. package/src/frontmatter-io.test.ts +62 -0
  20. package/src/frontmatter-io.ts +16 -2
  21. package/src/handlers.test.ts +21 -3
  22. package/src/handlers.ts +22 -3
  23. package/src/index.output-schema.test.ts +317 -0
  24. package/src/index.test.ts +486 -0
  25. package/src/index.ts +168 -8
  26. package/src/render.ts +46 -36
  27. package/src/row-block.test.ts +195 -0
  28. package/src/row-block.ts +186 -0
  29. package/src/run-outcome.test.ts +71 -0
  30. package/src/run-outcome.ts +84 -0
  31. package/src/spawn.test.ts +204 -19
  32. package/src/spawn.ts +49 -17
  33. package/src/stream.test.ts +215 -17
  34. package/src/stream.ts +47 -9
  35. package/src/thinking-validation.test.ts +96 -0
  36. package/src/thinking-validation.ts +78 -0
  37. package/src/tool-declarations.test.ts +58 -0
  38. package/src/tool-row.width-audit.test.ts +720 -0
  39. package/src/tool-schema.ts +85 -0
  40. package/src/tool-validation.test.ts +321 -0
  41. package/src/tool-validation.ts +262 -0
  42. package/src/types.ts +86 -0
  43. package/src/usage.test.ts +95 -0
  44. package/src/usage.ts +87 -0
  45. package/src/cost.test.ts +0 -62
  46. package/src/cost.ts +0 -8
package/src/index.ts CHANGED
@@ -1,15 +1,24 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Text, type TUI } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
- import { SUBAGENT_PARAMS_SCHEMA } from "./tool-schema.js";
4
+ import { SUBAGENT_PARAMS_SCHEMA, SUBAGENT_OUTPUT_SCHEMA, subagentStructuredContent } from "./tool-schema.js";
5
5
  import { executeSubagent, executeParallel } from "./execute.js";
6
- import { formatParallelResults } from "./format.js";
6
+ import { formatParallelResults, formatSingleResult } from "./format.js";
7
7
  // agent-manager.js lazy-loaded below to keep subagent tool registration fast
8
8
  import { renderSubagentResult } from "./render.js";
9
+ import { RowBlock } from "./row-block.js";
9
10
  import { OVERLAY_CHROME } from "@pi-archimedes/core/overlay";
10
11
  import { renderToolHeader } from "@pi-archimedes/core/tool-render";
12
+ import { reportPiSupport } from "@pi-archimedes/core/pi-support";
13
+ import { diag, describeError } from "@pi-archimedes/core/log";
11
14
  import { discoverAgents, discoverAgentsAll, findAgent, formatAgentList } from "./agents.js";
12
15
  import { validateModel, firstError } from "./model-validation.js";
16
+ import {
17
+ unknownToolEntries,
18
+ formatUnknownToolsWarning,
19
+ } from "./tool-validation.js";
20
+ import { resolveThinkingLevel } from "./thinking-validation.js";
21
+ import { toNativeUsage, sumNativeUsage } from "./usage.js";
13
22
  import type {
14
23
  SubagentDetails,
15
24
  SubagentProgress,
@@ -27,12 +36,38 @@ interface RenderTheme {
27
36
  // ── Tool registration ───────────────────────────────────────────────────────
28
37
 
29
38
  export function registerSubagent(pi: ExtensionAPI): void {
39
+ // Floor check before anything that can fail (ADR 0026). This package spawns
40
+ // pi with flags and reads event fields that only behave correctly on 1.x
41
+ // (`--tools` semantics, `agent_settled.aborted`, print-mode exit codes), and a
42
+ // peer range cannot say so at install time. Warn and register anyway — the
43
+ // tool must still show up in /plugins. Entry point for both meta and the
44
+ // standalone default export, so `registerAgentsCommand` needs no second call.
45
+ reportPiSupport("subagent", "1.2.0");
46
+
30
47
  pi.registerTool({
31
48
  name: "subagent",
32
49
  label: "Subagent",
33
50
  description:
34
51
  "Delegate tasks to subagents. Provide either 'task' (single) or 'tasks' (parallel). Agent is optional — omit for a config-less run with the parent's model and all tools. Model override is rarely needed; the agent config or parent model is used by default.",
35
52
  parameters: SUBAGENT_PARAMS_SCHEMA,
53
+ outputSchema: SUBAGENT_OUTPUT_SCHEMA,
54
+ /**
55
+ * Declared to the model, never callable from a codemode script — pi's own
56
+ * wording for this exposure is "orchestrating" tools
57
+ * (`dist/core/extensions/types.d.ts`, `ToolExposure`), and dispatching a
58
+ * child session is that.
59
+ *
60
+ * Measured cost, so it is not discovered later: before this line a codemode
61
+ * script could dispatch a child and read its report — `tools.subagent({ task:
62
+ * "Reply with exactly the single word: child" })` resolved to
63
+ * `{ mode: "single", results: [ { outcome: "completed", finalOutput: "child",
64
+ * usage: {…} } ] }` on a real headless pi. After it the same call reports
65
+ * `tools.subagent does not exist`, and `subagent` is gone from the script's
66
+ * `ALL_TOOLS`. A script that wants a child's work now has to ask the model to
67
+ * dispatch one; `list_agents` stays `direct`, so scripts can still read the
68
+ * catalogue. The model's own dispatches are unaffected.
69
+ */
70
+ exposure: "model-only",
36
71
 
37
72
  async execute(
38
73
  _id: string,
@@ -68,6 +103,9 @@ export function registerSubagent(pi: ExtensionAPI): void {
68
103
  // Skip model validation for tasks already caught by unknown-agent check
69
104
  if (t.agent && unknownAgentSet.has(t.agent)) continue;
70
105
  const taskAgentConfig = t.agent ? findAgent(agents, t.agent) : undefined;
106
+ // A `tools:` list the config read rejected is never applied, so the task
107
+ // cannot run as configured — say so before anything spawns.
108
+ if (taskAgentConfig?.toolsError) errors.push(taskAgentConfig.toolsError);
71
109
  const me = firstError(
72
110
  validateModel(t.model, ctx.modelRegistry, { agentName: t.agent }),
73
111
  validateModel(taskAgentConfig?.model, ctx.modelRegistry, {
@@ -88,6 +126,20 @@ export function registerSubagent(pi: ExtensionAPI): void {
88
126
  isError: true,
89
127
  };
90
128
  }
129
+ // Membership only becomes knowable here, with the handle in scope — and
130
+ // it is a warning, so the batch still runs (see warnUnavailableTools).
131
+ const warnedTools = new Set<string>();
132
+ const warnedThinking = new Set<string>();
133
+ for (const t of params.tasks) {
134
+ warnUnavailableTools(
135
+ pi,
136
+ ctx,
137
+ warnedTools,
138
+ t.agent ?? "subagent",
139
+ t.agent ? findAgent(agents, t.agent)?.tools : undefined,
140
+ );
141
+ warnUnknownThinkingLevel(ctx, warnedThinking, t.agent ? findAgent(agents, t.agent)?.thinking : undefined);
142
+ }
91
143
  const results: SubagentResult[] = await executeParallel({
92
144
  tasks: params.tasks.map((t) => ({
93
145
  agent: t.agent ?? undefined,
@@ -101,6 +153,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
101
153
  toolCallId: _id,
102
154
  })),
103
155
  signal: signal ?? undefined,
156
+ events: pi.events,
104
157
  onUpdate: (progress: SubagentProgress[]) => {
105
158
  onUpdate?.({
106
159
  content: [],
@@ -113,8 +166,11 @@ export function registerSubagent(pi: ExtensionAPI): void {
113
166
  },
114
167
  });
115
168
 
169
+ const nativeUsage = sumNativeUsage(results);
116
170
  return {
117
171
  content: [{ type: "text", text: formatParallelResults(results) }],
172
+ ...(nativeUsage ? { usage: nativeUsage } : {}),
173
+ structuredContent: subagentStructuredContent("parallel", results),
118
174
  details: {
119
175
  mode: "parallel",
120
176
  results,
@@ -158,6 +214,18 @@ export function registerSubagent(pi: ExtensionAPI): void {
158
214
  isError: true,
159
215
  };
160
216
  }
217
+ // A rejected `tools:` list is a config error, not a runtime one: the child
218
+ // would run with a different tool set than the config asked for, so it
219
+ // does not run at all.
220
+ if (agentConfig?.toolsError) {
221
+ return {
222
+ content: [{ type: "text", text: agentConfig.toolsError }],
223
+ details: { mode: "single", results: [], progress: undefined },
224
+ isError: true,
225
+ };
226
+ }
227
+ warnUnavailableTools(pi, ctx, new Set(), params.agent ?? "subagent", agentConfig?.tools);
228
+ warnUnknownThinkingLevel(ctx, new Set(), agentConfig?.thinking);
161
229
  const result: SubagentResult = await executeSubagent({
162
230
  agent: params.agent ?? undefined,
163
231
  agentConfig,
@@ -166,6 +234,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
166
234
  activeModel: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
167
235
  cwd: params.cwd ?? undefined,
168
236
  signal: signal ?? undefined,
237
+ events: pi.events,
169
238
  toolCallId: _id,
170
239
  onUpdate: (progress: SubagentProgress) => {
171
240
  onUpdate?.({
@@ -180,13 +249,31 @@ export function registerSubagent(pi: ExtensionAPI): void {
180
249
  });
181
250
 
182
251
  return {
183
- content: [{ type: "text", text: result.finalOutput ?? result.error ?? "completed" }],
252
+ content: [{ type: "text", text: formatSingleResult(result) }],
253
+ // The cost report — but only for a run this session performed. A
254
+ // bridge-dispatched run is a session of the desktop's own and books
255
+ // its usage there; pi folds a tool result's `usage` into THIS
256
+ // session's totals, so publishing it here would count the money twice
257
+ // (the same reason the `COST_UPDATE` route was deleted).
258
+ ...(result.usageAccountedElsewhere ? {} : { usage: toNativeUsage(result.usage) }),
259
+ // Successes only. The predicate is the one two lines below, so a run
260
+ // that reports itself as failed carries no structured content and a
261
+ // codemode script keeps rejecting with the text it rejects with today
262
+ // (see `SUBAGENT_OUTPUT_SCHEMA`).
263
+ ...(result.error === undefined ? { structuredContent: subagentStructuredContent("single", [result]) } : {}),
184
264
  details: {
185
265
  mode: "single",
186
266
  results: [result],
187
267
  progress: result.progress ? [result.progress] : undefined,
188
268
  },
189
- isError: result.exitCode !== 0,
269
+ // Only a *failed* run is a tool error. A truncated answer and a
270
+ // cancelled one both produced something, and `isError` is how the model
271
+ // and the Client decide to retry or give up (ADR 0028). `error` is the
272
+ // predicate because the exit code is not one — a json child exits 0 for
273
+ // an errored run — and `stream.ts` guarantees a failed run always
274
+ // carries an explanation while a completed/truncated/aborted one never
275
+ // does.
276
+ isError: result.error !== undefined,
190
277
  };
191
278
  }
192
279
 
@@ -231,18 +318,21 @@ export function registerSubagent(pi: ExtensionAPI): void {
231
318
  false) as boolean;
232
319
 
233
320
  const renderTheme = theme as unknown as RenderTheme;
234
- const text = new Text("", 0, 0);
321
+ // A RowBlock, not a plain Text: the subagent rows are cut at the width pi
322
+ // renders them at, and only the component is ever handed that width.
323
+ const lastComponent = (context as { lastComponent?: unknown })?.lastComponent;
324
+ const rows = lastComponent instanceof RowBlock ? lastComponent : new RowBlock();
235
325
  const renderContext = {
236
326
  expanded,
237
327
  isError: toolResult.isError ?? false,
238
- lastComponent: (context as { lastComponent?: Text })?.lastComponent,
328
+ lastComponent: rows,
239
329
  state: (context as Record<string, unknown>)?.state ?? {},
240
330
  invalidate: () => {},
241
331
  };
242
- (context as Record<string, unknown>).lastComponent = text;
332
+ (context as Record<string, unknown>).lastComponent = rows;
243
333
 
244
334
  try {
245
- const rendered = renderSubagentResult(text, toolResult, { expanded }, renderTheme, renderContext as any);
335
+ const rendered = renderSubagentResult(rows, toolResult, { expanded }, renderTheme, renderContext as any);
246
336
  return rendered;
247
337
  } catch (e) {
248
338
  debugText.setText("render error: " + (e instanceof Error ? e.message : String(e)));
@@ -256,6 +346,76 @@ export function registerSubagent(pi: ExtensionAPI): void {
256
346
 
257
347
  // ── Helpers ─────────────────────────────────────────────────────────────────
258
348
 
349
+ /**
350
+ * Warn (never fail) about allowlist entries no registered tool satisfies right
351
+ * now, naming each one.
352
+ *
353
+ * Membership cannot be checked any earlier: `pi.getAllTools()` needs this
354
+ * handle, and the config read is pure filesystem. And it must not fail the run:
355
+ * a legitimate name is missing whenever its plugin is toggled off or its MCP
356
+ * server is disconnected, so a hard error would make `tools: web_search` unusable
357
+ * for anyone who disabled `web`. One warning per entry per tool call — a parallel
358
+ * batch of eight agents sharing a list warns once, not eight times.
359
+ *
360
+ * Diagnostics go to `diag()` because a `console.*` write is discarded by a
361
+ * subagent child and smears the fullscreen frame (see `core/log.ts`); the
362
+ * on-screen copy only goes out where there is a UI to receive it.
363
+ */
364
+ function warnUnavailableTools(
365
+ pi: ExtensionAPI,
366
+ ctx: ExtensionContext,
367
+ alreadyWarned: Set<string>,
368
+ agentName: string,
369
+ tools: string[] | undefined,
370
+ ): void {
371
+ if (!tools || tools.length === 0) return;
372
+ let unknown: string[];
373
+ try {
374
+ const known = pi.getAllTools().map((t) => t.name);
375
+ unknown = unknownToolEntries(tools, known).filter((entry) => !alreadyWarned.has(entry));
376
+ } catch (e) {
377
+ // A handle that cannot answer is not a reason to lose the dispatch.
378
+ diag("subagent", `tool-membership check skipped for "${agentName}": ${describeError(e)}`);
379
+ return;
380
+ }
381
+ if (unknown.length === 0) return;
382
+ for (const entry of unknown) alreadyWarned.add(entry);
383
+ const message = formatUnknownToolsWarning(agentName, unknown);
384
+ diag("subagent", message);
385
+ if (!ctx.hasUI) return;
386
+ try {
387
+ ctx.ui.notify(message, "warning");
388
+ } catch {
389
+ // A context too stale to notify must not fail the run it is only warning about.
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Warn (never fail) about a `thinking:` level pi's `--thinking` does not take.
395
+ *
396
+ * `spawnSubagent` withholds the flag in this case, which is what pi would have
397
+ * done anyway — the difference is that the child no longer runs at the default
398
+ * level silently: pi's own notice is a stderr warning, and a successful json
399
+ * child's stderr is never read. One warning per distinct value per tool call.
400
+ */
401
+ function warnUnknownThinkingLevel(
402
+ ctx: ExtensionContext,
403
+ alreadyWarned: Set<string>,
404
+ thinking: string | undefined,
405
+ ): void {
406
+ if (thinking === undefined || alreadyWarned.has(thinking)) return;
407
+ const { warning } = resolveThinkingLevel(thinking);
408
+ if (!warning) return;
409
+ alreadyWarned.add(thinking);
410
+ diag("subagent", warning);
411
+ if (!ctx.hasUI) return;
412
+ try {
413
+ ctx.ui.notify(warning, "warning");
414
+ } catch {
415
+ // Same rule as the tool-membership warning: a report must not lose a run.
416
+ }
417
+ }
418
+
259
419
  export function registerListAgentsTool(pi: ExtensionAPI): void {
260
420
  pi.registerTool({
261
421
  name: "list_agents",
package/src/render.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Text } from "@earendil-works/pi-tui";
1
+ import { RowBlock, row, type RowSegment } from "./row-block.js";
2
2
  import type { SubagentDetails, SubagentProgress, SubagentToolResult } from "./types.js";
3
3
  import {
4
4
  renderCompactSingle,
@@ -17,107 +17,117 @@ type Theme = { fg: (token: string, text: string) => string; bold: (text: string)
17
17
  type RenderContext = {
18
18
  expanded: boolean;
19
19
  isError: boolean;
20
- lastComponent: Text | undefined;
20
+ lastComponent: RowBlock | undefined;
21
21
  state: Record<string, unknown>;
22
22
  invalidate: () => void;
23
23
  };
24
24
 
25
+ /**
26
+ * Draw a subagent result (or its live progress) into `rows`.
27
+ *
28
+ * `rows` is a `RowBlock` rather than a plain `Text` because the renderers below
29
+ * hand back whole content: the component is what knows the width pi is drawing
30
+ * at, and it is what cuts each row to that width (`row-block.ts`).
31
+ */
25
32
  export function renderSubagentResult(
26
- text: Text,
33
+ rows: RowBlock,
27
34
  result: SubagentToolResult,
28
35
  options: { expanded: boolean },
29
36
  theme: Theme,
30
37
  context: RenderContext,
31
- ): Text {
38
+ ): RowBlock {
32
39
  const details: SubagentDetails | undefined = result.details;
33
40
  const expanded = context.expanded ?? options.expanded;
34
41
 
35
42
  if (!details) {
36
- text.setText(theme.fg("dim", " no results"));
37
- return text;
43
+ rows.setText(theme.fg("dim", " no results"));
44
+ return rows;
38
45
  }
39
46
 
40
47
  // ── Streaming progress (results empty but progress has data) ──────────
41
48
  if (details.results.length === 0 && details.progress && details.progress.length > 0) {
42
49
  if (details.progress.length === 1) {
43
- return renderProgressUpdate(text, details.progress[0]!, expanded, theme, context);
50
+ return renderProgressUpdate(rows, details.progress[0]!, expanded, theme, context);
44
51
  }
45
- return renderProgressUpdatesParallel(text, details, expanded, theme, context);
52
+ return renderProgressUpdatesParallel(rows, details, expanded, theme, context);
46
53
  }
47
54
 
48
55
  if (details.results.length === 0) {
49
- text.setText(theme.fg("dim", " no results"));
50
- return text;
56
+ rows.setText(theme.fg("dim", " no results"));
57
+ return rows;
51
58
  }
52
59
 
53
60
  // ── Single agent ────────────────────────────────────────────────────────
54
61
  if (details.mode === "single" || details.results.length === 1) {
55
- return renderSingleAgent(text, details.results[0]!, details.progress?.[0], expanded, theme, context);
62
+ return renderSingleAgent(rows, details.results[0]!, details.progress?.[0], expanded, theme, context);
56
63
  }
57
64
 
58
65
  // ── Parallel agents ─────────────────────────────────────────────────────
59
- return renderParallelAgents(text, details, expanded, theme, context);
66
+ return renderParallelAgents(rows, details, expanded, theme, context);
60
67
  }
61
68
 
62
69
  function renderSingleAgent(
63
- text: Text,
70
+ rows: RowBlock,
64
71
  result: SubagentDetails["results"][number],
65
72
  progress: SubagentDetails["progress"] extends (infer U)[] | undefined ? U | undefined : undefined,
66
73
  expanded: boolean,
67
74
  theme: Theme,
68
75
  context: RenderContext,
69
- ): Text {
76
+ ): RowBlock {
70
77
  if (expanded) {
71
- return renderExpanded(text, result, progress, theme);
78
+ return renderExpanded(rows, result, progress, theme);
72
79
  }
73
- return renderCompactSingle(text, result, progress, theme, context);
80
+ renderCompactSingle(rows, result, progress, theme, context);
81
+ return rows;
74
82
  }
75
83
 
76
84
  function renderParallelAgents(
77
- text: Text,
85
+ rows: RowBlock,
78
86
  details: SubagentDetails,
79
87
  expanded: boolean,
80
88
  theme: Theme,
81
89
  context: RenderContext,
82
- ): Text {
90
+ ): RowBlock {
83
91
  if (expanded) {
84
92
  const progressArr: SubagentProgress[] = details.progress ?? [];
85
- const lines = details.results.map((result, i) => {
86
- return buildExpandedText(result, progressArr[i], theme);
87
- });
88
- text.setText(lines.join("\n\n"));
89
- return text;
93
+ rows.setSegments(joinBlocks(details.results.map((result, i) => buildExpandedText(result, progressArr[i], theme))));
94
+ return rows;
90
95
  }
91
- return renderCompactParallel(text, details, theme, context);
96
+ renderCompactParallel(rows, details, theme, context);
97
+ return rows;
92
98
  }
93
99
 
94
100
  function renderProgressUpdate(
95
- text: Text,
101
+ rows: RowBlock,
96
102
  progress: SubagentProgress,
97
103
  expanded: boolean,
98
104
  theme: Theme,
99
105
  context: RenderContext,
100
- ): Text {
106
+ ): RowBlock {
101
107
  if (expanded) {
102
- return renderProgressExpanded(text, progress, theme);
108
+ return renderProgressExpanded(rows, progress, theme);
103
109
  }
104
- return renderCompactProgress(text, progress, theme, context);
110
+ renderCompactProgress(rows, progress, theme, context);
111
+ return rows;
105
112
  }
106
113
 
107
114
  function renderProgressUpdatesParallel(
108
- text: Text,
115
+ rows: RowBlock,
109
116
  details: SubagentDetails,
110
117
  expanded: boolean,
111
118
  theme: Theme,
112
119
  context: RenderContext,
113
- ): Text {
120
+ ): RowBlock {
114
121
  if (expanded) {
115
122
  const progressArr: SubagentProgress[] = details.progress ?? [];
116
- const lines = progressArr.map((progress) => {
117
- return buildProgressExpandedText(progress, theme);
118
- });
119
- text.setText(lines.join("\n\n"));
120
- return text;
123
+ rows.setSegments(joinBlocks(progressArr.map((progress) => buildProgressExpandedText(progress, theme))));
124
+ return rows;
121
125
  }
122
- return renderCompactParallelProgress(text, details, theme, context);
126
+ renderCompactParallelProgress(rows, details, theme, context);
127
+ return rows;
128
+ }
129
+
130
+ /** One blank row between agents — the spacing the old `join("\n\n")` produced. */
131
+ function joinBlocks(blocks: RowSegment[][]): RowSegment[] {
132
+ return blocks.flatMap((blockLines, i) => (i === 0 ? blockLines : [row(""), ...blockLines]));
123
133
  }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Unit tests for the row primitives the subagent rows are drawn with.
3
+ *
4
+ * The two properties worth pinning down, both learned by measuring pi-tui:
5
+ *
6
+ * * `truncateToWidth` puts the ellipsis **after** a colour reset
7
+ * (`…\x1b[0m…` → `\x1b[0m...\x1b[0m`), so a truncated span loses the colour
8
+ * of the span it belongs to. That is why the rows do not use it for the
9
+ * ellipsis — see the "why not truncateToWidth" test, which pins the measured
10
+ * bytes so the claim cannot quietly rot.
11
+ * * `sliceByColumn` cuts on display cells (wide glyphs and emoji included) and
12
+ * leaves the ANSI state open at the cut, so a plain `"..."` appended after
13
+ * it inherits the style that was active where the line was cut.
14
+ */
15
+
16
+ import { describe, it, expect } from "vitest";
17
+ import { Text, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
18
+ import { RowBlock, block, row, truncateRow } from "./row-block.js";
19
+
20
+ const dim = (t: string) => `\x1b[2m${t}\x1b[0m`;
21
+ const bold = (t: string) => `\x1b[1m${t}\x1b[0m`;
22
+ const RESET = "\x1b[0m";
23
+
24
+ const drawn = (line: string) => visibleWidth(stripTerminalSequences(line).replace(/\s+$/, ""));
25
+
26
+ const LONG =
27
+ "Handle the review cycle for PR #107 in /home/daniel/Coding/Javascript/pi-archimedes and report every finding with a file:line reference so the maintainer can act on each one without re-reading the diff herself ";
28
+
29
+ describe("truncateRow", () => {
30
+ it("leaves a row that fits untouched, byte for byte", () => {
31
+ const line = bold("general") + dim(": short task");
32
+ expect(truncateRow(line, 80)).toBe(line);
33
+ });
34
+
35
+ it("cuts on display cells and fills the frame exactly", () => {
36
+ for (const width of [200, 120, 80, 60, 40, 20]) {
37
+ const truncated = truncateRow(dim(LONG), width);
38
+ expect(drawn(truncated), `width ${width}`).toBe(width);
39
+ expect(stripTerminalSequences(truncated).endsWith("..."), `width ${width}`).toBe(true);
40
+ }
41
+ });
42
+
43
+ it("keeps the ellipsis inside the styled span (byte-exact)", () => {
44
+ // 10 columns, 3 of them the ellipsis: 7 visible characters of the dim text,
45
+ // then the dots — still inside the open `\x1b[2m`, then closed for us.
46
+ expect(truncateRow(dim("abcdefghijklmnop"), 10)).toBe(`\x1b[2mabcdefg...${RESET}`);
47
+ });
48
+
49
+ it("why not truncateToWidth: its ellipsis lands after the colour reset", () => {
50
+ // Measured against pi-tui 1.2.0. The dots are outside the dim span, which is
51
+ // the exact defect the old character-based `truncLine` existed to avoid.
52
+ expect(truncateToWidth(dim("abcdefghijklmnop"), 10)).toBe(
53
+ "\x1b[2mabcdefg\x1b[0m...\x1b[0m",
54
+ );
55
+ // Ours keeps the colour: nothing resets between the span's own colour and
56
+ // the dots.
57
+ const ours = truncateRow(dim("abcdefghijklmnop"), 10);
58
+ const beforeDots = ours.slice(0, ours.indexOf("..."));
59
+ expect(ours.slice(0, -RESET.length).endsWith("...")).toBe(true);
60
+ expect(beforeDots.startsWith("\x1b[2m")).toBe(true);
61
+ expect(beforeDots.includes(RESET)).toBe(false);
62
+ });
63
+
64
+ it("closes the style it left open, so padding is not painted", () => {
65
+ expect(truncateRow(dim(LONG), 30).endsWith(RESET)).toBe(true);
66
+ });
67
+
68
+ it("cuts at the first newline instead of bleeding into the next row", () => {
69
+ // The rule the old truncLine enforced: a newline is a hard boundary.
70
+ expect(stripTerminalSequences(truncateRow("line one\nline two\nline three", 40))).toBe("line one...");
71
+ expect(stripTerminalSequences(truncateRow("a\n\n\nb", 40))).toBe("a...");
72
+ expect(stripTerminalSequences(truncateRow("\nhello", 40))).toBe("...");
73
+ // Including when the first line is short enough to fit the frame.
74
+ expect(stripTerminalSequences(truncateRow(dim("short\nrest"), 40))).toBe("short...");
75
+ });
76
+
77
+ it("never splits a wide glyph, at any width", () => {
78
+ const cjk = "検証サイクルをPR107で実行して結果を報告".repeat(3);
79
+ for (const width of [40, 30, 21, 11, 5]) {
80
+ const truncated = truncateRow(cjk, width);
81
+ const cells = visibleWidth(truncated);
82
+ expect(cells, `cjk width ${width}`).toBeLessThanOrEqual(width);
83
+ // One cell of slack is the most a dropped double-width glyph can cost.
84
+ expect(cells, `cjk width ${width}`).toBeGreaterThanOrEqual(Math.min(width, 3) - 1);
85
+ }
86
+ expect(stripTerminalSequences(truncateRow(cjk, 40)).endsWith("...")).toBe(true);
87
+ });
88
+
89
+ it("never splits an emoji cluster, at any width", () => {
90
+ const emoji = ("🚀 ship 🔥 the ✅ change 🧪 with 📦 care 🎯 now 🏁 ship 🧭 ").repeat(3);
91
+ for (const width of [40, 27, 13, 7]) {
92
+ expect(visibleWidth(truncateRow(emoji, width)), `emoji width ${width}`).toBeLessThanOrEqual(width);
93
+ }
94
+ });
95
+
96
+ it("survives widths with no room for an ellipsis", () => {
97
+ expect(truncateRow(LONG, 3)).toBe("...");
98
+ expect(truncateRow(LONG, 2)).toBe("..");
99
+ expect(truncateRow(LONG, 1)).toBe(".");
100
+ expect(truncateRow(LONG, 0)).toBe("");
101
+ expect(truncateRow(LONG, -5)).toBe("");
102
+ });
103
+ });
104
+
105
+ describe("RowBlock", () => {
106
+ const padTo = (line: string, width: number) =>
107
+ line + " ".repeat(Math.max(0, width - visibleWidth(line)));
108
+
109
+ const gamma = () => "gamma: " + LONG;
110
+
111
+ it("renders every logical line of setText as one row, padded to the frame", () => {
112
+ const rows = new RowBlock();
113
+ rows.setText(["alpha", "beta", gamma()].join("\n"));
114
+ const lines = rows.render(40);
115
+ expect(lines.length).toBe(3);
116
+ for (const line of lines) expect(visibleWidth(line)).toBe(40);
117
+ });
118
+
119
+ it("matches pi-tui's Text exactly while nothing needs truncating", () => {
120
+ // The rows used to be a plain Text; for short content the replacement must be
121
+ // byte-identical, or the fix silently restyles every row.
122
+ const text = ["alpha", "beta", "gamma"].join("\n");
123
+ const plain = new Text(text, 0, 0).render(80);
124
+ const rows = new RowBlock();
125
+ rows.setText(text);
126
+ expect(rows.render(80)).toEqual(plain);
127
+ });
128
+
129
+ it("truncates a long row to one line instead of wrapping it", () => {
130
+ const rows = new RowBlock();
131
+ rows.setText(bold("general") + dim(": " + LONG));
132
+ for (const width of [200, 120, 80, 60, 40]) {
133
+ const lines = rows.render(width);
134
+ expect(lines.length, `width ${width}`).toBe(1);
135
+ expect(drawn(lines[0]!), `width ${width}`).toBeGreaterThanOrEqual(width - 1);
136
+ expect(stripTerminalSequences(lines[0]!.replace(/\s+$/, "")).endsWith("..."), `width ${width}`).toBe(true);
137
+ }
138
+ });
139
+
140
+ it("keeps wrapped blocks wrapped, so multi-line content is not lost", () => {
141
+ const rows = new RowBlock();
142
+ rows.setSegments([row(bold("general") + dim(": " + LONG)), block(LONG)]);
143
+ const lines = rows.render(40);
144
+ // One row + a wrapped block: the block costs more than one line, the row not.
145
+ expect(lines.length).toBeGreaterThan(2);
146
+ expect(stripTerminalSequences(lines[0]!).trimEnd().endsWith("...")).toBe(true);
147
+ for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(40);
148
+ });
149
+
150
+ it("re-renders for a new width instead of serving a stale frame", () => {
151
+ const rows = new RowBlock();
152
+ rows.setText(bold("general") + dim(": " + LONG));
153
+ const narrow = rows.render(40);
154
+ const wide = rows.render(120);
155
+ expect(narrow).not.toEqual(wide);
156
+ for (const [width, lines] of [
157
+ [40, narrow],
158
+ [120, wide],
159
+ ] as const) {
160
+ expect(lines.length).toBe(1);
161
+ expect(drawn(lines[0]!)).toBeGreaterThanOrEqual(width - 1);
162
+ }
163
+ // …and the same width again is stable (the cache is keyed by content + width).
164
+ expect(rows.render(120)).toEqual(wide);
165
+ // …and new content invalidates it, rather than serving the cached frame.
166
+ rows.setText(bold("general") + dim(": now in a hurry"));
167
+ const changed = rows.render(120);
168
+ expect(changed).not.toEqual(wide);
169
+ expect(stripTerminalSequences(changed[0]!).trimEnd()).toBe("general: now in a hurry");
170
+ });
171
+
172
+ it("renders nothing for blank content, like Text", () => {
173
+ const rows = new RowBlock();
174
+ rows.setText("");
175
+ expect(rows.render(80)).toEqual([]);
176
+ rows.setSegments([]);
177
+ expect(rows.render(80)).toEqual([]);
178
+ const blank = new RowBlock();
179
+ blank.setText(" ");
180
+ expect(blank.render(80)).toEqual(new Text(" ", 0, 0).render(80));
181
+ });
182
+
183
+ it("normalizes tabs to three spaces, like Text", () => {
184
+ const rows = new RowBlock();
185
+ rows.setText("a\tb");
186
+ expect(rows.render(80)).toEqual(new Text("a\tb", 0, 0).render(80));
187
+ });
188
+
189
+ it("pads every produced row to the frame", () => {
190
+ const rows = new RowBlock();
191
+ rows.setSegments([row("short"), block(LONG), row(dim(LONG))]);
192
+ const lines = rows.render(60);
193
+ for (const line of lines) expect(padTo(line, 60)).toBe(line);
194
+ });
195
+ });