feat(cli): add trajectory stats tracking and completion summary (#773)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-02-01 16:37:30 -08:00
committed by GitHub
parent 514a3f8bcf
commit 2e1bd1ce78
8 changed files with 516 additions and 42 deletions

View File

@@ -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();
}
}

View File

@@ -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<number | null>(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<ReturnType<typeof executeApprovalBatch>>;
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 (
<Box key={resumeKey} flexDirection="column">
<Static
@@ -9618,6 +9827,8 @@ Plan file path: ${planFilePath}`;
<CommandMessage line={item} />
) : item.kind === "bash_command" ? (
<BashCommandMessage line={item} />
) : item.kind === "trajectory_summary" ? (
<TrajectorySummary line={item} />
) : item.kind === "approval_preview" ? (
<ApprovalPreview
toolName={item.toolName}
@@ -9866,7 +10077,9 @@ Plan file path: ${planFilePath}`;
streaming={
streaming && !abortControllerRef.current?.signal.aborted
}
tokenCount={tokenCount}
tokenCount={trajectoryTokenDisplay}
elapsedBaseMs={liveTrajectoryElapsedBaseMs}
elapsedMsOverride={streamElapsedMs}
thinkingMessage={thinkingMessage}
onSubmit={onSubmit}
onBashSubmit={handleBashSubmit}
@@ -10075,6 +10288,7 @@ Plan file path: ${planFilePath}`;
emittedIdsRef.current.clear();
setStaticItems([]);
setStaticRenderEpoch((e) => 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";

View File

@@ -56,7 +56,7 @@ export const AgentInfoBar = memo(function AgentInfoBar({
<Text>
{" "}Letta Code v{getVersion()} · Report bugs with /feedback or{" "}
<Link url="https://discord.gg/letta">
<Text>join our Discord </Text>
<Text>on Discord </Text>
</Link>
</Text>
</Box>

View File

@@ -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<void>;
@@ -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({
</Box>
);
}
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`;
}

View File

@@ -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 (
<Box flexDirection="row">
<Box width={2} flexShrink={0}>
<Text dimColor></Text>
</Box>
<Box flexGrow={1}>
<Text dimColor>{summary}</Text>
</Box>
</Box>
);
},
);
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(" ");
}

View File

@@ -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 };
/**

View File

@@ -43,6 +43,52 @@ const THINKING_VERBS = [
"internalizing",
] as const;
type ThinkingVerb = (typeof THINKING_VERBS)[number];
const PAST_TENSE_VERBS: Record<ThinkingVerb, string> = {
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();

View File

@@ -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;