Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit ad0c57d

Browse files
committed
Merge PR tintinweb#139: agent status elapsed time
1 parent def927c commit ad0c57d

5 files changed

Lines changed: 137 additions & 12 deletions

File tree

src/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,11 @@ import {
3737
type AgentActivity,
3838
type AgentDetails,
3939
AgentWidget,
40+
activityDurationMs,
4041
buildInvocationTags,
4142
describeActivity,
4243
fgPreservingNestedStyles,
44+
formatActivityWithElapsed,
4345
formatDuration,
4446
formatMs,
4547
formatTokens,
@@ -75,10 +77,12 @@ export function renderRunningAgentStatus(
7577
statsText: string,
7678
activity: string,
7779
theme: Pick<Theme, "fg">,
80+
activityDuration?: number,
7881
): Container {
7982
const container = new Container();
83+
const activityText = activityDuration == null ? activity : formatActivityWithElapsed(activity, activityDuration);
8084
container.addChild(new Text(theme.fg("accent", frame) + (statsText ? " " + statsText : ""), 0, 0));
81-
container.addChild(new Text(theme.fg("dim", ` ⎿ ${activity}`), 0, 0));
85+
container.addChild(new Text(fgPreservingNestedStyles(theme, "dim", ` ⎿ ${activityText}`), 0, 0));
8286
return container;
8387
}
8488

@@ -994,7 +998,7 @@ Terse command-style prompts produce shallow, generic work.
994998
if (isPartial || details.status === "running") {
995999
const frame = SPINNER[details.spinnerFrame ?? 0];
9961000
const s = stats(details);
997-
return renderRunningAgentStatus(frame, s, details.activity ?? "thinking…", theme);
1001+
return renderRunningAgentStatus(frame, s, details.activity ?? "thinking…", theme, details.activityDurationMs);
9981002
}
9991003

10001004
// ---- Background agent launched ----
@@ -1298,6 +1302,7 @@ Terse command-style prompts produce shallow, generic work.
12981302
let fgId: string | undefined;
12991303

13001304
const streamUpdate = () => {
1305+
const activity = describeActivity(fgState.activeTools, fgState.responseText);
13011306
const details: AgentDetails = {
13021307
...detailBase,
13031308
toolUses: fgState.toolUses,
@@ -1306,7 +1311,8 @@ Terse command-style prompts produce shallow, generic work.
13061311
maxTurns: fgState.maxTurns,
13071312
durationMs: Date.now() - startedAt,
13081313
status: "running",
1309-
activity: describeActivity(fgState.activeTools, fgState.responseText),
1314+
activity,
1315+
activityDurationMs: activityDurationMs(fgState, activity),
13101316
spinnerFrame: spinnerFrame % SPINNER.length,
13111317
};
13121318
onUpdate?.({

src/ui/agent-widget.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ export interface AgentActivity {
6262
maxTurns?: number;
6363
/** Lifetime usage breakdown — see LifetimeUsage docs. */
6464
lifetimeUsage: LifetimeUsage;
65+
/** Last visible activity/status text used for per-status elapsed rendering. */
66+
currentActivityText?: string;
67+
/** Wall-clock timestamp when the current visible activity/status text began. */
68+
currentActivityStartedAt?: number;
6569
}
6670

6771
/** Metadata attached to Agent tool results for custom rendering. */
@@ -75,6 +79,8 @@ export interface AgentDetails {
7579
status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error" | "background";
7680
/** Human-readable description of what the agent is currently doing. */
7781
activity?: string;
82+
/** Milliseconds spent in the current visible activity/status text. */
83+
activityDurationMs?: number;
7884
/** Current spinner frame index (for animated running indicator). */
7985
spinnerFrame?: number;
8086
/** Effective model display name used by this run. */
@@ -92,7 +98,7 @@ export interface AgentDetails {
9298
// ---- Formatting helpers ----
9399

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

153+
/** Track how long the current visible activity/status text has been displayed. */
154+
export function activityDurationMs(activity: AgentActivity, activityText: string, now = Date.now()): number {
155+
if (activity.currentActivityText !== activityText || activity.currentActivityStartedAt == null) {
156+
activity.currentActivityText = activityText;
157+
activity.currentActivityStartedAt = now;
158+
}
159+
return Math.max(0, now - activity.currentActivityStartedAt);
160+
}
161+
162+
/** Append the current-status elapsed time to a visible activity/status line. */
163+
export function formatActivityWithElapsed(activityText: string, elapsedMs: number): string {
164+
return `${activityText} · ${formatMs(elapsedMs)}`;
165+
}
166+
147167
/** Format duration from start/completed timestamps. */
148168
export function formatDuration(startedAt: number, completedAt?: number): string {
149169
if (completedAt) return formatMs(completedAt - startedAt);
@@ -404,10 +424,13 @@ export class AgentWidget {
404424
const statsText = parts.join(" · ");
405425

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

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

src/ui/conversation-viewer.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { extractText } from "../context.js";
1111
import type { AgentRecord } from "../types.js";
1212
import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
1313
import type { Theme } from "./agent-widget.js";
14-
import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
14+
import { type AgentActivity, activityDurationMs, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatActivityWithElapsed, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
1515
import { prepareConversationDisplay } from "./prepare-conversation-display.js";
1616
import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
1717

@@ -376,8 +376,9 @@ export class ConversationViewer implements Component {
376376
// Streaming indicator for running agents
377377
if (this.record.status === "running" && this.activity) {
378378
lines.push("");
379-
appendPrepared(describeActivity(this.activity.activeTools, this.activity.responseText), {
380-
frame: (line) => th.fg("accent", "▍ ") + th.fg("dim", line),
379+
const act = describeActivity(this.activity.activeTools, this.activity.responseText);
380+
appendPrepared(formatActivityWithElapsed(act, activityDurationMs(this.activity, act)), {
381+
frame: (line) => th.fg("accent", "▍ ") + fgPreservingNestedStyles(th, "dim", line),
381382
});
382383
}
383384

test/agent-widget.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it } from "vitest";
1+
import { afterEach, describe, expect, it, vi } from "vitest";
22
import { prepareInvocationTagLines, renderRunningAgentStatus } from "../src/index.js";
33
import type { WidgetMode } from "../src/types.js";
44
import {
@@ -120,13 +120,19 @@ describe("renderRunningAgentStatus", () => {
120120
describe("AgentWidget", () => {
121121
const theme = { fg: (_c: string, s: string) => s, bold: (s: string) => s };
122122

123+
afterEach(() => {
124+
vi.useRealTimers();
125+
});
126+
123127
function makeActivity(): AgentActivity {
124128
return {
125129
activeTools: new Map(),
126130
toolUses: 0,
127131
responseText: "",
128132
turnCount: 1,
129133
lifetimeUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
134+
currentActivityText: "thinking…",
135+
currentActivityStartedAt: Date.now(),
130136
};
131137
}
132138

@@ -164,6 +170,36 @@ describe("AgentWidget", () => {
164170
.join("\n");
165171
}
166172

173+
it("shows elapsed time on the current visible activity line and resets when it changes", () => {
174+
vi.useFakeTimers();
175+
vi.setSystemTime(10_000);
176+
const activity = makeActivity();
177+
const manager = { listAgents: () => [makeRecord("background", { isBackground: true })] };
178+
const widget = new AgentWidget(
179+
manager as any,
180+
new Map([["background", activity]]),
181+
() => "background",
182+
);
183+
let factory: any;
184+
widget.setUICtx({
185+
setStatus: () => {},
186+
setWidget: (_key, content) => { factory = content; },
187+
});
188+
widget.update();
189+
190+
vi.setSystemTime(23_500);
191+
let lines = factory({ terminal: { columns: 120 }, requestRender: () => {} }, theme).render().join("\n");
192+
expect(lines).toContain("⎿ thinking… · 13.5s");
193+
194+
activity.activeTools.set("tool-1", "bash");
195+
lines = factory({ terminal: { columns: 120 }, requestRender: () => {} }, theme).render().join("\n");
196+
expect(lines).toContain("⎿ running command… · 0.0s");
197+
198+
vi.setSystemTime(26_000);
199+
lines = factory({ terminal: { columns: 120 }, requestRender: () => {} }, theme).render().join("\n");
200+
expect(lines).toContain("⎿ running command… · 2.5s");
201+
});
202+
167203
// "all" (and the no-policy constructor default) shows every agent.
168204
it("shows foreground agents in 'all' mode (and by default)", () => {
169205
const manager = { listAgents: () => [makeRecord("foreground", { isBackground: false })] };

test/wait-queued.test.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,19 +75,78 @@ function deferredRuns() {
7575
return resolvers;
7676
}
7777

78-
async function spawnBackground(tools: Map<string, any>): Promise<{ id: string; queued: boolean }> {
78+
async function spawnBackground(
79+
tools: Map<string, any>,
80+
executionCtx = ctx(),
81+
description = "queued-wait test agent",
82+
): Promise<{ id: string; queued: boolean }> {
7983
const r = await tools.get("Agent").execute(
8084
"tc-spawn",
81-
{ prompt: "go", description: "queued-wait test agent", subagent_type: "general-purpose", run_in_background: true },
85+
{ prompt: "go", description, subagent_type: "general-purpose", run_in_background: true },
8286
undefined,
8387
undefined,
84-
ctx(),
88+
executionCtx,
8589
);
8690
const id = /Agent ID: (\S+)/.exec(textOf(r))![1];
8791
return { id, queued: textOf(r).includes("queued in background") };
8892
}
8993

9094
describe("get_subagent_result wait:true on a queued agent", () => {
95+
it("starts visible activity elapsed time when a queued agent begins running", async () => {
96+
vi.useFakeTimers();
97+
vi.setSystemTime(10_000);
98+
99+
const { pi, tools, lifecycle } = makePi();
100+
subagentsExtension(pi);
101+
const resolvers = deferredRuns();
102+
103+
try {
104+
let widgetFactory: any;
105+
const ui = {
106+
setStatus: vi.fn(),
107+
setWidget: vi.fn((_key: string, content: any) => {
108+
if (content) widgetFactory = content;
109+
}),
110+
notify: vi.fn(),
111+
onTerminalInput: vi.fn(() => vi.fn()),
112+
};
113+
await lifecycle.get("tool_execution_start")?.({}, { ...ctx(), hasUI: true, ui });
114+
expect(ui.onTerminalInput).toHaveBeenCalledOnce();
115+
116+
const executionCtx = { ...ctx(), hasUI: true, ui };
117+
let queuedId: string | undefined;
118+
let queuedDescription: string | undefined;
119+
for (let i = 0; i < 10 && !queuedId; i++) {
120+
const description = `queued-elapsed-${i}`;
121+
const { id, queued } = await spawnBackground(tools, executionCtx, description);
122+
if (queued) {
123+
queuedId = id;
124+
queuedDescription = description;
125+
}
126+
}
127+
expect(queuedId, "expected to hit the concurrency limit within 10 spawns").toBeDefined();
128+
129+
await vi.advanceTimersByTimeAsync(15_000);
130+
resolvers.shift()!();
131+
await vi.advanceTimersByTimeAsync(0);
132+
133+
expect(widgetFactory, JSON.stringify({
134+
status: ui.setStatus.mock.calls,
135+
widget: ui.setWidget.mock.calls.map((call: any[]) => [call[0], typeof call[1]]),
136+
})).toBeTypeOf("function");
137+
const theme = { fg: (_color: string, text: string) => text, bold: (text: string) => text };
138+
const lines = widgetFactory({ terminal: { columns: 120 }, requestRender: () => {} }, theme).render();
139+
const queuedAgentHeader = lines.findIndex((line: string) => line.includes(queuedDescription));
140+
expect(queuedAgentHeader).toBeGreaterThanOrEqual(0);
141+
expect(lines[queuedAgentHeader + 1]).toContain("thinking… · 0.0s");
142+
} finally {
143+
while (resolvers.length > 0) resolvers.shift()!();
144+
await vi.advanceTimersByTimeAsync(0);
145+
await lifecycle.get("session_shutdown")?.();
146+
vi.useRealTimers();
147+
}
148+
});
149+
91150
it("waits through queue start and returns the result (no 'still running')", async () => {
92151
const { pi, tools, lifecycle } = makePi();
93152
subagentsExtension(pi);

0 commit comments

Comments
 (0)