Skip to content
Merged
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
15 changes: 4 additions & 11 deletions src/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -267,7 +263,6 @@ export function App({
globallyOnboarded = false,
globalOnboardingPath,
mouseEvents,
sessionStartedAt: sessionStartedAtProp,
subAgentSessions,
goalApi,
telemetryNotice,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -694,7 +689,6 @@ export function App({
promptXaiRelogin,
setExpandedTools,
setInputValue,
setSessionStartedAt,
setEnteredSessionId,
setAgentsNavOpen,
setAgentsNavIndex,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1334,7 +1327,7 @@ export function App({
)}
<Box marginTop={1}>
<StatusBar
sessionElapsedMs={sessionElapsedMs}
{...(completedAgentsLabel !== undefined ? { completedAgentsLabel } : {})}
mcpCount={mcpStatus.connected.length}
model={model}
cwd={cwd}
Expand Down
12 changes: 10 additions & 2 deletions src/tui/components/agents-strip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -314,6 +315,13 @@ 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
? 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);
Expand All @@ -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 {
Expand Down
63 changes: 38 additions & 25 deletions src/tui/components/status-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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) } : {}),
Expand All @@ -199,7 +194,9 @@ export function StatusBar({
return (
<Box flexDirection="row" paddingX={1} gap={1} overflow="hidden">
<Text bold color={color("muted")} dimColor wrap="truncate-end">{BRAND}</Text>
<Text color={color("muted")} dimColor>{timerText}</Text>
{agentsText !== undefined && (
<Text color={color("muted")} dimColor>{agentsText}</Text>
)}
{modelCwdBranchText !== undefined && (
<Text color={color("muted")} dimColor wrap="truncate-end">{modelCwdBranchText}</Text>
)}
Expand All @@ -216,3 +213,19 @@ export function StatusBar({
</Box>
);
}

/** 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)}`;
}
3 changes: 0 additions & 3 deletions src/tui/hooks/use-message-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ export type UseMessagePipelineArgs = {
promptXaiRelogin: (name: string) => void;
setExpandedTools: Dispatch<SetStateAction<ReadonlySet<string>>>;
setInputValue: Dispatch<SetStateAction<string>>;
setSessionStartedAt: Dispatch<SetStateAction<number>>;
setEnteredSessionId: Dispatch<SetStateAction<string | null>>;
setAgentsNavOpen: Dispatch<SetStateAction<boolean>>;
setAgentsNavIndex: Dispatch<SetStateAction<number>>;
Expand Down Expand Up @@ -98,7 +97,6 @@ export function useMessagePipeline({
promptXaiRelogin,
setExpandedTools,
setInputValue,
setSessionStartedAt,
setEnteredSessionId,
setAgentsNavOpen,
setAgentsNavIndex,
Expand Down Expand Up @@ -226,7 +224,6 @@ export function useMessagePipeline({
lastSentMessageRef.current = "";
quotaAutoRetryFiredRef.current = true;
setInputValue("");
setSessionStartedAt(Date.now());
setEnteredSessionId(null);
setAgentsNavOpen(false);
setAgentsNavIndex(0);
Expand Down
20 changes: 0 additions & 20 deletions src/tui/hooks/use-session-clock.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/tui/runner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1466,7 +1466,6 @@ export async function runTUI(initialConfig: Config): Promise<number> {
activePlugins={activePlugins}
initialWorkflowStatus={workflowController.status()}
mouseEvents={mouseEvents}
sessionStartedAt={startedAt}
subAgentSessions={subAgentSessions}
goalApi={{
get: () => goalGovernor.get(),
Expand Down
12 changes: 11 additions & 1 deletion tests/unit/tui/agents-strip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/tui/chrome-zone-budgets.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ test("status budget matches the rows StatusBar paints inside App's marginTop wra
// App wraps StatusBar in <Box marginTop={1}>; mirror that wrapper here.
const { lastFrame } = render(
<Box marginTop={1}>
<StatusBar sessionElapsedMs={0} mcpCount={0} />
<StatusBar mcpCount={0} />
</Box>,
);
expect(frameRows(lastFrame())).toBe(CHROME_ZONE_ROWS.status);
Expand Down
Loading
Loading