Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 60 additions & 6 deletions packages/coding-agent/src/features/step-subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
laneMatches,
} from "./subagent/lane-lifecycle.ts";
import {
laneWidgetLines,
renderAgentNotification,
renderSubagentResult,
SubagentListWidget,
subagentListSignature,
Expand All @@ -51,7 +51,7 @@ export { routeSubagentRpcLine } from "./subagent/rpc-adapter.ts";
// moved to ./subagent/execute.ts and is imported above; it was never public.

// TUI render helpers (statusIcon/renderRecordSummary/renderExpandedRecord/
// renderSubagentResult/laneWidgetLines) moved to ./subagent/rendering.ts and
// renderSubagentResult/renderAgentNotification) moved to ./subagent/rendering.ts and
// are imported above; none were public.

// Pure utilities (usage math, sanitizeLabel/normalizeChildTools, the git
Expand Down Expand Up @@ -523,13 +523,65 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption
if (process.env[CHILD_MARKER] === "1") return;

const lanes = new Map<string, BackgroundAgentLane>();
const laneWidgetKey = (id: string): string => `step-agent:${id}`;
// Every background lane shares one list widget, built the same way as the
// blocking call's list: a row per task with a fixed title and metrics. A
// widget per lane, republished on every streamed delta with the child's
// live text, reordered the lanes on each publish (the host re-inserts the
// key) and changed height as lines wrapped, so the block flickered.
//
// Lanes stay listed until the whole batch settles, so the header reads
// "2/4 complete" rather than shrinking as lanes finish; then it clears and
// the transcript's agent-notification entries take over.
//
// Elapsed is read from the clock at render time. A blocking call gets its
// redraws from the working indicator, but background lanes usually run
// while the parent sits idle, so a 1s tick keeps the column moving; the
// differential renderer only rewrites the cells that changed.
const lanesWidgetKey = "step-agent-lanes";
const shownLanes = new Set<string>();
let lanesWidget: SubagentListWidget | undefined;
let lanesPublished: string | undefined;
let lanesTicker: ReturnType<typeof setInterval> | undefined;
const stopLanesTicker = (): void => {
if (lanesTicker) clearInterval(lanesTicker);
lanesTicker = undefined;
};
const updateLaneWidget = (lane: BackgroundAgentLane): void => {
if (!lane.ctx.hasUI) return;
shownLanes.add(lane.id);
const batch = [...shownLanes]
.map((id) => lanes.get(id))
.filter((entry): entry is BackgroundAgentLane => entry !== undefined);
try {
lane.ctx.ui.setWidget(laneWidgetKey(lane.id), laneWidgetLines(lane), {
placement: "aboveEditor",
});
if (batch.every((entry) => entry.status !== "running")) {
shownLanes.clear();
stopLanesTicker();
lanesWidget = undefined;
lanesPublished = undefined;
lane.ctx.ui.setWidget(lanesWidgetKey, undefined);
return;
}
const details: StepSubagentDetails = {
...lane.details,
mode: "parallel",
results: batch.flatMap((entry) => entry.details.results),
};
lanesWidget?.setDetails(details);
const signature = subagentListSignature(details);
if (signature === lanesPublished) return;
lanesPublished = signature;
lane.ctx.ui.setWidget(
lanesWidgetKey,
(tui, theme) => {
lanesWidget ??= new SubagentListWidget(details, theme, "background agents");
if (!lanesTicker) {
lanesTicker = setInterval(() => tui.requestRender(), 1_000);
lanesTicker.unref?.();
}
return lanesWidget;
},
{ placement: "aboveEditor" },
);
} catch {
// A host may tear down its UI while a detached child is finishing.
}
Expand All @@ -543,6 +595,8 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption
updateLaneWidget,
});

pi.registerMessageRenderer("agent-notification", renderAgentNotification);

