feat(pi): add read-only subagent attach view
This commit is contained in:
@@ -6,7 +6,7 @@ import { SubprocessRpcRunner } from "./runner.ts";
|
|||||||
import { Supervisor } from "./supervisor.ts";
|
import { Supervisor } from "./supervisor.ts";
|
||||||
import { milestoneNotification } from "./status.ts";
|
import { milestoneNotification } from "./status.ts";
|
||||||
import type { SpawnRequest, SubagentStatus } from "./types.ts";
|
import type { SpawnRequest, SubagentStatus } from "./types.ts";
|
||||||
import { widget } from "./ui.ts";
|
import { attachedChildView, widget } from "./ui.ts";
|
||||||
|
|
||||||
let supervisor: Supervisor | undefined;
|
let supervisor: Supervisor | undefined;
|
||||||
let lastDiagnostics: Diagnostics = { warnings: [] };
|
let lastDiagnostics: Diagnostics = { warnings: [] };
|
||||||
@@ -230,6 +230,28 @@ export default function subagents(pi: ExtensionAPI) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-attach", {
|
||||||
|
description: "Open a read-only attached view for a subagent id",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const id = args.trim();
|
||||||
|
if (!id) {
|
||||||
|
ctx.ui.notify("Usage: /subagent-attach <id>", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentSupervisor = getSupervisor(ctx);
|
||||||
|
currentSupervisor.status(id);
|
||||||
|
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => attachedChildView({
|
||||||
|
status: () => currentSupervisor.status(id),
|
||||||
|
activity: () => currentSupervisor.activity(id),
|
||||||
|
onDetach: () => done(),
|
||||||
|
onChange: () => tui.requestRender(),
|
||||||
|
}), {
|
||||||
|
overlay: true,
|
||||||
|
overlayOptions: { width: "90%", maxHeight: "90%", minWidth: 60 },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
pi.registerCommand("subagent-wait", {
|
pi.registerCommand("subagent-wait", {
|
||||||
description: "Wait for subagent ids separated by spaces",
|
description: "Wait for subagent ids separated by spaces",
|
||||||
handler: async (args, ctx) => {
|
handler: async (args, ctx) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
ChildHandle,
|
ChildHandle,
|
||||||
ChildRecord,
|
ChildRecord,
|
||||||
ChildRunner,
|
ChildRunner,
|
||||||
|
SubagentActivityEvent,
|
||||||
ContextMode,
|
ContextMode,
|
||||||
RunnerActivity,
|
RunnerActivity,
|
||||||
RunnerEvents,
|
RunnerEvents,
|
||||||
@@ -323,7 +324,7 @@ export class Supervisor {
|
|||||||
return [...this.children.values()].find((child) => child.record === record);
|
return [...this.children.values()].find((child) => child.record === record);
|
||||||
}
|
}
|
||||||
|
|
||||||
activity(id: string) {
|
activity(id: string): SubagentActivityEvent[] {
|
||||||
return this.require(id).record.activityEvents.map((event) => ({ ...event }));
|
return this.require(id).record.activityEvents.map((event) => ({ ...event }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import type { SubagentState, SubagentStatus } from "./types.ts";
|
import type { SubagentState, SubagentStatus } from "./types.ts";
|
||||||
import { renderInspector, renderSummary, widget } from "./ui.ts";
|
import { attachedChildView, renderAttachedChildView, renderInspector, renderSummary, widget } from "./ui.ts";
|
||||||
|
|
||||||
function status(overrides: Partial<SubagentStatus> & { id: string; label: string; state: SubagentState }): SubagentStatus {
|
function status(overrides: Partial<SubagentStatus> & { id: string; label: string; state: SubagentState }): SubagentStatus {
|
||||||
return {
|
return {
|
||||||
@@ -55,6 +55,42 @@ test("expanded monitor shows concise current activity summaries instead of raw e
|
|||||||
assert.doesNotMatch(rendered.join("\n"), /message_update|private transcript body/u);
|
assert.doesNotMatch(rendered.join("\n"), /message_update|private transcript body/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("attached child view is read-only, renders transcript activity, and supports detach plus scrolling", () => {
|
||||||
|
const child = status({ id: "sg-child", label: "Research worker", state: "running" });
|
||||||
|
const activity = Array.from({ length: 24 }, (_, index) => ({
|
||||||
|
type: "message_update",
|
||||||
|
summary: `assistant message ${index + 1}`,
|
||||||
|
at: `2026-08-01T00:00:${String(index + 1).padStart(2, "0")}.000Z`,
|
||||||
|
role: "assistant",
|
||||||
|
text: `captured child message ${index + 1}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const bottom = renderAttachedChildView(child, activity, { width: 100, scrollOffset: 0 });
|
||||||
|
assert.match(bottom.join("\n"), /read-only attached view/u);
|
||||||
|
assert.match(bottom.join("\n"), /Esc\/q detach/u);
|
||||||
|
assert.match(bottom.join("\n"), /captured child message 24/u);
|
||||||
|
assert.doesNotMatch(bottom.join("\n"), /> |prompt|send|input channel/ui);
|
||||||
|
|
||||||
|
const scrolled = renderAttachedChildView(child, activity, { width: 100, scrollOffset: 6 });
|
||||||
|
assert.match(scrolled.join("\n"), /captured child message 1[0-9]/u);
|
||||||
|
assert.doesNotMatch(scrolled.join("\n"), /captured child message 24/u);
|
||||||
|
|
||||||
|
let detached = false;
|
||||||
|
const component = attachedChildView({
|
||||||
|
status: () => child,
|
||||||
|
activity: () => activity,
|
||||||
|
onDetach: () => {
|
||||||
|
detached = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
component.handleInput("\u001b[A");
|
||||||
|
assert.doesNotMatch(component.render(100).join("\n"), /captured child message 24/u);
|
||||||
|
component.handleInput("\u001b[B");
|
||||||
|
assert.match(component.render(100).join("\n"), /captured child message 24/u);
|
||||||
|
component.handleInput("q");
|
||||||
|
assert.equal(detached, true);
|
||||||
|
});
|
||||||
|
|
||||||
test("expanded monitor renders one truncated row per child with state, elapsed time, and activity marker", () => {
|
test("expanded monitor renders one truncated row per child with state, elapsed time, and activity marker", () => {
|
||||||
const lines = renderInspector([
|
const lines = renderInspector([
|
||||||
status({
|
status({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { SubagentState, SubagentStatus } from "./types.ts";
|
import type { SubagentActivityEvent, SubagentState, SubagentStatus } from "./types.ts";
|
||||||
|
|
||||||
const COMPACT_GROUPS: Array<{ label: string; states: SubagentState[] }> = [
|
const COMPACT_GROUPS: Array<{ label: string; states: SubagentState[] }> = [
|
||||||
{ label: "queued", states: ["queued"] },
|
{ label: "queued", states: ["queued"] },
|
||||||
@@ -46,6 +46,58 @@ export function widget(statuses: SubagentStatus[], expanded: boolean) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AttachedChildViewOptions {
|
||||||
|
status: () => SubagentStatus;
|
||||||
|
activity: () => SubagentActivityEvent[];
|
||||||
|
onDetach: () => void;
|
||||||
|
onChange?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachedChildView(options: AttachedChildViewOptions) {
|
||||||
|
let scrollOffset = 0;
|
||||||
|
return {
|
||||||
|
invalidate() {},
|
||||||
|
render(width: number) {
|
||||||
|
const status = options.status();
|
||||||
|
const activity = options.activity();
|
||||||
|
const lines = renderAttachedChildView(status, activity, { width, scrollOffset });
|
||||||
|
scrollOffset = clampScrollOffset(scrollOffset, transcriptLines(activity).length, attachedViewportHeight(width));
|
||||||
|
return lines;
|
||||||
|
},
|
||||||
|
handleInput(data: string) {
|
||||||
|
const key = keyName(data);
|
||||||
|
if (key === "escape" || data === "q") {
|
||||||
|
options.onDetach();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const viewportHeight = attachedViewportHeight(80);
|
||||||
|
if (key === "up") scrollOffset += 1;
|
||||||
|
else if (key === "down") scrollOffset -= 1;
|
||||||
|
else if (key === "pageup") scrollOffset += viewportHeight;
|
||||||
|
else if (key === "pagedown") scrollOffset -= viewportHeight;
|
||||||
|
else return;
|
||||||
|
scrollOffset = clampScrollOffset(scrollOffset, options.activity().length, viewportHeight);
|
||||||
|
options.onChange?.();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderAttachedChildView(status: SubagentStatus, activity: SubagentActivityEvent[], options: { width: number; scrollOffset?: number }): string[] {
|
||||||
|
const viewportHeight = attachedViewportHeight(options.width);
|
||||||
|
const body = transcriptLines(activity);
|
||||||
|
const offset = clampScrollOffset(options.scrollOffset ?? 0, body.length, viewportHeight);
|
||||||
|
const start = Math.max(0, body.length - viewportHeight - offset);
|
||||||
|
const visible = body.slice(start, start + viewportHeight);
|
||||||
|
const scrollHint = body.length > viewportHeight ? ` · ${start + 1}-${start + visible.length}/${body.length}` : "";
|
||||||
|
const lines = [
|
||||||
|
`subagent ${status.id} · ${status.label} · ${STATE_PRESENTATION[status.state].label}`,
|
||||||
|
`read-only attached view · ↑/↓ scroll · PgUp/PgDn · Esc/q detach${scrollHint}`,
|
||||||
|
"",
|
||||||
|
...(visible.length > 0 ? visible : ["system no captured child activity yet"]),
|
||||||
|
];
|
||||||
|
return lines.map((line) => truncateLine(line, options.width));
|
||||||
|
}
|
||||||
|
|
||||||
function renderStatusRow(status: SubagentStatus): string {
|
function renderStatusRow(status: SubagentStatus): string {
|
||||||
const presentation = STATE_PRESENTATION[status.state];
|
const presentation = STATE_PRESENTATION[status.state];
|
||||||
const marker = statusMarker(status);
|
const marker = statusMarker(status);
|
||||||
@@ -79,3 +131,47 @@ function truncateLine(line: string, width: number): string {
|
|||||||
if (width === 1) return "…";
|
if (width === 1) return "…";
|
||||||
return `${line.slice(0, width - 1)}…`;
|
return `${line.slice(0, width - 1)}…`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachedViewportHeight(width: number): number {
|
||||||
|
return width < 60 ? 8 : 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transcriptLines(activity: SubagentActivityEvent[]): string[] {
|
||||||
|
return activity.map((event) => transcriptLine(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
function transcriptLine(event: SubagentActivityEvent): string {
|
||||||
|
if (event.error) return `tool ${event.tool ?? event.type} failed: ${event.error}`;
|
||||||
|
if (event.tool) return `tool ${event.tool}${event.phase ? ` ${event.phase}` : ""}${valueHint(event.input)}`;
|
||||||
|
if (event.text) return `${(event.role ?? "assistant").padEnd(8)} ${event.text}`;
|
||||||
|
if (event.output !== undefined) return `tool ${event.type} output${valueHint(event.output)}`;
|
||||||
|
return `system ${event.summary}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueHint(value: unknown): string {
|
||||||
|
if (value === undefined) return "";
|
||||||
|
if (typeof value === "string") return ` ${truncateActivityHint(value)}`;
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
|
||||||
|
const path = (value as { path?: unknown }).path;
|
||||||
|
if (typeof path === "string" && path.trim()) return ` ${path.trim()}`;
|
||||||
|
const command = (value as { command?: unknown }).command;
|
||||||
|
if (typeof command === "string" && command.trim()) return ` ${truncateActivityHint(command.trim())}`;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateActivityHint(value: string): string {
|
||||||
|
return value.length <= 80 ? value : `${value.slice(0, 79).trimEnd()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampScrollOffset(offset: number, lineCount: number, viewportHeight: number): number {
|
||||||
|
return Math.max(0, Math.min(offset, Math.max(0, lineCount - viewportHeight)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyName(data: string): string {
|
||||||
|
if (data === "\u001b") return "escape";
|
||||||
|
if (data === "\u001b[A") return "up";
|
||||||
|
if (data === "\u001b[B") return "down";
|
||||||
|
if (data === "\u001b[5~") return "pageup";
|
||||||
|
if (data === "\u001b[6~") return "pagedown";
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user