@d3ara1n/pi-subagent 3.2.2 → 3.3.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 +23 -17
- package/package.json +1 -1
- package/src/activity.test.ts +53 -0
- package/src/activity.ts +23 -0
- package/src/index.ts +21 -39
- package/src/reminder.test.ts +2 -2
- package/src/reminder.ts +10 -9
- package/src/render-async.ts +5 -9
- package/src/render.test.ts +44 -3
- package/src/render.ts +3 -11
- package/src/spawn.ts +18 -9
- package/src/types.ts +2 -1
- package/src/utils.test.ts +17 -2
- package/src/utils.ts +39 -16
- package/src/view.test.ts +62 -29
- package/src/view.ts +92 -47
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Role-based subagent orchestration for [pi](https://github.com/earendil-works/pi).
|
|
6
6
|
|
|
7
|
-
Provides a `subagent_delegate` tool that lets the main model offload tasks to specialized pi child processes with configurable model roles, real-time TUI progress, and AI-generated summaries. Runs can be foreground (blocking) or background (asynchronous, collected later via `
|
|
7
|
+
Provides a `subagent_delegate` tool that lets the main model offload tasks to specialized pi child processes with configurable model roles, real-time TUI progress, and AI-generated summaries. Runs can be foreground (blocking) or background (asynchronous, collected later via `subagent_check`, cancellable via `subagent_cancel`). A centered live view (`/subagent:view`) shows every run's activity feed as it happens, with a per-run brief page for inputs and stats; mid-run corrections can be queued into a running subagent from the view's steer editor or via `subagent_steer`.
|
|
8
8
|
|
|
9
9
|
## Design Philosophy
|
|
10
10
|
|
|
@@ -55,7 +55,7 @@ Observations on how main models behave with this plugin, one family per subsecti
|
|
|
55
55
|
|
|
56
56
|
## TUI Display
|
|
57
57
|
|
|
58
|
-
- **During execution**: the task's first line with a ⏳ (or ⏸ queued) indicator, a live stream of thinking blocks
|
|
58
|
+
- **During execution**: the task's first line with a ⏳ (or ⏸ queued) indicator, a live stream of thinking blocks, tool calls, and steering messages (latest 5 collapsed, everything expanded), and a usage line (elapsed/budget time, turns, tokens, peak context, cost, model). Delegates using inherited conversation are marked in the tool-call title.
|
|
59
59
|
- **Collapsed result**: the task's first line, then `✓` + the AI-generated summary (or the first line of the output), then the usage line — no activity replay
|
|
60
60
|
- **Expanded result** (Ctrl+O): reference files, context size, the full task, the complete activity stream, the final output as rendered Markdown, and usage details
|
|
61
61
|
- **Fallback trace**: when a provider error (429, quota, timeout, ...) kills a run and it is retried on the role's `fallbackRole`, a `⚠ fallback: first attempt <model> failed (<reason>)` line appears in both views — also while the retry is running (see [Fallback observability](#fallback-observability))
|
|
@@ -64,20 +64,20 @@ Observations on how main models behave with this plugin, one family per subsecti
|
|
|
64
64
|
|
|
65
65
|
| Command | Description |
|
|
66
66
|
|---------|-------------|
|
|
67
|
-
| `/subagent:view` | Open the live view: a tabbed overlay with a per-run activity feed and a brief detail page (inputs, files, stats), plus modal steer input for the focused run |
|
|
67
|
+
| `/subagent:view` | Open the live view: a tabbed overlay with a per-run activity feed and a brief detail page (inputs, files, stats), plus modal steer input for the focused run — doubles as the session's run archive |
|
|
68
68
|
| `/subagent:doctor` | Diagnose pi invocation, model-role resolution, configuration, and role references |
|
|
69
69
|
| `/subagent:status` | List background runs and their current state |
|
|
70
70
|
| `/subagent:cancel <id\|all> [reason]` | Cancel a live background run (or every live run); the optional reason is recorded with the run |
|
|
71
71
|
|
|
72
72
|
### Live view (`/subagent:view`)
|
|
73
73
|
|
|
74
|
-
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`.
|
|
74
|
+
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 forward and `Shift+Tab` backward, and the rest of the viewport belongs to it alone — showing one of two pages, toggled with `d`. Runs are ordered newest-first, and the row is windowed around the focused cell — the archive can outgrow the terminal width, but focus never scrolls off-screen.
|
|
75
75
|
|
|
76
|
-
The **activity page** (default) is the run's live feed: a continuous
|
|
76
|
+
The **activity page** (default) is the run's live feed: a continuous list with pending input pinned below current activity, 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. Background runs stay listed for the whole session — the view doubles as the session's run archive, so a run's activity and brief remain browsable even after the model has collected the result. The centered empty notice (with `Esc close` hinted) appears only when nothing has been delegated yet.
|
|
77
77
|
|
|
78
78
|
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), inherited-conversation size and truncation status when enabled (never its text), 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.
|
|
79
79
|
|
|
80
|
-
Steer input is modal so keys never conflict with typing: in browse mode `s` opens the editor, `Enter` queues the message into the focused run (only while it is running) and returns to browse, `Esc` cancels and clears. The message appears immediately
|
|
80
|
+
Steer input is modal so keys never conflict with typing: in browse mode `s` opens the editor, `Enter` queues the message into the focused run (only while it is running) and returns to browse, `Esc` cancels and clears. The message appears immediately at the bottom of the feed as an `↩ steer (queued):` entry. When the child consumes the user message, the queued marker disappears in place; the entry remains accent-colored in the history. Delivery happens after the current tool batch, before the next LLM call, without resetting the run's progress. `Esc` in browse mode closes the overlay.
|
|
81
81
|
|
|
82
82
|
## Dependencies
|
|
83
83
|
|
|
@@ -240,12 +240,12 @@ Three execution properties, kept separate:
|
|
|
240
240
|
| Tool | Purpose | Returns to the model |
|
|
241
241
|
|------|---------|---------------------|
|
|
242
242
|
| `subagent_delegate(background: true)` | Start an async run | Just the id (`sub-N`) |
|
|
243
|
-
| `subagent_wait(ids
|
|
243
|
+
| `subagent_wait(ids?)` | Block until **all** listed runs end (omit `ids` for all current background runs) | A per-run roll call — one `id (role): state (turns, elapsed, tokens, cost)` line per run; retrieve results with `subagent_check` |
|
|
244
244
|
| `subagent_check(id)` | One-shot snapshot of a single run | `queued` / `running` + current activity, elapsed/budget, and usage so far / the **full output** with a usage footer once finished / failure reason + partial output + usage on failed or cancelled. Idempotent: re-delivers the same terminal snapshot — the run stays in the registry for the whole session |
|
|
245
245
|
| `subagent_steer(id, message)` | Queue a mid-run correction into one running run (typically right after a check revealed it heading down a wrong path) | Confirmation that the steer is queued — delivered after the child's current tool batch, before its next LLM call; the run keeps its progress |
|
|
246
246
|
| `subagent_cancel(id, reason?)` | Kill one live (queued/running) run | Confirmation — the same roll-call line wait uses, plus a pointer to check for the partial output; the run settles as `cancelled` (warning styling, same family as timeout/budget) with the reason in its error message |
|
|
247
247
|
|
|
248
|
-
|
|
248
|
+
Example tool calls made by the main model:
|
|
249
249
|
|
|
250
250
|
```json
|
|
251
251
|
[
|
|
@@ -254,22 +254,28 @@ Typical flow:
|
|
|
254
254
|
]
|
|
255
255
|
```
|
|
256
256
|
|
|
257
|
-
|
|
257
|
+
The main model continues its own work while these runs execute. Before each model request, the extension refreshes an inbox reminder with the status of runs whose results have not been collected. When the model needs a result from a run that has ended, it retrieves it through `subagent_check`:
|
|
258
258
|
|
|
259
259
|
```json
|
|
260
|
-
{ "
|
|
260
|
+
{ "id": "sub-1" }
|
|
261
261
|
```
|
|
262
262
|
|
|
263
|
-
|
|
263
|
+
If the needed run is still queued or running, the model can wait for it through `subagent_wait`:
|
|
264
|
+
|
|
265
|
+
```json
|
|
266
|
+
{ "ids": ["sub-1"] }
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Once the wait returns, the model retrieves the result through `subagent_check`. The reminder supplies status information, while check provides execution details and results; a separate check is not required before waiting. `subagent_check` accepts one id per call because results can be large; `subagent_wait` can await multiple unfinished runs together.
|
|
264
270
|
|
|
265
271
|
Semantics worth knowing:
|
|
266
272
|
|
|
267
|
-
- **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
|
|
273
|
+
- **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 collects results with `subagent_check`, using `subagent_wait` when a run has not ended yet. The inbox reminder (below) lists runs not yet collected by a terminal check on the active branch on every request, but it never pushes results.
|
|
268
274
|
- **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.
|
|
269
275
|
- **Idempotent check, session-tree delivery state:** `subagent_check` re-delivers the same terminal snapshot on every call — runs stay in the registry for the whole session, so no result can ever be stranded by branch navigation or compaction. Whether a run still needs collecting is not tracked in the registry: it derives from the session tree itself. The session is append-only, so branching back past a check entry drops it from the active path — the inbox reminder re-arms and the model simply checks again (the id still resolves; the run is still there). Branching forward to the original branch restores the check entry and silences the reminder again.
|
|
270
276
|
- **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 remove the run: `subagent_check` still returns the partial output, and `subagent_wait` reports the run as `cancelled` with its usage stats.
|
|
271
|
-
- **Inbox reminder:** every LLM call carries a `[background subagent runs]` system reminder listing the runs not yet
|
|
272
|
-
-
|
|
277
|
+
- **Inbox reminder:** every LLM call carries a `[background subagent runs]` system reminder listing the runs not yet collected by a terminal check on the active branch (queued, running, and finished/failed-but-unchecked alike, including cancelled ones — shown as `cancelled — <reason>`), injected at a cache-stable head position. Only checks that returned a terminal snapshot count — a peek at a live frame never silences the inbox, so the steer flow's check → steer → check-later cycle cannot strand an outcome. Runs missing from the list were already checked on this branch — so a finished run the model forgot to check keeps surfacing until it does. Branch navigation keeps this honest: the list derives from the session tree, not registry bookkeeping.
|
|
278
|
+
- **Wait has no timeout parameter.** `subagent_wait` blocks until all specified runs end. Each run's own role timeout and budgets still apply; cancelling the wait leaves the runs running.
|
|
273
279
|
- Background runs share the global `maxConcurrency` gate — extra runs show up as `queued` in wait/check views.
|
|
274
280
|
- **Top-level only:** nested subagents cannot delegate in the background (a subagent process exits when its task finishes, which would orphan the run).
|
|
275
281
|
- The run registry lives in the pi process: a `/reload` or restart orphans in-flight background runs (their ids stop resolving). `/subagent:status` lists every registered run and its current state.
|
|
@@ -281,16 +287,16 @@ Steering queues a correction into a running subagent without killing it — the
|
|
|
281
287
|
There are two channels into the same mechanism:
|
|
282
288
|
|
|
283
289
|
- **The model** calls `subagent_steer(id, message)` — typically right after a `subagent_check` snapshot revealed the run heading down a wrong path (check → steer → check again later).
|
|
284
|
-
- **The user** types into the input box of `/subagent:view`, targeting the focused run. Every accepted steer also appears in the run's activity feed
|
|
290
|
+
- **The user** types into the input box of `/subagent:view`, targeting the focused run. Every accepted steer also appears in the run's activity feed, so whoever watches the view sees what was injected and when.
|
|
285
291
|
|
|
286
|
-
|
|
292
|
+
Steering messages appear in the delegate, wait, check, and live-view activity streams. Pending entries use `↩ steer (queued):`, stay below current activity, and count toward the same five-item collapsed limit. Consumption removes the queued marker without moving the row; `↩ steer:` remains accent-colored in history. Expanded views retain the full activity history. This is TUI presentation only: wait's model-facing response remains a status roll call.
|
|
287
293
|
|
|
288
294
|
### Background TUI display
|
|
289
295
|
|
|
290
296
|
Each tool row renders one aspect of the same decomposition the foreground row shows all at once (input · process · result · usage):
|
|
291
297
|
|
|
292
298
|
- **Background subagent_delegate row = input only.** Collapsed: `▶ sub-1 <task first line>`. Expanded: plus `@file` references, context size, inherited-conversation size/truncation metadata when enabled, and the full task text. Static — the run progresses invisibly until a subagent_wait/subagent_check row picks it up.
|
|
293
|
-
- **subagent_wait row = process + usage.** The input line shows the id list (or `(all)`)
|
|
299
|
+
- **subagent_wait row = process + usage.** The input line shows the id list (or `(all)`). One block per watched run: status line (`⏸ queued / ⏳ running` + id + task preview; bare, icon-free once terminal), a live activity stream (collapsed keeps the latest 5 items with a leading ellipsis; expanded shows everything) and a ticking usage bar. Once a run finishes, its process stream is replaced by a **status-only** result line (`✓ finished` / `⏲ budget-exceeded with the reason` / `⏱ timed out` / `⏹ cancelled with the reason` / `✗ <reason>`) — the output itself never appears in a subagent_wait row; expanded keeps the full process stream instead.
|
|
294
300
|
- **subagent_check row = the result view.** Same block shape as subagent_wait's single-run view (no id — there is only one), but the result line shows `✓ <AI summary>` (or the budget/failure reason when the run stopped early) and the expanded view renders the **full output** — subagent_check is where the conclusion lives.
|
|
295
301
|
- **subagent_cancel row = confirmation only.** Collapsed: `⏹ sub-1 (worker): cancelled after 1 turn (~29s)` (or `• sub-1 (worker) already finished — nothing to cancel` for a no-op). Expanded adds the reason and the pointer to `subagent_check` — the partial output **never renders here**; it stays in the registry until a check row fetches it (layer contract: delegate = input, wait = process, cancel = intervention, check = result).
|
|
296
302
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d3ara1n/pi-subagent",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.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",
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { appendActivity, consumeSteer } from "./activity.ts";
|
|
4
|
+
import { buildDisplayItems, formatDisplayItem, renderDisplayItems } from "./utils.ts";
|
|
5
|
+
import type { ActivityEntry } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
const plain = (_color: string, text: string) => text;
|
|
8
|
+
|
|
9
|
+
test("pending entries stay below internal activity and consume in place", () => {
|
|
10
|
+
const log: ActivityEntry[] = [];
|
|
11
|
+
const first = appendActivity(log, { kind: "toolCall", id: "a", status: "running" });
|
|
12
|
+
appendActivity(log, { kind: "steer", id: "s1", status: "queued", text: "correct" });
|
|
13
|
+
appendActivity(log, { kind: "steer", id: "s2", status: "queued", text: "correct" });
|
|
14
|
+
const second = appendActivity(log, { kind: "toolCall", id: "b", status: "running" });
|
|
15
|
+
assert.deepEqual(log.map((e) => e.id), ["a", "b", "s1", "s2"]);
|
|
16
|
+
assert.equal(log[first].id, "a");
|
|
17
|
+
assert.equal(log[second].id, "b");
|
|
18
|
+
const before = log.map((e) => e.id);
|
|
19
|
+
assert.equal(consumeSteer(log, { role: "assistant", content: "correct" }), false);
|
|
20
|
+
assert.equal(consumeSteer(log, { role: "user", content: "unrelated" }), false);
|
|
21
|
+
assert.equal(consumeSteer(log, { role: "user", content: [{ type: "text", text: "correct" }] }), true);
|
|
22
|
+
assert.deepEqual(log.map((e) => e.id), before);
|
|
23
|
+
assert.deepEqual(log.slice(-2).map((e) => e.status), ["done", "queued"]);
|
|
24
|
+
appendActivity(log, { kind: "thinking", id: "next", status: "running" });
|
|
25
|
+
assert.deepEqual(log.map((e) => e.id), ["a", "b", "s1", "next", "s2"]);
|
|
26
|
+
assert.equal(consumeSteer(log, { role: "user", content: "correct" }), true);
|
|
27
|
+
assert.equal(consumeSteer(log, { role: "user", content: "correct" }), false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("queued items share the visible limit and consumption changes only the marker", () => {
|
|
31
|
+
const log: ActivityEntry[] = Array.from({ length: 6 }, (_, i) => ({
|
|
32
|
+
kind: "toolCall", id: `t${i}`, toolName: `tool${i}`, status: "done",
|
|
33
|
+
}));
|
|
34
|
+
appendActivity(log, { kind: "steer", id: "s", status: "queued", text: "first\nsecond" });
|
|
35
|
+
const before = renderDisplayItems(buildDisplayItems(log), 5, plain);
|
|
36
|
+
assert.equal(before.split("\n").length, 6); // Five items plus the existing history marker.
|
|
37
|
+
assert.ok(!before.includes("tool1"));
|
|
38
|
+
assert.ok(before.endsWith("\u21a9 steer (queued): first second"));
|
|
39
|
+
consumeSteer(log, { role: "user", content: "first\nsecond" });
|
|
40
|
+
const after = renderDisplayItems(buildDisplayItems(log), 5, plain);
|
|
41
|
+
assert.equal(after, before.replace(" (queued)", ""));
|
|
42
|
+
assert.equal(buildDisplayItems(log).length, 7);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("queued rendering uses status for every display type and steers retain accent", () => {
|
|
46
|
+
const color = (name: string, text: string) => `<${name}>${text}</${name}>`;
|
|
47
|
+
for (const status of ["queued", "done"] as const) {
|
|
48
|
+
assert.match(formatDisplayItem({ type: "steer", status, text: "input" }, color), /^<accent>/);
|
|
49
|
+
}
|
|
50
|
+
assert.equal(formatDisplayItem({ type: "steer", status: "done", text: "queued" }, plain), "\u21a9 steer: queued");
|
|
51
|
+
assert.match(formatDisplayItem({ type: "thinking", status: "queued" }, plain), /\(queued\)/);
|
|
52
|
+
assert.match(formatDisplayItem({ type: "toolCall", name: "read", args: {}, status: "queued" }, plain), /\(queued\)/);
|
|
53
|
+
});
|
package/src/activity.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ActivityEntry } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** @internal Insert live activity before pending entries, keeping the queue at the tail. */
|
|
4
|
+
export function appendActivity(log: ActivityEntry[], entry: ActivityEntry): number {
|
|
5
|
+
const queued = entry.status === "queued" ? -1 : log.findIndex((item) => item.status === "queued");
|
|
6
|
+
const index = queued < 0 ? log.length : queued;
|
|
7
|
+
log.splice(index, 0, entry);
|
|
8
|
+
return index;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** @internal Match the user message actually consumed by the child, one occurrence at a time. */
|
|
12
|
+
export function consumeSteer(log: ActivityEntry[], message: { role?: string; content?: unknown }): boolean {
|
|
13
|
+
if (message.role !== "user") return false;
|
|
14
|
+
const text = typeof message.content === "string"
|
|
15
|
+
? message.content
|
|
16
|
+
: Array.isArray(message.content)
|
|
17
|
+
? message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n")
|
|
18
|
+
: undefined;
|
|
19
|
+
const entry = log.find((item) => item.kind === "steer" && item.status === "queued" && item.text === text);
|
|
20
|
+
if (!entry) return false;
|
|
21
|
+
entry.status = "done";
|
|
22
|
+
return true;
|
|
23
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -43,7 +43,7 @@ import { startSubagentRun, type RunHandle } from "./run.ts";
|
|
|
43
43
|
import { buildInboxReminder, injectReminder } from "./reminder.ts";
|
|
44
44
|
import { serializeInheritedConversation } from "./inheritance.ts";
|
|
45
45
|
import { renderDelegateCall, renderDelegateResult } from "./render.ts";
|
|
46
|
-
import { createViewPanel
|
|
46
|
+
import { createViewPanel } from "./view.ts";
|
|
47
47
|
import {
|
|
48
48
|
createSteerCallRender,
|
|
49
49
|
renderBackgroundDelegateCall,
|
|
@@ -60,11 +60,6 @@ import {
|
|
|
60
60
|
|
|
61
61
|
const BACKGROUND_COMPLETION_MESSAGE_TYPE = "subagent-completion";
|
|
62
62
|
|
|
63
|
-
/** Refresh ceiling for the /subagent:view delivered-ids cache (ms). The panel
|
|
64
|
-
* re-renders on every animation tick (~150ms); deriving delivery from the
|
|
65
|
-
* session tree is throttled so the overlay stays cheap. */
|
|
66
|
-
const VIEW_DELIVERED_CACHE_MS = 1000;
|
|
67
|
-
|
|
68
63
|
// ── Extension entry ────────────────────────────────────────────────
|
|
69
64
|
|
|
70
65
|
export default function subagentExtension(pi: ExtensionAPI) {
|
|
@@ -184,7 +179,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
184
179
|
"BACKGROUND DELEGATION:",
|
|
185
180
|
"",
|
|
186
181
|
"- Use it only when you have your own work this turn (including an ongoing discussion with the user) while the run executes; otherwise let the call block and return the result directly.",
|
|
187
|
-
"-
|
|
182
|
+
"- When you need a background run's result, collect it with subagent_check if the run has ended; otherwise use subagent_wait, then check to collect it.",
|
|
188
183
|
"- Cancel a run you no longer need with subagent_cancel(id) — the child stops and its partial output stays in the registry for subagent_check to collect.",
|
|
189
184
|
"- Background delegation works only in the top-level session.",
|
|
190
185
|
);
|
|
@@ -309,7 +304,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
309
304
|
name: "subagent_delegate",
|
|
310
305
|
label: "Delegate to subagent",
|
|
311
306
|
description:
|
|
312
|
-
"Delegate a task to a specialized subagent. By default the call blocks until the run finishes and returns the final output — intermediate tool output stays out of your context. With background: true it returns an id immediately and you collect the result later with
|
|
307
|
+
"Delegate a task to a specialized subagent. By default the call blocks until the run finishes and returns the final output — intermediate tool output stays out of your context. With background: true it returns an id immediately and you collect the result later with subagent_check. Subagents are isolated by default; inheritConversation optionally injects a filtered snapshot of the active parent branch.",
|
|
313
308
|
promptSnippet: "Delegate tasks to specialized subagents",
|
|
314
309
|
promptGuidelines: guidelines,
|
|
315
310
|
|
|
@@ -340,7 +335,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
340
335
|
background: Type.Optional(
|
|
341
336
|
Type.Boolean({
|
|
342
337
|
description:
|
|
343
|
-
"
|
|
338
|
+
"Return immediately with a run id instead of waiting for the final output. Results are pull-only: a completion notice is shown to the user, but does not deliver the result to the model or wake it.",
|
|
344
339
|
}),
|
|
345
340
|
),
|
|
346
341
|
cwd: Type.Optional(Type.String({ description: "Working directory (defaults to current)" })),
|
|
@@ -550,7 +545,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
550
545
|
name: "subagent_wait",
|
|
551
546
|
label: "Wait for background subagents",
|
|
552
547
|
description:
|
|
553
|
-
"
|
|
548
|
+
"Wait until all specified background subagent runs end. Omit ids to wait for all current background runs. Returns status and usage for each run; use subagent_check to retrieve results. Cancelling the wait leaves the runs running.",
|
|
554
549
|
promptSnippet: "Wait for background subagents to finish",
|
|
555
550
|
parameters: Type.Object({
|
|
556
551
|
ids: Type.Optional(
|
|
@@ -560,12 +555,12 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
560
555
|
"Run ids returned by background delegate calls. Omit to wait for all current background runs.",
|
|
561
556
|
}),
|
|
562
557
|
),
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
),
|
|
558
|
+
// Timeout is hidden from the tool schema; restore with the timeoutMs expression below.
|
|
559
|
+
// timeout: Type.Optional(
|
|
560
|
+
// Type.Number({
|
|
561
|
+
// description: "Max time to wait in seconds. Omit to wait until all specified runs end.",
|
|
562
|
+
// }),
|
|
563
|
+
// ),
|
|
569
564
|
}),
|
|
570
565
|
|
|
571
566
|
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
@@ -583,7 +578,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
583
578
|
);
|
|
584
579
|
}
|
|
585
580
|
const runs = ids.map((id) => backgroundRuns.get(id)!);
|
|
586
|
-
|
|
581
|
+
// Keep the timeout machinery available while the tool exposes completion-only waiting.
|
|
582
|
+
// const timeoutMs = typeof params.timeout === "number" && params.timeout > 0 ? params.timeout * 1000 : 0;
|
|
583
|
+
const timeoutMs = 0;
|
|
587
584
|
|
|
588
585
|
// ── Live mirror: forward combined snapshots into this tool row ──
|
|
589
586
|
const entries = () => runs.map((r) => ({ id: r.id, role: r.role, result: r.snapshot }));
|
|
@@ -812,28 +809,13 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
812
809
|
});
|
|
813
810
|
|
|
814
811
|
pi.registerCommand("subagent:view", {
|
|
815
|
-
description: "Open the
|
|
812
|
+
description: "Open the subagent activity view (watch runs, steer, browse the session's archive)",
|
|
816
813
|
handler: async (_args, ctx) => {
|
|
817
|
-
// Union of every known run: the background registry
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
//
|
|
822
|
-
// reminder), then leave the view: it is for live watching and pending
|
|
823
|
-
// collection, not an archive. Delivery re-derivation is throttled
|
|
824
|
-
// because the panel re-renders on every animation tick.
|
|
825
|
-
const deliveredCache = { ids: new Set<string>(), at: 0 };
|
|
826
|
-
const deliveredIds = (): Set<string> => {
|
|
827
|
-
if (Date.now() - deliveredCache.at > VIEW_DELIVERED_CACHE_MS) {
|
|
828
|
-
try {
|
|
829
|
-
deliveredCache.ids = collectDeliveredIds(ctx.sessionManager.buildContextEntries());
|
|
830
|
-
} catch {
|
|
831
|
-
/* keep the previous set */
|
|
832
|
-
}
|
|
833
|
-
deliveredCache.at = Date.now();
|
|
834
|
-
}
|
|
835
|
-
return deliveredCache.ids;
|
|
836
|
-
};
|
|
814
|
+
// Union of every known run: the background registry — append-only for
|
|
815
|
+
// the whole session, the view doubles as the run archive and derives
|
|
816
|
+
// nothing from delivery state — plus live in-flight runs (foreground
|
|
817
|
+
// delegate calls included, visible only while in flight). Dedupe by
|
|
818
|
+
// id — background runs appear in both.
|
|
837
819
|
const runsProvider = () => {
|
|
838
820
|
const seen = new Set<string>();
|
|
839
821
|
const out: RunHandle[] = [];
|
|
@@ -843,7 +825,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
843
825
|
out.push(r);
|
|
844
826
|
}
|
|
845
827
|
}
|
|
846
|
-
return
|
|
828
|
+
return out;
|
|
847
829
|
};
|
|
848
830
|
if (runsProvider().length === 0) {
|
|
849
831
|
ctx.ui.notify("No subagent runs yet.", "info");
|
package/src/reminder.test.ts
CHANGED
|
@@ -143,9 +143,9 @@ describe("buildInboxReminder", () => {
|
|
|
143
143
|
assert.ok(text.includes(`"${"x".repeat(70)}..."`));
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
-
test("header
|
|
146
|
+
test("header identifies results awaiting collection with check", () => {
|
|
147
147
|
const text = buildInboxReminder([entry({ id: "sub-1", state: "running" })], new Set())!;
|
|
148
|
-
assert.match(text, /^\[background subagent runs — results
|
|
148
|
+
assert.match(text, /^\[background subagent runs — results not yet collected with subagent_check/);
|
|
149
149
|
assert.match(text, /already checked on this branch\]/);
|
|
150
150
|
});
|
|
151
151
|
|
package/src/reminder.ts
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Injected into the LLM context before every provider call via the `context`
|
|
5
5
|
* event. The reminder lists every delegated run not yet delivered by a
|
|
6
|
-
* subagent_check on the active branch — queued,
|
|
7
|
-
* finished/failed alike — so the model cannot forget about
|
|
8
|
-
* state is derived from the session tree (see
|
|
9
|
-
* tracked in the registry: branching past a check
|
|
10
|
-
* branching back silences it, and compaction un-delivers
|
|
6
|
+
* subagent_check of a terminal snapshot on the active branch — queued,
|
|
7
|
+
* running, and finished/failed alike — so the model cannot forget about
|
|
8
|
+
* them. Delivery state is derived from the session tree (see
|
|
9
|
+
* collectDeliveredIds), not tracked in the registry: branching past a check
|
|
10
|
+
* re-arms the inbox, branching back silences it, and compaction un-delivers
|
|
11
|
+
* naturally.
|
|
11
12
|
*
|
|
12
13
|
* Cache discipline: the reminder is prepended to the FIRST user message, so
|
|
13
14
|
* it sits at a stable position in the message prefix. Its content must stay
|
|
@@ -30,7 +31,7 @@ export interface InboxEntry {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
const INBOX_HEADER =
|
|
33
|
-
"[background subagent runs — results
|
|
34
|
+
"[background subagent runs — results not yet collected with subagent_check; a terminal check removes a run from this list; runs missing here were already checked on this branch]";
|
|
34
35
|
|
|
35
36
|
/** `42s`, `3m12s`, `4m` — whole seconds, no live clocks. */
|
|
36
37
|
function formatDuration(totalSec: number): string {
|
|
@@ -65,9 +66,9 @@ function inboxStatus(entry: InboxEntry): string {
|
|
|
65
66
|
* Build the inbox reminder text, or undefined when every delegated run has
|
|
66
67
|
* already been checked on the active branch (nothing to remind about —
|
|
67
68
|
* inject nothing, keep the context untouched and the provider cache fully
|
|
68
|
-
* stable).
|
|
69
|
-
*
|
|
70
|
-
* never
|
|
69
|
+
* stable). The state guard pins the inbox's own invariant locally — only
|
|
70
|
+
* terminal runs can ever count as delivered (collectDeliveredIds ignores
|
|
71
|
+
* live-frame checks), so a past peek at a running run never silences it.
|
|
71
72
|
*/
|
|
72
73
|
export function buildInboxReminder(entries: Iterable<InboxEntry>, delivered: Set<string>): string | undefined {
|
|
73
74
|
const rows: string[] = [];
|
package/src/render-async.ts
CHANGED
|
@@ -48,13 +48,11 @@ import {
|
|
|
48
48
|
ensureElapsedTimer,
|
|
49
49
|
formatFallback,
|
|
50
50
|
formatInheritedConversationInput,
|
|
51
|
-
|
|
51
|
+
formatDisplayItem,
|
|
52
52
|
formatTimePart,
|
|
53
|
-
formatToolCall,
|
|
54
53
|
formatUsageStats,
|
|
55
54
|
renderDisplayItems,
|
|
56
55
|
runIcon,
|
|
57
|
-
statusStyle,
|
|
58
56
|
taskPreview,
|
|
59
57
|
terminalResultLine,
|
|
60
58
|
} from "./utils.ts";
|
|
@@ -85,12 +83,8 @@ function addActivityRows(container: Container, r: SubagentResult, fg: Fg): void
|
|
|
85
83
|
return;
|
|
86
84
|
}
|
|
87
85
|
for (const item of activity) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
} else {
|
|
91
|
-
const { prefix, color } = statusStyle(item.status, fg);
|
|
92
|
-
container.addChild(new Text(prefix + formatToolCall(item.name, item.args, color), 0, 0));
|
|
93
|
-
}
|
|
86
|
+
const row = formatDisplayItem(item, fg);
|
|
87
|
+
container.addChild(item.type === "steer" ? collapsedText(row) : new Text(row, 0, 0));
|
|
94
88
|
}
|
|
95
89
|
}
|
|
96
90
|
|
|
@@ -207,6 +201,8 @@ function checkEntryExpandedContainer(r: SubagentResult, fg: Fg): Container {
|
|
|
207
201
|
|
|
208
202
|
if (state === "finished" || state === "failed") {
|
|
209
203
|
container.addChild(new Text(terminalResultLine(r, fg), 0, 0));
|
|
204
|
+
container.addChild(new Spacer(1));
|
|
205
|
+
addActivityRows(container, r, fg);
|
|
210
206
|
// check is the result-fetcher: the full output lives here.
|
|
211
207
|
container.addChild(new Spacer(1));
|
|
212
208
|
if (r.output.trim()) {
|
package/src/render.test.ts
CHANGED
|
@@ -1,9 +1,50 @@
|
|
|
1
|
-
/** Tests for
|
|
1
|
+
/** Tests for delegate and background-run TUI observability. */
|
|
2
2
|
|
|
3
3
|
import assert from "node:assert/strict";
|
|
4
4
|
import test from "node:test";
|
|
5
|
-
import { renderBackgroundDelegateCall, renderBackgroundDelegateResult } from "./render-async.ts";
|
|
6
|
-
import { renderDelegateCall } from "./render.ts";
|
|
5
|
+
import { renderBackgroundDelegateCall, renderBackgroundDelegateResult, renderWaitResult, renderCheckResult } from "./render-async.ts";
|
|
6
|
+
import { renderDelegateCall, renderDelegateResult } from "./render.ts";
|
|
7
|
+
import type { SubagentResult } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
test("delegate, wait, and check preserve steer rows through consumption and completion", () => {
|
|
10
|
+
const run: SubagentResult = {
|
|
11
|
+
role: "worker", task: "task", exitCode: -1, output: "", stderr: "",
|
|
12
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0, contextTokens: 0 },
|
|
13
|
+
activityLog: [
|
|
14
|
+
...Array.from({ length: 6 }, (_, i) => ({ kind: "toolCall" as const, id: `t${i}`, toolName: `tool${i}`, status: "done" as const })),
|
|
15
|
+
{ kind: "steer", id: "s", status: "queued", text: "correction ".repeat(30) },
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
const renderers = [
|
|
19
|
+
() => ({ render: renderDelegateResult, details: { results: [run] } }),
|
|
20
|
+
() => ({ render: renderWaitResult, details: { entries: [{ id: "sub-1", role: "worker", result: run }] } }),
|
|
21
|
+
() => ({ render: renderCheckResult, details: { id: "sub-1", role: "worker", result: run } }),
|
|
22
|
+
];
|
|
23
|
+
for (const make of renderers) {
|
|
24
|
+
for (const expanded of [false, true]) {
|
|
25
|
+
for (const width of [40, 100]) {
|
|
26
|
+
const { render, details } = make();
|
|
27
|
+
const draw = () => render({ content: [], details } as any, { expanded, isPartial: true }, theme, { state: {} } as any)
|
|
28
|
+
.render(width).map((line) => line.trimEnd());
|
|
29
|
+
run.exitCode = -1;
|
|
30
|
+
run.activityLog.at(-1)!.status = "queued";
|
|
31
|
+
const pending = draw();
|
|
32
|
+
const index = pending.findIndex((line) => line.includes("steer (queued):"));
|
|
33
|
+
assert.ok(index >= 0);
|
|
34
|
+
assert.ok(expanded || !pending.join("\n").includes("tool1"));
|
|
35
|
+
run.activityLog.at(-1)!.status = "done";
|
|
36
|
+
const consumed = draw();
|
|
37
|
+
assert.equal(consumed.length, pending.length);
|
|
38
|
+
assert.ok(consumed[index].includes("steer:"));
|
|
39
|
+
assert.ok(!consumed[index].includes("(queued)"));
|
|
40
|
+
if (expanded) {
|
|
41
|
+
run.exitCode = 0;
|
|
42
|
+
assert.ok(draw().some((line) => line.includes("steer:")));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
7
48
|
|
|
8
49
|
const theme = {
|
|
9
50
|
fg: (_color: string, text: string) => text,
|
package/src/render.ts
CHANGED
|
@@ -16,13 +16,11 @@ import {
|
|
|
16
16
|
ensureElapsedTimer,
|
|
17
17
|
formatFallback,
|
|
18
18
|
formatInheritedConversationInput,
|
|
19
|
-
|
|
19
|
+
formatDisplayItem,
|
|
20
20
|
formatTimePart,
|
|
21
|
-
formatToolCall,
|
|
22
21
|
formatUsageStats,
|
|
23
22
|
renderDisplayItems,
|
|
24
23
|
runIcon,
|
|
25
|
-
statusStyle,
|
|
26
24
|
taskPreview,
|
|
27
25
|
terminalResultLine,
|
|
28
26
|
} from "./utils.ts";
|
|
@@ -150,14 +148,8 @@ export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme
|
|
|
150
148
|
container.addChild(new Text(theme.fg("muted", runningLabel), 0, 0));
|
|
151
149
|
} else {
|
|
152
150
|
for (const item of displayItems) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
} else {
|
|
156
|
-
const { prefix, color } = statusStyle(item.status, fg);
|
|
157
|
-
container.addChild(
|
|
158
|
-
new Text(prefix + formatToolCall(item.name, item.args, color), 0, 0),
|
|
159
|
-
);
|
|
160
|
-
}
|
|
151
|
+
const row = formatDisplayItem(item, fg);
|
|
152
|
+
container.addChild(item.type === "steer" ? collapsedText(row) : new Text(row, 0, 0));
|
|
161
153
|
}
|
|
162
154
|
}
|
|
163
155
|
|
package/src/spawn.ts
CHANGED
|
@@ -13,6 +13,7 @@ import * as fs from "node:fs";
|
|
|
13
13
|
import * as os from "node:os";
|
|
14
14
|
import * as path from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { appendActivity, consumeSteer } from "./activity.ts";
|
|
16
17
|
import type { SubagentControl, SubagentMessage, SubagentResult } from "./types.ts";
|
|
17
18
|
|
|
18
19
|
const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
@@ -463,6 +464,10 @@ export async function spawnSubagent(
|
|
|
463
464
|
}
|
|
464
465
|
}
|
|
465
466
|
|
|
467
|
+
if (event.type === "message_start" && event.message && consumeSteer(result.activityLog, event.message)) {
|
|
468
|
+
emitProgress();
|
|
469
|
+
}
|
|
470
|
+
|
|
466
471
|
if (event.type === "message_end" && event.message) {
|
|
467
472
|
const msg = event.message as SubagentMessage;
|
|
468
473
|
|
|
@@ -481,7 +486,12 @@ export async function spawnSubagent(
|
|
|
481
486
|
usage.totalTokens || 0,
|
|
482
487
|
);
|
|
483
488
|
}
|
|
484
|
-
|
|
489
|
+
// The child's AssistantMessage carries the bare model id with the
|
|
490
|
+
// provider in a separate field — compose the full `provider/model-id`
|
|
491
|
+
// ref so usage displays match the delegate-facing modelRef format.
|
|
492
|
+
if (!result.model && msg.model) {
|
|
493
|
+
result.model = msg.provider ? `${msg.provider}/${msg.model}` : msg.model;
|
|
494
|
+
}
|
|
485
495
|
// message_end is authoritative for the latest assistant attempt. A
|
|
486
496
|
// successful native retry must clear the transient error left by the
|
|
487
497
|
// failed attempt instead of triggering a redundant whole-run fallback.
|
|
@@ -505,14 +515,14 @@ export async function spawnSubagent(
|
|
|
505
515
|
// Activity log: track thinking blocks and tool calls in arrival order.
|
|
506
516
|
// Both update in place so the TUI reflects real-time state.
|
|
507
517
|
if (event.type === "tool_execution_start" && event.toolCallId) {
|
|
508
|
-
|
|
509
|
-
result.activityLog.push({
|
|
518
|
+
const index = appendActivity(result.activityLog, {
|
|
510
519
|
kind: "toolCall",
|
|
511
520
|
id: event.toolCallId,
|
|
512
521
|
status: "running",
|
|
513
522
|
toolName: event.toolName,
|
|
514
523
|
args: event.args ?? {},
|
|
515
524
|
});
|
|
525
|
+
toolCallIndex.set(event.toolCallId, index);
|
|
516
526
|
// Pause the parent timeout clock while the child delegates — nested
|
|
517
527
|
// subagents get their own full budget instead of racing this clock.
|
|
518
528
|
// Ref-counted: concurrent subagent_delegate calls pause once and resume
|
|
@@ -540,7 +550,7 @@ export async function spawnSubagent(
|
|
|
540
550
|
const aev = event.assistantMessageEvent;
|
|
541
551
|
if (event.type === "message_update" && aev) {
|
|
542
552
|
if (aev.type === "thinking_start") {
|
|
543
|
-
result.activityLog
|
|
553
|
+
appendActivity(result.activityLog, {
|
|
544
554
|
kind: "thinking",
|
|
545
555
|
id: `thinking-${thinkingCounter++}`,
|
|
546
556
|
status: "running",
|
|
@@ -559,7 +569,7 @@ export async function spawnSubagent(
|
|
|
559
569
|
}
|
|
560
570
|
emitProgress();
|
|
561
571
|
} else if (aev.type === "text_start") {
|
|
562
|
-
result.activityLog
|
|
572
|
+
appendActivity(result.activityLog, {
|
|
563
573
|
kind: "text",
|
|
564
574
|
id: `text-${textCounter++}`,
|
|
565
575
|
status: "running",
|
|
@@ -714,12 +724,11 @@ export async function spawnSubagent(
|
|
|
714
724
|
steer(message: string) {
|
|
715
725
|
if (processExited || terminationRequested) return;
|
|
716
726
|
sendCommand({ type: "steer", message });
|
|
717
|
-
//
|
|
718
|
-
|
|
719
|
-
result.activityLog.push({
|
|
727
|
+
// Keep pending input visible until the child emits its user message.
|
|
728
|
+
appendActivity(result.activityLog, {
|
|
720
729
|
kind: "steer",
|
|
721
730
|
id: `steer-${steerCounter++}`,
|
|
722
|
-
status: "
|
|
731
|
+
status: "queued",
|
|
723
732
|
text: message,
|
|
724
733
|
});
|
|
725
734
|
emitProgress();
|
package/src/types.ts
CHANGED
|
@@ -87,7 +87,7 @@ export interface SubagentRole {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
/** Status of an individual tool call within a subagent run. */
|
|
90
|
-
export type ToolStatus = "running" | "done" | "failed";
|
|
90
|
+
export type ToolStatus = "queued" | "running" | "done" | "failed";
|
|
91
91
|
|
|
92
92
|
/** A single entry in the real-time activity log (thinking block, tool call, streamed assistant text, or a user steer). */
|
|
93
93
|
export interface ActivityEntry {
|
|
@@ -137,6 +137,7 @@ export interface SubagentMessage {
|
|
|
137
137
|
cost?: { total?: number };
|
|
138
138
|
totalTokens?: number;
|
|
139
139
|
};
|
|
140
|
+
provider?: string;
|
|
140
141
|
model?: string;
|
|
141
142
|
stopReason?: string;
|
|
142
143
|
errorMessage?: string;
|
package/src/utils.test.ts
CHANGED
|
@@ -910,9 +910,15 @@ describe("briefFilesUsed", () => {
|
|
|
910
910
|
});
|
|
911
911
|
|
|
912
912
|
describe("collectDeliveredIds", () => {
|
|
913
|
-
|
|
913
|
+
// A check tool result carrying a terminal snapshot (exitCode >= 0) —
|
|
914
|
+
// the shape the check tool persists into the session tree.
|
|
915
|
+
const checkEntry = (id: string, exitCode = 0) => ({
|
|
914
916
|
type: "message",
|
|
915
|
-
message: {
|
|
917
|
+
message: {
|
|
918
|
+
role: "toolResult",
|
|
919
|
+
toolName: "subagent_check",
|
|
920
|
+
details: { id, role: "worker", result: { exitCode } },
|
|
921
|
+
},
|
|
916
922
|
});
|
|
917
923
|
|
|
918
924
|
test("collects ids from subagent_check tool results only", () => {
|
|
@@ -932,6 +938,11 @@ describe("collectDeliveredIds", () => {
|
|
|
932
938
|
assert.deepEqual(collectDeliveredIds([checkEntry("sub-1"), checkEntry("sub-1")]), new Set(["sub-1"]));
|
|
933
939
|
});
|
|
934
940
|
|
|
941
|
+
test("a check of a live frame never delivers (stale peek must not silence the inbox)", () => {
|
|
942
|
+
const entries = [checkEntry("sub-1", -1), checkEntry("sub-2")];
|
|
943
|
+
assert.deepEqual(collectDeliveredIds(entries), new Set(["sub-2"]));
|
|
944
|
+
});
|
|
945
|
+
|
|
935
946
|
test("empty path means nothing delivered (branch rewound past the check)", () => {
|
|
936
947
|
assert.equal(collectDeliveredIds([]).size, 0);
|
|
937
948
|
});
|
|
@@ -941,6 +952,10 @@ describe("collectDeliveredIds", () => {
|
|
|
941
952
|
{ type: "message", message: { role: "toolResult", toolName: "subagent_check" } },
|
|
942
953
|
{ type: "message", message: { role: "toolResult", toolName: "subagent_check", details: {} } },
|
|
943
954
|
{ type: "message", message: { role: "toolResult", toolName: "subagent_check", details: { id: 42 } } },
|
|
955
|
+
{
|
|
956
|
+
type: "message",
|
|
957
|
+
message: { role: "toolResult", toolName: "subagent_check", details: { id: "sub-1", result: {} } },
|
|
958
|
+
},
|
|
944
959
|
];
|
|
945
960
|
assert.equal(collectDeliveredIds(entries).size, 0);
|
|
946
961
|
});
|
package/src/utils.ts
CHANGED
|
@@ -142,7 +142,8 @@ export function formatTimePart(r: {
|
|
|
142
142
|
|
|
143
143
|
export type DisplayItem =
|
|
144
144
|
| { type: "toolCall"; name: string; args: Record<string, any>; status?: ToolStatus }
|
|
145
|
-
| { type: "thinking"; status?: ToolStatus }
|
|
145
|
+
| { type: "thinking"; status?: ToolStatus }
|
|
146
|
+
| { type: "steer"; text: string; status?: ToolStatus };
|
|
146
147
|
|
|
147
148
|
/**
|
|
148
149
|
* Map the real-time activity log into renderable display items (in order).
|
|
@@ -151,12 +152,12 @@ export type DisplayItem =
|
|
|
151
152
|
*/
|
|
152
153
|
export function buildDisplayItems(activityLog: ActivityEntry[]): DisplayItem[] {
|
|
153
154
|
return activityLog
|
|
154
|
-
.filter((a) => a.kind
|
|
155
|
-
.map((a) =>
|
|
156
|
-
a.kind === "thinking"
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
);
|
|
155
|
+
.filter((a) => a.kind !== "text")
|
|
156
|
+
.map((a): DisplayItem => {
|
|
157
|
+
if (a.kind === "thinking") return { type: "thinking", status: a.status };
|
|
158
|
+
if (a.kind === "steer") return { type: "steer", text: a.text ?? "", status: a.status };
|
|
159
|
+
return { type: "toolCall", name: a.toolName ?? "?", args: a.args ?? {}, status: a.status };
|
|
160
|
+
});
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
export function shortenPath(p: string): string {
|
|
@@ -280,6 +281,24 @@ export function formatThinking(
|
|
|
280
281
|
return fg("dim", "\u25C6 thought");
|
|
281
282
|
}
|
|
282
283
|
|
|
284
|
+
export function formatDisplayItem(
|
|
285
|
+
item: DisplayItem,
|
|
286
|
+
fg: (color: string, text: string) => string,
|
|
287
|
+
): string {
|
|
288
|
+
const queued = item.status === "queued";
|
|
289
|
+
const marker = queued ? " (queued)" : "";
|
|
290
|
+
if (item.type === "steer") {
|
|
291
|
+
return fg("accent", `\u21a9 steer${marker}: ${oneLine(item.text)}`);
|
|
292
|
+
}
|
|
293
|
+
if (queued) {
|
|
294
|
+
const body = item.type === "thinking" ? "thinking" : formatToolCall(item.name, item.args, (_c, text) => text);
|
|
295
|
+
return fg("accent", `\u23f8 ${body}${marker}`);
|
|
296
|
+
}
|
|
297
|
+
if (item.type === "thinking") return formatThinking(item.status, fg);
|
|
298
|
+
const { prefix, color } = statusStyle(item.status, fg);
|
|
299
|
+
return prefix + formatToolCall(item.name, item.args, color);
|
|
300
|
+
}
|
|
301
|
+
|
|
283
302
|
export function renderDisplayItems(
|
|
284
303
|
items: DisplayItem[],
|
|
285
304
|
limit: number | undefined,
|
|
@@ -290,12 +309,7 @@ export function renderDisplayItems(
|
|
|
290
309
|
let text = "";
|
|
291
310
|
if (skipped > 0) text += fg("muted", `... ${skipped} earlier items\n`);
|
|
292
311
|
for (const item of toShow) {
|
|
293
|
-
|
|
294
|
-
text += `${formatThinking(item.status, fg)}\n`;
|
|
295
|
-
} else {
|
|
296
|
-
const { prefix, color } = statusStyle(item.status, fg);
|
|
297
|
-
text += `${prefix}${formatToolCall(item.name, item.args, color)}\n`;
|
|
298
|
-
}
|
|
312
|
+
text += `${formatDisplayItem(item, fg)}\n`;
|
|
299
313
|
}
|
|
300
314
|
return text.trimEnd();
|
|
301
315
|
}
|
|
@@ -622,7 +636,7 @@ export function describeCurrentActivity(r: { activityLog: ActivityEntry[] }): st
|
|
|
622
636
|
if (!last) return "waiting for first event";
|
|
623
637
|
if (last.kind === "thinking") return last.status === "running" ? "thinking" : "thought";
|
|
624
638
|
if (last.kind === "text") return last.status === "running" ? "responding" : "responded";
|
|
625
|
-
if (last.kind === "steer") return "
|
|
639
|
+
if (last.kind === "steer") return last.status === "queued" ? "steer queued — awaiting delivery" : "steer received";
|
|
626
640
|
return formatToolCall(last.toolName ?? "?", last.args ?? {}, (_color, text) => text);
|
|
627
641
|
}
|
|
628
642
|
|
|
@@ -903,6 +917,11 @@ interface SessionEntryLike {
|
|
|
903
917
|
* truth for delivery state: it is append-only and branch navigation rebuilds
|
|
904
918
|
* the active path, so branching past a check entry un-delivers (the inbox
|
|
905
919
|
* re-arms) while branching back re-delivers — no mirrored state to sync.
|
|
920
|
+
*
|
|
921
|
+
* Only checks that returned a TERMINAL snapshot count: a peek at a live
|
|
922
|
+
* frame (running/queued, exitCode -1) delivers nothing — the steer flow
|
|
923
|
+
* re-checks later — so an early check can never silence the inbox before
|
|
924
|
+
* the outcome exists.
|
|
906
925
|
*/
|
|
907
926
|
export function collectDeliveredIds(entries: Iterable<SessionEntryLike>): Set<string> {
|
|
908
927
|
const ids = new Set<string>();
|
|
@@ -910,8 +929,12 @@ export function collectDeliveredIds(entries: Iterable<SessionEntryLike>): Set<st
|
|
|
910
929
|
if (entry.type !== "message") continue;
|
|
911
930
|
const message = entry.message;
|
|
912
931
|
if (!message || message.role !== "toolResult" || message.toolName !== "subagent_check") continue;
|
|
913
|
-
const
|
|
914
|
-
if (typeof id
|
|
932
|
+
const details = message.details as { id?: unknown; result?: { exitCode?: unknown } } | undefined;
|
|
933
|
+
if (typeof details?.id !== "string" || !details.id) continue;
|
|
934
|
+
// A malformed or live frame (exitCode -1) is not a delivery — leave the
|
|
935
|
+
// inbox armed.
|
|
936
|
+
if (typeof details.result?.exitCode !== "number" || details.result.exitCode === -1) continue;
|
|
937
|
+
ids.add(details.id);
|
|
915
938
|
}
|
|
916
939
|
return ids;
|
|
917
940
|
}
|
package/src/view.test.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert";
|
|
3
|
-
import {
|
|
4
|
-
filterDeliveredRuns,
|
|
5
|
-
inheritedConversationFields,
|
|
6
|
-
sortViewRuns,
|
|
7
|
-
} from "./view.ts";
|
|
3
|
+
import { inheritedConversationFields, sortViewRuns, windowTabCells } from "./view.ts";
|
|
8
4
|
import type { RunHandle } from "./run.ts";
|
|
9
5
|
|
|
10
6
|
// ── Fakes ──────────────────────────────────────────────────────────
|
|
@@ -13,26 +9,6 @@ function fakeHandle(id: string, state: RunHandle["state"]): RunHandle {
|
|
|
13
9
|
return { id, state } as unknown as RunHandle;
|
|
14
10
|
}
|
|
15
11
|
|
|
16
|
-
// ── filterDeliveredRuns ────────────────────────────────────────────
|
|
17
|
-
|
|
18
|
-
test("filterDeliveredRuns drops delivered terminal runs, keeps live and undelivered", () => {
|
|
19
|
-
const running = fakeHandle("sub-1", "running");
|
|
20
|
-
const queued = fakeHandle("sub-2", "queued");
|
|
21
|
-
const finishedUndelivered = fakeHandle("sub-3", "finished");
|
|
22
|
-
const finishedDelivered = fakeHandle("sub-4", "finished");
|
|
23
|
-
const failedDelivered = fakeHandle("sub-5", "failed");
|
|
24
|
-
|
|
25
|
-
const out = filterDeliveredRuns(
|
|
26
|
-
[running, queued, finishedUndelivered, finishedDelivered, failedDelivered],
|
|
27
|
-
new Set(["sub-4", "sub-5"]),
|
|
28
|
-
);
|
|
29
|
-
|
|
30
|
-
assert.deepEqual(
|
|
31
|
-
out.map((r) => r.id),
|
|
32
|
-
["sub-1", "sub-2", "sub-3"],
|
|
33
|
-
);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
12
|
// ── inheritedConversationFields ────────────────────────────────────
|
|
37
13
|
|
|
38
14
|
test("inheritedConversationFields returns aligned-view metadata without body text", () => {
|
|
@@ -50,15 +26,72 @@ test("inheritedConversationFields returns aligned-view metadata without body tex
|
|
|
50
26
|
|
|
51
27
|
// ── sortViewRuns ───────────────────────────────────────────────────
|
|
52
28
|
|
|
53
|
-
test("sortViewRuns
|
|
29
|
+
test("sortViewRuns orders newest first by numeric id (creation order)", () => {
|
|
54
30
|
const out = sortViewRuns([
|
|
55
|
-
fakeHandle("sub-3", "finished"),
|
|
56
31
|
fakeHandle("sub-2", "running"),
|
|
57
|
-
fakeHandle("sub-
|
|
32
|
+
fakeHandle("sub-10", "finished"),
|
|
58
33
|
fakeHandle("sub-1", "failed"),
|
|
34
|
+
fakeHandle("sub-9", "queued"),
|
|
59
35
|
]);
|
|
60
36
|
assert.deepEqual(
|
|
61
37
|
out.map((r) => r.id),
|
|
62
|
-
["sub-
|
|
38
|
+
["sub-10", "sub-9", "sub-2", "sub-1"],
|
|
63
39
|
);
|
|
64
40
|
});
|
|
41
|
+
|
|
42
|
+
test("sortViewRuns is state-agnostic — a run settling never reshuffles rows", () => {
|
|
43
|
+
const before = sortViewRuns([
|
|
44
|
+
fakeHandle("sub-1", "running"),
|
|
45
|
+
fakeHandle("sub-2", "running"),
|
|
46
|
+
fakeHandle("sub-3", "running"),
|
|
47
|
+
]);
|
|
48
|
+
const after = sortViewRuns([
|
|
49
|
+
fakeHandle("sub-1", "running"),
|
|
50
|
+
fakeHandle("sub-2", "failed"),
|
|
51
|
+
fakeHandle("sub-3", "finished"),
|
|
52
|
+
]);
|
|
53
|
+
assert.deepEqual(
|
|
54
|
+
after.map((r) => r.id),
|
|
55
|
+
before.map((r) => r.id),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ── windowTabCells ─────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
test("windowTabCells returns everything when the row fits", () => {
|
|
62
|
+
const cells = ["[a sub-1]", "[b sub-22]", "[c sub-333]"];
|
|
63
|
+
const win = windowTabCells(cells, 1, 100);
|
|
64
|
+
assert.deepEqual(win, { items: cells, leftClipped: false, rightClipped: false });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("windowTabCells keeps the focused cell visible under a tight budget", () => {
|
|
68
|
+
const cells = ["[a sub-1 x]", "[b sub-2 y]", "[c sub-3 z]"];
|
|
69
|
+
// Cell widths with one separator space each: 13 + 12 = 25 fits the left
|
|
70
|
+
// neighbor too; the right one (13) does not fit.
|
|
71
|
+
const win = windowTabCells(cells, 1, 25);
|
|
72
|
+
assert.equal(win.leftClipped, false); // sub-1 fits on the left
|
|
73
|
+
assert.equal(win.rightClipped, true); // sub-3 does not fit
|
|
74
|
+
assert.deepEqual(win.items, [cells[0], cells[1]]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("windowTabCells clips both sides around a mid-list focus", () => {
|
|
78
|
+
const cells = ["[1]", "[22]", "[333]", "[4444]", "[55555]"];
|
|
79
|
+
// Focused cell (width 6) plus left neighbor (width 5) = 11.
|
|
80
|
+
const win = windowTabCells(cells, 2, 11);
|
|
81
|
+
assert.deepEqual(win.items, [cells[1], cells[2]]);
|
|
82
|
+
assert.equal(win.leftClipped, true);
|
|
83
|
+
assert.equal(win.rightClipped, true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("windowTabCells still returns the focused cell when it alone overflows", () => {
|
|
87
|
+
const cells = ["[tiny]", "[enormous-cell]"];
|
|
88
|
+
const win = windowTabCells(cells, 1, 3);
|
|
89
|
+
assert.deepEqual(win.items, [cells[1]]);
|
|
90
|
+
assert.equal(win.leftClipped, true);
|
|
91
|
+
assert.equal(win.rightClipped, false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("windowTabCells handles an empty list and clamps an out-of-range focus", () => {
|
|
95
|
+
assert.deepEqual(windowTabCells([], 0, 50), { items: [], leftClipped: false, rightClipped: false });
|
|
96
|
+
assert.deepEqual(windowTabCells(["[a]"], 99, 50).items, ["[a]"]);
|
|
97
|
+
});
|
package/src/view.ts
CHANGED
|
@@ -23,8 +23,13 @@
|
|
|
23
23
|
* Steer input is modal so keys never conflict with the editor: browse mode
|
|
24
24
|
* owns navigation; `s` opens the editor (Enter sends and returns to browse,
|
|
25
25
|
* Esc cancels and clears). Esc in browse closes the overlay; Tab cycles
|
|
26
|
-
* the focused run
|
|
27
|
-
* re-pinned.
|
|
26
|
+
* the focused run forward, Shift+Tab backward, and cycling resets its
|
|
27
|
+
* view — page back to activity, scrolls re-pinned.
|
|
28
|
+
*
|
|
29
|
+
* The run list is the session's archive: every background run stays listed
|
|
30
|
+
* (newest first) after it settles — results remain browsable even after the
|
|
31
|
+
* model collects them, so nothing is ever derived from delivery state here.
|
|
32
|
+
* Foreground runs appear only while their delegate call is in flight.
|
|
28
33
|
*
|
|
29
34
|
* Layout: a centered screen overlay (overlay:true) occupying most of the
|
|
30
35
|
* terminal, framed with a thin border. An embedded Editor accepts steering
|
|
@@ -53,6 +58,7 @@ import {
|
|
|
53
58
|
briefFilesUsed,
|
|
54
59
|
formatFallback,
|
|
55
60
|
formatThinking,
|
|
61
|
+
formatDisplayItem,
|
|
56
62
|
formatTimePart,
|
|
57
63
|
formatToolCall,
|
|
58
64
|
formatTokens,
|
|
@@ -110,25 +116,54 @@ function capBriefText(text: string): string {
|
|
|
110
116
|
}
|
|
111
117
|
|
|
112
118
|
/**
|
|
113
|
-
* Build the display list of runs for the panel:
|
|
114
|
-
*
|
|
119
|
+
* Build the display list of runs for the panel: newest first, by numeric
|
|
120
|
+
* id (creation order). Creation order never reshuffles — the archive is
|
|
121
|
+
* append-only, so rows stay put when runs settle and focus remains
|
|
122
|
+
* predictable.
|
|
115
123
|
*/
|
|
116
124
|
export function sortViewRuns(runs: RunHandle[]): RunHandle[] {
|
|
117
|
-
const
|
|
118
|
-
return [...runs].sort((a, b) =>
|
|
125
|
+
const num = (r: RunHandle) => Number.parseInt(r.id.replace(/^sub-/, ""), 10) || 0;
|
|
126
|
+
return [...runs].sort((a, b) => num(b) - num(a));
|
|
119
127
|
}
|
|
120
128
|
|
|
121
129
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
130
|
+
* @internal — exported for testing; fits a window of tab cells around the
|
|
131
|
+
* focused one into `budget` visible columns. The archive can outgrow the
|
|
132
|
+
* row, but the focused cell never scrolls off-screen: neighbors expand
|
|
133
|
+
* outward (cheaper side first) while they fit, and clipped sides collapse
|
|
134
|
+
* to `…` markers.
|
|
127
135
|
*/
|
|
128
|
-
export function
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
136
|
+
export function windowTabCells(
|
|
137
|
+
cells: string[],
|
|
138
|
+
focusIdx: number,
|
|
139
|
+
budget: number,
|
|
140
|
+
): { items: string[]; leftClipped: boolean; rightClipped: boolean } {
|
|
141
|
+
if (cells.length === 0) return { items: [], leftClipped: false, rightClipped: false };
|
|
142
|
+
const focus = Math.max(0, Math.min(focusIdx, cells.length - 1));
|
|
143
|
+
const widths = cells.map((c) => visibleWidth(c) + 1); // +1 separator space
|
|
144
|
+
let lo = focus;
|
|
145
|
+
let hi = focus;
|
|
146
|
+
let used = widths[focus];
|
|
147
|
+
const tryAdd = (i: number): boolean => {
|
|
148
|
+
if (i < 0 || i >= cells.length || used + widths[i] > budget) return false;
|
|
149
|
+
used += widths[i];
|
|
150
|
+
return true;
|
|
151
|
+
};
|
|
152
|
+
for (;;) {
|
|
153
|
+
const canLeft = lo > 0;
|
|
154
|
+
const canRight = hi < cells.length - 1;
|
|
155
|
+
if (!canLeft && !canRight) break;
|
|
156
|
+
const preferLeft = canLeft && (!canRight || widths[lo - 1] <= widths[hi + 1]);
|
|
157
|
+
if (preferLeft) {
|
|
158
|
+
if (tryAdd(lo - 1)) { lo--; continue; }
|
|
159
|
+
if (canRight && tryAdd(hi + 1)) { hi++; continue; }
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
if (tryAdd(hi + 1)) { hi++; continue; }
|
|
163
|
+
if (canLeft && tryAdd(lo - 1)) { lo--; continue; }
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
return { items: cells.slice(lo, hi + 1), leftClipped: lo > 0, rightClipped: hi < cells.length - 1 };
|
|
132
167
|
}
|
|
133
168
|
|
|
134
169
|
/**
|
|
@@ -205,9 +240,10 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
205
240
|
}, ANIMATION_INTERVAL_MS);
|
|
206
241
|
}
|
|
207
242
|
|
|
208
|
-
/** Resolve the focused run by id; stable across
|
|
209
|
-
*
|
|
210
|
-
* (page + scrolls) resets only when the focused run actually
|
|
243
|
+
/** Resolve the focused run by id; stable across list changes (a
|
|
244
|
+
* foreground run settling drops out of the registry). The focused run's
|
|
245
|
+
* view state (page + scrolls) resets only when the focused run actually
|
|
246
|
+
* changes. */
|
|
211
247
|
private focusedRun(): RunHandle | undefined {
|
|
212
248
|
const runs = sortViewRuns(this.runsProvider());
|
|
213
249
|
if (runs.length === 0) {
|
|
@@ -231,15 +267,21 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
231
267
|
this.briefTop = 0;
|
|
232
268
|
}
|
|
233
269
|
|
|
234
|
-
private cycleRun(): void {
|
|
270
|
+
private cycleRun(direction: 1 | -1): void {
|
|
235
271
|
const runs = sortViewRuns(this.runsProvider());
|
|
236
272
|
if (runs.length < 2) return;
|
|
237
273
|
const idx = runs.findIndex((r) => r.id === this.focusId);
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
274
|
+
if (idx < 0) {
|
|
275
|
+
// Focus id gone from the list (a foreground run settled): fall back
|
|
276
|
+
// to the newest run, same as focusedRun().
|
|
277
|
+
const run = runs[0];
|
|
278
|
+
if (run.id === this.focusId) return;
|
|
279
|
+
this.focusId = run.id;
|
|
280
|
+
} else {
|
|
281
|
+
const next = runs[(idx + direction + runs.length) % runs.length];
|
|
282
|
+
if (next.id === this.focusId) return;
|
|
283
|
+
this.focusId = next.id;
|
|
284
|
+
}
|
|
243
285
|
this.resetRunView();
|
|
244
286
|
this.tui.requestRender();
|
|
245
287
|
}
|
|
@@ -282,7 +324,11 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
282
324
|
return;
|
|
283
325
|
}
|
|
284
326
|
if (matchesKey(data, Key.tab)) {
|
|
285
|
-
this.cycleRun();
|
|
327
|
+
this.cycleRun(1);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (matchesKey(data, "shift+tab")) {
|
|
331
|
+
this.cycleRun(-1);
|
|
286
332
|
return;
|
|
287
333
|
}
|
|
288
334
|
const ch = printableChar(data);
|
|
@@ -351,8 +397,7 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
351
397
|
return truncateToWidth(indent + formatThinking(e.status, fg), width);
|
|
352
398
|
}
|
|
353
399
|
if (e.kind === "steer") {
|
|
354
|
-
|
|
355
|
-
return truncateToWidth(indent + fg("accent", `↩ steer: ${firstLine}`), width);
|
|
400
|
+
return truncateToWidth(indent + formatDisplayItem({ type: "steer", text: e.text ?? "", status: e.status }, fg), width);
|
|
356
401
|
}
|
|
357
402
|
if (e.kind === "text") {
|
|
358
403
|
const buffer = e.text ?? "";
|
|
@@ -482,25 +527,30 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
482
527
|
const runningCount = runs.filter((r) => r.state === "running").length;
|
|
483
528
|
const focused = this.focusedRun();
|
|
484
529
|
|
|
485
|
-
// ── Tab row: one cell per run; the focused one is highlighted.
|
|
530
|
+
// ── Tab row: one cell per run; the focused one is highlighted. The
|
|
531
|
+
// archive can outgrow the row — window the cells around the focused one
|
|
532
|
+
// so focus never scrolls off-screen; Tab/⇧Tab walk the full list.
|
|
486
533
|
if (runs.length > 0) {
|
|
487
534
|
// Brackets stay on every cell, focused included — the selectedBg +
|
|
488
535
|
// accent highlight is the indicator, so Tab doesn't shift text.
|
|
489
536
|
const cells = runs.map((r) => {
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
const styled = isFocused ? th.bg("selectedBg", fg("accent", label)) : fg("dim", label);
|
|
493
|
-
return `[${styled}]`;
|
|
537
|
+
const label = `[${runIcon(r.snapshot, fg)} ${r.id} ${r.role}]`;
|
|
538
|
+
return r === focused ? th.bg("selectedBg", fg("accent", label)) : fg("dim", label);
|
|
494
539
|
});
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
);
|
|
540
|
+
const header =
|
|
541
|
+
`${fg("accent", th.bold("subagents"))} ` +
|
|
542
|
+
th.fg("dim", `${runningCount} running · ${runs.length} total`) +
|
|
543
|
+
" ";
|
|
544
|
+
const focusIdx = Math.max(0, runs.findIndex((r) => r === focused));
|
|
545
|
+
const win = windowTabCells(cells, focusIdx, innerW - visibleWidth(header));
|
|
546
|
+
const parts: string[] = [];
|
|
547
|
+
if (win.leftClipped) parts.push(fg("dim", "…"));
|
|
548
|
+
parts.push(...win.items);
|
|
549
|
+
if (win.rightClipped) parts.push(fg("dim", "…"));
|
|
550
|
+
lines.push(row(header + parts.join(th.fg("dim", " "))));
|
|
501
551
|
} else {
|
|
502
|
-
// Empty registry —
|
|
503
|
-
//
|
|
552
|
+
// Empty registry — nothing was delegated this session and no
|
|
553
|
+
// foreground run is in flight. Give the state real presence — a
|
|
504
554
|
// full-size panel with a centered message and the close hint — and
|
|
505
555
|
// fold steer mode back to browse so Esc closes immediately.
|
|
506
556
|
if (this.mode === "steer") {
|
|
@@ -510,12 +560,7 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
510
560
|
lines.push(row(""));
|
|
511
561
|
lines.push(row(fg("muted", centerText("no subagent runs", innerW))));
|
|
512
562
|
lines.push(
|
|
513
|
-
row(
|
|
514
|
-
fg(
|
|
515
|
-
"dim",
|
|
516
|
-
centerText("a run leaves the view once its result is in the conversation", innerW),
|
|
517
|
-
),
|
|
518
|
-
),
|
|
563
|
+
row(fg("dim", centerText("start one with subagent_delegate to see it here", innerW))),
|
|
519
564
|
);
|
|
520
565
|
lines.push(row(""));
|
|
521
566
|
lines.push(row(""));
|
|
@@ -565,7 +610,7 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
565
610
|
lines.push(row(fg("dim", `steer → ${label} · press s`)));
|
|
566
611
|
}
|
|
567
612
|
const pageKey = this.page === "activity" ? "d brief" : "d activity";
|
|
568
|
-
lines.push(row(fg("dim", `↑↓ scroll · ${pageKey} · Tab run · s steer · Esc close`)));
|
|
613
|
+
lines.push(row(fg("dim", `↑↓ scroll · ${pageKey} · Tab/⇧Tab run · s steer · Esc close`)));
|
|
569
614
|
}
|
|
570
615
|
}
|
|
571
616
|
|