@pi-archimedes/subagent 2.10.3 → 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.
- package/README.md +1 -1
- package/package.json +10 -10
- package/src/agent-panel.mcp.test.ts +76 -0
- package/src/agent-panel.tools.test.ts +162 -0
- package/src/agent-panel.ts +37 -8
- package/src/agent-panel.width-audit.test.ts +258 -0
- package/src/agents-parse.test.ts +252 -0
- package/src/agents.ts +72 -5
- package/src/compact.test.ts +136 -57
- package/src/compact.ts +57 -37
- package/src/dispatch.test.ts +73 -1
- package/src/dispatch.ts +20 -2
- package/src/execute.test.ts +9 -1
- package/src/execute.ts +26 -29
- package/src/expanded.test.ts +61 -0
- package/src/expanded.ts +96 -80
- package/src/format.test.ts +71 -29
- package/src/format.ts +71 -28
- package/src/frontmatter-io.test.ts +62 -0
- package/src/frontmatter-io.ts +16 -2
- package/src/handlers.test.ts +21 -3
- package/src/handlers.ts +22 -3
- package/src/index.output-schema.test.ts +317 -0
- package/src/index.test.ts +486 -0
- package/src/index.ts +168 -8
- package/src/render.ts +46 -36
- package/src/row-block.test.ts +195 -0
- package/src/row-block.ts +186 -0
- package/src/run-outcome.test.ts +71 -0
- package/src/run-outcome.ts +84 -0
- package/src/spawn.test.ts +204 -19
- package/src/spawn.ts +49 -17
- package/src/stream.test.ts +215 -17
- package/src/stream.ts +47 -9
- package/src/thinking-validation.test.ts +96 -0
- package/src/thinking-validation.ts +78 -0
- package/src/tool-declarations.test.ts +58 -0
- package/src/tool-row.width-audit.test.ts +720 -0
- package/src/tool-schema.ts +85 -0
- package/src/tool-validation.test.ts +321 -0
- package/src/tool-validation.ts +262 -0
- package/src/types.ts +86 -0
- package/src/usage.test.ts +95 -0
- package/src/usage.ts +87 -0
- package/src/cost.test.ts +0 -62
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
328
|
+
lastComponent: rows,
|
|
239
329
|
state: (context as Record<string, unknown>)?.state ?? {},
|
|
240
330
|
invalidate: () => {},
|
|
241
331
|
};
|
|
242
|
-
(context as Record<string, unknown>).lastComponent =
|
|
332
|
+
(context as Record<string, unknown>).lastComponent = rows;
|
|
243
333
|
|
|
244
334
|
try {
|
|
245
|
-
const rendered = renderSubagentResult(
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
33
|
+
rows: RowBlock,
|
|
27
34
|
result: SubagentToolResult,
|
|
28
35
|
options: { expanded: boolean },
|
|
29
36
|
theme: Theme,
|
|
30
37
|
context: RenderContext,
|
|
31
|
-
):
|
|
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
|
-
|
|
37
|
-
return
|
|
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(
|
|
50
|
+
return renderProgressUpdate(rows, details.progress[0]!, expanded, theme, context);
|
|
44
51
|
}
|
|
45
|
-
return renderProgressUpdatesParallel(
|
|
52
|
+
return renderProgressUpdatesParallel(rows, details, expanded, theme, context);
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
if (details.results.length === 0) {
|
|
49
|
-
|
|
50
|
-
return
|
|
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(
|
|
62
|
+
return renderSingleAgent(rows, details.results[0]!, details.progress?.[0], expanded, theme, context);
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
// ── Parallel agents ─────────────────────────────────────────────────────
|
|
59
|
-
return renderParallelAgents(
|
|
66
|
+
return renderParallelAgents(rows, details, expanded, theme, context);
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
function renderSingleAgent(
|
|
63
|
-
|
|
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
|
-
):
|
|
76
|
+
): RowBlock {
|
|
70
77
|
if (expanded) {
|
|
71
|
-
return renderExpanded(
|
|
78
|
+
return renderExpanded(rows, result, progress, theme);
|
|
72
79
|
}
|
|
73
|
-
|
|
80
|
+
renderCompactSingle(rows, result, progress, theme, context);
|
|
81
|
+
return rows;
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
function renderParallelAgents(
|
|
77
|
-
|
|
85
|
+
rows: RowBlock,
|
|
78
86
|
details: SubagentDetails,
|
|
79
87
|
expanded: boolean,
|
|
80
88
|
theme: Theme,
|
|
81
89
|
context: RenderContext,
|
|
82
|
-
):
|
|
90
|
+
): RowBlock {
|
|
83
91
|
if (expanded) {
|
|
84
92
|
const progressArr: SubagentProgress[] = details.progress ?? [];
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
96
|
+
renderCompactParallel(rows, details, theme, context);
|
|
97
|
+
return rows;
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
function renderProgressUpdate(
|
|
95
|
-
|
|
101
|
+
rows: RowBlock,
|
|
96
102
|
progress: SubagentProgress,
|
|
97
103
|
expanded: boolean,
|
|
98
104
|
theme: Theme,
|
|
99
105
|
context: RenderContext,
|
|
100
|
-
):
|
|
106
|
+
): RowBlock {
|
|
101
107
|
if (expanded) {
|
|
102
|
-
return renderProgressExpanded(
|
|
108
|
+
return renderProgressExpanded(rows, progress, theme);
|
|
103
109
|
}
|
|
104
|
-
|
|
110
|
+
renderCompactProgress(rows, progress, theme, context);
|
|
111
|
+
return rows;
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
function renderProgressUpdatesParallel(
|
|
108
|
-
|
|
115
|
+
rows: RowBlock,
|
|
109
116
|
details: SubagentDetails,
|
|
110
117
|
expanded: boolean,
|
|
111
118
|
theme: Theme,
|
|
112
119
|
context: RenderContext,
|
|
113
|
-
):
|
|
120
|
+
): RowBlock {
|
|
114
121
|
if (expanded) {
|
|
115
122
|
const progressArr: SubagentProgress[] = details.progress ?? [];
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
+
});
|