Skip to content
Open
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
12 changes: 9 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ import {
type AgentActivity,
type AgentDetails,
AgentWidget,
activityDurationMs,
buildInvocationTags,
describeActivity,
fgPreservingNestedStyles,
formatActivityWithElapsed,
formatDuration,
formatMs,
formatTokens,
Expand All @@ -66,10 +68,12 @@ export function renderRunningAgentStatus(
statsText: string,
activity: string,
theme: Pick<Theme, "fg">,
activityDuration?: number,
): Container {
const container = new Container();
const activityText = activityDuration == null ? activity : formatActivityWithElapsed(activity, activityDuration);
container.addChild(new Text(theme.fg("accent", frame) + (statsText ? " " + statsText : ""), 0, 0));
container.addChild(new Text(theme.fg("dim", ` ⎿ ${activity}`), 0, 0));
container.addChild(new Text(fgPreservingNestedStyles(theme, "dim", ` ⎿ ${activityText}`), 0, 0));
return container;
}

Expand Down Expand Up @@ -993,7 +997,7 @@ Terse command-style prompts produce shallow, generic work.
if (isPartial || details.status === "running") {
const frame = SPINNER[details.spinnerFrame ?? 0];
const s = stats(details);
return renderRunningAgentStatus(frame, s, details.activity ?? "thinking…", theme);
return renderRunningAgentStatus(frame, s, details.activity ?? "thinking…", theme, details.activityDurationMs);
}

// ---- Background agent launched ----
Expand Down Expand Up @@ -1321,6 +1325,7 @@ Terse command-style prompts produce shallow, generic work.
let fgId: string | undefined;

const streamUpdate = () => {
const activity = describeActivity(fgState.activeTools, fgState.responseText);
const details: AgentDetails = {
...detailBase,
toolUses: fgState.toolUses,
Expand All @@ -1329,7 +1334,8 @@ Terse command-style prompts produce shallow, generic work.
maxTurns: fgState.maxTurns,
durationMs: Date.now() - startedAt,
status: "running",
activity: describeActivity(fgState.activeTools, fgState.responseText),
activity,
activityDurationMs: activityDurationMs(fgState, activity),
spinnerFrame: spinnerFrame % SPINNER.length,
};
onUpdate?.({
Expand Down
27 changes: 25 additions & 2 deletions src/ui/agent-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ export interface AgentActivity {
maxTurns?: number;
/** Lifetime usage breakdown — see LifetimeUsage docs. */
lifetimeUsage: LifetimeUsage;
/** Last visible activity/status text used for per-status elapsed rendering. */
currentActivityText?: string;
/** Wall-clock timestamp when the current visible activity/status text began. */
currentActivityStartedAt?: number;
}

/** Metadata attached to Agent tool results for custom rendering. */
Expand All @@ -74,6 +78,8 @@ export interface AgentDetails {
status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error" | "background";
/** Human-readable description of what the agent is currently doing. */
activity?: string;
/** Milliseconds spent in the current visible activity/status text. */
activityDurationMs?: number;
/** Current spinner frame index (for animated running indicator). */
spinnerFrame?: number;
/** Short model name if different from parent (e.g. "haiku", "sonnet"). */
Expand All @@ -91,7 +97,7 @@ export interface AgentDetails {
// ---- Formatting helpers ----

/** Apply foreground styling while restoring it after nested foreground/full ANSI resets. */
export function fgPreservingNestedStyles(theme: Theme, color: string, text: string): string {
export function fgPreservingNestedStyles(theme: Pick<Theme, "fg">, color: string, text: string): string {
const styledEmpty = theme.fg(color, "");
const styleStart = styledEmpty.replace(/\u001b\[(?:0|39)m/g, "");
return theme.fg(color, text.replace(/\u001b\[(?:0|39)m/g, reset => `${reset}${styleStart}`));
Expand Down Expand Up @@ -143,6 +149,20 @@ export function formatMs(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`;
}

/** Track how long the current visible activity/status text has been displayed. */
export function activityDurationMs(activity: AgentActivity, activityText: string, now = Date.now()): number {
if (activity.currentActivityText !== activityText || activity.currentActivityStartedAt == null) {
activity.currentActivityText = activityText;
activity.currentActivityStartedAt = now;
}
return Math.max(0, now - activity.currentActivityStartedAt);
}

/** Append the current-status elapsed time to a visible activity/status line. */
export function formatActivityWithElapsed(activityText: string, elapsedMs: number): string {
return `${activityText} · ${formatMs(elapsedMs)}`;
}

/** Format duration from start/completed timestamps. */
export function formatDuration(startedAt: number, completedAt?: number): string {
if (completedAt) return formatMs(completedAt - startedAt);
Expand Down Expand Up @@ -396,10 +416,13 @@ export class AgentWidget {
const statsText = parts.join(" · ");

const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
const activityText = bg
? formatActivityWithElapsed(activity, activityDurationMs(bg, activity))
: formatActivityWithElapsed(activity, Date.now() - a.startedAt);

runningLines.push([
truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
truncate(theme.fg("dim", "│ ") + fgPreservingNestedStyles(theme, "dim", ` ⎿ ${activityText}`)),
]);
}

Expand Down
4 changes: 2 additions & 2 deletions src/ui/conversation-viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { extractText } from "../context.js";
import type { AgentRecord } from "../types.js";
import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
import type { Theme } from "./agent-widget.js";
import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
import { type AgentActivity, activityDurationMs, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatActivityWithElapsed, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";

/** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
Expand Down Expand Up @@ -354,7 +354,7 @@ export class ConversationViewer implements Component {
if (this.record.status === "running" && this.activity) {
const act = describeActivity(this.activity.activeTools, this.activity.responseText);
lines.push("");
lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
lines.push(truncateToWidth(th.fg("accent", "▍ ") + fgPreservingNestedStyles(th, "dim", formatActivityWithElapsed(act, activityDurationMs(this.activity, act))), width));
}

return lines.map(l => truncateToWidth(l, width));
Expand Down
38 changes: 37 additions & 1 deletion test/agent-widget.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderRunningAgentStatus } from "../src/index.js";
import type { WidgetMode } from "../src/types.js";
import { type AgentActivity, AgentWidget, fgPreservingNestedStyles, formatSessionTokens } from "../src/ui/agent-widget.js";
Expand Down Expand Up @@ -57,13 +57,19 @@ describe("renderRunningAgentStatus", () => {
describe("AgentWidget", () => {
const theme = { fg: (_c: string, s: string) => s, bold: (s: string) => s };

afterEach(() => {
vi.useRealTimers();
});

function makeActivity(): AgentActivity {
return {
activeTools: new Map(),
toolUses: 0,
responseText: "",
turnCount: 1,
lifetimeUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
currentActivityText: "thinking…",
currentActivityStartedAt: Date.now(),
};
}

Expand Down Expand Up @@ -101,6 +107,36 @@ describe("AgentWidget", () => {
.join("\n");
}

it("shows elapsed time on the current visible activity line and resets when it changes", () => {
vi.useFakeTimers();
vi.setSystemTime(10_000);
const activity = makeActivity();
const manager = { listAgents: () => [makeRecord("background", { isBackground: true })] };
const widget = new AgentWidget(
manager as any,
new Map([["background", activity]]),
() => "background",
);
let factory: any;
widget.setUICtx({
setStatus: () => {},
setWidget: (_key, content) => { factory = content; },
});
widget.update();

vi.setSystemTime(23_500);
let lines = factory({ terminal: { columns: 120 }, requestRender: () => {} }, theme).render().join("\n");
expect(lines).toContain("⎿ thinking… · 13.5s");

activity.activeTools.set("tool-1", "bash");
lines = factory({ terminal: { columns: 120 }, requestRender: () => {} }, theme).render().join("\n");
expect(lines).toContain("⎿ running command… · 0.0s");

vi.setSystemTime(26_000);
lines = factory({ terminal: { columns: 120 }, requestRender: () => {} }, theme).render().join("\n");
expect(lines).toContain("⎿ running command… · 2.5s");
});

// "all" (and the no-policy constructor default) shows every agent.
it("shows foreground agents in 'all' mode (and by default)", () => {
const manager = { listAgents: () => [makeRecord("foreground", { isBackground: false })] };
Expand Down
65 changes: 62 additions & 3 deletions test/wait-queued.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,19 +75,78 @@ function deferredRuns() {
return resolvers;
}

async function spawnBackground(tools: Map<string, any>): Promise<{ id: string; queued: boolean }> {
async function spawnBackground(
tools: Map<string, any>,
executionCtx = ctx(),
description = "queued-wait test agent",
): Promise<{ id: string; queued: boolean }> {
const r = await tools.get("Agent").execute(
"tc-spawn",
{ prompt: "go", description: "queued-wait test agent", subagent_type: "general-purpose", run_in_background: true },
{ prompt: "go", description, subagent_type: "general-purpose", run_in_background: true },
undefined,
undefined,
ctx(),
executionCtx,
);
const id = /Agent ID: (\S+)/.exec(textOf(r))![1];
return { id, queued: textOf(r).includes("queued in background") };
}

describe("get_subagent_result wait:true on a queued agent", () => {
it("starts visible activity elapsed time when a queued agent begins running", async () => {
vi.useFakeTimers();
vi.setSystemTime(10_000);

const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
const resolvers = deferredRuns();

try {
let widgetFactory: any;
const ui = {
setStatus: vi.fn(),
setWidget: vi.fn((_key: string, content: any) => {
if (content) widgetFactory = content;
}),
notify: vi.fn(),
onTerminalInput: vi.fn(() => vi.fn()),
};
await lifecycle.get("tool_execution_start")?.({}, { ...ctx(), hasUI: true, ui });
expect(ui.onTerminalInput).toHaveBeenCalledOnce();

const executionCtx = { ...ctx(), hasUI: true, ui };
let queuedId: string | undefined;
let queuedDescription: string | undefined;
for (let i = 0; i < 10 && !queuedId; i++) {
const description = `queued-elapsed-${i}`;
const { id, queued } = await spawnBackground(tools, executionCtx, description);
if (queued) {
queuedId = id;
queuedDescription = description;
}
}
expect(queuedId, "expected to hit the concurrency limit within 10 spawns").toBeDefined();

await vi.advanceTimersByTimeAsync(15_000);
resolvers.shift()!();
await vi.advanceTimersByTimeAsync(0);

expect(widgetFactory, JSON.stringify({
status: ui.setStatus.mock.calls,
widget: ui.setWidget.mock.calls.map((call: any[]) => [call[0], typeof call[1]]),
})).toBeTypeOf("function");
const theme = { fg: (_color: string, text: string) => text, bold: (text: string) => text };
const lines = widgetFactory({ terminal: { columns: 120 }, requestRender: () => {} }, theme).render();
const queuedAgentHeader = lines.findIndex((line: string) => line.includes(queuedDescription));
expect(queuedAgentHeader).toBeGreaterThanOrEqual(0);
expect(lines[queuedAgentHeader + 1]).toContain("thinking… · 0.0s");
} finally {
while (resolvers.length > 0) resolvers.shift()!();
await vi.advanceTimersByTimeAsync(0);
await lifecycle.get("session_shutdown")?.();
vi.useRealTimers();
}
});

it("waits through queue start and returns the result (no 'still running')", async () => {
const { pi, tools, lifecycle } = makePi();
subagentsExtension(pi);
Expand Down
Loading