From 245147f5cb5d1298bafb63555a112f1b1648471c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 4 Aug 2026 19:46:31 -0700 Subject: [PATCH 1/5] Replace session wall clock with completed sub-agent durations Status bar no longer shows whole-session elapsed time. It sums finished sub-agent wall times instead, and the Agents strip shows per-agent duration. Closes CL-5346 --- src/tui/app.tsx | 9 ++--- src/tui/components/agents-strip.tsx | 21 +++++++++-- src/tui/components/status-bar.tsx | 49 +++++++++++++++++++------- tests/unit/tui/status-bar.test.tsx | 54 ++++++++++++++++++++++------- 4 files changed, 101 insertions(+), 32 deletions(-) diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 9e14aae27..c1aed526c 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -7,7 +7,7 @@ import { useState, useMemo, useEffect, useRef, type ReactNode } from "react"; import { useAgentStream } from "./use-stream.js"; import { Header } from "./components/header.js"; import { EventLog, TEXT_GUTTER, resolveViewportExpandIds } from "./components/event-log.js"; -import { StatusBar } from "./components/status-bar.js"; +import { StatusBar, formatCompletedAgentsLabel } from "./components/status-bar.js"; import { useGitBranch } from "./git-branch.js"; import { formatStatusBarSegments } from "../cost/cost-summary.js"; import { OnboardingAnimation } from "./components/onboarding-animation.js"; @@ -42,7 +42,6 @@ import type { SubAgentProvider, SubAgentSessionStore } from "../subagent/index.j import { useSpinner } from "./hooks/use-spinner.js"; import { chromeDividerLine } from "./chrome-zones.js"; import { useQuotaRetry } from "./hooks/use-quota-retry.js"; -import { useSessionClock } from "./hooks/use-session-clock.js"; import { useRevolvingVerb } from "./hooks/use-revolving-verb.js"; import { color } from "./theme.js"; import { useTerminalSize } from "./hooks/use-terminal-size.js"; @@ -758,7 +757,6 @@ export function App({ streamingType: state.streamingType, }); - const sessionElapsedMs = useSessionClock(sessionStartedAt); // Persistent status bar segment: refreshes on an interval, never // blocks render on the git process. const gitBranch = useGitBranch(cwd); @@ -1334,7 +1332,10 @@ export function App({ )} { + const agentsLabel = formatCompletedAgentsLabel(subAgentSessions?.list() ?? []); + return agentsLabel !== undefined ? { completedAgentsLabel: agentsLabel } : {}; + })()} mcpCount={mcpStatus.connected.length} model={model} cwd={cwd} diff --git a/src/tui/components/agents-strip.tsx b/src/tui/components/agents-strip.tsx index 39b70b059..b5a92ba1e 100644 --- a/src/tui/components/agents-strip.tsx +++ b/src/tui/components/agents-strip.tsx @@ -314,6 +314,13 @@ function currentToolArguments(session: SubAgentSession): string { } export function formatSessionLabel(session: SubAgentSession): string { + const duration = + session.finishedAt !== undefined && session.finishedAt >= session.startedAt + ? formatStripDuration(session.finishedAt - session.startedAt) + : session.status === "running" + ? formatStripDuration(Date.now() - session.startedAt) + : undefined; + const durationSuffix = duration !== undefined ? ` · ${duration}` : ""; if (session.status === "running" && session.currentToolName !== null) { const args = currentToolArguments(session); const { summary, isShell } = describeToolCall(session.currentToolName, args); @@ -325,13 +332,23 @@ export function formatSessionLabel(session: SubAgentSession): string { ? `${session.currentToolName} ${summary}` : session.currentToolName; const tool = ` — ${preview}`; - return `${session.agentId}: ${session.description}${tool}`; + return `${session.agentId}: ${session.description}${tool}${durationSuffix}`; } const tool = session.toolNames.length > 0 && session.status !== "running" ? ` · ${session.toolNames.length} tool${session.toolNames.length === 1 ? "" : "s"}` : ""; - return `${session.agentId}: ${session.description}${tool}`; + return `${session.agentId}: ${session.description}${tool}${durationSuffix}`; +} + +function formatStripDuration(ms: number): string { + const totalSec = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(totalSec / 3600); + const minutes = Math.floor((totalSec % 3600) / 60); + const seconds = totalSec % 60; + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; } function summaryCounts(sessions: readonly SubAgentSession[]): string { diff --git a/src/tui/components/status-bar.tsx b/src/tui/components/status-bar.tsx index 09690a819..ecd3aa445 100644 --- a/src/tui/components/status-bar.tsx +++ b/src/tui/components/status-bar.tsx @@ -9,8 +9,9 @@ import { color, type SemanticRole } from "../theme.js"; import { PRODUCT_NAME } from "../../branding.js"; export type StatusBarProps = { - // Whole-session elapsed time, always counting (not per-turn). - sessionElapsedMs: number; + // Label for completed sub-agent timing (e.g. "agents 2m 14s"). Replaces the + // whole-session wall clock which was noise for long sessions. + completedAgentsLabel?: string; mcpCount: number; // Pre-formatted by src/cost/cost-summary.ts; omitted entirely when cost // should stay hidden (free model, coding plan) rather than shown as $0. @@ -76,7 +77,7 @@ const MIN_TRUNCATED_CWD = 5; export type StatusBarLayoutArgs = { columns: number; - timerText: string; + agentsText?: string; mcpText?: string; model?: string; // Already home-abbreviated. @@ -108,7 +109,7 @@ function joinModelCwdBranch(model?: string, cwd?: string, gitBranch?: string): s } // Decides which low-priority segments fit in the terminal width. Priority -// (highest to lowest, dropped first when narrow): brand+timer > MCP > +// (highest to lowest, dropped first when narrow): brand+agents > MCP > // model/cwd/branch > cost > context. Only the cwd part is truncated — model // and branch names stay intact; if the cwd cannot absorb the overflow the // whole model/cwd/branch segment is dropped. @@ -121,7 +122,7 @@ export function planStatusBarLayout(args: StatusBarLayoutArgs): StatusBarLayout const overflow = () => usedColumns([ BRAND, - args.timerText, + args.agentsText, segment, showCost ? args.costLabel : undefined, showContext ? args.contextLabel : undefined, @@ -160,16 +161,17 @@ export function formatElapsed(elapsedMs: number): string { return `${hours}h ${minutes}m ${seconds}s`; } -// Slim footer: brand anchors the bottom-left with the session timer beside it; -// MCP health sits on the right. The per-turn timer lives on the in-flight -// indicator above the prompt box, not here. +// Slim footer: brand anchors the bottom-left; completed sub-agent timing sits +// beside it when any worker has finished this session. MCP health sits on the +// right. The per-turn timer lives on the in-flight indicator above the prompt +// box, not here. // // Segment priority (highest to lowest, dropped first when the terminal is -// narrow): brand+timer > model/cwd/branch > cost/context. cwd is truncated +// narrow): brand+agents > model/cwd/branch > cost/context. cwd is truncated // with a middle ellipsis before the model/cwd/branch segment is dropped // entirely. export function StatusBar({ - sessionElapsedMs, + completedAgentsLabel, mcpCount, costLabel, contextLabel, @@ -179,11 +181,14 @@ export function StatusBar({ gitBranch, columns, }: StatusBarProps): ReactNode { - const timerText = formatElapsed(sessionElapsedMs); + const agentsText = + completedAgentsLabel !== undefined && completedAgentsLabel.length > 0 + ? completedAgentsLabel + : undefined; const mcpText = mcpCount > 0 ? `MCP ✓ ${mcpCount}` : undefined; const { modelCwdBranchText, showCost, showContext } = planStatusBarLayout({ columns: columns ?? 120, - timerText, + ...(agentsText !== undefined ? { agentsText } : {}), ...(mcpText !== undefined ? { mcpText } : {}), ...(model !== undefined ? { model } : {}), ...(cwd !== undefined ? { cwd: abbreviateHome(cwd) } : {}), @@ -199,7 +204,9 @@ export function StatusBar({ return ( {BRAND} - {timerText} + {agentsText !== undefined && ( + {agentsText} + )} {modelCwdBranchText !== undefined && ( {modelCwdBranchText} )} @@ -216,3 +223,19 @@ export function StatusBar({ ); } + +/** Sum finished sub-agent wall times into a compact status-bar label. */ +export function formatCompletedAgentsLabel( + sessions: ReadonlyArray<{ status: string; startedAt: number; finishedAt?: number }>, +): string | undefined { + let totalMs = 0; + let count = 0; + for (const s of sessions) { + if (s.status !== "done" && s.status !== "failed" && s.status !== "cancelled") continue; + if (s.finishedAt === undefined || s.finishedAt < s.startedAt) continue; + totalMs += s.finishedAt - s.startedAt; + count += 1; + } + if (count === 0) return undefined; + return `agents ${formatElapsed(totalMs)}`; +} diff --git a/tests/unit/tui/status-bar.test.tsx b/tests/unit/tui/status-bar.test.tsx index 1171200b9..61c25ae74 100644 --- a/tests/unit/tui/status-bar.test.tsx +++ b/tests/unit/tui/status-bar.test.tsx @@ -14,7 +14,7 @@ import { function renderBar( props: { - sessionElapsedMs?: number; + completedAgentsLabel?: string; mcpCount?: number; costLabel?: string; contextLabel?: string; @@ -27,8 +27,8 @@ function renderBar( ) { return render( { expect(frame.trimStart().startsWith("Corbits Code")).toBe(true); }); -test("StatusBar shows the session elapsed time beside the brand", () => { - const { lastFrame } = renderBar({ sessionElapsedMs: 65_000 }); - expect(lastFrame()).toContain("1m 5s"); +test("StatusBar shows completed sub-agent durations beside the brand", () => { + const { lastFrame } = renderBar({ completedAgentsLabel: "agents 1m 5s" }); + expect(lastFrame()).toContain("agents 1m 5s"); +}); + +test("StatusBar hides agent timing when none have completed", () => { + const { lastFrame } = renderBar(); + expect(lastFrame()).not.toContain("agents "); }); test("StatusBar shows MCP health on the right when servers are connected", () => { @@ -152,7 +157,7 @@ test("truncateMiddle keeps the head and tail around an ellipsis", () => { test("planStatusBarLayout keeps everything when it fits", () => { const layout = planStatusBarLayout({ columns: 120, - timerText: "5s", + agentsText: "agents 5s", mcpText: "MCP ✓ 2", model: "gpt-5", cwd: "~/repo", @@ -168,19 +173,19 @@ test("planStatusBarLayout keeps everything when it fits", () => { }); test("planStatusBarLayout counts padding and per-child gaps in the budget", () => { - // brand(12) + timer(2) = 14 text columns; children are brand, timer, and the + // brand(12) + agents(2) = 14 text columns; children are brand, agents, and the // flex spacer, so 2 gaps; plus 2 padding columns = 18 total. - const fits = planStatusBarLayout({ columns: 18, timerText: "5s", contextLabel: "Ctx" }); - const overflows = planStatusBarLayout({ columns: 17, timerText: "5s", contextLabel: "Ctx" }); + const fits = planStatusBarLayout({ columns: 18, agentsText: "5s", contextLabel: "Ctx" }); + const overflows = planStatusBarLayout({ columns: 17, agentsText: "5s", contextLabel: "Ctx" }); expect(fits.showContext).toBe(false); // "Ctx"(3) + its gap(1) pushes the fitting width to 22. - expect(planStatusBarLayout({ columns: 22, timerText: "5s", contextLabel: "Ctx" }).showContext).toBe(true); + expect(planStatusBarLayout({ columns: 22, agentsText: "5s", contextLabel: "Ctx" }).showContext).toBe(true); expect(overflows.showContext).toBe(false); }); test("planStatusBarLayout drops context before cost before the model segment", () => { const base = { - timerText: "5s", + agentsText: "5s", model: "gpt-5", cwd: "~/some/project/path", gitBranch: "main", @@ -202,7 +207,7 @@ test("planStatusBarLayout drops context before cost before the model segment", ( test("planStatusBarLayout truncates only the cwd, never model or branch", () => { const layout = planStatusBarLayout({ columns: 60, - timerText: "5s", + agentsText: "5s", model: "gpt-5", cwd: "~/a/very/deeply/nested/project/directory", gitBranch: "feature-branch", @@ -216,7 +221,30 @@ test("planStatusBarLayout truncates only the cwd, never model or branch", () => test("planStatusBarLayout drops the model segment when cwd cannot absorb the overflow", () => { const layout = planStatusBarLayout({ columns: 20, - timerText: "5s", + agentsText: "5s", + model: "gpt-5-with-long-name", + cwd: "/x", + gitBranch: "main", + }); + expect(layout.modelCwdBranchText).toBeUndefined(); +}); + +test("formatCompletedAgentsLabel sums finished sub-agent durations", async () => { + const { formatCompletedAgentsLabel } = await import("../../../src/tui/components/status-bar.js"); + expect( + formatCompletedAgentsLabel([ + { status: "done", startedAt: 0, finishedAt: 65_000 }, + { status: "running", startedAt: 0 }, + { status: "failed", startedAt: 0, finishedAt: 5_000 }, + ]), + ).toBe("agents 1m 10s"); + expect(formatCompletedAgentsLabel([{ status: "running", startedAt: 0 }])).toBeUndefined(); +}); + +test("planStatusBarLayout drops the model segment when cwd cannot absorb the overflow", () => { + const layout = planStatusBarLayout({ + columns: 20, + agentsText: "5s", model: "gpt-5-with-long-name", cwd: "~/repo", gitBranch: "main", From 929d2f4cc1adc2ee60ab5a032a0b791811e5adca Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 4 Aug 2026 20:11:41 -0700 Subject: [PATCH 2/5] Show finished sub-agent durations only and fix related tests Drop live Date.now() duration from running strip labels so formatSessionLabel stays deterministic. Update chrome-zone StatusBar props and agents-strip expectations for finishedAt durations. --- src/tui/components/agents-strip.tsx | 6 +++--- tests/unit/tui/agents-strip.test.ts | 12 +++++++++++- tests/unit/tui/chrome-zone-budgets.test.tsx | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/tui/components/agents-strip.tsx b/src/tui/components/agents-strip.tsx index b5a92ba1e..f6b9cdeda 100644 --- a/src/tui/components/agents-strip.tsx +++ b/src/tui/components/agents-strip.tsx @@ -314,12 +314,12 @@ function currentToolArguments(session: SubAgentSession): string { } export function formatSessionLabel(session: SubAgentSession): string { + // Only finished workers get a duration suffix — matches status-bar policy + // (completed sub-agent times, not a live tick for in-flight sessions). const duration = session.finishedAt !== undefined && session.finishedAt >= session.startedAt ? formatStripDuration(session.finishedAt - session.startedAt) - : session.status === "running" - ? formatStripDuration(Date.now() - session.startedAt) - : undefined; + : undefined; const durationSuffix = duration !== undefined ? ` · ${duration}` : ""; if (session.status === "running" && session.currentToolName !== null) { const args = currentToolArguments(session); diff --git a/tests/unit/tui/agents-strip.test.ts b/tests/unit/tui/agents-strip.test.ts index 86bae7aa5..60523d102 100644 --- a/tests/unit/tui/agents-strip.test.ts +++ b/tests/unit/tui/agents-strip.test.ts @@ -258,8 +258,9 @@ describe("formatSessionLabel", () => { status: "done", currentToolName: null, toolNames: ["grep", "read_file"], + finishedAt: 5_000, }); - expect(formatSessionLabel(session)).toBe("researcher: researching things · 2 tools"); + expect(formatSessionLabel(session)).toBe("researcher: researching things · 2 tools · 5s"); }); test("shell tool preview leads with the command, not a redundant tool name", () => { @@ -269,6 +270,15 @@ describe("formatSessionLabel", () => { const session = baseSession({ currentToolName: "run_shell", entries }); expect(formatSessionLabel(session)).toBe("researcher: researching things — bun test"); }); + + test("appends finished duration when finishedAt is set", () => { + const session = baseSession({ + status: "done", + currentToolName: null, + finishedAt: 65_000, + }); + expect(formatSessionLabel(session)).toBe("researcher: researching things · 1m 5s"); + }); }); describe("agentsStripRowColor", () => { diff --git a/tests/unit/tui/chrome-zone-budgets.test.tsx b/tests/unit/tui/chrome-zone-budgets.test.tsx index 7117a651a..ffddebba4 100644 --- a/tests/unit/tui/chrome-zone-budgets.test.tsx +++ b/tests/unit/tui/chrome-zone-budgets.test.tsx @@ -35,7 +35,7 @@ test("status budget matches the rows StatusBar paints inside App's marginTop wra // App wraps StatusBar in ; mirror that wrapper here. const { lastFrame } = render( - + , ); expect(frameRows(lastFrame())).toBe(CHROME_ZONE_ROWS.status); From bb86e83c2987163dea97bf0e02474e04b0b6371f Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 4 Aug 2026 20:33:15 -0700 Subject: [PATCH 3/5] Remove dead session wall-clock plumbing The status bar no longer shows a session timer, so drop useSessionClock, its tests, and the unread sessionStartedAt prop/state chain. --- src/tui/app.tsx | 7 ----- src/tui/hooks/use-message-pipeline.ts | 3 -- src/tui/hooks/use-session-clock.ts | 20 ------------- src/tui/runner.tsx | 1 - tests/unit/tui/use-session-clock.test.tsx | 34 ----------------------- 5 files changed, 65 deletions(-) delete mode 100644 src/tui/hooks/use-session-clock.ts delete mode 100644 tests/unit/tui/use-session-clock.test.tsx diff --git a/src/tui/app.tsx b/src/tui/app.tsx index c1aed526c..0915b4780 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -178,9 +178,6 @@ export type AppProps = { // Emits "scrollUp"/"scrollDown" for mouse-wheel events, which are stripped // from stdin before they reach useInput (see createFilteredStdin). mouseEvents?: EventEmitter; - // Wall-clock ms timestamp the session started. Drives the whole-session timer - // in the status bar; reset on /new. - sessionStartedAt?: number; // Inspectable child sessions for the Agents strip and enter-session UI. subAgentSessions?: SubAgentSessionStore; /** Goal mode operator surface. */ @@ -266,7 +263,6 @@ export function App({ globallyOnboarded = false, globalOnboardingPath, mouseEvents, - sessionStartedAt: sessionStartedAtProp, subAgentSessions, goalApi, telemetryNotice, @@ -651,8 +647,6 @@ export function App({ const copyTargetList = copyModeOpen ? copyTargetsRef.current : []; const [, forceRender] = useState(0); - // Whole-session timer for the status bar. Held in state so /new can zero it. - const [sessionStartedAt, setSessionStartedAt] = useState(sessionStartedAtProp ?? Date.now()); const { sendMessage, @@ -693,7 +687,6 @@ export function App({ promptXaiRelogin, setExpandedTools, setInputValue, - setSessionStartedAt, setEnteredSessionId, setAgentsNavOpen, setAgentsNavIndex, diff --git a/src/tui/hooks/use-message-pipeline.ts b/src/tui/hooks/use-message-pipeline.ts index 8461e648a..70395bcd8 100644 --- a/src/tui/hooks/use-message-pipeline.ts +++ b/src/tui/hooks/use-message-pipeline.ts @@ -44,7 +44,6 @@ export type UseMessagePipelineArgs = { promptXaiRelogin: (name: string) => void; setExpandedTools: Dispatch>>; setInputValue: Dispatch>; - setSessionStartedAt: Dispatch>; setEnteredSessionId: Dispatch>; setAgentsNavOpen: Dispatch>; setAgentsNavIndex: Dispatch>; @@ -98,7 +97,6 @@ export function useMessagePipeline({ promptXaiRelogin, setExpandedTools, setInputValue, - setSessionStartedAt, setEnteredSessionId, setAgentsNavOpen, setAgentsNavIndex, @@ -226,7 +224,6 @@ export function useMessagePipeline({ lastSentMessageRef.current = ""; quotaAutoRetryFiredRef.current = true; setInputValue(""); - setSessionStartedAt(Date.now()); setEnteredSessionId(null); setAgentsNavOpen(false); setAgentsNavIndex(0); diff --git a/src/tui/hooks/use-session-clock.ts b/src/tui/hooks/use-session-clock.ts deleted file mode 100644 index bfd74bf39..000000000 --- a/src/tui/hooks/use-session-clock.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useEffect, useState } from "react"; - -// 1s resolution is enough for a session timer; faster would burn renders for no -// visible change. -const TICK_MS = 1000; - -// Monotonically increasing elapsed since the session start. Unlike the per-turn -// spinner clock, this never resets between turns — it tracks the whole session. -// `startedAt` is held in App state so a `/new` session resets the clock. -export function useSessionClock(startedAt: number): number { - const [elapsedMs, setElapsedMs] = useState(() => Date.now() - startedAt); - - useEffect(() => { - setElapsedMs(Date.now() - startedAt); - const id = setInterval(() => setElapsedMs(Date.now() - startedAt), TICK_MS); - return () => clearInterval(id); - }, [startedAt]); - - return elapsedMs; -} diff --git a/src/tui/runner.tsx b/src/tui/runner.tsx index da13bf0ba..face8712d 100644 --- a/src/tui/runner.tsx +++ b/src/tui/runner.tsx @@ -1466,7 +1466,6 @@ export async function runTUI(initialConfig: Config): Promise { activePlugins={activePlugins} initialWorkflowStatus={workflowController.status()} mouseEvents={mouseEvents} - sessionStartedAt={startedAt} subAgentSessions={subAgentSessions} goalApi={{ get: () => goalGovernor.get(), diff --git a/tests/unit/tui/use-session-clock.test.tsx b/tests/unit/tui/use-session-clock.test.tsx deleted file mode 100644 index b5e357a8d..000000000 --- a/tests/unit/tui/use-session-clock.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { test, expect } from "bun:test"; -import { render } from "ink-testing-library"; -import { Text } from "ink"; -import { act } from "react"; -import { useSessionClock } from "../../../src/tui/hooks/use-session-clock.js"; - -function Clock({ startedAt }: { startedAt: number }) { - const elapsed = useSessionClock(startedAt); - return {elapsed}; -} - -test("useSessionClock reports elapsed since the start timestamp", () => { - const startedAt = Date.now() - 5000; - const { lastFrame } = render(); - const elapsed = Number(lastFrame()); - expect(elapsed).toBeGreaterThanOrEqual(5000); -}); - -test("useSessionClock resets when startedAt changes", async () => { - const oldStart = Date.now() - 10_000; - const { lastFrame, rerender } = render(); - expect(Number(lastFrame())).toBeGreaterThanOrEqual(10_000); - - const newStart = Date.now() - 500; - // The reset is applied inside an effect; wrap the rerender in act() so the - // effect flushes before we read the frame. - await act(async () => { - rerender(); - }); - // After the reset the clock reads near-zero, not the prior 10s. - const afterReset = Number(lastFrame()); - expect(afterReset).toBeGreaterThanOrEqual(500); - expect(afterReset).toBeLessThan(5000); -}); From 6334d02c6a8f9561cd7c015db767f6432c245eef Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 4 Aug 2026 21:15:58 -0700 Subject: [PATCH 4/5] Share formatElapsed and drop status-bar IIFE Reuse the in-flight indicator duration formatter for the agents strip and completed-agents label so three near-copies do not drift. Compute completedAgentsLabel once above the return instead of an IIFE spread. --- src/tui/app.tsx | 7 +++---- src/tui/components/agents-strip.tsx | 13 ++----------- src/tui/components/status-bar.tsx | 14 ++------------ 3 files changed, 7 insertions(+), 27 deletions(-) diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 0915b4780..693081150 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -578,6 +578,8 @@ export function App({ providerCatalog, extraChromeRows, }); + + const completedAgentsLabel = formatCompletedAgentsLabel(subAgentSessions?.list() ?? []); const { leftWidth, visibleRows, effectiveOverlayRows, permissionsOverlayRows } = layout; // Text wraps and renders inside the gutter so prose never touches the edges. const contentWidth = Math.max(8, leftWidth - TEXT_GUTTER * 2); @@ -1325,10 +1327,7 @@ export function App({ )} { - const agentsLabel = formatCompletedAgentsLabel(subAgentSessions?.list() ?? []); - return agentsLabel !== undefined ? { completedAgentsLabel: agentsLabel } : {}; - })()} + {...(completedAgentsLabel !== undefined ? { completedAgentsLabel } : {})} mcpCount={mcpStatus.connected.length} model={model} cwd={cwd} diff --git a/src/tui/components/agents-strip.tsx b/src/tui/components/agents-strip.tsx index f6b9cdeda..3b827ed1b 100644 --- a/src/tui/components/agents-strip.tsx +++ b/src/tui/components/agents-strip.tsx @@ -4,6 +4,7 @@ import type { Task } from "../../agent/tasks.js"; import type { SubAgentSession, SubAgentSessionStatus } from "../../subagent/session-store.js"; import { color } from "../theme.js"; import { describeToolCall } from "../tool-formatter.js"; +import { formatElapsed } from "./in-flight-indicator.js"; export type AgentsStripProps = { sessions: readonly SubAgentSession[]; @@ -318,7 +319,7 @@ export function formatSessionLabel(session: SubAgentSession): string { // (completed sub-agent times, not a live tick for in-flight sessions). const duration = session.finishedAt !== undefined && session.finishedAt >= session.startedAt - ? formatStripDuration(session.finishedAt - session.startedAt) + ? formatElapsed(session.finishedAt - session.startedAt) : undefined; const durationSuffix = duration !== undefined ? ` · ${duration}` : ""; if (session.status === "running" && session.currentToolName !== null) { @@ -341,16 +342,6 @@ export function formatSessionLabel(session: SubAgentSession): string { return `${session.agentId}: ${session.description}${tool}${durationSuffix}`; } -function formatStripDuration(ms: number): string { - const totalSec = Math.max(0, Math.floor(ms / 1000)); - const hours = Math.floor(totalSec / 3600); - const minutes = Math.floor((totalSec % 3600) / 60); - const seconds = totalSec % 60; - if (hours > 0) return `${hours}h ${minutes}m`; - if (minutes > 0) return `${minutes}m ${seconds}s`; - return `${seconds}s`; -} - function summaryCounts(sessions: readonly SubAgentSession[]): string { const running = sessions.filter((s) => s.status === "running").length; const done = sessions.filter((s) => s.status === "done").length; diff --git a/src/tui/components/status-bar.tsx b/src/tui/components/status-bar.tsx index ecd3aa445..445fb62f1 100644 --- a/src/tui/components/status-bar.tsx +++ b/src/tui/components/status-bar.tsx @@ -148,18 +148,8 @@ export function planStatusBarLayout(args: StatusBarLayoutArgs): StatusBarLayout }; } -export function formatElapsed(elapsedMs: number): string { - const totalSeconds = Math.floor(elapsedMs / 1000); - if (totalSeconds < 60) return `${totalSeconds}s`; - - const seconds = totalSeconds % 60; - const totalMinutes = Math.floor(totalSeconds / 60); - if (totalMinutes < 60) return `${totalMinutes}m ${seconds}s`; - - const minutes = totalMinutes % 60; - const hours = Math.floor(totalMinutes / 60); - return `${hours}h ${minutes}m ${seconds}s`; -} +export { formatElapsed } from "./in-flight-indicator.js"; +import { formatElapsed } from "./in-flight-indicator.js"; // Slim footer: brand anchors the bottom-left; completed sub-agent timing sits // beside it when any worker has finished this session. MCP health sits on the From 26e01a048b82820408c30bf2bbcd20eef2e9ea6d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 4 Aug 2026 22:31:00 -0700 Subject: [PATCH 5/5] Drop duplicate status-bar layout test and clarify agents sum comment --- src/tui/components/status-bar.tsx | 2 +- tests/unit/tui/status-bar.test.tsx | 11 ----------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/tui/components/status-bar.tsx b/src/tui/components/status-bar.tsx index 445fb62f1..166b85165 100644 --- a/src/tui/components/status-bar.tsx +++ b/src/tui/components/status-bar.tsx @@ -214,7 +214,7 @@ export function StatusBar({ ); } -/** Sum finished sub-agent wall times into a compact status-bar label. */ +/** Sum of finished agent wall times (not multi-agent phase wall clock) as a compact status-bar label. */ export function formatCompletedAgentsLabel( sessions: ReadonlyArray<{ status: string; startedAt: number; finishedAt?: number }>, ): string | undefined { diff --git a/tests/unit/tui/status-bar.test.tsx b/tests/unit/tui/status-bar.test.tsx index 61c25ae74..1b075bec3 100644 --- a/tests/unit/tui/status-bar.test.tsx +++ b/tests/unit/tui/status-bar.test.tsx @@ -241,17 +241,6 @@ test("formatCompletedAgentsLabel sums finished sub-agent durations", async () => expect(formatCompletedAgentsLabel([{ status: "running", startedAt: 0 }])).toBeUndefined(); }); -test("planStatusBarLayout drops the model segment when cwd cannot absorb the overflow", () => { - const layout = planStatusBarLayout({ - columns: 20, - agentsText: "5s", - model: "gpt-5-with-long-name", - cwd: "~/repo", - gitBranch: "main", - }); - expect(layout.modelCwdBranchText).toBeUndefined(); -}); - test("contextMeterTone stays normal below the compaction threshold", () => { const warningAt = Math.round(COMPACTION_WINDOW_FRACTION * 100); expect(contextMeterTone(0)).toBe("normal");