feat(pi): add read-only subagent attach view

This commit is contained in:
2026-08-01 23:06:55 -04:00
parent ede3c0583f
commit 7bc0d0772c
4 changed files with 159 additions and 4 deletions

View File

@@ -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[] }> = [
{ 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 {
const presentation = STATE_PRESENTATION[status.state];
const marker = statusMarker(status);
@@ -79,3 +131,47 @@ function truncateLine(line: string, width: number): string {
if (width === 1) return "…";
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;
}