diff --git a/src/agent/stats.ts b/src/agent/stats.ts index dbc47fe..968e9e1 100644 --- a/src/agent/stats.ts +++ b/src/agent/stats.ts @@ -9,6 +9,8 @@ export interface UsageStats { stepCount: number; } +export type UsageStatsDelta = UsageStats; + export interface SessionStatsSnapshot { sessionStartMs: number; totalWallMs: number; @@ -16,10 +18,27 @@ export interface SessionStatsSnapshot { usage: UsageStats; } +export interface TrajectoryStatsSnapshot { + trajectoryStartMs: number; + wallMs: number; + workMs: number; + apiMs: number; + localMs: number; + stepCount: number; + tokens: number; +} + export class SessionStats { private sessionStartMs: number; private totalApiMs: number; private usage: UsageStats; + private lastUsageSnapshot: UsageStats; + private trajectoryStartMs: number | null; + private trajectoryApiMs: number; + private trajectoryLocalMs: number; + private trajectoryWallMs: number; + private trajectoryStepCount: number; + private trajectoryTokens: number; constructor() { this.sessionStartMs = performance.now(); @@ -32,14 +51,108 @@ export class SessionStats { reasoningTokens: 0, stepCount: 0, }; + this.lastUsageSnapshot = { ...this.usage }; + this.trajectoryStartMs = null; + this.trajectoryApiMs = 0; + this.trajectoryLocalMs = 0; + this.trajectoryWallMs = 0; + this.trajectoryStepCount = 0; + this.trajectoryTokens = 0; } endTurn(apiDurationMs: number): void { this.totalApiMs += apiDurationMs; } - updateUsageFromBuffers(buffers: Buffers): void { - this.usage = { ...buffers.usage }; + updateUsageFromBuffers(buffers: Buffers): UsageStatsDelta { + const nextUsage = { ...buffers.usage }; + const prevUsage = this.lastUsageSnapshot; + + const delta: UsageStatsDelta = { + promptTokens: Math.max( + 0, + nextUsage.promptTokens - prevUsage.promptTokens, + ), + completionTokens: Math.max( + 0, + nextUsage.completionTokens - prevUsage.completionTokens, + ), + totalTokens: Math.max(0, nextUsage.totalTokens - prevUsage.totalTokens), + cachedTokens: Math.max( + 0, + nextUsage.cachedTokens - prevUsage.cachedTokens, + ), + reasoningTokens: Math.max( + 0, + nextUsage.reasoningTokens - prevUsage.reasoningTokens, + ), + stepCount: Math.max(0, nextUsage.stepCount - prevUsage.stepCount), + }; + + this.usage = nextUsage; + this.lastUsageSnapshot = nextUsage; + return delta; + } + + startTrajectory(): void { + if (this.trajectoryStartMs === null) { + this.trajectoryStartMs = performance.now(); + } + } + + accumulateTrajectory(options: { + apiDurationMs?: number; + localToolMs?: number; + wallMs?: number; + usageDelta?: UsageStatsDelta; + tokenDelta?: number; + }): void { + this.startTrajectory(); + + if (options.apiDurationMs) { + this.trajectoryApiMs += options.apiDurationMs; + } + if (options.localToolMs) { + this.trajectoryLocalMs += options.localToolMs; + } + if (options.wallMs) { + this.trajectoryWallMs += options.wallMs; + } + if (options.usageDelta) { + this.trajectoryStepCount += options.usageDelta.stepCount; + } + if (options.tokenDelta) { + this.trajectoryTokens += options.tokenDelta; + } + } + + getTrajectorySnapshot(): TrajectoryStatsSnapshot | null { + if (this.trajectoryStartMs === null) return null; + const workMs = this.trajectoryApiMs + this.trajectoryLocalMs; + return { + trajectoryStartMs: this.trajectoryStartMs, + wallMs: this.trajectoryWallMs, + workMs, + apiMs: this.trajectoryApiMs, + localMs: this.trajectoryLocalMs, + stepCount: this.trajectoryStepCount, + tokens: this.trajectoryTokens, + }; + } + + endTrajectory(): TrajectoryStatsSnapshot | null { + const snapshot = this.getTrajectorySnapshot(); + this.resetTrajectory(); + return snapshot; + } + + resetTrajectory(): void { + this.trajectoryStartMs = null; + this.trajectoryApiMs = 0; + this.trajectoryLocalMs = 0; + this.trajectoryWallMs = 0; + this.trajectoryStepCount = 0; + this.trajectoryTokens = 0; } getSnapshot(): SessionStatsSnapshot { @@ -63,5 +176,7 @@ export class SessionStats { reasoningTokens: 0, stepCount: 0, }; + this.lastUsageSnapshot = { ...this.usage }; + this.resetTrajectory(); } } diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 439a5ae..fed2e36 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -145,6 +145,7 @@ import { SystemPromptSelector } from "./components/SystemPromptSelector"; import { Text } from "./components/Text"; import { ToolCallMessage } from "./components/ToolCallMessageRich"; import { ToolsetSelector } from "./components/ToolsetSelector"; +import { TrajectorySummary } from "./components/TrajectorySummary"; import { UserMessage } from "./components/UserMessageRich"; import { WelcomeScreen } from "./components/WelcomeScreen"; import { AnimationProvider } from "./contexts/AnimationContext"; @@ -193,7 +194,10 @@ import { interruptActiveSubagents, subscribe as subscribeToSubagents, } from "./helpers/subagentState"; -import { getRandomThinkingVerb } from "./helpers/thinkingMessages"; +import { + getRandomPastTenseVerb, + getRandomThinkingVerb, +} from "./helpers/thinkingMessages"; import { isFileEditTool, isFileWriteTool, @@ -1175,6 +1179,15 @@ export default function App({ // Live, approximate token counter (resets each turn) const [tokenCount, setTokenCount] = useState(0); + // Trajectory token/time bases (accumulated across runs) + const [trajectoryTokenBase, setTrajectoryTokenBase] = useState(0); + const [trajectoryElapsedBaseMs, setTrajectoryElapsedBaseMs] = useState(0); + const [streamElapsedMs, setStreamElapsedMs] = useState(0); + const trajectoryRunTokenStartRef = useRef(0); + const trajectoryTokenDisplayRef = useRef(0); + const trajectorySegmentStartRef = useRef(null); + const streamElapsedMsRef = useRef(0); + // Current thinking message (rotates each turn) const [thinkingMessage, setThinkingMessage] = useState( getRandomThinkingVerb(), @@ -1185,6 +1198,43 @@ export default function App({ const sessionStartTimeRef = useRef(Date.now()); const sessionHooksRanRef = useRef(false); + const syncTrajectoryTokenBase = useCallback(() => { + const snapshot = sessionStatsRef.current.getTrajectorySnapshot(); + setTrajectoryTokenBase(snapshot?.tokens ?? 0); + }, []); + + const openTrajectorySegment = useCallback(() => { + if (trajectorySegmentStartRef.current === null) { + trajectorySegmentStartRef.current = performance.now(); + sessionStatsRef.current.startTrajectory(); + } + }, []); + + const closeTrajectorySegment = useCallback(() => { + const start = trajectorySegmentStartRef.current; + if (start !== null) { + const segmentMs = performance.now() - start; + sessionStatsRef.current.accumulateTrajectory({ wallMs: segmentMs }); + trajectorySegmentStartRef.current = null; + } + }, []); + + const syncTrajectoryElapsedBase = useCallback(() => { + const snapshot = sessionStatsRef.current.getTrajectorySnapshot(); + setTrajectoryElapsedBaseMs(snapshot?.wallMs ?? 0); + }, []); + + const resetTrajectoryBases = useCallback(() => { + sessionStatsRef.current.resetTrajectory(); + setTrajectoryTokenBase(0); + setTrajectoryElapsedBaseMs(0); + setStreamElapsedMs(0); + trajectoryRunTokenStartRef.current = 0; + trajectoryTokenDisplayRef.current = 0; + trajectorySegmentStartRef.current = null; + streamElapsedMsRef.current = 0; + }, []); + // Wire up session stats to telemetry for safety net handlers useEffect(() => { telemetry.setSessionStatsGetter(() => @@ -1197,6 +1247,41 @@ export default function App({ }; }, []); + // Track trajectory wall time based on streaming state (matches InputRich timer) + useEffect(() => { + if (streaming) { + openTrajectorySegment(); + return; + } + closeTrajectorySegment(); + syncTrajectoryElapsedBase(); + }, [ + streaming, + openTrajectorySegment, + closeTrajectorySegment, + syncTrajectoryElapsedBase, + ]); + + useEffect(() => { + if (!streaming) { + streamElapsedMsRef.current = 0; + setStreamElapsedMs(0); + return; + } + + openTrajectorySegment(); + const tick = () => { + const start = trajectorySegmentStartRef.current; + const next = start ? performance.now() - start : 0; + streamElapsedMsRef.current = next; + setStreamElapsedMs(next); + }; + + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [streaming, openTrajectorySegment]); + // Run SessionStart hooks when agent becomes available useEffect(() => { if (agentId && !sessionHooksRanRef.current) { @@ -1479,7 +1564,12 @@ export default function App({ if (emittedIdsRef.current.has(id)) continue; const ln = b.byId.get(id); if (!ln) continue; - if (ln.kind === "user" || ln.kind === "error" || ln.kind === "status") { + if ( + ln.kind === "user" || + ln.kind === "error" || + ln.kind === "status" || + ln.kind === "trajectory_summary" + ) { emittedIdsRef.current.add(id); newlyCommitted.push({ ...ln }); continue; @@ -2429,6 +2519,7 @@ export default function App({ } setStreaming(true); + openTrajectorySegment(); setNetworkPhase("upload"); abortControllerRef.current = new AbortController(); @@ -2759,6 +2850,10 @@ export default function App({ void syncAgentState(); }; + const runTokenStart = buffersRef.current.tokenCount; + trajectoryRunTokenStartRef.current = runTokenStart; + sessionStatsRef.current.startTrajectory(); + const { stopReason, approval, @@ -2777,9 +2872,21 @@ export default function App({ // Update currentRunId for error reporting in catch block currentRunId = lastRunId ?? undefined; - // Track API duration + // Track API duration and trajectory deltas sessionStatsRef.current.endTurn(apiDurationMs); - sessionStatsRef.current.updateUsageFromBuffers(buffersRef.current); + const usageDelta = sessionStatsRef.current.updateUsageFromBuffers( + buffersRef.current, + ); + const tokenDelta = Math.max( + 0, + buffersRef.current.tokenCount - runTokenStart, + ); + sessionStatsRef.current.accumulateTrajectory({ + apiDurationMs, + usageDelta, + tokenDelta, + }); + syncTrajectoryTokenBase(); const wasInterrupted = !!buffersRef.current.interrupted; const wasAborted = !!signal?.aborted; @@ -2812,6 +2919,12 @@ export default function App({ // Case 1: Turn ended normally if (stopReasonToHandle === "end_turn") { setStreaming(false); + const liveElapsedMs = (() => { + const snapshot = sessionStatsRef.current.getTrajectorySnapshot(); + const base = snapshot?.wallMs ?? 0; + return base + streamElapsedMsRef.current; + })(); + closeTrajectorySegment(); llmApiErrorRetriesRef.current = 0; // Reset retry counter on success conversationBusyRetriesRef.current = 0; lastDequeuedMessageRef.current = null; // Clear - message was processed successfully @@ -2883,6 +2996,32 @@ export default function App({ setNeedsEagerApprovalCheck(false); } + const trajectorySnapshot = sessionStatsRef.current.endTrajectory(); + setTrajectoryTokenBase(0); + setTrajectoryElapsedBaseMs(0); + trajectoryRunTokenStartRef.current = 0; + trajectoryTokenDisplayRef.current = 0; + if (trajectorySnapshot) { + const summaryWallMs = Math.max( + liveElapsedMs, + trajectorySnapshot.wallMs, + ); + const shouldShowSummary = + trajectorySnapshot.stepCount > 3 || summaryWallMs > 10000; + if (shouldShowSummary) { + const summaryId = uid("trajectory-summary"); + buffersRef.current.byId.set(summaryId, { + kind: "trajectory_summary", + id: summaryId, + durationMs: summaryWallMs, + stepCount: trajectorySnapshot.stepCount, + verb: getRandomPastTenseVerb(), + }); + buffersRef.current.order.push(summaryId); + refreshDerived(); + } + } + // Send desktop notification when turn completes // and we're not about to auto-send another queued message if (!waitingForQueueCancelRef.current) { @@ -2923,6 +3062,8 @@ export default function App({ // Case 1.5: Stream was cancelled by user if (stopReasonToHandle === "cancelled") { setStreaming(false); + closeTrajectorySegment(); + syncTrajectoryElapsedBase(); // Check if this cancel was triggered by queue threshold if (waitingForQueueCancelRef.current) { @@ -2973,6 +3114,9 @@ export default function App({ setAutoHandledResults([]); setAutoDeniedApprovals([]); lastSentInputRef.current = null; // Clear - message was received by server + setStreaming(false); + closeTrajectorySegment(); + syncTrajectoryElapsedBase(); // Use new approvals array, fallback to legacy approval for backward compat const approvalsToProcess = @@ -3718,6 +3862,7 @@ export default function App({ setStreaming(false); sendDesktopNotification("Stream error", "error"); // Notify user of error refreshDerived(); + resetTrajectoryBases(); return; } @@ -3790,6 +3935,7 @@ export default function App({ setStreaming(false); sendDesktopNotification(); refreshDerived(); + resetTrajectoryBases(); return; } } else { @@ -3817,6 +3963,7 @@ export default function App({ setStreaming(false); sendDesktopNotification("Execution error", "error"); // Notify user of error refreshDerived(); + resetTrajectoryBases(); return; } } catch (e) { @@ -3871,6 +4018,7 @@ export default function App({ setStreaming(false); sendDesktopNotification("Processing error", "error"); // Notify user of error refreshDerived(); + resetTrajectoryBases(); } finally { // Check if this conversation was superseded by an ESC interrupt const isStale = myGeneration !== conversationGenerationRef.current; @@ -3898,6 +4046,11 @@ export default function App({ queueApprovalResults, consumeQueuedMessages, maybeSyncMemoryFilesystemAfterTurn, + openTrajectorySegment, + syncTrajectoryTokenBase, + syncTrajectoryElapsedBase, + closeTrajectorySegment, + resetTrajectoryBases, ], ); @@ -4264,6 +4417,7 @@ export default function App({ emittedIdsRef.current.clear(); setStaticItems([]); setStaticRenderEpoch((e) => e + 1); + resetTrajectoryBases(); // Reset turn counter for memory reminders when switching agents turnCountRef.current = 0; @@ -4322,7 +4476,14 @@ export default function App({ setCommandRunning(false); } }, - [refreshDerived, agentId, agentName, setCommandRunning, isAgentBusy], + [ + refreshDerived, + agentId, + agentName, + setCommandRunning, + isAgentBusy, + resetTrajectoryBases, + ], ); // Handle creating a new agent and switching to it @@ -4362,6 +4523,7 @@ export default function App({ emittedIdsRef.current.clear(); setStaticItems([]); setStaticRenderEpoch((e) => e + 1); + resetTrajectoryBases(); // Reset turn counter for memory reminders turnCountRef.current = 0; @@ -4411,7 +4573,7 @@ export default function App({ setCommandRunning(false); } }, - [refreshDerived, agentId, setCommandRunning], + [refreshDerived, agentId, setCommandRunning, resetTrajectoryBases], ); // Handle bash mode command submission @@ -5911,6 +6073,7 @@ export default function App({ emittedIdsRef.current.clear(); setStaticItems([]); setStaticRenderEpoch((e) => e + 1); + resetTrajectoryBases(); // Build success message const currentAgentName = agentState.name || "Unnamed Agent"; @@ -7147,12 +7310,19 @@ ${SYSTEM_REMINDER_CLOSE} // Reset token counter for this turn (only count the agent's response) buffersRef.current.tokenCount = 0; + // If the previous trajectory ended, ensure the live token display resets. + if (!sessionStatsRef.current.getTrajectorySnapshot()) { + trajectoryTokenDisplayRef.current = 0; + setTrajectoryTokenBase(0); + trajectoryRunTokenStartRef.current = 0; + } // Clear interrupted flag from previous turn buffersRef.current.interrupted = false; // Rotate to a new thinking message for this turn setThinkingMessage(getRandomThinkingVerb()); // Show streaming state immediately for responsiveness (pending approval check takes ~100ms) setStreaming(true); + openTrajectorySegment(); refreshDerived(); // Check for pending approvals before sending message (skip if we already have @@ -7795,6 +7965,8 @@ ${SYSTEM_REMINDER_CLOSE} setStreaming, setCommandRunning, pendingRalphConfig, + openTrajectorySegment, + resetTrajectoryBases, ], ); @@ -7889,6 +8061,7 @@ ${SYSTEM_REMINDER_CLOSE} // Show "thinking" state and lock input while executing approved tools client-side setStreaming(true); + openTrajectorySegment(); // Ensure interrupted flag is cleared for this execution buffersRef.current.interrupted = false; @@ -7918,30 +8091,40 @@ ${SYSTEM_REMINDER_CLOSE} const { executeApprovalBatch } = await import( "../agent/approval-execution" ); - const executedResults = await executeApprovalBatch( - allDecisions, - (chunk) => { - onChunk(buffersRef.current, chunk); - // Also log errors to the UI error display - if ( - chunk.status === "error" && - chunk.message_type === "tool_return_message" - ) { - const isToolError = chunk.tool_return?.startsWith( - "Error executing tool:", - ); - if (isToolError) { - appendError(chunk.tool_return); + sessionStatsRef.current.startTrajectory(); + const toolRunStart = performance.now(); + let executedResults: Awaited>; + try { + executedResults = await executeApprovalBatch( + allDecisions, + (chunk) => { + onChunk(buffersRef.current, chunk); + // Also log errors to the UI error display + if ( + chunk.status === "error" && + chunk.message_type === "tool_return_message" + ) { + const isToolError = chunk.tool_return?.startsWith( + "Error executing tool:", + ); + if (isToolError) { + appendError(chunk.tool_return); + } } - } - // Flush UI so completed tools show up while the batch continues - refreshDerived(); - }, - { - abortSignal: approvalAbortController.signal, - onStreamingOutput: updateStreamingOutput, - }, - ); + // Flush UI so completed tools show up while the batch continues + refreshDerived(); + }, + { + abortSignal: approvalAbortController.signal, + onStreamingOutput: updateStreamingOutput, + }, + ); + } finally { + const toolRunMs = performance.now() - toolRunStart; + sessionStatsRef.current.accumulateTrajectory({ + localToolMs: toolRunMs, + }); + } // Combine with auto-handled and auto-denied results using snapshots const allResults = [ @@ -8005,6 +8188,8 @@ ${SYSTEM_REMINDER_CLOSE} queueApprovalResults(allResults as ApprovalResult[]); } setStreaming(false); + closeTrajectorySegment(); + syncTrajectoryElapsedBase(); // Reset queue-cancel flag so dequeue effect can fire waitingForQueueCancelRef.current = false; @@ -8062,6 +8247,9 @@ ${SYSTEM_REMINDER_CLOSE} updateStreamingOutput, queueApprovalResults, consumeQueuedMessages, + syncTrajectoryElapsedBase, + closeTrajectorySegment, + openTrajectorySegment, ], ); @@ -8219,6 +8407,7 @@ ${SYSTEM_REMINDER_CLOSE} setAutoDeniedApprovals([]); setStreaming(true); + openTrajectorySegment(); buffersRef.current.interrupted = false; // Set phase to "running" for all approved tools @@ -8294,6 +8483,7 @@ ${SYSTEM_REMINDER_CLOSE} refreshDerived, isExecutingTool, setStreaming, + openTrajectorySegment, updateStreamingOutput, ], ); @@ -9579,6 +9769,25 @@ Plan file path: ${planFilePath}`; releaseNotes, ]); + const liveTrajectorySnapshot = + sessionStatsRef.current.getTrajectorySnapshot(); + const liveTrajectoryTokenBase = + liveTrajectorySnapshot?.tokens ?? trajectoryTokenBase; + const liveTrajectoryElapsedBaseMs = + liveTrajectorySnapshot?.wallMs ?? trajectoryElapsedBaseMs; + const runTokenDelta = Math.max( + 0, + tokenCount - trajectoryRunTokenStartRef.current, + ); + const trajectoryTokenDisplay = Math.max( + liveTrajectoryTokenBase + runTokenDelta, + trajectoryTokenDisplayRef.current, + ); + + useEffect(() => { + trajectoryTokenDisplayRef.current = trajectoryTokenDisplay; + }, [trajectoryTokenDisplay]); + return ( ) : item.kind === "bash_command" ? ( + ) : item.kind === "trajectory_summary" ? ( + ) : item.kind === "approval_preview" ? ( e + 1); + resetTrajectoryBases(); // Build success command with agent + conversation info const currentAgentName = @@ -10239,6 +10453,7 @@ Plan file path: ${planFilePath}`; emittedIdsRef.current.clear(); setStaticItems([]); setStaticRenderEpoch((e) => e + 1); + resetTrajectoryBases(); // Build success command with agent + conversation info const currentAgentName = @@ -10364,6 +10579,7 @@ Plan file path: ${planFilePath}`; emittedIdsRef.current.clear(); setStaticItems([]); setStaticRenderEpoch((e) => e + 1); + resetTrajectoryBases(); const currentAgentName = agentState.name || "Unnamed Agent"; diff --git a/src/cli/components/AgentInfoBar.tsx b/src/cli/components/AgentInfoBar.tsx index 2c2cd80..4ad085d 100644 --- a/src/cli/components/AgentInfoBar.tsx +++ b/src/cli/components/AgentInfoBar.tsx @@ -56,7 +56,7 @@ export const AgentInfoBar = memo(function AgentInfoBar({ {" "}Letta Code v{getVersion()} · Report bugs with /feedback or{" "} - join our Discord ↗ + on Discord ↗ diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index dfccbf0..53a7d43 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -184,6 +184,8 @@ export function Input({ visible = true, streaming, tokenCount, + elapsedBaseMs = 0, + elapsedMsOverride, thinkingMessage, onSubmit, onBashSubmit, @@ -214,6 +216,8 @@ export function Input({ visible?: boolean; streaming: boolean; tokenCount: number; + elapsedBaseMs?: number; + elapsedMsOverride?: number; thinkingMessage: string; onSubmit: (message?: string) => Promise<{ submitted: boolean }>; onBashSubmit?: (command: string) => Promise; @@ -658,14 +662,19 @@ export function Input({ // Elapsed time tracking useEffect(() => { + if (elapsedMsOverride !== undefined) { + streamStartRef.current = null; + setElapsedMs(0); + return; + } if (streaming && visible) { // Start tracking when streaming begins if (streamStartRef.current === null) { - streamStartRef.current = Date.now(); + streamStartRef.current = performance.now(); } const id = setInterval(() => { if (streamStartRef.current !== null) { - setElapsedMs(Date.now() - streamStartRef.current); + setElapsedMs(performance.now() - streamStartRef.current); } }, 1000); return () => clearInterval(id); @@ -673,7 +682,7 @@ export function Input({ // Reset when streaming stops streamStartRef.current = null; setElapsedMs(0); - }, [streaming, visible]); + }, [streaming, visible, elapsedMsOverride]); const handleSubmit = async () => { // Don't submit if autocomplete is active with matches @@ -834,11 +843,13 @@ export function Input({ }, [ralphPending, ralphPendingYolo, ralphActive, currentMode]); const estimatedTokens = charsToTokens(tokenCount); + const effectiveElapsedMs = elapsedMsOverride ?? elapsedMs; + const totalElapsedMs = elapsedBaseMs + effectiveElapsedMs; const shouldShowTokenCount = streaming && estimatedTokens > TOKEN_DISPLAY_THRESHOLD; const shouldShowElapsed = - streaming && elapsedMs > ELAPSED_DISPLAY_THRESHOLD_MS; - const elapsedMinutes = Math.floor(elapsedMs / 60000); + streaming && totalElapsedMs > ELAPSED_DISPLAY_THRESHOLD_MS; + const elapsedLabel = formatElapsedLabel(totalElapsedMs); const networkArrow = useMemo(() => { if (!networkPhase) return ""; @@ -846,6 +857,7 @@ export function Input({ if (networkPhase === "download") return "↑"; // Use ↑ for both to avoid distracting flip (change to ↓ to restore) return "↑\u0338"; }, [networkPhase]); + const showErrorArrow = networkArrow === "↑\u0338"; // Build the status hint text (esc to interrupt · 2m · 1.2k ↑) // Uses chalk.dim to match reasoning text styling @@ -855,13 +867,13 @@ export function Input({ const hintBold = hintColor.bold; const parts: string[] = []; if (shouldShowElapsed) { - parts.push(`${elapsedMinutes}m`); + parts.push(elapsedLabel); } if (shouldShowTokenCount) { parts.push( `${formatCompact(estimatedTokens)}${networkArrow ? ` ${networkArrow}` : ""}`, ); - } else if (networkArrow) { + } else if (showErrorArrow) { parts.push(networkArrow); } const suffix = `${parts.length > 0 ? ` · ${parts.join(" · ")}` : ""})`; @@ -873,11 +885,12 @@ export function Input({ ); }, [ shouldShowElapsed, - elapsedMinutes, + elapsedLabel, shouldShowTokenCount, estimatedTokens, interruptRequested, networkArrow, + showErrorArrow, ]); // Create a horizontal line using box-drawing characters @@ -991,3 +1004,21 @@ export function Input({ ); } + +function formatElapsedLabel(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const seconds = totalSeconds % 60; + const totalMinutes = Math.floor(totalSeconds / 60); + if (totalMinutes === 0) { + return `${seconds}s`; + } + const minutes = totalMinutes % 60; + const hours = Math.floor(totalMinutes / 60); + if (hours > 0) { + const parts: string[] = [`${hours}hr`]; + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0) parts.push(`${seconds}s`); + return parts.join(" "); + } + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; +} diff --git a/src/cli/components/TrajectorySummary.tsx b/src/cli/components/TrajectorySummary.tsx new file mode 100644 index 0000000..db1c4ef --- /dev/null +++ b/src/cli/components/TrajectorySummary.tsx @@ -0,0 +1,53 @@ +import { Box } from "ink"; +import { memo } from "react"; +import { Text } from "./Text"; + +type TrajectorySummaryLine = { + kind: "trajectory_summary"; + id: string; + durationMs: number; + stepCount: number; + verb: string; +}; + +export const TrajectorySummary = memo( + ({ line }: { line: TrajectorySummaryLine }) => { + const duration = formatSummaryDuration(line.durationMs); + const verb = + line.verb.length > 0 + ? line.verb.charAt(0).toUpperCase() + line.verb.slice(1) + : line.verb; + const summary = `${verb} for ${duration}`; + + return ( + + + + + + {summary} + + + ); + }, +); + +TrajectorySummary.displayName = "TrajectorySummary"; + +function formatSummaryDuration(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + if (totalSeconds < 60) { + return `${Math.max(0, totalSeconds)}s`; + } + const totalMinutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (totalMinutes < 60) { + return seconds > 0 ? `${totalMinutes}m ${seconds}s` : `${totalMinutes}m`; + } + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + const parts: string[] = [`${hours}hr`]; + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0) parts.push(`${seconds}s`); + return parts.join(" "); +} diff --git a/src/cli/helpers/accumulator.ts b/src/cli/helpers/accumulator.ts index ebb86c2..0a9154c 100644 --- a/src/cli/helpers/accumulator.ts +++ b/src/cli/helpers/accumulator.ts @@ -174,6 +174,13 @@ export type Line = id: string; lines: string[]; // Multi-line status message with arrow formatting } + | { + kind: "trajectory_summary"; + id: string; + durationMs: number; + stepCount: number; + verb: string; + } | { kind: "separator"; id: string }; /** diff --git a/src/cli/helpers/thinkingMessages.ts b/src/cli/helpers/thinkingMessages.ts index fb59552..5017c5f 100644 --- a/src/cli/helpers/thinkingMessages.ts +++ b/src/cli/helpers/thinkingMessages.ts @@ -43,6 +43,52 @@ const THINKING_VERBS = [ "internalizing", ] as const; +type ThinkingVerb = (typeof THINKING_VERBS)[number]; + +const PAST_TENSE_VERBS: Record = { + thinking: "thought", + processing: "processed", + computing: "computed", + calculating: "calculated", + analyzing: "analyzed", + synthesizing: "synthesized", + deliberating: "deliberated", + cogitating: "cogitated", + reflecting: "reflected", + reasoning: "reasoned", + spinning: "spun", + focusing: "focused", + machinating: "machinated", + contemplating: "contemplated", + ruminating: "ruminated", + considering: "considered", + pondering: "pondered", + evaluating: "evaluated", + assessing: "assessed", + inferring: "inferred", + deducing: "deduced", + interpreting: "interpreted", + formulating: "formulated", + strategizing: "strategized", + orchestrating: "orchestrated", + optimizing: "optimized", + calibrating: "calibrated", + indexing: "indexed", + compiling: "compiled", + rendering: "rendered", + executing: "executed", + initializing: "initialized", + "absolutely right": "was absolutely right", + "thinking about thinking": "thought about thinking", + metathinking: "did metathinking", + learning: "learned", + adapting: "adapted", + evolving: "evolved", + remembering: "remembered", + absorbing: "absorbed", + internalizing: "internalized", +}; + // Get a random thinking verb (e.g., "thinking", "processing") function getRandomVerb(): string { const index = Math.floor(Math.random() * THINKING_VERBS.length); @@ -54,6 +100,12 @@ export function getRandomThinkingVerb(): string { return `is ${getRandomVerb()}`; } +// Get a random past tense verb (e.g., "thought", "processed") +export function getRandomPastTenseVerb(): string { + const verb = getRandomVerb() as ThinkingVerb; + return PAST_TENSE_VERBS[verb] ?? "completed"; +} + // Get a random thinking message (full string with agent name) export function getRandomThinkingMessage(agentName?: string | null): string { const verb = getRandomVerb(); diff --git a/src/constants.ts b/src/constants.ts index 1d675a9..fb72e99 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -42,4 +42,4 @@ export const COMPACTION_SUMMARY_HEADER = // Show token count after 100 estimated tokens (shows exact count until 1k, then compact) export const TOKEN_DISPLAY_THRESHOLD = 100; // Show elapsed time after 2 minutes (in ms) -export const ELAPSED_DISPLAY_THRESHOLD_MS = 2 * 60 * 1000; +export const ELAPSED_DISPLAY_THRESHOLD_MS = 60 * 1000;