@d3ara1n/pi-subagent 2.0.0 → 2.2.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 +3 -3
- package/package.json +1 -1
- package/src/index.ts +19 -10
- package/src/render-async.ts +57 -3
- package/src/roles.ts +13 -6
- package/src/spawn.ts +3 -0
- package/src/types.ts +16 -0
- package/src/utils.test.ts +55 -0
- package/src/utils.ts +35 -0
- package/src/view.ts +30 -2
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ This means:
|
|
|
32
32
|
|
|
33
33
|
| Role | Model Role | Timeout | Tools | Can Delegate To | Description |
|
|
34
34
|
|------|-----------|---------|-------|-----------------|-------------|
|
|
35
|
-
| `explorer` | fast | 900s | read, find, grep | — | Fast code exploration (read-only) |
|
|
35
|
+
| `explorer` | fast | 900s | read, find, grep, bash | — | Fast code exploration incl. git history inspection (read-only) |
|
|
36
36
|
| `reviewer` | heavy | 3600s | read, bash, grep, find | — | Deep code review, runs git/tests for evidence (read-only) |
|
|
37
37
|
| `worker` | default | 2400s | all (no whitelist) | explorer, researcher | Implementation — the only role that can modify files; full tool access (web, MCP, everything) |
|
|
38
38
|
| `researcher` | fast | 2400s | web_search, fetch_content, source_check, get_search_content, read, bash, edit, write, delegate | explorer | Web research + GitHub repo analysis; writes artifacts only inside its temp dir |
|
|
@@ -63,7 +63,7 @@ This means:
|
|
|
63
63
|
|
|
64
64
|
A centered overlay covering most of the screen. A tab row across the top lists every run (icon · id · role); `Tab` cycles the focused run, and the rest of the viewport belongs to it alone — showing one of two pages, toggled with `d`.
|
|
65
65
|
|
|
66
|
-
The **activity page** (default) is the run's live feed: a continuous, append-only list where each entry is static text with a state icon, and the only animated thing is the ellipsis on a running entry (`.` → `..` → `...`). Finishing freezes an entry in place — its position never changes, only the icon flips. Streamed assistant text grows in place as the run's last line and settles into plain terminal-colored text at the turn boundary. The feed is scrollable (`↑↓`, `PgUp/PgDn`, `Home`/`End`): the view pins to the end and auto-follows new entries; scrolling up unpins (a `⋮ N earlier` marker appears), and reaching the bottom again re-pins. Both foreground and background runs appear here; a foreground run stays listed while its delegate call blocks the main agent.
|
|
66
|
+
The **activity page** (default) is the run's live feed: a continuous, append-only list where each entry is static text with a state icon, and the only animated thing is the ellipsis on a running entry (`.` → `..` → `...`). Finishing freezes an entry in place — its position never changes, only the icon flips. Streamed assistant text grows in place as the run's last line and settles into plain terminal-colored text at the turn boundary. The feed is scrollable (`↑↓`, `PgUp/PgDn`, `Home`/`End`): the view pins to the end and auto-follows new entries; scrolling up unpins (a `⋮ N earlier` marker appears), and reaching the bottom again re-pins. Both foreground and background runs appear here; a foreground run stays listed while its delegate call blocks the main agent. A run leaves the view once its result is in the conversation — when the last one goes, the overlay shows a centered empty notice (with `Esc close` hinted) rather than shrinking away.
|
|
67
67
|
|
|
68
68
|
The **brief page** shows the run's inputs and vitals at full width: the task and context verbatim (wrapped; head+tail elided beyond 20k chars), the reference file list annotated with `✓`/`·` for whether the child's tool calls actually touched each file, usage and time stats, the fallback trace, and a stderr tail on failures.
|
|
69
69
|
|
|
@@ -229,7 +229,7 @@ Typical flow:
|
|
|
229
229
|
|
|
230
230
|
Semantics worth knowing:
|
|
231
231
|
|
|
232
|
-
- **Results are pull-only for the model.** A purple completion notice is shown to the user, but nothing delivers the result to the model or wakes it up. The model owns the collection point: `subagent_wait`, then `subagent_check` each run. The inbox reminder (below) lists unclaimed runs on every request, but it never pushes results.
|
|
232
|
+
- **Results are pull-only for the model.** A purple completion notice is shown to the user, but nothing delivers the result to the model or wakes it up. The notice is a pure notification in the same visual family as pi's `[compaction]` card — a `[subagent] id (role) outcome` header with the bare task preview beneath, each line truncated to the terminal width — and deliberately unlike the tool rows, so it never reads as model behavior; the result itself never appears in the notice, only in `subagent_check` (model) or `/subagent:status` (user). The model owns the collection point: `subagent_wait`, then `subagent_check` each run. The inbox reminder (below) lists unclaimed runs on every request, but it never pushes results.
|
|
233
233
|
- **Background runs survive turn cancellation** and are unaffected by a cancelled `subagent_wait` — cancelling the wait never cancels the runs; call `subagent_wait` or `subagent_check` again later.
|
|
234
234
|
- **Read-once collection:** `subagent_check` on a terminal run returns the result and frees it — the output now lives in the conversation history, and only a lightweight tombstone stays in the registry (`/subagent:status` lists it under "Collected"). Re-checking a collected id explains that its result is already in the history.
|
|
235
235
|
- **Cancellation keeps the partial output.** `subagent_cancel(id, reason?)` kills the child (SIGTERM, escalating to SIGKILL) and settles the run as `cancelled` — its own stop reason in the same family as `timeout`/`budget_exceeded` (TUI warning styling ⏹, not the error-red ✗ of real failures) — with whatever it had produced. The `reason` becomes the error message verbatim, so whoever reads the partial output later via `subagent_check` — or the audit history — sees `cancelled — <reason>`; the source is distinguishable too (`user: ...` for `/subagent:cancel`, the model's own words for the tool, `session shutdown` for reaping). Cancelling does not collect: `subagent_check` still returns the partial output once, and `subagent_wait` reports the run as `cancelled (partial output kept)`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d3ara1n/pi-subagent",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Role-based subagent orchestration for pi — delegates tasks to specialized pi child processes with configurable model roles",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/index.ts
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
renderCancelResult,
|
|
54
54
|
renderCheckCall,
|
|
55
55
|
renderCheckResult,
|
|
56
|
+
renderCompletionNotice,
|
|
56
57
|
renderWaitCall,
|
|
57
58
|
renderWaitResult,
|
|
58
59
|
} from "./render-async.ts";
|
|
@@ -145,6 +146,12 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
145
146
|
"- Delegate ONLY when a task involves significant work (heavy analysis, multi-step investigation, large-scope changes) AND you only care about the conclusion, not intermediate steps. A good test: the task would clutter your context with 3+ turns of raw tool output.",
|
|
146
147
|
"- DO NOT delegate simple tasks — a single read, a one-line edit, a basic grep, or straightforward changes touching 1-2 files. Just do them yourself; spawning a child process costs more than the task.",
|
|
147
148
|
"",
|
|
149
|
+
"SELF-CONTAINED DELEGATION (subagents have NO memory of this conversation):",
|
|
150
|
+
"",
|
|
151
|
+
"- The child sees exactly three things: task, context, files — nothing else of this chat exists for it. Any requirement, decision, or constraint it must know has to be restated in those channels.",
|
|
152
|
+
"- Never write phantom references (\"as discussed above\", \"per the requirements\") — the child has never seen that material. Inline the actual content instead.",
|
|
153
|
+
"- The isolation is deliberate: don't dump your whole conversation either. Pass what the task needs and nothing more, so the child's judgment isn't steered by irrelevant history.",
|
|
154
|
+
"",
|
|
148
155
|
"AVAILABLE ROLES:",
|
|
149
156
|
...entries.map(([name, role]) => ` - ${name}: ${role.description}`),
|
|
150
157
|
"",
|
|
@@ -280,6 +287,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
280
287
|
for (const run of liveRuns) run.abort("session shutdown");
|
|
281
288
|
});
|
|
282
289
|
|
|
290
|
+
// The completion notice card — system-notice styling, not the tool-row
|
|
291
|
+
// family, so it never reads as model behavior.
|
|
292
|
+
pi.registerMessageRenderer(BACKGROUND_COMPLETION_MESSAGE_TYPE, renderCompletionNotice);
|
|
293
|
+
|
|
283
294
|
pi.registerTool({
|
|
284
295
|
name: "subagent_delegate",
|
|
285
296
|
label: "Delegate to subagent",
|
|
@@ -292,7 +303,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
292
303
|
role: Type.String({ description: "Subagent role to use" }),
|
|
293
304
|
task: Type.String({
|
|
294
305
|
description:
|
|
295
|
-
"The work to do
|
|
306
|
+
"The work to do — self-contained: the subagent cannot see this conversation, so restate any requirement or constraint it needs here or in `context`. Instructions only — background material belongs in `context`, reference file paths in `files`.",
|
|
296
307
|
}),
|
|
297
308
|
context: Type.Optional(
|
|
298
309
|
Type.String({
|
|
@@ -384,19 +395,17 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
384
395
|
: result.stopReason === "cancelled"
|
|
385
396
|
? "cancelled"
|
|
386
397
|
: "finished";
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
: outcome === "cancelled"
|
|
391
|
-
? result.errorMessage
|
|
392
|
-
: result.summary;
|
|
393
|
-
const detailText = detail?.trim() ? ` — ${taskPreview(detail)}` : "";
|
|
398
|
+
// Pure notification: id + outcome only. The result itself surfaces
|
|
399
|
+
// through subagent_check (model) or /subagent:status (user) — the
|
|
400
|
+
// notice never previews it.
|
|
394
401
|
pi.sendMessage(
|
|
395
402
|
{
|
|
396
403
|
customType: BACKGROUND_COMPLETION_MESSAGE_TYPE,
|
|
397
|
-
content: `Background subagent ${run.id} (${run.role}) ${outcome}: "${taskPreview(run.task)}"
|
|
404
|
+
content: `Background subagent ${run.id} (${run.role}) ${outcome}: "${taskPreview(run.task)}"`,
|
|
398
405
|
display: true,
|
|
399
|
-
|
|
406
|
+
// Structured payload for the notice renderer; the content string
|
|
407
|
+
// stays as the non-TUI fallback (export, print mode).
|
|
408
|
+
details: { id: run.id, role: run.role, outcome, task: run.task },
|
|
400
409
|
},
|
|
401
410
|
{ triggerTurn: false },
|
|
402
411
|
);
|
package/src/render-async.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TUI rendering for background delegation: the background delegate input
|
|
3
|
-
* block, the wait live view,
|
|
3
|
+
* block, the wait live view, the check snapshot view, and the completion
|
|
4
|
+
* notice card.
|
|
4
5
|
*
|
|
5
6
|
* This module is deliberately independent of ./render.ts (the foreground
|
|
6
7
|
* delegate family): the two presentation shapes evolve separately and share
|
|
@@ -20,12 +21,17 @@
|
|
|
20
21
|
* line takes over the icon — never both.
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
|
-
import {
|
|
24
|
-
|
|
24
|
+
import {
|
|
25
|
+
getMarkdownTheme,
|
|
26
|
+
type MessageRenderer,
|
|
27
|
+
type ToolDefinition,
|
|
28
|
+
} from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import { Box, Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
25
30
|
import type {
|
|
26
31
|
BackgroundDelegateDetails,
|
|
27
32
|
CancelDetails,
|
|
28
33
|
CheckDetails,
|
|
34
|
+
CompletionNoticeDetails,
|
|
29
35
|
RunViewEntry,
|
|
30
36
|
SubagentResult,
|
|
31
37
|
WaitDetails,
|
|
@@ -35,6 +41,7 @@ import {
|
|
|
35
41
|
cancelStopSummary,
|
|
36
42
|
clearElapsedTimer,
|
|
37
43
|
collapsedText,
|
|
44
|
+
completionNoticeLines,
|
|
38
45
|
contentText,
|
|
39
46
|
deriveRunState,
|
|
40
47
|
ensureElapsedTimer,
|
|
@@ -224,6 +231,53 @@ function checkEntryExpandedContainer(r: SubagentResult, fg: Fg): Container {
|
|
|
224
231
|
return container;
|
|
225
232
|
}
|
|
226
233
|
|
|
234
|
+
// ── completion notice: system-notice card, not the tool-row family ──
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Renderer for the `subagent-completion` custom message — the background-run
|
|
238
|
+
* completion notice. Same visual language as pi's [compaction] card
|
|
239
|
+
* (bracket label + purple box) and deliberately unlike the tool rows above:
|
|
240
|
+
* no tool-title prefix, no status icons, no usage lines. The bracket label
|
|
241
|
+
* is the "this is a system event, not model behavior" signal.
|
|
242
|
+
*
|
|
243
|
+
* Collapsed: header row + bare task preview beneath, each line truncated
|
|
244
|
+
* to the box interior width with "…" — the header can never fall off the
|
|
245
|
+
* right edge. Expanded (ctrl+o): the same lines untruncated, wrapped by Text.
|
|
246
|
+
*/
|
|
247
|
+
export const renderCompletionNotice: MessageRenderer<CompletionNoticeDetails> = (
|
|
248
|
+
message,
|
|
249
|
+
{ expanded },
|
|
250
|
+
theme,
|
|
251
|
+
) => {
|
|
252
|
+
const fg = theme.fg.bind(theme) as Fg;
|
|
253
|
+
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
|
254
|
+
|
|
255
|
+
const details = message.details;
|
|
256
|
+
if (!details) {
|
|
257
|
+
// Sessions persisted before structured details: fall back to the plain
|
|
258
|
+
// content string that was sent alongside them.
|
|
259
|
+
const text =
|
|
260
|
+
typeof message.content === "string"
|
|
261
|
+
? message.content
|
|
262
|
+
: (message.content ?? [])
|
|
263
|
+
.filter((c) => c.type === "text")
|
|
264
|
+
.map((c) => c.text)
|
|
265
|
+
.join("\n");
|
|
266
|
+
box.addChild(new Text(fg("customMessageText", text), 0, 0));
|
|
267
|
+
return box;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const lines = completionNoticeLines(details, fg, theme.bold.bind(theme));
|
|
271
|
+
if (expanded) {
|
|
272
|
+
for (const line of lines) box.addChild(new Text(line, 0, 0));
|
|
273
|
+
} else {
|
|
274
|
+
// Per-line width-aware truncation inside the box (Box hands children the
|
|
275
|
+
// interior width; collapsedText never lets a line wrap).
|
|
276
|
+
box.addChild(collapsedText(lines.join("\n")));
|
|
277
|
+
}
|
|
278
|
+
return box;
|
|
279
|
+
};
|
|
280
|
+
|
|
227
281
|
// ── Background delegate: static input block ────────────────────
|
|
228
282
|
|
|
229
283
|
export const renderBackgroundDelegateCall: RenderCallFn = (args, theme) => {
|
package/src/roles.ts
CHANGED
|
@@ -15,13 +15,18 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
|
|
|
15
15
|
fallbackRole: "default",
|
|
16
16
|
timeout: 900,
|
|
17
17
|
description:
|
|
18
|
-
"READ-ONLY codebase exploration — locate files, grep symbols, trace imports, explain structures.",
|
|
19
|
-
examples: [
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
"READ-ONLY codebase exploration — locate files, grep symbols, trace imports, explain structures, inspect git history (log/diff/blame).",
|
|
19
|
+
examples: [
|
|
20
|
+
"Find where auth middleware is implemented",
|
|
21
|
+
"Map the routing structure",
|
|
22
|
+
"Summarize what the uncommitted diff changes, file by file",
|
|
23
|
+
],
|
|
24
|
+
decisionTrigger: "Task finds or maps code (including git history) without touch?",
|
|
25
|
+
tools: ["read", "find", "grep", "bash"],
|
|
22
26
|
systemPrompt: [
|
|
23
27
|
"Code explorer. READ-ONLY — locate code, understand it, and report findings; never modify anything.",
|
|
24
28
|
"Search to locate → read the files relevant to the task → trace imports, identify types, interfaces, functions.",
|
|
29
|
+
"Bash for read-only inspection only: git log/show/blame/diff, ls, wc. Never run a command that modifies files or state (sed, tee, echo >, git checkout/commit, installs).",
|
|
25
30
|
"Skip noise: lockfiles, vendored, minified, and generated files.",
|
|
26
31
|
"",
|
|
27
32
|
"Output format:",
|
|
@@ -35,7 +40,7 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
|
|
|
35
40
|
fallbackRole: "default",
|
|
36
41
|
timeout: 3600,
|
|
37
42
|
description:
|
|
38
|
-
"READ-ONLY code review & analysis — audit code, assess architecture, review diffs, run tests for evidence.",
|
|
43
|
+
"READ-ONLY code review & analysis — audit code, assess architecture, review diffs, run tests for evidence. Reports findings and suggested fixes but never implements them.",
|
|
39
44
|
examples: [
|
|
40
45
|
"Review the error handling in src/api/ for security issues",
|
|
41
46
|
"Audit this PR diff for performance regressions",
|
|
@@ -44,6 +49,7 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
|
|
|
44
49
|
tools: ["read", "bash", "grep", "find"],
|
|
45
50
|
systemPrompt: [
|
|
46
51
|
"Senior code reviewer. READ-ONLY — you must NOT modify any file.",
|
|
52
|
+
"If the task asks you to fix or implement, do NOT do it: report findings and suggested fixes, and state that implementation is out of scope for this role.",
|
|
47
53
|
"Run only read-only commands (git diff/log/show, test runs). Never use sed, tee, echo >, or any write command.",
|
|
48
54
|
"Provide evidence-backed findings with file:line references.",
|
|
49
55
|
"",
|
|
@@ -84,7 +90,7 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
|
|
|
84
90
|
fallbackRole: "default",
|
|
85
91
|
timeout: 2400,
|
|
86
92
|
description:
|
|
87
|
-
"the ONLY role with WEB ACCESS — search docs, fetch pages, verify claims, analyze GitHub repos. Can clone repos & delegate to explorer.",
|
|
93
|
+
"the ONLY role with WEB ACCESS — search docs, fetch pages, verify claims, analyze GitHub repos. Reports verified facts and sources only; decisions and proposals stay with the caller. Can clone repos & delegate to explorer.",
|
|
88
94
|
examples: ["Find the React 19 migration guide", "Check GitHub issue #1234 for context"],
|
|
89
95
|
decisionTrigger: "Task searches web or GitHub?",
|
|
90
96
|
tools: [
|
|
@@ -101,6 +107,7 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
|
|
|
101
107
|
subagentRoles: ["explorer"],
|
|
102
108
|
systemPrompt: [
|
|
103
109
|
"Web researcher. Search with varied angles, prefer official docs over blogs.",
|
|
110
|
+
"Report verified facts and sources only — do not propose solutions or make design decisions; the caller weighs your findings and decides.",
|
|
104
111
|
"If first results are insufficient, refine queries and search again.",
|
|
105
112
|
"",
|
|
106
113
|
"## Research artifacts",
|
package/src/spawn.ts
CHANGED
|
@@ -196,6 +196,9 @@ export function buildChildArgs(
|
|
|
196
196
|
"- If the task needs a capability you do not have (web access, bash, file",
|
|
197
197
|
" writes, ...) or material that is not present locally or in the provided",
|
|
198
198
|
" context/files, it is out of scope for you. Do NOT improvise workarounds.",
|
|
199
|
+
"- The task may reference material as 'discussed above' or 'the requirements'",
|
|
200
|
+
" — you have NO prior conversation; only this prompt exists. If referenced",
|
|
201
|
+
" material is not in this prompt, report it as Missing — do not guess it.",
|
|
199
202
|
'- "Cannot complete" means a capability or material gap — not "difficult" or',
|
|
200
203
|
' "uncertain". If it is merely hard, keep working within your tools.',
|
|
201
204
|
"- When you hit a genuine gap, stop early and return:",
|
package/src/types.ts
CHANGED
|
@@ -252,3 +252,19 @@ export interface CheckDetails {
|
|
|
252
252
|
* reason, never the partial output (check is the result-fetcher).
|
|
253
253
|
*/
|
|
254
254
|
export type CancelDetails = CheckDetails;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Details for the background-run completion notice (custom message
|
|
258
|
+
* `subagent-completion`). The renderer lays these out as a structured notice
|
|
259
|
+
* card; the message's plain `content` string stays as the non-TUI fallback
|
|
260
|
+
* (export, print mode) and for sessions persisted before this shape existed
|
|
261
|
+
* (task absent there).
|
|
262
|
+
*/
|
|
263
|
+
export interface CompletionNoticeDetails {
|
|
264
|
+
/** Registry id (sub-N). */
|
|
265
|
+
id: string;
|
|
266
|
+
role: string;
|
|
267
|
+
outcome: "finished" | "failed" | "cancelled";
|
|
268
|
+
/** Full task text; the renderer flattens and truncates it. */
|
|
269
|
+
task?: string;
|
|
270
|
+
}
|
package/src/utils.test.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
createThrottler,
|
|
42
42
|
terminalResultLine,
|
|
43
43
|
buildDisplayItems,
|
|
44
|
+
completionNoticeLines,
|
|
44
45
|
formatToolCall,
|
|
45
46
|
briefFilesUsed,
|
|
46
47
|
} from "./utils.ts";
|
|
@@ -437,6 +438,60 @@ describe("terminalResultLine", () => {
|
|
|
437
438
|
});
|
|
438
439
|
});
|
|
439
440
|
|
|
441
|
+
// ── completionNoticeLines: background-run completion notice card ──
|
|
442
|
+
describe("completionNoticeLines", () => {
|
|
443
|
+
// Marker fakes so assertions can see both text and color placement.
|
|
444
|
+
const fg = (color: string, text: string) => `<${color}>${text}</${color}>`;
|
|
445
|
+
const bold = (text: string) => `*${text}*`;
|
|
446
|
+
|
|
447
|
+
test("header: bracket label + id (role) + plain-text outcome", () => {
|
|
448
|
+
assert.deepEqual(
|
|
449
|
+
completionNoticeLines({ id: "sub-3", role: "worker", outcome: "finished" }, fg, bold),
|
|
450
|
+
[
|
|
451
|
+
"<customMessageLabel>*[subagent]*</customMessageLabel> " +
|
|
452
|
+
"<customMessageText>sub-3 (worker)</customMessageText> " +
|
|
453
|
+
"<customMessageText>finished</customMessageText>",
|
|
454
|
+
],
|
|
455
|
+
);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("failed colors the outcome error-red, cancelled warning-yellow", () => {
|
|
459
|
+
const failed = completionNoticeLines({ id: "sub-1", role: "x", outcome: "failed" }, fg, bold)[0];
|
|
460
|
+
assert.ok(failed.includes("<error>failed</error>"));
|
|
461
|
+
const cancelled = completionNoticeLines(
|
|
462
|
+
{ id: "sub-1", role: "x", outcome: "cancelled" },
|
|
463
|
+
fg,
|
|
464
|
+
bold,
|
|
465
|
+
)[0];
|
|
466
|
+
assert.ok(cancelled.includes("<warning>cancelled</warning>"));
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("task preview on its own line without a prefix, newlines flattened", () => {
|
|
470
|
+
const lines = completionNoticeLines(
|
|
471
|
+
{
|
|
472
|
+
id: "sub-2",
|
|
473
|
+
role: "explorer",
|
|
474
|
+
outcome: "finished",
|
|
475
|
+
task: "Map the routing\nstructure",
|
|
476
|
+
},
|
|
477
|
+
fg,
|
|
478
|
+
bold,
|
|
479
|
+
);
|
|
480
|
+
assert.deepEqual(lines[1], "<dim>Map the routing structure</dim>");
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("whitespace-only task: header-only notice", () => {
|
|
484
|
+
assert.deepEqual(
|
|
485
|
+
completionNoticeLines(
|
|
486
|
+
{ id: "sub-4", role: "worker", outcome: "finished", task: " " },
|
|
487
|
+
fg,
|
|
488
|
+
bold,
|
|
489
|
+
).length,
|
|
490
|
+
1,
|
|
491
|
+
);
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
|
|
440
495
|
// ── formatToolCall: single-line guarantee for TUI rows ──
|
|
441
496
|
describe("formatToolCall newline sanitization", () => {
|
|
442
497
|
const id = (_color: string, text: string) => text;
|
package/src/utils.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { Component } from "@earendil-works/pi-tui";
|
|
|
9
9
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
10
10
|
import type {
|
|
11
11
|
ActivityEntry,
|
|
12
|
+
CompletionNoticeDetails,
|
|
12
13
|
FallbackFrom,
|
|
13
14
|
RunState,
|
|
14
15
|
SubagentDetails,
|
|
@@ -336,6 +337,40 @@ export function taskPreview(task: string): string {
|
|
|
336
337
|
return firstLine.length > 70 ? `${firstLine.slice(0, 70)}...` : firstLine;
|
|
337
338
|
}
|
|
338
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Lines of the background-run completion notice. Bracket label + id/role +
|
|
342
|
+
* outcome on the header line, the bare task preview beneath (dim). Pure
|
|
343
|
+
* notification: the result itself never appears here — it surfaces through
|
|
344
|
+
* subagent_check (model) or /subagent:status (user). The renderer truncates
|
|
345
|
+
* each line to the terminal width independently, so the header never falls
|
|
346
|
+
* off the right edge.
|
|
347
|
+
*
|
|
348
|
+
* Visual family: the [compaction] system-notice card (bracket label, purple
|
|
349
|
+
* box), deliberately NOT the tool-row family — no tool-title prefix, no
|
|
350
|
+
* status icons. Signals "system event", not model behavior.
|
|
351
|
+
*/
|
|
352
|
+
export function completionNoticeLines(
|
|
353
|
+
details: CompletionNoticeDetails,
|
|
354
|
+
fg: (color: string, text: string) => string,
|
|
355
|
+
bold: (text: string) => string,
|
|
356
|
+
): string[] {
|
|
357
|
+
// Intentional stops keep the plain text color; only real failures go red
|
|
358
|
+
// (cancelled keeps warning yellow, mirroring result-line semantics).
|
|
359
|
+
const outcomeColor =
|
|
360
|
+
details.outcome === "failed" ? "error" : details.outcome === "cancelled" ? "warning" : "customMessageText";
|
|
361
|
+
const header =
|
|
362
|
+
fg("customMessageLabel", bold("[subagent]")) +
|
|
363
|
+
` ${fg("customMessageText", `${details.id} (${details.role})`)} ` +
|
|
364
|
+
fg(outcomeColor, details.outcome);
|
|
365
|
+
|
|
366
|
+
const lines = [header];
|
|
367
|
+
const task = oneLine(details.task ?? "").trim();
|
|
368
|
+
if (task) {
|
|
369
|
+
lines.push(fg("dim", task));
|
|
370
|
+
}
|
|
371
|
+
return lines;
|
|
372
|
+
}
|
|
373
|
+
|
|
339
374
|
/**
|
|
340
375
|
* Width-aware collapsed-view component: renders each line truncated with "…"
|
|
341
376
|
* to the actual viewport width (never wraps), padded full-width like Text(0,0).
|
package/src/view.ts
CHANGED
|
@@ -93,6 +93,12 @@ function printableChar(data: string): string | undefined {
|
|
|
93
93
|
return data.length === 1 && data >= " " && data <= "~" ? data : undefined;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/** Center a string within `width` visible columns (pad-right handles the tail). */
|
|
97
|
+
function centerText(s: string, width: number): string {
|
|
98
|
+
const pad = Math.max(0, Math.floor((width - visibleWidth(s)) / 2));
|
|
99
|
+
return " ".repeat(pad) + s;
|
|
100
|
+
}
|
|
101
|
+
|
|
96
102
|
/** Elide oversized brief text to head + tail around an elision marker. */
|
|
97
103
|
function capBriefText(text: string): string {
|
|
98
104
|
if (text.length <= BRIEF_TEXT_CAP) return text;
|
|
@@ -432,11 +438,13 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
432
438
|
|
|
433
439
|
// ── Tab row: one cell per run; the focused one is highlighted. ──
|
|
434
440
|
if (runs.length > 0) {
|
|
441
|
+
// Brackets stay on every cell, focused included — the selectedBg +
|
|
442
|
+
// accent highlight is the indicator, so Tab doesn't shift text.
|
|
435
443
|
const cells = runs.map((r) => {
|
|
436
444
|
const isFocused = r === focused;
|
|
437
445
|
const label = `${runIcon(r.snapshot, fg)} ${r.id} ${r.role}`;
|
|
438
446
|
const styled = isFocused ? th.bg("selectedBg", fg("accent", label)) : fg("dim", label);
|
|
439
|
-
return
|
|
447
|
+
return `[${styled}]`;
|
|
440
448
|
});
|
|
441
449
|
lines.push(
|
|
442
450
|
row(
|
|
@@ -445,7 +453,27 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
445
453
|
),
|
|
446
454
|
);
|
|
447
455
|
} else {
|
|
448
|
-
|
|
456
|
+
// Empty registry — every run left the view (a run disappears once its
|
|
457
|
+
// result is in the conversation). Give the state real presence — a
|
|
458
|
+
// full-size panel with a centered message and the close hint — and
|
|
459
|
+
// fold steer mode back to browse so Esc closes immediately.
|
|
460
|
+
if (this.mode === "steer") {
|
|
461
|
+
this.editor.setText("");
|
|
462
|
+
this.mode = "browse";
|
|
463
|
+
}
|
|
464
|
+
lines.push(row(""));
|
|
465
|
+
lines.push(row(fg("muted", centerText("no subagent runs", innerW))));
|
|
466
|
+
lines.push(
|
|
467
|
+
row(
|
|
468
|
+
fg(
|
|
469
|
+
"dim",
|
|
470
|
+
centerText("a run leaves the view once its result is in the conversation", innerW),
|
|
471
|
+
),
|
|
472
|
+
),
|
|
473
|
+
);
|
|
474
|
+
lines.push(row(""));
|
|
475
|
+
lines.push(row(""));
|
|
476
|
+
lines.push(row(fg("dim", "Esc close")));
|
|
449
477
|
}
|
|
450
478
|
|
|
451
479
|
// ── Focused run: header line, then the current page. ──
|