pi.registerTool<typeof StepSubagentParamsSchema, StepSubagentDetails>({
name: "subagent",
label: "Subagent",
Expand Down
27 changes: 25 additions & 2 deletions packages/coding-agent/src/features/subagent/lane-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ export type BackgroundLaneEvent =
| "background_progress"
| "background_restarted";

/** `details` on an `<agent-notification>` message; read by its transcript renderer. */
export interface AgentNotificationDetails {
agentId: string;
event: BackgroundLaneEvent;
status: BackgroundAgentLane["status"];
/** Alias when the lane has one, otherwise its id. */
label?: string;
/** Agent names running in the lane, e.g. ["general"]. */
agents?: string[];
/** Unescaped body below the headline (output, failure reasons, progress). */
detail?: string;
}

/** Minimum interval between background_progress notifications per lane. */
const PROGRESS_NOTIFY_INTERVAL_MS = 15_000;

Expand Down Expand Up @@ -55,6 +68,14 @@ export function notifyLaneEvent(
? `Background agent ${label} restarted its child process.`
: `Background agent ${label} ${lane.status}.`;
const body = detail?.trim() ? `${headline}\n${detail.trim()}` : headline;
const details: AgentNotificationDetails = {
agentId: lane.id,
event,
status: lane.status,
label,
agents: [...new Set(lane.details.results.map((record) => record.agent))],
detail: detail?.trim() || undefined,
};
pi.sendMessage(
{
customType: "agent-notification",
Expand All @@ -66,8 +87,10 @@ export function notifyLaneEvent(
content:
`<agent-notification agentId="${escapeXmlAttr(lane.id)}" alias="${escapeXmlAttr(label)}" event="${event}" status="${lane.status}">` +
`${escapeXmlAttr(body)}</agent-notification>`,
display: true,
details: { agentId: lane.id, event, status: lane.status },
// Progress is for the parent model; the user already watches the lanes
// widget, and a transcript entry every 15s per lane buries the session.
display: event !== "background_progress",
details,
},
{ deliverAs: "steer" },
);
Expand Down
77 changes: 51 additions & 26 deletions packages/coding-agent/src/features/subagent/rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
import type { AgentToolResult } from "@step-harness/agent-core";
import type { Component } from "@step-harness/pi-tui";
import { Container, Markdown, Spacer, Text, truncateToWidth, visibleWidth } from "@step-harness/pi-tui";
import type { ToolRenderResultOptions } from "../../core/extensions/types.ts";
import type { MessageRenderOptions, ToolRenderResultOptions } from "../../core/extensions/types.ts";
import type { CustomMessage } from "../../core/messages.ts";
import type { Theme } from "../../theme/theme.ts";
import { getMarkdownTheme } from "../../theme/theme.ts";
import {
Expand All @@ -17,8 +18,7 @@ import {
type StepSubagentResultRecord,
type StepSubagentUsage,
} from "../step-subagent.ts";
import { truncateText } from "./lane-events.ts";
import type { BackgroundAgentLane } from "./lane-lifecycle.ts";
import { type AgentNotificationDetails, truncateText } from "./lane-events.ts";

const COLLAPSED_OUTPUT_LINES = 8;
/** Rows the live widget will show before collapsing the rest into a counter. */
Expand Down Expand Up @@ -198,10 +198,12 @@ export function subagentListSignature(details: StepSubagentDetails): string {
export class SubagentListWidget implements Component {
private details: StepSubagentDetails;
private readonly theme: Theme;
private readonly title: string;

constructor(details: StepSubagentDetails, theme: Theme) {
constructor(details: StepSubagentDetails, theme: Theme, title = "subagent") {
this.details = details;
this.theme = theme;
this.title = title;
}

setDetails(details: StepSubagentDetails): void {
Expand All @@ -226,13 +228,16 @@ export class SubagentListWidget implements Component {
if (queued > 0) summary.push(`${queued} queued`);
if (failed > 0) summary.push(`${failed} failed`);
if (skipped > 0) summary.push(`${skipped} skipped`);
const header = ` ${theme.fg("toolTitle", theme.bold("subagent"))} ${theme.fg("accent", summary.join(", "))}`;
const header = ` ${theme.fg("toolTitle", theme.bold(this.title))} ${theme.fg("accent", summary.join(", "))}`;
const lines = [visibleWidth(header) > width ? truncateToWidth(header, width, "\u2026") : header];

// Right column is sized across all shown rows so the metrics line up.
const shown = records.slice(0, WIDGET_MAX_ROWS);
const metrics = shown.map((record) => {
const tokens = record.usage.output;
// Tokens are a liveness readout for a lane still producing output; once
// it settles the row keeps only its final elapsed, so finished rows read
// as done at a glance instead of as one more counter.
const tokens = record.status === "running" ? record.usage.output : 0;
// Same shape as the working indicator's "· ↓ 1.2k tokens", so the two
// token readouts on screen read as one unit.
return `${formatElapsed(record)}${tokens > 0 ? ` \u00b7 \u2193 ${formatTokenCount(tokens)} tokens` : ""}`;
Expand Down Expand Up @@ -299,24 +304,44 @@ export function renderSubagentResult(
return new Text(text, 0, 0);
}

export function laneWidgetLines(lane: BackgroundAgentLane): string[] {
const records = lane.details.results;
const lines = [
`agent ${lane.id} ${lane.status}`,
...records.map((record) => {
const live = record.activeTool
? ` | ${record.activeTool}`
: record.activeText
? ` | ${record.activeText.split(/\r?\n/u).at(-1)?.slice(0, 100) ?? ""}`
: "";
return `${statusIcon(record.status, themeForWidget)} ${record.agent}${live}`;
}),
];
return lines;
}
const NOTIFICATION_PREVIEW_LINES = 3;

const NOTIFICATION_STYLE: Record<
AgentNotificationDetails["event"],
{ icon: string; color: "success" | "error" | "warning" | "dim"; verb: string }
> = {
background_done: { icon: "\u2713", color: "success", verb: "finished" },
background_failed: { icon: "x", color: "error", verb: "failed" },
background_interrupted: { icon: "x", color: "warning", verb: "interrupted" },
background_needs_input: { icon: "?", color: "warning", verb: "needs input" },
background_progress: { icon: "~", color: "dim", verb: "progress" },
background_restarted: { icon: "~", color: "warning", verb: "restarted its child process" },
};

// Widgets receive the same color callback shape as the native renderer. Keep
// this tiny fallback local so background lanes can also be shown in test hosts.
const themeForWidget = {
fg: (_color: string, text: string): string => text,
} as unknown as Theme;
/**
* Transcript entry for an `<agent-notification>` message: one status line plus
* a short preview of the detail, instead of the raw pseudo-XML the parent model
* reads. Returns undefined for messages without structured details (sessions
* recorded before they were added), which keeps the host's default rendering.
*/
export function renderAgentNotification(
message: CustomMessage<AgentNotificationDetails>,
options: MessageRenderOptions,
theme: Theme,
): Component | undefined {
const details = message.details;
const style = details?.event ? NOTIFICATION_STYLE[details.event] : undefined;
if (!details || !style) return undefined;
const label = details.label ?? details.agentId;
const agents = details.agents?.length ? ` ${theme.fg("muted", `(${details.agents.join(", ")})`)}` : "";
let text = `${theme.fg(style.color, style.icon)} ${theme.fg("toolTitle", theme.bold("background agent"))} ${theme.fg("accent", label)}${agents} ${theme.fg(style.color, style.verb)}`;
const lines = (details.detail ?? "").split(/\r?\n/u).filter((line) => line.trim().length > 0);
const shown = options.expanded ? lines : lines.slice(0, NOTIFICATION_PREVIEW_LINES);
for (const line of shown) {
text += `\n ${theme.fg("dim", options.expanded ? line : truncateText(line, 200).split("\n")[0])}`;
}
if (shown.length < lines.length) {
text += `\n ${theme.fg("dim", `... ${lines.length - shown.length} more lines (Ctrl+O to expand)`)}`;
}
return new Text(text, options.outputPad, 0);
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ function createApi(): {
registerCommand(name: string, command: { handler: (args: string, ctx: ExtensionContext) => unknown }) {
commands.set(name, command);
},
registerMessageRenderer: () => {},
registerFlag: () => {},
registerShortcut: () => {},
on(event: string, handler: (event: never, ctx: ExtensionContext) => unknown) {
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/test/step-subagent-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ function createApi(): { api: ExtensionAPI; tools: Map<string, ToolDefinition>; s
tools.set(tool.name, tool);
},
registerCommand: () => {},
registerMessageRenderer: () => {},
registerFlag: () => {},
registerShortcut: () => {},
on: () => {},
Expand Down
Loading
Loading