diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 9e14aae27..693081150 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"; @@ -179,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. */ @@ -267,7 +263,6 @@ export function App({ globallyOnboarded = false, globalOnboardingPath, mouseEvents, - sessionStartedAt: sessionStartedAtProp, subAgentSessions, goalApi, telemetryNotice, @@ -583,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); @@ -652,8 +649,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, @@ -694,7 +689,6 @@ export function App({ promptXaiRelogin, setExpandedTools, setInputValue, - setSessionStartedAt, setEnteredSessionId, setAgentsNavOpen, setAgentsNavIndex, @@ -758,7 +752,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 +1327,7 @@ export function App({ )} = session.startedAt + ? formatElapsed(session.finishedAt - 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 +333,13 @@ 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 summaryCounts(sessions: readonly SubAgentSession[]): string { diff --git a/src/tui/components/status-bar.tsx b/src/tui/components/status-bar.tsx index 09690a819..166b85165 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, @@ -147,29 +148,20 @@ export function planStatusBarLayout(args: StatusBarLayoutArgs): StatusBarLayout }; } -export function formatElapsed(elapsedMs: number): string { - const totalSeconds = Math.floor(elapsedMs / 1000); - if (totalSeconds < 60) return `${totalSeconds}s`; +export { formatElapsed } from "./in-flight-indicator.js"; +import { formatElapsed } from "./in-flight-indicator.js"; - 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`; -} - -// 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 +171,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 +194,9 @@ export function StatusBar({ return ( {BRAND} - {timerText} + {agentsText !== undefined && ( + {agentsText} + )} {modelCwdBranchText !== undefined && ( {modelCwdBranchText} )} @@ -216,3 +213,19 @@ export function StatusBar({ ); } + +/** 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 { + 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/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/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); diff --git a/tests/unit/tui/status-bar.test.tsx b/tests/unit/tui/status-bar.test.tsx index 1171200b9..1b075bec3 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,14 +221,26 @@ 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: "~/repo", + 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("contextMeterTone stays normal below the compaction threshold", () => { const warningAt = Math.round(COMPACTION_WINDOW_FRACTION * 100); expect(contextMeterTone(0)).toBe("normal"); 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); -});