@d3ara1n/pi-subagent 3.2.1 → 3.2.3
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 +5 -5
- package/package.json +1 -1
- package/src/index.ts +8 -28
- package/src/reminder.ts +9 -8
- package/src/spawn.ts +6 -1
- package/src/types.ts +1 -0
- package/src/utils.test.ts +17 -2
- package/src/utils.ts +11 -2
- package/src/view.test.ts +74 -22
- package/src/view.ts +112 -51
package/README.md
CHANGED
|
@@ -64,16 +64,16 @@ 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, 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.
|
|
76
|
+
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. 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
|
|
|
@@ -264,11 +264,11 @@ Typical flow:
|
|
|
264
264
|
|
|
265
265
|
Semantics worth knowing:
|
|
266
266
|
|
|
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 owns the collection point: `subagent_wait`, then `subagent_check` each run. The inbox reminder (below) lists runs not yet
|
|
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 owns the collection point: `subagent_wait`, then `subagent_check` each run. 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
268
|
- **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
269
|
- **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
270
|
- **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
|
|
271
|
+
- **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.
|
|
272
272
|
- **`timeout` (seconds) is optional — omitting it is the normal usage.** `subagent_wait` blocks until every run finishes, with each run bounded by its own role timeout; subagent runs typically take minutes. Set a timeout only when the waiter must resume soon (e.g. to report progress to the user).
|
|
273
273
|
- Background runs share the global `maxConcurrency` gate — extra runs show up as `queued` in wait/check views.
|
|
274
274
|
- **Top-level only:** nested subagents cannot delegate in the background (a subagent process exits when its task finishes, which would orphan the run).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d3ara1n/pi-subagent",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Role-based subagent orchestration for pi — delegates tasks to specialized pi child processes with configurable model roles",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/index.ts
CHANGED
|
@@ -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) {
|
|
@@ -812,28 +807,13 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
812
807
|
});
|
|
813
808
|
|
|
814
809
|
pi.registerCommand("subagent:view", {
|
|
815
|
-
description: "Open the
|
|
810
|
+
description: "Open the subagent activity view (watch runs, steer, browse the session's archive)",
|
|
816
811
|
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
|
-
};
|
|
812
|
+
// Union of every known run: the background registry — append-only for
|
|
813
|
+
// the whole session, the view doubles as the run archive and derives
|
|
814
|
+
// nothing from delivery state — plus live in-flight runs (foreground
|
|
815
|
+
// delegate calls included, visible only while in flight). Dedupe by
|
|
816
|
+
// id — background runs appear in both.
|
|
837
817
|
const runsProvider = () => {
|
|
838
818
|
const seen = new Set<string>();
|
|
839
819
|
const out: RunHandle[] = [];
|
|
@@ -843,7 +823,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
843
823
|
out.push(r);
|
|
844
824
|
}
|
|
845
825
|
}
|
|
846
|
-
return
|
|
826
|
+
return out;
|
|
847
827
|
};
|
|
848
828
|
if (runsProvider().length === 0) {
|
|
849
829
|
ctx.ui.notify("No subagent runs yet.", "info");
|
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
|
|
@@ -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/spawn.ts
CHANGED
|
@@ -481,7 +481,12 @@ export async function spawnSubagent(
|
|
|
481
481
|
usage.totalTokens || 0,
|
|
482
482
|
);
|
|
483
483
|
}
|
|
484
|
-
|
|
484
|
+
// The child's AssistantMessage carries the bare model id with the
|
|
485
|
+
// provider in a separate field — compose the full `provider/model-id`
|
|
486
|
+
// ref so usage displays match the delegate-facing modelRef format.
|
|
487
|
+
if (!result.model && msg.model) {
|
|
488
|
+
result.model = msg.provider ? `${msg.provider}/${msg.model}` : msg.model;
|
|
489
|
+
}
|
|
485
490
|
// message_end is authoritative for the latest assistant attempt. A
|
|
486
491
|
// successful native retry must clear the transient error left by the
|
|
487
492
|
// failed attempt instead of triggering a redundant whole-run fallback.
|
package/src/types.ts
CHANGED
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
|
@@ -903,6 +903,11 @@ interface SessionEntryLike {
|
|
|
903
903
|
* truth for delivery state: it is append-only and branch navigation rebuilds
|
|
904
904
|
* the active path, so branching past a check entry un-delivers (the inbox
|
|
905
905
|
* re-arms) while branching back re-delivers — no mirrored state to sync.
|
|
906
|
+
*
|
|
907
|
+
* Only checks that returned a TERMINAL snapshot count: a peek at a live
|
|
908
|
+
* frame (running/queued, exitCode -1) delivers nothing — the steer flow
|
|
909
|
+
* re-checks later — so an early check can never silence the inbox before
|
|
910
|
+
* the outcome exists.
|
|
906
911
|
*/
|
|
907
912
|
export function collectDeliveredIds(entries: Iterable<SessionEntryLike>): Set<string> {
|
|
908
913
|
const ids = new Set<string>();
|
|
@@ -910,8 +915,12 @@ export function collectDeliveredIds(entries: Iterable<SessionEntryLike>): Set<st
|
|
|
910
915
|
if (entry.type !== "message") continue;
|
|
911
916
|
const message = entry.message;
|
|
912
917
|
if (!message || message.role !== "toolResult" || message.toolName !== "subagent_check") continue;
|
|
913
|
-
const
|
|
914
|
-
if (typeof id
|
|
918
|
+
const details = message.details as { id?: unknown; result?: { exitCode?: unknown } } | undefined;
|
|
919
|
+
if (typeof details?.id !== "string" || !details.id) continue;
|
|
920
|
+
// A malformed or live frame (exitCode -1) is not a delivery — leave the
|
|
921
|
+
// inbox armed.
|
|
922
|
+
if (typeof details.result?.exitCode !== "number" || details.result.exitCode === -1) continue;
|
|
923
|
+
ids.add(details.id);
|
|
915
924
|
}
|
|
916
925
|
return ids;
|
|
917
926
|
}
|
package/src/view.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert";
|
|
3
|
-
import {
|
|
3
|
+
import { inheritedConversationFields, sortViewRuns, windowTabCells } from "./view.ts";
|
|
4
4
|
import type { RunHandle } from "./run.ts";
|
|
5
5
|
|
|
6
6
|
// ── Fakes ──────────────────────────────────────────────────────────
|
|
@@ -9,37 +9,89 @@ function fakeHandle(id: string, state: RunHandle["state"]): RunHandle {
|
|
|
9
9
|
return { id, state } as unknown as RunHandle;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
// ──
|
|
12
|
+
// ── inheritedConversationFields ────────────────────────────────────
|
|
13
13
|
|
|
14
|
-
test("
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
[
|
|
23
|
-
|
|
24
|
-
);
|
|
25
|
-
|
|
26
|
-
assert.deepEqual(
|
|
27
|
-
out.map((r) => r.id),
|
|
28
|
-
["sub-1", "sub-2", "sub-3"],
|
|
29
|
-
);
|
|
14
|
+
test("inheritedConversationFields returns aligned-view metadata without body text", () => {
|
|
15
|
+
assert.deepEqual(inheritedConversationFields(50_000, true), [
|
|
16
|
+
["inherited", "yes"],
|
|
17
|
+
["size", "50k chars"],
|
|
18
|
+
["truncated", "yes"],
|
|
19
|
+
]);
|
|
20
|
+
assert.deepEqual(inheritedConversationFields(0, false), [
|
|
21
|
+
["inherited", "yes"],
|
|
22
|
+
["size", "0 chars"],
|
|
23
|
+
["truncated", "no"],
|
|
24
|
+
]);
|
|
30
25
|
});
|
|
31
26
|
|
|
32
27
|
// ── sortViewRuns ───────────────────────────────────────────────────
|
|
33
28
|
|
|
34
|
-
test("sortViewRuns
|
|
29
|
+
test("sortViewRuns orders newest first by numeric id (creation order)", () => {
|
|
35
30
|
const out = sortViewRuns([
|
|
36
|
-
fakeHandle("sub-3", "finished"),
|
|
37
31
|
fakeHandle("sub-2", "running"),
|
|
38
|
-
fakeHandle("sub-
|
|
32
|
+
fakeHandle("sub-10", "finished"),
|
|
39
33
|
fakeHandle("sub-1", "failed"),
|
|
34
|
+
fakeHandle("sub-9", "queued"),
|
|
40
35
|
]);
|
|
41
36
|
assert.deepEqual(
|
|
42
37
|
out.map((r) => r.id),
|
|
43
|
-
["sub-
|
|
38
|
+
["sub-10", "sub-9", "sub-2", "sub-1"],
|
|
39
|
+
);
|
|
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),
|
|
44
56
|
);
|
|
45
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
|
|
@@ -52,7 +57,6 @@ import type { ActivityEntry } from "./types.ts";
|
|
|
52
57
|
import {
|
|
53
58
|
briefFilesUsed,
|
|
54
59
|
formatFallback,
|
|
55
|
-
formatInheritedConversationInput,
|
|
56
60
|
formatThinking,
|
|
57
61
|
formatTimePart,
|
|
58
62
|
formatToolCall,
|
|
@@ -111,25 +115,68 @@ function capBriefText(text: string): string {
|
|
|
111
115
|
}
|
|
112
116
|
|
|
113
117
|
/**
|
|
114
|
-
* Build the display list of runs for the panel:
|
|
115
|
-
*
|
|
118
|
+
* Build the display list of runs for the panel: newest first, by numeric
|
|
119
|
+
* id (creation order). Creation order never reshuffles — the archive is
|
|
120
|
+
* append-only, so rows stay put when runs settle and focus remains
|
|
121
|
+
* predictable.
|
|
116
122
|
*/
|
|
117
123
|
export function sortViewRuns(runs: RunHandle[]): RunHandle[] {
|
|
118
|
-
const
|
|
119
|
-
return [...runs].sort((a, b) =>
|
|
124
|
+
const num = (r: RunHandle) => Number.parseInt(r.id.replace(/^sub-/, ""), 10) || 0;
|
|
125
|
+
return [...runs].sort((a, b) => num(b) - num(a));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @internal — exported for testing; fits a window of tab cells around the
|
|
130
|
+
* focused one into `budget` visible columns. The archive can outgrow the
|
|
131
|
+
* row, but the focused cell never scrolls off-screen: neighbors expand
|
|
132
|
+
* outward (cheaper side first) while they fit, and clipped sides collapse
|
|
133
|
+
* to `…` markers.
|
|
134
|
+
*/
|
|
135
|
+
export function windowTabCells(
|
|
136
|
+
cells: string[],
|
|
137
|
+
focusIdx: number,
|
|
138
|
+
budget: number,
|
|
139
|
+
): { items: string[]; leftClipped: boolean; rightClipped: boolean } {
|
|
140
|
+
if (cells.length === 0) return { items: [], leftClipped: false, rightClipped: false };
|
|
141
|
+
const focus = Math.max(0, Math.min(focusIdx, cells.length - 1));
|
|
142
|
+
const widths = cells.map((c) => visibleWidth(c) + 1); // +1 separator space
|
|
143
|
+
let lo = focus;
|
|
144
|
+
let hi = focus;
|
|
145
|
+
let used = widths[focus];
|
|
146
|
+
const tryAdd = (i: number): boolean => {
|
|
147
|
+
if (i < 0 || i >= cells.length || used + widths[i] > budget) return false;
|
|
148
|
+
used += widths[i];
|
|
149
|
+
return true;
|
|
150
|
+
};
|
|
151
|
+
for (;;) {
|
|
152
|
+
const canLeft = lo > 0;
|
|
153
|
+
const canRight = hi < cells.length - 1;
|
|
154
|
+
if (!canLeft && !canRight) break;
|
|
155
|
+
const preferLeft = canLeft && (!canRight || widths[lo - 1] <= widths[hi + 1]);
|
|
156
|
+
if (preferLeft) {
|
|
157
|
+
if (tryAdd(lo - 1)) { lo--; continue; }
|
|
158
|
+
if (canRight && tryAdd(hi + 1)) { hi++; continue; }
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
if (tryAdd(hi + 1)) { hi++; continue; }
|
|
162
|
+
if (canLeft && tryAdd(lo - 1)) { lo--; continue; }
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
return { items: cells.slice(lo, hi + 1), leftClipped: lo > 0, rightClipped: hi < cells.length - 1 };
|
|
120
166
|
}
|
|
121
167
|
|
|
122
168
|
/**
|
|
123
|
-
*
|
|
124
|
-
* by subagent_check on the active branch (the same session-tree source of
|
|
125
|
-
* truth as the inbox reminder). Live runs and undelivered terminal runs
|
|
126
|
-
* stay: the view is for live watching and pending collection, not an
|
|
127
|
-
* archive.
|
|
169
|
+
* @internal — exported for testing; formats the metadata shown in the brief view.
|
|
128
170
|
*/
|
|
129
|
-
export function
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
171
|
+
export function inheritedConversationFields(
|
|
172
|
+
chars: number,
|
|
173
|
+
truncated: boolean,
|
|
174
|
+
): Array<[label: string, value: string]> {
|
|
175
|
+
return [
|
|
176
|
+
["inherited", "yes"],
|
|
177
|
+
["size", `${formatTokens(chars)} chars`],
|
|
178
|
+
["truncated", truncated ? "yes" : "no"],
|
|
179
|
+
];
|
|
133
180
|
}
|
|
134
181
|
|
|
135
182
|
export class SubagentViewPanel implements Component, Focusable {
|
|
@@ -192,9 +239,10 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
192
239
|
}, ANIMATION_INTERVAL_MS);
|
|
193
240
|
}
|
|
194
241
|
|
|
195
|
-
/** Resolve the focused run by id; stable across
|
|
196
|
-
*
|
|
197
|
-
* (page + scrolls) resets only when the focused run actually
|
|
242
|
+
/** Resolve the focused run by id; stable across list changes (a
|
|
243
|
+
* foreground run settling drops out of the registry). The focused run's
|
|
244
|
+
* view state (page + scrolls) resets only when the focused run actually
|
|
245
|
+
* changes. */
|
|
198
246
|
private focusedRun(): RunHandle | undefined {
|
|
199
247
|
const runs = sortViewRuns(this.runsProvider());
|
|
200
248
|
if (runs.length === 0) {
|
|
@@ -218,15 +266,21 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
218
266
|
this.briefTop = 0;
|
|
219
267
|
}
|
|
220
268
|
|
|
221
|
-
private cycleRun(): void {
|
|
269
|
+
private cycleRun(direction: 1 | -1): void {
|
|
222
270
|
const runs = sortViewRuns(this.runsProvider());
|
|
223
271
|
if (runs.length < 2) return;
|
|
224
272
|
const idx = runs.findIndex((r) => r.id === this.focusId);
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
273
|
+
if (idx < 0) {
|
|
274
|
+
// Focus id gone from the list (a foreground run settled): fall back
|
|
275
|
+
// to the newest run, same as focusedRun().
|
|
276
|
+
const run = runs[0];
|
|
277
|
+
if (run.id === this.focusId) return;
|
|
278
|
+
this.focusId = run.id;
|
|
279
|
+
} else {
|
|
280
|
+
const next = runs[(idx + direction + runs.length) % runs.length];
|
|
281
|
+
if (next.id === this.focusId) return;
|
|
282
|
+
this.focusId = next.id;
|
|
283
|
+
}
|
|
230
284
|
this.resetRunView();
|
|
231
285
|
this.tui.requestRender();
|
|
232
286
|
}
|
|
@@ -269,7 +323,11 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
269
323
|
return;
|
|
270
324
|
}
|
|
271
325
|
if (matchesKey(data, Key.tab)) {
|
|
272
|
-
this.cycleRun();
|
|
326
|
+
this.cycleRun(1);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (matchesKey(data, "shift+tab")) {
|
|
330
|
+
this.cycleRun(-1);
|
|
273
331
|
return;
|
|
274
332
|
}
|
|
275
333
|
const ch = printableChar(data);
|
|
@@ -383,12 +441,15 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
383
441
|
}
|
|
384
442
|
|
|
385
443
|
if (run.inheritConversation) {
|
|
386
|
-
section(
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
),
|
|
444
|
+
section("conversation");
|
|
445
|
+
const fields = inheritedConversationFields(
|
|
446
|
+
run.inheritedConversationChars ?? 0,
|
|
447
|
+
run.inheritedConversationTruncated === true,
|
|
391
448
|
);
|
|
449
|
+
const labelWidth = Math.max(...fields.map(([label]) => label.length));
|
|
450
|
+
for (const [label, value] of fields) {
|
|
451
|
+
lines.push(` ${fg("dim", label.padEnd(labelWidth))} ${fg("accent", value)}`);
|
|
452
|
+
}
|
|
392
453
|
}
|
|
393
454
|
|
|
394
455
|
if (run.files && run.files.length > 0) {
|
|
@@ -466,25 +527,30 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
466
527
|
const runningCount = runs.filter((r) => r.state === "running").length;
|
|
467
528
|
const focused = this.focusedRun();
|
|
468
529
|
|
|
469
|
-
// ── 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.
|
|
470
533
|
if (runs.length > 0) {
|
|
471
534
|
// Brackets stay on every cell, focused included — the selectedBg +
|
|
472
535
|
// accent highlight is the indicator, so Tab doesn't shift text.
|
|
473
536
|
const cells = runs.map((r) => {
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
const styled = isFocused ? th.bg("selectedBg", fg("accent", label)) : fg("dim", label);
|
|
477
|
-
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);
|
|
478
539
|
});
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
);
|
|
540
|
+
const header =
|
|
541
|
+
`${fg("accent", th.bold("subagents"))} ` +
|
|
542
|
+
th.fg("dim", `${runningCount} running · ${runs.length} total · Tab/⇧Tab switch`) +
|
|
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", " "))));
|
|
485
551
|
} else {
|
|
486
|
-
// Empty registry —
|
|
487
|
-
//
|
|
552
|
+
// Empty registry — nothing was delegated this session and no
|
|
553
|
+
// foreground run is in flight. Give the state real presence — a
|
|
488
554
|
// full-size panel with a centered message and the close hint — and
|
|
489
555
|
// fold steer mode back to browse so Esc closes immediately.
|
|
490
556
|
if (this.mode === "steer") {
|
|
@@ -494,12 +560,7 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
494
560
|
lines.push(row(""));
|
|
495
561
|
lines.push(row(fg("muted", centerText("no subagent runs", innerW))));
|
|
496
562
|
lines.push(
|
|
497
|
-
row(
|
|
498
|
-
fg(
|
|
499
|
-
"dim",
|
|
500
|
-
centerText("a run leaves the view once its result is in the conversation", innerW),
|
|
501
|
-
),
|
|
502
|
-
),
|
|
563
|
+
row(fg("dim", centerText("start one with subagent_delegate to see it here", innerW))),
|
|
503
564
|
);
|
|
504
565
|
lines.push(row(""));
|
|
505
566
|
lines.push(row(""));
|
|
@@ -549,7 +610,7 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
549
610
|
lines.push(row(fg("dim", `steer → ${label} · press s`)));
|
|
550
611
|
}
|
|
551
612
|
const pageKey = this.page === "activity" ? "d brief" : "d activity";
|
|
552
|
-
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`)));
|
|
553
614
|
}
|
|
554
615
|
}
|
|
555
616
|
|