feat(task-rendering): added unified call/result task rendering

- Enabled call/result merging by setting mergeCallAndResult on TaskTool.
- Reworked task item lines into bullet lists and removed tree-style prefixes.
- Rendered task calls as framed blocks with isolated headers and pending status metadata.
- Changed result previews to hide task titles and `Tasks` headings when a result is present.
This commit is contained in:
can1357
2026-06-07 05:13:58 +02:00
parent 1ebe2c7484
commit 28c3e2a986
3 changed files with 173 additions and 150 deletions
+4
View File
@@ -275,6 +275,10 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
readonly strict = true;
readonly loadMode = "discoverable";
readonly renderResult = renderResult;
// Suppress the streaming call preview once a (partial or final) result exists
// so the task renders as ONE block that transitions in place — not a pending
// call frame stacked above the result frame. Mirrors `taskToolRenderer`.
readonly mergeCallAndResult = true;
readonly #discoveredAgents: AgentDefinition[];
readonly #blockedAgent: string | undefined;
+159 -140
View File
@@ -18,6 +18,7 @@ import {
formatMoreItems,
formatStatusIcon,
replaceTabs,
type ToolUIStatus,
truncateToWidth,
} from "../tools/render-utils";
import {
@@ -28,7 +29,7 @@ import {
type ReportFindingDetails,
type SubmitReviewDetails,
} from "../tools/review";
import { Ellipsis, Hasher, type RenderCache, renderStatusLine } from "../tui";
import { framedBlock, renderStatusLine } from "../tui";
import { subprocessToolRegistry } from "./subprocess-tool-registry";
import type { AgentProgress, SingleResult, TaskItem, TaskParams, TaskToolDetails } from "./types";
@@ -506,28 +507,20 @@ function formatOutputInline(data: unknown, theme: Theme, maxWidth = 80): string
* preview. The args stream in token by token, so the array grows over time and
* trailing entries may be partially parsed — every field access is defensive.
*/
function renderTaskItemLines(
tasks: TaskItem[] | undefined,
contPrefix: string,
expanded: boolean,
theme: Theme,
): string[] {
function renderTaskItemLines(tasks: TaskItem[] | undefined, expanded: boolean, theme: Theme): string[] {
const items = tasks ?? [];
if (items.length === 0) return [];
const branch = theme.fg("dim", theme.tree.branch);
const last = theme.fg("dim", theme.tree.last);
const bullet = theme.fg("dim", "•");
const cap = expanded ? items.length : Math.min(items.length, 12);
const truncated = cap < items.length;
const lines: string[] = [];
for (let i = 0; i < cap; i++) {
const task = items[i] as Partial<TaskItem> | undefined;
const isLastLine = !truncated && i === items.length - 1;
const connector = isLastLine ? last : branch;
const rawId = task?.id?.trim();
const idLabel = rawId ? formatTaskId(rawId) : `#${i + 1}`;
let line = `${contPrefix}${connector} ${theme.fg("accent", theme.bold(idLabel))}`;
let line = `${bullet} ${theme.fg("accent", theme.bold(idLabel))}`;
const desc = task?.description?.trim();
if (desc) {
line += `: ${theme.fg("muted", truncateToWidth(replaceTabs(desc), 64))}`;
@@ -535,7 +528,7 @@ function renderTaskItemLines(
lines.push(line);
}
if (truncated) {
lines.push(`${contPrefix}${last} ${theme.fg("dim", formatMoreItems(items.length - cap, "agent"))}`);
lines.push(`${bullet} ${theme.fg("dim", formatMoreItems(items.length - cap, "agent"))}`);
}
return lines;
}
@@ -548,44 +541,39 @@ export function renderCall(
options: RenderResultOptions & { renderContext?: { hasResult?: boolean } },
theme: Theme,
): Component {
const lines: string[] = [];
lines.push(renderStatusLine({ icon: "pending", title: "Task", description: args.agent }, theme));
const context = (args.context ?? "").trim();
const hasContext = context.length > 0;
const branch = theme.fg("dim", theme.tree.branch);
const last = theme.fg("dim", theme.tree.last);
const vertical = theme.fg("dim", theme.tree.vertical);
const showIsolated = "isolated" in args && args.isolated === true;
const taskCount = args.tasks?.length ?? 0;
const header = renderStatusLine({ icon: "pending", title: "Task", description: args.agent }, theme);
return framedBlock(theme, width => {
const context = (args.context ?? "").trim();
const taskCount = args.tasks?.length ?? 0;
const sections: Array<{ label?: string; lines: string[] }> = [];
if (hasContext) {
lines.push(` ${branch} ${theme.fg("dim", "Context")}`);
const contextLines = context.split("\n").map(line => {
const content = line ? theme.fg("muted", replaceTabs(line)) : "";
return ` ${vertical} ${content}`;
});
lines.push(...contextLines);
}
if (context) {
sections.push({
lines: context.split("\n").map(line => (line ? theme.fg("muted", replaceTabs(line)) : "")),
});
}
// `Tasks` is the last child unless the isolation flag follows it.
const tasksIsLast = !showIsolated;
const tasksPrefix = tasksIsLast ? last : branch;
lines.push(` ${tasksPrefix} ${theme.fg("dim", "Tasks")} ${theme.fg("muted", `(${taskCount})`)}`);
const tasksContPrefix = tasksIsLast ? " " : ` ${vertical} `;
// The per-task preview list only exists to surface dispatched agents while
// the call args stream in. Once a result snapshot exists, `renderResult`
// draws the same agents as progress/result lines (id + description), so
// emitting the preview here would render every task twice.
if (!options.renderContext?.hasResult) {
lines.push(...renderTaskItemLines(args.tasks, tasksContPrefix, options.expanded, theme));
}
// The per-task preview list only exists to surface dispatched agents while
// the call args stream in. Once a result snapshot exists, `renderResult`
// draws the same agents as progress/result lines, so showing the Tasks
// section here would just repeat the count the result frame already shows.
if (!options.renderContext?.hasResult) {
sections.push({
label: `Tasks (${taskCount})`,
lines: renderTaskItemLines(args.tasks, options.expanded, theme),
});
}
if (showIsolated) {
lines.push(` ${last} ${theme.fg("dim", "Isolated")}: ${theme.fg("muted", "true")}`);
}
return new Text(lines.join("\n"), 0, 0);
return {
header,
headerMeta: showIsolated ? "isolated" : undefined,
sections,
state: "pending",
borderColor: "borderMuted",
width,
};
});
}
/**
@@ -593,14 +581,13 @@ export function renderCall(
*/
function renderAgentProgress(
progress: AgentProgress,
isLast: boolean,
prefix: string,
continuePrefix: string,
expanded: boolean,
theme: Theme,
spinnerFrame?: number,
): string[] {
const lines: string[] = [];
const prefix = isLast ? theme.fg("dim", theme.tree.last) : theme.fg("dim", theme.tree.branch);
const continuePrefix = isLast ? " " : `${theme.fg("dim", theme.tree.vertical)} `;
const icon = getStatusIcon(progress.status, theme, spinnerFrame);
const iconColor =
@@ -614,7 +601,7 @@ function renderAgentProgress(
const description = progress.description?.trim();
const displayId = formatTaskId(progress.id);
const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)}`;
let statusLine = `${prefix ? `${prefix} ` : ""}${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)}`;
// Show retry-blocked badge so the parent immediately sees that a child
// is sleeping on a provider 429, not silently progressing. Wins over the
@@ -867,10 +854,14 @@ function renderFindings(
/**
* Render final result for a single agent.
*/
function renderAgentResult(result: SingleResult, isLast: boolean, expanded: boolean, theme: Theme): string[] {
function renderAgentResult(
result: SingleResult,
prefix: string,
continuePrefix: string,
expanded: boolean,
theme: Theme,
): string[] {
const lines: string[] = [];
const prefix = isLast ? theme.fg("dim", theme.tree.last) : theme.fg("dim", theme.tree.branch);
const continuePrefix = isLast ? " " : `${theme.fg("dim", theme.tree.vertical)} `;
const { warning: missingCompleteWarning, rest: outputWithoutWarning } = extractMissingYieldWarning(result.output);
const aborted = result.aborted ?? false;
@@ -899,7 +890,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
const description = result.description?.trim();
const displayId = formatTaskId(result.id);
const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)} ${formatBadge(
let statusLine = `${prefix ? `${prefix} ` : ""}${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)} ${formatBadge(
statusText,
iconColor,
theme,
@@ -1049,95 +1040,112 @@ export function renderResult(
if (!details) {
const text = result.content.find(c => c.type === "text")?.text || "";
return new Text(theme.fg("dim", truncateToWidth(text, 100)), 0, 0);
const header = renderStatusLine({ icon: "success", title: "Task" }, theme);
return framedBlock(theme, width => ({
header,
sections: text ? [{ lines: [theme.fg("dim", truncateToWidth(text, width))] }] : [],
state: "success",
borderColor: "borderMuted",
width,
}));
}
let cached: RenderCache | undefined;
const hasResults = Boolean(details.results && details.results.length > 0);
const aborted = hasResults && details.results.some(r => r.aborted);
const failed = hasResults && details.results.some(r => !r.aborted && r.exitCode !== 0);
const mergeFailed = hasResults && details.results.some(r => !r.aborted && r.exitCode === 0 && Boolean(r.error));
const isError = aborted || failed;
const agentCount = hasResults ? details.results.length : (details.progress?.length ?? 0);
const icon: ToolUIStatus = options.isPartial ? "running" : isError ? "error" : mergeFailed ? "warning" : "success";
const header = renderStatusLine(
{
icon,
spinnerFrame: options.spinnerFrame,
title: "Task",
meta: agentCount > 0 ? [`${agentCount} ${agentCount === 1 ? "agent" : "agents"}`] : undefined,
},
theme,
);
return {
render(width) {
const { expanded, isPartial, spinnerFrame } = options;
const key = new Hasher()
.bool(expanded)
.bool(isPartial)
.u32(spinnerFrame ?? 0)
.u32(width)
.digest();
if (cached?.key === key) return cached.lines;
return framedBlock(theme, width => {
const { expanded, isPartial, spinnerFrame } = options;
const lines: string[] = [];
const lines: string[] = [];
const shouldRenderProgress =
Boolean(details.progress && details.progress.length > 0) && (isPartial || details.results.length === 0);
if (shouldRenderProgress && details.progress) {
details.progress.forEach(progress => {
lines.push(...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame));
});
} else if (details.results && details.results.length > 0) {
details.results.forEach(res => {
lines.push(...renderAgentResult(res, "", " ", expanded, theme));
});
const shouldRenderProgress =
Boolean(details.progress && details.progress.length > 0) && (isPartial || details.results.length === 0);
if (shouldRenderProgress && details.progress) {
details.progress.forEach((progress, i) => {
const isLast = i === details.progress!.length - 1;
lines.push(...renderAgentProgress(progress, isLast, expanded, theme, spinnerFrame));
});
} else if (details.results && details.results.length > 0) {
details.results.forEach((res, i) => {
const isLast = i === details.results.length - 1;
lines.push(...renderAgentResult(res, isLast, expanded, theme));
});
const abortedCount = details.results.filter(r => r.aborted).length;
const mergeFailedCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && r.error).length;
const successCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && !r.error).length;
const failCount = details.results.length - successCount - mergeFailedCount - abortedCount;
let summary = `${theme.fg("dim", "Total:")} `;
if (abortedCount > 0) {
summary += theme.fg("error", `${abortedCount} aborted`);
if (successCount > 0 || mergeFailedCount > 0 || failCount > 0) summary += theme.sep.dot;
}
if (successCount > 0) {
summary += theme.fg("success", `${successCount} succeeded`);
if (mergeFailedCount > 0 || failCount > 0) summary += theme.sep.dot;
}
if (mergeFailedCount > 0) {
summary += theme.fg("warning", `${mergeFailedCount} merge failed`);
if (failCount > 0) summary += theme.sep.dot;
}
if (failCount > 0) {
summary += theme.fg("error", `${failCount} failed`);
}
summary += `${theme.sep.dot}${theme.fg("dim", formatDuration(details.totalDurationMs))}`;
lines.push(summary);
const abortedCount = details.results.filter(r => r.aborted).length;
const mergeFailedCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && r.error).length;
const successCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && !r.error).length;
const failCount = details.results.length - successCount - mergeFailedCount - abortedCount;
let summary = `${theme.fg("dim", "Total:")} `;
if (abortedCount > 0) {
summary += theme.fg("error", `${abortedCount} aborted`);
if (successCount > 0 || mergeFailedCount > 0 || failCount > 0) summary += theme.sep.dot;
}
if (lines.length === 0) {
const text = fallbackText.trim() ? fallbackText : "No results";
const result = [theme.fg("dim", truncateToWidth(text, width))];
cached = { key, lines: result };
return result;
if (successCount > 0) {
summary += theme.fg("success", `${successCount} succeeded`);
if (mergeFailedCount > 0 || failCount > 0) summary += theme.sep.dot;
}
if (fallbackText.trim()) {
const summaryLines = fallbackText.split("\n");
const markerIndex = summaryLines.findIndex(
line =>
line.includes("<system-notification>") ||
line.startsWith("Applied patches:") ||
line.startsWith("No changes to apply."),
);
if (markerIndex >= 0) {
const extra = summaryLines.slice(markerIndex);
for (const line of extra) {
if (!line.trim()) continue;
lines.push(theme.fg("dim", line));
}
}
if (mergeFailedCount > 0) {
summary += theme.fg("warning", `${mergeFailedCount} merge failed`);
if (failCount > 0) summary += theme.sep.dot;
}
if (failCount > 0) {
summary += theme.fg("error", `${failCount} failed`);
}
summary += `${theme.sep.dot}${theme.fg("dim", formatDuration(details.totalDurationMs))}`;
lines.push(summary);
}
const indented = lines.map(line =>
line.length > 0 ? truncateToWidth(` ${line}`, width, Ellipsis.Omit) : "",
const state = isPartial ? "running" : isError ? "error" : mergeFailed ? "warning" : "success";
const borderColor = isError ? "error" : "borderMuted";
if (lines.length === 0) {
const text = fallbackText.trim() ? fallbackText : "No results";
return {
header,
sections: [{ lines: [theme.fg("dim", truncateToWidth(text, width))] }],
state,
borderColor,
width,
};
}
if (fallbackText.trim()) {
const summaryLines = fallbackText.split("\n");
const markerIndex = summaryLines.findIndex(
line =>
line.includes("<system-notification>") ||
line.startsWith("Applied patches:") ||
line.startsWith("No changes to apply."),
);
cached = { key, lines: indented };
return indented;
},
invalidate() {
cached = undefined;
},
};
if (markerIndex >= 0) {
const extra = summaryLines.slice(markerIndex);
for (const line of extra) {
if (!line.trim()) continue;
lines.push(theme.fg("dim", line));
}
}
}
while (lines.length > 0 && lines[0].trim() === "") lines.shift();
return {
header,
sections: lines.length > 0 ? [{ lines }] : [],
state,
borderColor,
width,
};
});
}
function isTaskToolDetails(value: unknown): value is TaskToolDetails {
@@ -1149,13 +1157,23 @@ function isTaskToolDetails(value: unknown): value is TaskToolDetails {
);
}
// Nested subagent snapshots sit one or more levels below the frame border, so
// they keep tree guides to convey depth (the parent prepends its own continue
// prefix). Only the top-level agent list drops guides (the frame is its box).
function nestedMarkers(isLast: boolean, theme: Theme): { prefix: string; continuePrefix: string } {
return {
prefix: isLast ? theme.fg("dim", theme.tree.last) : theme.fg("dim", theme.tree.branch),
continuePrefix: isLast ? " " : `${theme.fg("dim", theme.tree.vertical)} `,
};
}
function renderNestedTaskResults(detailsList: TaskToolDetails[], expanded: boolean, theme: Theme): string[] {
const lines: string[] = [];
for (const details of detailsList) {
if (!details.results || details.results.length === 0) continue;
details.results.forEach((result, index) => {
const isLast = index === details.results.length - 1;
lines.push(...renderAgentResult(result, isLast, expanded, theme));
const { prefix, continuePrefix } = nestedMarkers(index === details.results.length - 1, theme);
lines.push(...renderAgentResult(result, prefix, continuePrefix, expanded, theme));
});
}
return lines;
@@ -1177,16 +1195,16 @@ function renderNestedTaskTree(
const hasResults = Boolean(details.results && details.results.length > 0);
if (hasResults) {
details.results.forEach((result, index) => {
const isLast = index === details.results.length - 1;
lines.push(...renderAgentResult(result, isLast, expanded, theme));
const { prefix, continuePrefix } = nestedMarkers(index === details.results.length - 1, theme);
lines.push(...renderAgentResult(result, prefix, continuePrefix, expanded, theme));
});
continue;
}
const inflight = details.progress;
if (inflight && inflight.length > 0) {
inflight.forEach((prog, index) => {
const isLast = index === inflight.length - 1;
lines.push(...renderAgentProgress(prog, isLast, expanded, theme, spinnerFrame));
const { prefix, continuePrefix } = nestedMarkers(index === inflight.length - 1, theme);
lines.push(...renderAgentProgress(prog, prefix, continuePrefix, expanded, theme, spinnerFrame));
});
}
}
@@ -1207,4 +1225,5 @@ subprocessToolRegistry.register<TaskToolDetails>("task", {
export const taskToolRenderer = {
renderCall,
renderResult,
mergeCallAndResult: true,
};
@@ -84,7 +84,7 @@ describe("task renderer: streaming call preview", () => {
expect(expanded).not.toContain("more agents");
});
it("keeps the isolation flag as the final child after the task list", () => {
it("surfaces the isolation flag in the header bar", () => {
const args: TaskParams = {
agent: "task",
isolated: true,
@@ -94,15 +94,16 @@ describe("task renderer: streaming call preview", () => {
const lines = out.split("\n");
expect(out).toContain("Only");
expect(out).toContain("Isolated");
// Isolation flag is rendered last, after every task entry.
expect(lines.at(-1)).toContain("Isolated");
// Isolation is surfaced as header meta in the frame's top bar (first line),
// not as a trailing child row under the task list.
expect(lines[0]).toContain("isolated");
});
// Once the tool produces a result, `renderResult` draws each agent as a
// progress/result line. The call preview must drop its own per-agent list
// so the non-streaming path doesn't render every task twice.
it("suppresses the per-task preview list once a result snapshot exists", () => {
// Once the tool produces a result, the container suppresses the call entirely
// via `mergeCallAndResult` and `renderResult` draws each agent. As a safety
// net, `renderCall` also drops its duplicate per-task preview when a result
// snapshot is present, so the two never stack.
it("drops the per-task preview list once a result snapshot exists", () => {
const args: TaskParams = {
agent: "reviewer",
tasks: [
@@ -117,9 +118,8 @@ describe("task renderer: streaming call preview", () => {
);
const out = Bun.stripANSI(component.render(160).join("\n"));
// Header stays as a section label, but the duplicated agent rows are gone.
expect(out).toContain("Tasks (2)");
expect(out).not.toContain("Audit the auth module");
expect(out).not.toContain("Audit the db layer");
expect(out).not.toContain("Tasks (");
});
});