@d3ara1n/pi-subagent 1.7.1 → 2.0.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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Role-based subagent orchestration for [pi](https://github.com/earendil-works/pi).
4
4
 
5
- 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_wait`/`subagent_check`, cancellable via `subagent_cancel`). A centered live view (`/subagent:view`) shows every run's activity feed as it happens, and mid-run corrections can be queued into a running subagent from the view's input box or via `subagent_steer`.
5
+ 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_wait`/`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`.
6
6
 
7
7
  ## Design Philosophy
8
8
 
@@ -23,7 +23,7 @@ This means:
23
23
 
24
24
  1. Main model calls the `subagent_delegate` tool with a role and task description
25
25
  2. The extension resolves the role to a model via pi-model-roles
26
- 3. Spawns an isolated pi child process in RPC mode (`--mode rpc`) with the configured model, tools, and system prompt — agent events stream back over stdout while stdin carries the initial prompt and mid-run steering commands
26
+ 3. Spawns an isolated pi child process in RPC mode (`--mode rpc`) with the configured model, tools, and system prompt — agent events stream back over stdout while stdin carries the initial prompt and mid-run steering commands. Children are headless: interactive extension dialogs (`ctx.ui.select/confirm/input`) are answered automatically with `cancelled` (standard "user declined" semantics), so an extension that asks never hangs the run
27
27
  4. **Real-time TUI progress** shows tool calls, turns, and elapsed time as the subagent runs
28
28
  5. After completion, an **AI-generated one-line summary** is produced for compact display
29
29
  6. Returns the result to the main model with usage statistics (turns, tokens, cost)
@@ -54,18 +54,20 @@ This means:
54
54
 
55
55
  | Command | Description |
56
56
  |---------|-------------|
57
- | `/subagent:view` | Open the live view: a tabbed overlay showing one run's full activity feed at a time, with an input box to steer the focused run |
57
+ | `/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 |
58
58
  | `/subagent:doctor` | Diagnose pi invocation, model-role resolution, configuration, and role references |
59
59
  | `/subagent:status` | List background runs (active + collected) and their current state |
60
60
  | `/subagent:cancel <id\|all> [reason]` | Cancel a live background run (or every live run); the optional reason is recorded with the run |
61
61
 
62
62
  ### Live view (`/subagent:view`)
63
63
 
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 — its complete activity feed, tail-capped to the visible area with a `⋮ earlier activity` marker when older entries roll off.
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 feed itself is a continuous, append-only list: 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. 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.
67
67
 
68
- Below the feed sits a steer input box: type a correction and press Enter to queue it into the focused run (only while it is running). The message appears immediately in the feed as an `↩ steer:` entry and is delivered to the child after its current tool batch, before its next LLM call — the run keeps its progress. `Esc` closes the overlay.
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
+
70
+ 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 in the feed as an `↩ steer:` entry and is delivered to the child after its current tool batch, before its next LLM call — the run keeps its progress. `Esc` in browse mode closes the overlay.
69
71
 
70
72
  ## Dependencies
71
73
 
@@ -150,11 +152,18 @@ Override, disable, or add subagent roles via `agentOverrides`. Built-in and cust
150
152
  }
151
153
  ```
152
154
 
153
- **Required fields for custom roles:** `role`, `description`, `examples`, `decisionTrigger`, `systemPrompt`. `tools` is optional — absent means all tools, a list restricts to those exact tool names.
155
+ **Required fields for custom roles:** `role`, `description`, `examples`, `decisionTrigger`, `systemPrompt`.
156
+
157
+ **Tool policy (optional, pick exactly one):**
158
+
159
+ - `tools` — exact-name allowlist: absent means all tools, a list restricts to exactly those tool names, an **empty array means zero tools**.
160
+ - `excludeTools` — denylist: everything except the listed tool names (handy for e.g. withholding interactive tools from an otherwise full-access role). Absent or empty means no restriction.
161
+
162
+ Configuring both on the same role is an error — the role is skipped with an error notification at session start.
154
163
 
155
164
  **Optional fields:** `subagentRoles` (roles this role can spawn via delegate), `timeout` (per-role active-time timeout in seconds; unset or `0` is unlimited, negative values normalize to `0`), `maxTurns` / `maxCost` (per-role budget overrides; unset uses the top-level `maxTurns` / `maxCost` setting, `0` is unlimited, negative values normalize to `0`), `fallbackRole` (backup pi-model-roles role the whole run is retried on after a provider error; unset means no retry — see [Fallback observability](#fallback-observability)).
156
165
 
157
- Invalid custom roles (missing required fields) are silently skipped with an error notification at session start.
166
+ Invalid custom roles (missing required fields) are skipped with an error notification at session start.
158
167
 
159
168
  ## Usage (by the main model)
160
169
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-subagent",
3
- "version": "1.7.1",
3
+ "version": "2.0.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/preview.png CHANGED
Binary file
package/src/index.ts CHANGED
@@ -203,6 +203,22 @@ export default function subagentExtension(pi: ExtensionAPI) {
203
203
  refreshAvailableRoles();
204
204
  applyAgentOverrides(availableRoles, config.agentOverrides);
205
205
 
206
+ // Tool policy is one-dimensional: `tools` is a closed allowlist,
207
+ // `excludeTools` an open denylist — carrying both is a contradiction
208
+ // (intent bug, e.g. "append to the allowlist" written as both fields),
209
+ // never a combination to resolve. Skip the role loudly; the surviving
210
+ // role set self-documents via guidelines, and delegate fast-fails on the
211
+ // missing name.
212
+ for (const [name, role] of Object.entries(availableRoles)) {
213
+ if (role.tools !== undefined && role.excludeTools !== undefined) {
214
+ delete availableRoles[name];
215
+ ctx.ui.notify(
216
+ `[pi-subagent] Role "${name}" skipped — "tools" and "excludeTools" are mutually exclusive; configure exactly one.`,
217
+ "error",
218
+ );
219
+ }
220
+ }
221
+
206
222
  // Validate custom roles (skip built-in roles — they already have all fields)
207
223
  // `tools` is optional — absent means the role gets all tools.
208
224
  const REQUIRED_FIELDS = [
package/src/run.ts CHANGED
@@ -290,6 +290,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
290
290
  cwd: opts.cwd,
291
291
  thinking,
292
292
  tools: opts.roleDef.tools,
293
+ excludeTools: opts.roleDef.excludeTools,
293
294
  systemPrompt: opts.roleDef.systemPrompt,
294
295
  context: opts.context,
295
296
  contextFiles: opts.files,
@@ -325,6 +326,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
325
326
  cwd: opts.cwd,
326
327
  thinking: fallback.config.thinking,
327
328
  tools: opts.roleDef.tools,
329
+ excludeTools: opts.roleDef.excludeTools,
328
330
  systemPrompt: opts.roleDef.systemPrompt,
329
331
  context: opts.context,
330
332
  contextFiles: opts.files,
package/src/spawn.test.ts CHANGED
@@ -9,7 +9,7 @@ import assert from "node:assert/strict";
9
9
  import * as fs from "node:fs";
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
12
- import { composeInitialMessage } from "./spawn.ts";
12
+ import { buildChildArgs, composeInitialMessage } from "./spawn.ts";
13
13
 
14
14
  describe("composeInitialMessage", () => {
15
15
  test("wraps reference files in <file> blocks ahead of context and task", async () => {
@@ -47,3 +47,60 @@ describe("composeInitialMessage", () => {
47
47
  assert.equal(message, "<task>\nt\n</task>");
48
48
  });
49
49
  });
50
+
51
+ describe("buildChildArgs", () => {
52
+ test("base invocation: rpc mode, no session, model, then env+policy prompt blocks", () => {
53
+ const tmpDir = "/tmp/pi-subagent-x";
54
+ const args = buildChildArgs("prov/model", {}, tmpDir);
55
+ assert.deepEqual(args.slice(0, 6), [
56
+ "--mode",
57
+ "rpc",
58
+ "--no-session",
59
+ "--model",
60
+ "prov/model",
61
+ "--append-system-prompt",
62
+ ]);
63
+ const joined = args.join(" ");
64
+ assert.ok(joined.includes(`<subagent_env>\nPI_SUBAGENT_TMPDIR=${tmpDir}`));
65
+ assert.ok(joined.includes("<subagent_policy>"));
66
+ assert.ok(!joined.includes("--tools"));
67
+ assert.ok(!joined.includes("--exclude-tools"));
68
+ });
69
+
70
+ test("tools list → exact allowlist flag", () => {
71
+ const args = buildChildArgs("m", { tools: ["read", "grep"] }, "/t");
72
+ assert.ok(args.includes("--tools"));
73
+ assert.equal(args[args.indexOf("--tools") + 1], "read,grep");
74
+ });
75
+
76
+ test("tools: [] → --no-tools (literally zero tools)", () => {
77
+ const args = buildChildArgs("m", { tools: [] }, "/t");
78
+ assert.ok(args.includes("--no-tools"));
79
+ assert.ok(!args.includes("--tools"));
80
+ });
81
+
82
+ test("excludeTools → denylist flag; empty ≡ absent (no flag)", () => {
83
+ const args = buildChildArgs("m", { excludeTools: ["ask_user"] }, "/t");
84
+ assert.equal(args[args.indexOf("--exclude-tools") + 1], "ask_user");
85
+ const empty = buildChildArgs("m", { excludeTools: [] }, "/t");
86
+ assert.ok(!empty.includes("--exclude-tools"));
87
+ });
88
+
89
+ test("tools wins over excludeTools (both set is rejected upstream; belt only)", () => {
90
+ const args = buildChildArgs("m", { tools: ["read"], excludeTools: ["bash"] }, "/t");
91
+ assert.equal(args[args.indexOf("--tools") + 1], "read");
92
+ assert.ok(!args.includes("--exclude-tools"));
93
+ });
94
+
95
+ test("thinking and role system prompt are wrapped in their blocks", () => {
96
+ const args = buildChildArgs(
97
+ "m",
98
+ { thinking: "high", systemPrompt: " Be brief. " },
99
+ "/t",
100
+ );
101
+ assert.equal(args[args.indexOf("--thinking") + 1], "high");
102
+ const roleIdx = args.findIndex((a) => a.startsWith("<subagent_role>"));
103
+ assert.ok(roleIdx > 0);
104
+ assert.equal(args[roleIdx], "<subagent_role>\nBe brief.\n</subagent_role>");
105
+ });
106
+ });
package/src/spawn.ts CHANGED
@@ -131,6 +131,88 @@ export function getPiInvocation(args: string[]): { command: string; args: string
131
131
  return { command: "pi", args };
132
132
  }
133
133
 
134
+ /**
135
+ * Build the pi CLI args for one child run (RPC mode).
136
+ *
137
+ * Tool policy mirrors pi's CLI flags in three mutually exclusive states
138
+ * (tools + excludeTools together is rejected at role resolution):
139
+ * - `tools` set → exact allowlist (`--tools`; empty = `--no-tools`, literally zero tools)
140
+ * - `excludeTools` set → denylist (`--exclude-tools`; empty ≡ absent — nothing excluded)
141
+ * - neither → all tools (no flag)
142
+ *
143
+ * @internal — exported for testing.
144
+ */
145
+ export function buildChildArgs(
146
+ modelRef: string,
147
+ options: {
148
+ thinking?: string;
149
+ tools?: string[];
150
+ excludeTools?: string[];
151
+ systemPrompt?: string;
152
+ },
153
+ tmpDir: string,
154
+ ): string[] {
155
+ const args: string[] = ["--mode", "rpc", "--no-session", "--model", modelRef];
156
+
157
+ if (options.thinking) {
158
+ args.push("--thinking", options.thinking);
159
+ }
160
+
161
+ if (options.tools) {
162
+ if (options.tools.length > 0) {
163
+ args.push("--tools", options.tools.join(","));
164
+ } else {
165
+ args.push("--no-tools");
166
+ }
167
+ } else if (options.excludeTools && options.excludeTools.length > 0) {
168
+ args.push("--exclude-tools", options.excludeTools.join(","));
169
+ }
170
+
171
+ // System prompt channel: inline text via --append-system-prompt. pi's
172
+ // resolvePromptInput treats an existing path as a file to read and any
173
+ // non-path string as literal text, so structured blocks go directly — no
174
+ // temp file, zero disk I/O.
175
+ if (options.systemPrompt?.trim()) {
176
+ args.push(
177
+ "--append-system-prompt",
178
+ `<subagent_role>\n${options.systemPrompt.trim()}\n</subagent_role>`,
179
+ );
180
+ }
181
+ args.push(
182
+ "--append-system-prompt",
183
+ `<subagent_env>\nPI_SUBAGENT_TMPDIR=${tmpDir}\nAvailable as $PI_SUBAGENT_TMPDIR in bash. Use for git clone and scratch files.\n</subagent_env>`,
184
+ );
185
+ // Shared behavioral policy for EVERY subagent run — built-in roles and
186
+ // agentOverrides customs alike. Role prompts (roles.ts) shape WHAT a role
187
+ // does; this shapes HOW any subagent behaves when the task exceeds its
188
+ // actual capabilities: report the gap and stop instead of improvising
189
+ // workarounds until timeout.
190
+ args.push(
191
+ "--append-system-prompt",
192
+ [
193
+ "<subagent_policy>",
194
+ "Before attempting the task, check it against your actual capabilities in this",
195
+ "session — the tool list here is definitive.",
196
+ "- If the task needs a capability you do not have (web access, bash, file",
197
+ " writes, ...) or material that is not present locally or in the provided",
198
+ " context/files, it is out of scope for you. Do NOT improvise workarounds.",
199
+ '- "Cannot complete" means a capability or material gap — not "difficult" or',
200
+ ' "uncertain". If it is merely hard, keep working within your tools.',
201
+ "- When you hit a genuine gap, stop early and return:",
202
+ " ## Cannot complete",
203
+ " - Missing: the capability or material that is absent",
204
+ " - Needed: what would complete the task",
205
+ " - Found: partial findings so far (optional)",
206
+ "",
207
+ 'An early "cannot complete" report is a successful outcome; grinding on',
208
+ "impossible workarounds until timeout is the failure.",
209
+ "</subagent_policy>",
210
+ ].join("\n"),
211
+ );
212
+
213
+ return args;
214
+ }
215
+
134
216
  /**
135
217
  * Compose the initial RPC prompt message: reference files as <file> blocks,
136
218
  * then context and task as structured tags — the same shape the child saw in
@@ -177,6 +259,8 @@ export async function spawnSubagent(
177
259
  cwd?: string;
178
260
  /** Thinking level passed to the child pi process when the role defines one. */
179
261
  thinking?: string;
262
+ /** Extra tools withheld from the child (denylist); ignored when `tools` is set. */
263
+ excludeTools?: string[];
180
264
  tools?: string[];
181
265
  systemPrompt?: string;
182
266
  /** Extra context delivered as a separate channel from the task. */
@@ -232,64 +316,12 @@ export async function spawnSubagent(
232
316
  let tmpDir: string | null = null;
233
317
 
234
318
  try {
235
- // Build CLI args. RPC mode (not json): stdin carries the initial prompt and
236
- // mid-run steering commands; stdout streams the same agent events.
237
- const args: string[] = ["--mode", "rpc", "--no-session", "--model", modelRef];
238
-
239
- if (options.thinking) {
240
- args.push("--thinking", options.thinking);
241
- }
242
-
243
- if (options.tools && options.tools.length > 0) {
244
- args.push("--tools", options.tools.join(","));
245
- }
246
-
247
319
  // Scratch dir handed to the child as PI_SUBAGENT_TMPDIR for its bash work
248
320
  // (e.g. git clone). The initial prompt goes over stdin, so there is no
249
321
  // argv length limit and no spill-to-tempfile channel anymore.
250
322
  tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
251
323
 
252
- // ── System prompt channel: inline text via --append-system-prompt ──
253
- // pi's resolvePromptInput treats an existing path as a file to read and any
254
- // non-path string as literal text, so we pass structured blocks directly —
255
- // no temp file, zero disk I/O. Multiple flags are joined with "\n\n".
256
- if (options.systemPrompt?.trim()) {
257
- args.push(
258
- "--append-system-prompt",
259
- `<subagent_role>\n${options.systemPrompt.trim()}\n</subagent_role>`,
260
- );
261
- }
262
- args.push(
263
- "--append-system-prompt",
264
- `<subagent_env>\nPI_SUBAGENT_TMPDIR=${tmpDir}\nAvailable as $PI_SUBAGENT_TMPDIR in bash. Use for git clone and scratch files.\n</subagent_env>`,
265
- );
266
- // Shared behavioral policy for EVERY subagent run — built-in roles and
267
- // agentOverrides customs alike. Role prompts (roles.ts) shape WHAT a role
268
- // does; this shapes HOW any subagent behaves when the task exceeds its
269
- // actual capabilities: report the gap and stop instead of improvising
270
- // workarounds until timeout.
271
- args.push(
272
- "--append-system-prompt",
273
- [
274
- "<subagent_policy>",
275
- "Before attempting the task, check it against your actual capabilities in this",
276
- "session — the tool list here is definitive.",
277
- "- If the task needs a capability you do not have (web access, bash, file",
278
- " writes, ...) or material that is not present locally or in the provided",
279
- " context/files, it is out of scope for you. Do NOT improvise workarounds.",
280
- '- "Cannot complete" means a capability or material gap — not "difficult" or',
281
- ' "uncertain". If it is merely hard, keep working within your tools.',
282
- "- When you hit a genuine gap, stop early and return:",
283
- " ## Cannot complete",
284
- " - Missing: the capability or material that is absent",
285
- " - Needed: what would complete the task",
286
- " - Found: partial findings so far (optional)",
287
- "",
288
- 'An early "cannot complete" report is a successful outcome; grinding on',
289
- "impossible workarounds until timeout is the failure.",
290
- "</subagent_policy>",
291
- ].join("\n"),
292
- );
324
+ const args = buildChildArgs(modelRef, options, tmpDir);
293
325
 
294
326
  // ── Initial prompt channel: one RPC prompt command over stdin ──
295
327
  // RPC mode rejects @file argv, so reference files are inlined here as
@@ -367,6 +399,20 @@ export async function spawnSubagent(
367
399
  // RPC command acknowledgements ("response" lines) carry no agent state.
368
400
  if (event.type === "response") return;
369
401
 
402
+ // The child is a headless worker — nobody can answer its dialogs.
403
+ // RPC-mode ctx.ui.select/confirm/input emit extension_ui_request and
404
+ // park the child's extension on a promise that has NO default timeout;
405
+ // without a response the child hangs until the run timeout kills it.
406
+ // Answer immediately with cancelled:true — the standard "user declined"
407
+ // semantics (undefined/false) extensions already handle. Fire-and-forget
408
+ // methods (notify/setStatus/setWidget/setTitle) need no answer.
409
+ if (event.type === "extension_ui_request" && event.id) {
410
+ if (event.method === "select" || event.method === "confirm" || event.method === "input") {
411
+ sendCommand({ type: "extension_ui_response", id: event.id, cancelled: true });
412
+ }
413
+ return;
414
+ }
415
+
370
416
  // RPC mode is a resident server. agent_end only marks one low-level run
371
417
  // and may be followed by an automatic retry, compaction, or queued
372
418
  // continuation. agent_settled is the authoritative terminal event. We run
package/src/types.ts CHANGED
@@ -55,10 +55,17 @@ export interface SubagentRole {
55
55
  /** System prompt for the subagent */
56
56
  systemPrompt: string;
57
57
  /**
58
- * Tools available to this subagent. Empty or absent = all tools (no restriction).
59
- * When set, only the listed tool names are exposed to the child (exact-name allowlist).
58
+ * Tools available to this subagent exact-name allowlist mapped to pi's `--tools`.
59
+ * Absent = all tools (no restriction); empty array = literally zero tools (`--no-tools`).
60
+ * Mutually exclusive with `excludeTools`.
60
61
  */
61
62
  tools?: string[];
63
+ /**
64
+ * Tools withheld from this subagent — everything else stays available (mapped
65
+ * to pi's `--exclude-tools`). Absent or empty = no restriction.
66
+ * Mutually exclusive with `tools`.
67
+ */
68
+ excludeTools?: string[];
62
69
  /** If this role has `delegate`, restrict which roles it may spawn. undefined = no restriction. */
63
70
  subagentRoles?: string[];
64
71
  /** Per-role active-time timeout in seconds. `0` or unset means unlimited; negative values are normalized to `0`. */
package/src/utils.test.ts CHANGED
@@ -42,6 +42,7 @@ import {
42
42
  terminalResultLine,
43
43
  buildDisplayItems,
44
44
  formatToolCall,
45
+ briefFilesUsed,
45
46
  } from "./utils.ts";
46
47
  import type { ActivityEntry, SubagentResult, SubagentRole } from "./types.ts";
47
48
 
@@ -749,3 +750,51 @@ describe("streamed-text activity entries", () => {
749
750
  );
750
751
  });
751
752
  });
753
+
754
+ describe("briefFilesUsed", () => {
755
+ const call = (toolName: string, args: Record<string, any>): ActivityEntry => ({
756
+ kind: "toolCall",
757
+ id: `tc-${toolName}`,
758
+ status: "done",
759
+ toolName,
760
+ args,
761
+ });
762
+ const F1 = "/Users/x/proj/src/a.ts";
763
+ const F2 = "/Users/x/proj/docs/guide.md";
764
+
765
+ test("marks a file matched by an exact-path arg", () => {
766
+ const used = briefFilesUsed([F1, F2], [call("read", { file_path: F1 })]);
767
+ assert.equal(used.get(F1), true);
768
+ assert.equal(used.get(F2), false);
769
+ });
770
+
771
+ test("marks a file referenced inside a bash command", () => {
772
+ const used = briefFilesUsed([F1], [call("bash", { command: `cat ${F1} | head -5` })]);
773
+ assert.equal(used.get(F1), true);
774
+ });
775
+
776
+ test("marks a file the child addressed by relative path", () => {
777
+ const used = briefFilesUsed([F1], [call("edit", { file_path: "src/a.ts" })]);
778
+ assert.equal(used.get(F1), true);
779
+ });
780
+
781
+ test("ignores short separator-less values — no false positives", () => {
782
+ const used = briefFilesUsed([F1], [call("grep", { pattern: "a.ts" })]);
783
+ assert.equal(used.get(F1), false);
784
+ });
785
+
786
+ test("collects strings from nested args one level deep", () => {
787
+ const used = briefFilesUsed([F1], [
788
+ call("edit", { edits: [{ oldText: "x", path: F1 }] }),
789
+ ]);
790
+ assert.equal(used.get(F1), true);
791
+ });
792
+
793
+ test("skips non-toolCall entries and empty file lists", () => {
794
+ assert.equal(briefFilesUsed(undefined, [call("read", { file_path: F1 })]).size, 0);
795
+ const used = briefFilesUsed([F1], [
796
+ { kind: "text", id: "text-0", status: "done", text: "hi" },
797
+ ]);
798
+ assert.equal(used.get(F1), false);
799
+ });
800
+ });
package/src/utils.ts CHANGED
@@ -269,6 +269,56 @@ export function renderDisplayItems(
269
269
  return text.trimEnd();
270
270
  }
271
271
 
272
+ // ── Brief page (subagent:view detail view) ─────────────────────
273
+
274
+ /** Collect string values from a tool call's args, up to a small nesting depth. */
275
+ function argStrings(args: Record<string, any> | undefined): string[] {
276
+ const out: string[] = [];
277
+ const walk = (v: unknown, depth: number): void => {
278
+ if (typeof v === "string") {
279
+ out.push(v);
280
+ } else if (depth > 0 && Array.isArray(v)) {
281
+ for (const item of v) walk(item, depth - 1);
282
+ } else if (depth > 0 && v && typeof v === "object") {
283
+ for (const item of Object.values(v)) walk(item, depth - 1);
284
+ }
285
+ };
286
+ walk(args, 3);
287
+ return out;
288
+ }
289
+
290
+ /** Arg values shorter than this (and without a separator) are ignored as path candidates. */
291
+ const PATH_MATCH_MIN = 5;
292
+
293
+ /**
294
+ * Cross-annotation for the brief page's file list: for each delegate
295
+ * reference file, did any tool call in the activity log touch it? Heuristic
296
+ * string match — a hit is either an arg containing the full path (absolute
297
+ * use, bash references) or the file path ending with the arg (the child
298
+ * using a relative form). Short separator-less values never match, so plain
299
+ * query strings cannot false-positive.
300
+ */
301
+ export function briefFilesUsed(
302
+ files: string[] | undefined,
303
+ activityLog: ActivityEntry[],
304
+ ): Map<string, boolean> {
305
+ const used = new Map<string, boolean>((files ?? []).map((f) => [f, false]));
306
+ if (used.size === 0) return used;
307
+ for (const entry of activityLog) {
308
+ if (entry.kind !== "toolCall") continue;
309
+ for (const v of argStrings(entry.args)) {
310
+ if (v.length < PATH_MATCH_MIN) continue;
311
+ for (const [file, hit] of used) {
312
+ if (hit) continue;
313
+ if (v.includes(file) || (v.includes("/") && file.endsWith(v))) {
314
+ used.set(file, true);
315
+ }
316
+ }
317
+ }
318
+ }
319
+ return used;
320
+ }
321
+
272
322
  // ── Shared result-view composition ─────────────────────────────
273
323
  // Used by the foreground delegate row (./render.ts) and the background
274
324
  // wait/check rows (./render-async.ts) so every view renders the same
package/src/view.ts CHANGED
@@ -9,43 +9,71 @@
9
9
  * adjacent spinning lines. Streamed assistant text is the single growing
10
10
  * element: it renders as the run's last line and freezes at message_end.
11
11
  *
12
+ * The focused run has two full-width pages, toggled with `d`:
13
+ * - activity (default): the live feed, scrollable with ↑/↓/PgUp/PgDn. The
14
+ * view pins to the end and auto-follows new entries; scrolling up unpins
15
+ * (a "⋮ N earlier" marker appears), reaching the bottom again (or End)
16
+ * re-pins.
17
+ * - brief: the run's inputs and vitals — task and context verbatim (wrapped;
18
+ * head+tail elided when huge), the reference file list annotated with ✓/·
19
+ * for whether the child's tool calls touched each file, usage and time
20
+ * stats, the fallback trace, and a stderr tail on failures.
21
+ *
22
+ * Steer input is modal so keys never conflict with the editor: browse mode
23
+ * owns navigation; `s` opens the editor (Enter sends and returns to browse,
24
+ * Esc cancels and clears). Esc in browse closes the overlay; Tab cycles
25
+ * the focused run and resets scrolls (activity re-pins, brief returns to
26
+ * the top).
27
+ *
12
28
  * Layout: a centered screen overlay (overlay:true) occupying most of the
13
29
  * terminal, framed with a thin border. An embedded Editor accepts steering
14
- * input for the focused run (Tab cycles targets); Enter queues the message
15
- * through the run's RPC stdin channel — delivered after the child's current
16
- * tool batch, before its next LLM call.
30
+ * input for the focused run; Enter queues the message through the run's RPC
31
+ * stdin channel — delivered after the child's current tool batch, before its
32
+ * next LLM call.
17
33
  */
18
34
 
19
35
  import type { Theme } from "@earendil-works/pi-coding-agent";
20
36
  import {
37
+ decodeKittyPrintable,
21
38
  Editor,
22
39
  type Component,
23
40
  type EditorTheme,
24
41
  type Focusable,
25
42
  Key,
26
43
  matchesKey,
44
+ stripTerminalSequences,
27
45
  truncateToWidth,
28
46
  visibleWidth,
47
+ wrapTextWithAnsi,
29
48
  } from "@earendil-works/pi-tui";
30
49
  import type { RunHandle } from "./run.ts";
31
50
  import type { ActivityEntry } from "./types.ts";
32
51
  import {
52
+ briefFilesUsed,
53
+ formatFallback,
33
54
  formatThinking,
34
55
  formatTimePart,
35
56
  formatToolCall,
57
+ formatTokens,
36
58
  formatUsageStats,
37
59
  runIcon,
60
+ shortenPath,
38
61
  statusStyle,
39
62
  taskPreview,
40
63
  } from "./utils.ts";
41
64
 
42
- /** Max body lines kept visible; older entries roll off the top. */
65
+ /** Max body lines kept visible; older lines scroll off behind a marker. */
43
66
  const VIEWPORT_LINES = 26;
44
67
  /** Animation tick for the running-entry ellipsis. */
45
68
  const ANIMATION_INTERVAL_MS = 150;
69
+ /** Brief-page text (task/context) is elided to head+tail beyond this many
70
+ * chars, so huge contexts stay cheap to re-wrap on every animation tick. */
71
+ const BRIEF_TEXT_CAP = 20_000;
46
72
 
47
73
  type TuiLike = { requestRender(): void };
48
74
  type Fg = (color: string, text: string) => string;
75
+ type Page = "activity" | "brief";
76
+ type Mode = "browse" | "steer";
49
77
 
50
78
  /** Pad a string with trailing spaces to a visible width (left-justified). */
51
79
  function padRight(s: string, width: number): string {
@@ -58,6 +86,22 @@ function dots(): string {
58
86
  return ".".repeat(1 + (Math.floor(Date.now() / 300) % 3));
59
87
  }
60
88
 
89
+ /** Normalize one keypress to its printable character (Kitty CSI-u aware). */
90
+ function printableChar(data: string): string | undefined {
91
+ const kitty = decodeKittyPrintable(data);
92
+ if (kitty !== undefined) return kitty;
93
+ return data.length === 1 && data >= " " && data <= "~" ? data : undefined;
94
+ }
95
+
96
+ /** Elide oversized brief text to head + tail around an elision marker. */
97
+ function capBriefText(text: string): string {
98
+ if (text.length <= BRIEF_TEXT_CAP) return text;
99
+ const head = text.slice(0, Math.floor(BRIEF_TEXT_CAP * 0.8));
100
+ const tail = text.slice(-Math.floor(BRIEF_TEXT_CAP * 0.2));
101
+ const elided = text.length - head.length - tail.length;
102
+ return `${head}\n… [${formatTokens(elided)} chars elided] …\n${tail}`;
103
+ }
104
+
61
105
  /**
62
106
  * Build the display list of runs for the panel: running/queued first, then
63
107
  * finished, each group ordered by registry id.
@@ -75,8 +119,19 @@ export class SubagentViewPanel implements Component, Focusable {
75
119
  private tui: TuiLike;
76
120
  private close: () => void;
77
121
  private editor: Editor;
78
- /** Focused run (Tab cycles); the whole viewport belongs to it. */
79
- private focusIndex = 0;
122
+ /** Focused run id (Tab cycles); the whole viewport belongs to it. */
123
+ private focusId: string | null = null;
124
+ /** browse = navigation keys; steer = the editor owns input. */
125
+ private mode: Mode = "browse";
126
+ /** Focused run's visible page. */
127
+ private page: Page = "activity";
128
+ /** Activity scroll: null = pinned to the end (auto-follow), else the
129
+ * index of the first visible entry line. */
130
+ private activityTop: number | null = null;
131
+ /** Brief scroll: index of the first visible line (clamped in render). */
132
+ private briefTop = 0;
133
+ /** Brief scroll ceiling, recomputed each render (content is static). */
134
+ private briefMax = 0;
80
135
  /** Transient feedback line ("steer sent to sub-N"), auto-clears. */
81
136
  private flash = "";
82
137
  private flashUntil = 0;
@@ -116,11 +171,38 @@ export class SubagentViewPanel implements Component, Focusable {
116
171
  }, ANIMATION_INTERVAL_MS);
117
172
  }
118
173
 
174
+ /** Resolve the focused run by id; stable across sort-order reshuffles
175
+ * (e.g. a run finishing re-ranks the list). Scroll state resets only when
176
+ * the focused run actually changes. */
119
177
  private focusedRun(): RunHandle | undefined {
120
178
  const runs = sortViewRuns(this.runsProvider());
121
- if (runs.length === 0) return undefined;
122
- if (this.focusIndex >= runs.length) this.focusIndex = 0;
123
- return runs[this.focusIndex];
179
+ if (runs.length === 0) {
180
+ this.focusId = null;
181
+ return undefined;
182
+ }
183
+ let run = runs.find((r) => r.id === this.focusId);
184
+ if (!run) {
185
+ run = runs[0];
186
+ this.focusId = run.id;
187
+ this.resetScrolls();
188
+ }
189
+ return run;
190
+ }
191
+
192
+ private resetScrolls(): void {
193
+ this.activityTop = null;
194
+ this.briefTop = 0;
195
+ }
196
+
197
+ private cycleRun(): void {
198
+ const runs = sortViewRuns(this.runsProvider());
199
+ if (runs.length < 2) return;
200
+ const idx = runs.findIndex((r) => r.id === this.focusId);
201
+ const next = runs[((idx >= 0 ? idx : 0) + 1) % runs.length];
202
+ if (next.id === this.focusId) return;
203
+ this.focusId = next.id;
204
+ this.resetScrolls();
205
+ this.tui.requestRender();
124
206
  }
125
207
 
126
208
  private submitSteer(value: string): void {
@@ -134,6 +216,7 @@ export class SubagentViewPanel implements Component, Focusable {
134
216
  target.steer(text);
135
217
  this.editor.setText("");
136
218
  this.showFlash(`steer queued for ${target.id} (${target.role})`);
219
+ this.mode = "browse";
137
220
  }
138
221
 
139
222
  private showFlash(message: string): void {
@@ -142,19 +225,66 @@ export class SubagentViewPanel implements Component, Focusable {
142
225
  }
143
226
 
144
227
  handleInput(data: string): void {
228
+ if (this.mode === "steer") {
229
+ // Everything types into the editor; Esc cancels (never closes the panel).
230
+ if (matchesKey(data, Key.escape)) {
231
+ this.editor.setText("");
232
+ this.mode = "browse";
233
+ this.tui.requestRender();
234
+ return;
235
+ }
236
+ this.editor.handleInput(data);
237
+ this.tui.requestRender();
238
+ return;
239
+ }
240
+
145
241
  if (matchesKey(data, Key.escape)) {
146
242
  this.closePanel();
147
243
  return;
148
244
  }
149
245
  if (matchesKey(data, Key.tab)) {
150
- const n = sortViewRuns(this.runsProvider()).length;
151
- if (n > 1) {
152
- this.focusIndex = (this.focusIndex + 1) % n;
153
- this.tui.requestRender();
154
- }
246
+ this.cycleRun();
247
+ return;
248
+ }
249
+ const ch = printableChar(data);
250
+ if (ch === "d") {
251
+ this.page = this.page === "activity" ? "brief" : "activity";
252
+ this.tui.requestRender();
155
253
  return;
156
254
  }
157
- this.editor.handleInput(data);
255
+ if (ch === "s") {
256
+ this.mode = "steer";
257
+ this.tui.requestRender();
258
+ return;
259
+ }
260
+ const page = Math.max(3, this.browseBudget() - 1);
261
+ if (matchesKey(data, Key.up)) this.scrollBy(-1);
262
+ else if (matchesKey(data, Key.down)) this.scrollBy(1);
263
+ else if (matchesKey(data, Key.pageUp)) this.scrollBy(-page);
264
+ else if (matchesKey(data, Key.pageDown)) this.scrollBy(page);
265
+ else if (matchesKey(data, Key.home)) {
266
+ if (this.page === "activity") this.activityTop = 0;
267
+ else this.briefTop = 0;
268
+ this.tui.requestRender();
269
+ } else if (matchesKey(data, Key.end)) {
270
+ if (this.page === "activity") this.activityTop = null;
271
+ else this.briefTop = this.briefMax;
272
+ this.tui.requestRender();
273
+ }
274
+ }
275
+
276
+ /** Scroll the visible page by `delta` lines. Activity pins back to the end
277
+ * once the bottom is reached (auto-follow resumes). */
278
+ private scrollBy(delta: number): void {
279
+ if (this.page === "activity") {
280
+ const n = this.focusedRun()?.snapshot.activityLog.length ?? 0;
281
+ const maxTop = Math.max(0, n - this.browseBudget());
282
+ const cur = this.activityTop ?? maxTop;
283
+ const next = Math.max(0, Math.min(maxTop, cur + delta));
284
+ this.activityTop = next >= maxTop ? null : next;
285
+ } else {
286
+ this.briefTop = Math.max(0, Math.min(this.briefMax, this.briefTop + delta));
287
+ }
158
288
  this.tui.requestRender();
159
289
  }
160
290
 
@@ -166,6 +296,11 @@ export class SubagentViewPanel implements Component, Focusable {
166
296
  this.close();
167
297
  }
168
298
 
299
+ /** Content line budget in browse mode (tab row + header + steer hint + key hint). */
300
+ private browseBudget(): number {
301
+ return Math.max(3, VIEWPORT_LINES - 4);
302
+ }
303
+
169
304
  /** Render one activity entry as a static line; running entries get the
170
305
  * animated ellipsis suffix. */
171
306
  private renderEntry(e: ActivityEntry, width: number, fg: Fg): string {
@@ -197,6 +332,88 @@ export class SubagentViewPanel implements Component, Focusable {
197
332
  return truncateToWidth(indent + line, width);
198
333
  }
199
334
 
335
+ /** Render the brief page's full content (pre-scroll): task/context verbatim,
336
+ * annotated file list, stats, fallback trace, failure stderr tail. */
337
+ private renderBriefLines(run: RunHandle, width: number, fg: Fg): string[] {
338
+ const snap = run.snapshot;
339
+ const lines: string[] = [];
340
+ const section = (title: string) => {
341
+ const label = `── ${title} `;
342
+ lines.push(fg("dim", label + "─".repeat(Math.max(0, width - visibleWidth(label)))));
343
+ };
344
+ const body = (text: string, color?: string) => {
345
+ for (const ln of wrapTextWithAnsi(capBriefText(text), width - 2)) {
346
+ lines.push(color ? ` ${fg(color, ln)}` : ` ${ln}`);
347
+ }
348
+ };
349
+
350
+ section("task");
351
+ body(run.task);
352
+
353
+ if (run.context) {
354
+ section(`context · ${formatTokens(run.context.length)} chars`);
355
+ body(run.context);
356
+ }
357
+
358
+ if (run.files && run.files.length > 0) {
359
+ section(`files · ${run.files.length}`);
360
+ const used = briefFilesUsed(run.files, snap.activityLog);
361
+ for (const f of run.files) {
362
+ lines.push(
363
+ used.get(f)
364
+ ? fg("success", ` ✓ ${shortenPath(f)}`)
365
+ : fg("muted", ` · ${shortenPath(f)}`),
366
+ );
367
+ }
368
+ }
369
+
370
+ section("stats");
371
+ const usage = formatUsageStats(snap.usage, snap.model);
372
+ lines.push(fg("dim", ` ${usage || "no usage yet"}`));
373
+ const time = formatTimePart({ ...snap, exitCode: run.state === "queued" ? -1 : snap.exitCode });
374
+ if (time) lines.push(fg("dim", ` ${time}`));
375
+ if (run.state === "finished" || run.state === "failed") {
376
+ lines.push(fg("dim", ` exit ${snap.exitCode}${snap.stopReason ? ` · ${snap.stopReason}` : ""}`));
377
+ }
378
+ if (snap.fallbackFrom) {
379
+ lines.push(fg("warning", ` ⚠ ${formatFallback(snap.fallbackFrom)}`));
380
+ }
381
+ if (run.state === "failed") {
382
+ if (snap.errorMessage) body(snap.errorMessage, "error");
383
+ const tail = stripTerminalSequences(snap.stderr)
384
+ .split("\n")
385
+ .map((l) => l.trim())
386
+ .filter(Boolean)
387
+ .slice(-6);
388
+ if (tail.length > 0) {
389
+ lines.push(fg("dim", " stderr (tail):"));
390
+ for (const l of tail) lines.push(truncateToWidth(fg("muted", ` ${l}`), width));
391
+ }
392
+ }
393
+ return lines;
394
+ }
395
+
396
+ /** Push the focused run's current page (activity or brief) as framed rows. */
397
+ private renderPage(run: RunHandle, budget: number, innerW: number, fg: Fg, row: (s: string) => string): string[] {
398
+ const lines: string[] = [];
399
+ if (this.page === "activity") {
400
+ const entries = run.snapshot.activityLog.map((e) => this.renderEntry(e, innerW, fg));
401
+ const maxTop = Math.max(0, entries.length - budget);
402
+ const top = this.activityTop === null ? maxTop : Math.min(this.activityTop, maxTop);
403
+ if (top > 0) lines.push(row(fg("muted", `⋮ ${top} earlier`)));
404
+ for (const ln of entries.slice(top, top + budget - (top > 0 ? 1 : 0))) lines.push(row(ln));
405
+ return lines;
406
+ }
407
+ const all = this.renderBriefLines(run, innerW, fg);
408
+ this.briefMax = Math.max(0, all.length - budget);
409
+ this.briefTop = Math.min(this.briefTop, this.briefMax);
410
+ if (this.briefTop > 0) lines.push(row(fg("muted", `⋮ ${this.briefTop} earlier`)));
411
+ for (const ln of all.slice(this.briefTop, this.briefTop + budget - (this.briefTop > 0 ? 1 : 0))) {
412
+ lines.push(row(ln));
413
+ }
414
+ return lines;
415
+ }
416
+
200
417
  render(width: number): string[] {
201
418
  const th = this.theme;
202
419
  // Same adaptation render.ts uses: utils formatters take a loose Fg.
@@ -210,16 +427,16 @@ export class SubagentViewPanel implements Component, Focusable {
210
427
 
211
428
  const runs = sortViewRuns(this.runsProvider());
212
429
  const lines: string[] = [];
213
-
214
430
  const runningCount = runs.filter((r) => r.state === "running").length;
431
+ const focused = this.focusedRun();
215
432
 
216
433
  // ── Tab row: one cell per run; the focused one is highlighted. ──
217
434
  if (runs.length > 0) {
218
- const cells = runs.map((r, i) => {
219
- const focused = r === this.focusedRun();
435
+ const cells = runs.map((r) => {
436
+ const isFocused = r === focused;
220
437
  const label = `${runIcon(r.snapshot, fg)} ${r.id} ${r.role}`;
221
- const styled = focused ? th.bg("selectedBg", fg("accent", label)) : fg("dim", label);
222
- return focused ? styled : `[${styled}]`;
438
+ const styled = isFocused ? th.bg("selectedBg", fg("accent", label)) : fg("dim", label);
439
+ return isFocused ? styled : `[${styled}]`;
223
440
  });
224
441
  lines.push(
225
442
  row(
@@ -231,44 +448,52 @@ export class SubagentViewPanel implements Component, Focusable {
231
448
  lines.push(row(fg("muted", "subagents: no runs.")));
232
449
  }
233
450
 
234
- // ── Focused run: full viewport, tail-capped entries. ──
235
- // Reserve room for tab row (1) + steer bar (2) + hint (1).
236
- const budget = Math.max(3, VIEWPORT_LINES - 4);
237
- const run = this.focusedRun();
238
- if (run) {
239
- const snap = run.snapshot;
451
+ // ── Focused run: header line, then the current page. ──
452
+ if (focused) {
453
+ const snap = focused.snapshot;
240
454
  const icon = runIcon(snap, fg);
241
- const time = formatTimePart({ ...snap, exitCode: run.state === "queued" ? -1 : snap.exitCode });
455
+ const time = formatTimePart({ ...snap, exitCode: focused.state === "queued" ? -1 : snap.exitCode });
456
+ const inputBits: string[] = [];
457
+ if (focused.files?.length) {
458
+ inputBits.push(`${focused.files.length} file${focused.files.length === 1 ? "" : "s"}`);
459
+ }
460
+ if (focused.context) inputBits.push(`context ${formatTokens(focused.context.length)}`);
242
461
  const parts = [
243
- `${icon} ${fg("accent", th.bold(run.id))}`,
244
- fg("text", run.role),
245
- fg("dim", taskPreview(run.task)),
462
+ `${icon} ${fg("accent", th.bold(focused.id))}`,
463
+ fg("text", focused.role),
464
+ fg("dim", taskPreview(focused.task)),
465
+ ...(inputBits.length > 0 ? [fg("dim", inputBits.join(" · "))] : []),
246
466
  time ? fg("dim", time) : "",
247
467
  fg("dim", formatUsageStats(snap.usage, snap.model)),
248
468
  ].filter(Boolean);
249
469
  lines.push(row(parts.join(th.fg("dim", " · "))));
250
- const entries = snap.activityLog.map((entry) => this.renderEntry(entry, innerW, fg));
251
- if (entries.length > budget) {
252
- entries.splice(0, entries.length - budget);
253
- lines.push(row(fg("muted", "⋮ earlier activity")));
254
- }
255
- for (const ln of entries.slice(0, budget)) lines.push(row(ln));
256
- }
257
470
 
258
- // ── Steer bar ──
259
- if (Date.now() < this.flashUntil) {
260
- lines.push(row(fg("success", this.flash)));
261
- } else {
262
- const target = run && run.state === "running" ? `${run.id} (${run.role})` : null;
263
- const label = target
264
- ? fg("accent", target)
265
- : fg("dim", run ? `${run.id} not running` : "no runs");
266
- lines.push(row(fg("dim", `steer → ${label}`)));
267
- }
268
- for (const el of this.editor.render(innerW)) {
269
- lines.push(row(el));
471
+ if (this.mode === "steer") {
472
+ const edLines = this.editor.render(innerW);
473
+ const budget = Math.max(3, VIEWPORT_LINES - 3 - edLines.length);
474
+ lines.push(...this.renderPage(focused, budget, innerW, fg, row));
475
+ const label =
476
+ focused.state === "running"
477
+ ? fg("accent", `${focused.id} (${focused.role})`)
478
+ : fg("dim", focused.state === "queued" ? `${focused.id} still queued` : `${focused.id} not running`);
479
+ lines.push(row(fg("dim", `steer → ${label}`)));
480
+ for (const el of edLines) lines.push(row(el));
481
+ lines.push(row(fg("dim", "Enter send · Esc cancel")));
482
+ } else {
483
+ lines.push(...this.renderPage(focused, this.browseBudget(), innerW, fg, row));
484
+ if (Date.now() < this.flashUntil) {
485
+ lines.push(row(fg("success", this.flash)));
486
+ } else {
487
+ const label =
488
+ focused.state === "running"
489
+ ? fg("accent", `${focused.id} (${focused.role})`)
490
+ : fg("dim", focused.state === "queued" ? `${focused.id} still queued` : `${focused.id} not running`);
491
+ lines.push(row(fg("dim", `steer → ${label} · press s`)));
492
+ }
493
+ const pageKey = this.page === "activity" ? "d brief" : "d activity";
494
+ lines.push(row(fg("dim", `↑↓ scroll · ${pageKey} · Tab run · s steer · Esc close`)));
495
+ }
270
496
  }
271
- lines.push(row(fg("dim", "Enter steer · Tab switch run · Esc close")));
272
497
 
273
498
  // Frame.
274
499
  return [