From 5fb039c807a752fd75cfa3a266a73ada10736d9e Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Sat, 31 Jan 2026 20:24:43 -0800 Subject: [PATCH] feat(cli): add network phase arrows to streaming status (#765) Co-authored-by: Letta --- src/cli/App.tsx | 19 ++++++++++++++++++- src/cli/components/InputRich.tsx | 26 ++++++++++++++++++++++---- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/cli/App.tsx b/src/cli/App.tsx index ff97b10..e05f8d4 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -793,6 +793,15 @@ export default function App({ // Whether a stream is in flight (disables input) // Uses synced state to keep ref in sync for reliable async checks const [streaming, setStreaming, streamingRef] = useSyncedState(false); + const [networkPhase, setNetworkPhase] = useState< + "upload" | "download" | "error" | null + >(null); + + useEffect(() => { + if (!streaming) { + setNetworkPhase(null); + } + }, [streaming]); // Guard ref for preventing concurrent processConversation calls // Separate from streaming state which may be set early for UI responsiveness @@ -2419,6 +2428,7 @@ export default function App({ } setStreaming(true); + setNetworkPhase("upload"); abortControllerRef.current = new AbortController(); // Recover interrupted message: if cache contains ONLY user messages, prepend them @@ -2743,6 +2753,11 @@ export default function App({ } }; + const handleFirstMessage = () => { + setNetworkPhase("download"); + void syncAgentState(); + }; + const { stopReason, approval, @@ -2755,7 +2770,7 @@ export default function App({ buffersRef.current, refreshDerivedThrottled, signal, // Use captured signal, not ref (which may be nulled by handleInterrupt) - syncAgentState, + handleFirstMessage, ); // Update currentRunId for error reporting in catch block @@ -3684,6 +3699,7 @@ export default function App({ // If we have a client-side stream error (e.g., JSON parse error), show it directly // Fallback error: no run_id available, show whatever error message we have if (fallbackError) { + setNetworkPhase("error"); const errorMsg = lastRunId ? `Stream error: ${fallbackError}\n(run_id: ${lastRunId})` : `Stream error: ${fallbackError}`; @@ -9847,6 +9863,7 @@ Plan file path: ${planFilePath}`; onPasteError={handlePasteError} restoredInput={restoredInput} onRestoredInputConsumed={() => setRestoredInput(null)} + networkPhase={networkPhase} /> diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index 3858961..e7a2e33 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -209,6 +209,7 @@ export function Input({ onPasteError, restoredInput, onRestoredInputConsumed, + networkPhase = null, }: { visible?: boolean; streaming: boolean; @@ -238,6 +239,7 @@ export function Input({ onPasteError?: (message: string) => void; restoredInput?: string | null; onRestoredInputConsumed?: () => void; + networkPhase?: "upload" | "download" | "error" | null; }) { const [value, setValue] = useState(""); const [escapePressed, setEscapePressed] = useState(false); @@ -838,16 +840,31 @@ export function Input({ streaming && elapsedMs > ELAPSED_DISPLAY_THRESHOLD_MS; const elapsedMinutes = Math.floor(elapsedMs / 60000); + const networkArrow = useMemo(() => { + if (!networkPhase) return ""; + if (networkPhase === "upload") return "↑"; + if (networkPhase === "download") return "↓"; + return "↑\u0338"; + }, [networkPhase]); + // Build the status hint text (esc to interrupt · 2m · 1.2k ↑) // Uses chalk.dim to match reasoning text styling // Memoized to prevent unnecessary re-renders during shimmer updates const statusHintText = useMemo(() => { const hintColor = chalk.hex(colors.subagent.hint); const hintBold = hintColor.bold; - const suffix = - (shouldShowElapsed ? ` · ${elapsedMinutes}m` : "") + - (shouldShowTokenCount ? ` · ${formatCompact(estimatedTokens)} ↑` : "") + - ")"; + const parts: string[] = []; + if (shouldShowElapsed) { + parts.push(`${elapsedMinutes}m`); + } + if (shouldShowTokenCount) { + parts.push( + `${formatCompact(estimatedTokens)}${networkArrow ? ` ${networkArrow}` : ""}`, + ); + } else if (networkArrow) { + parts.push(networkArrow); + } + const suffix = `${parts.length > 0 ? ` · ${parts.join(" · ")}` : ""})`; if (interruptRequested) { return hintColor(` (interrupting${suffix}`); } @@ -860,6 +877,7 @@ export function Input({ shouldShowTokenCount, estimatedTokens, interruptRequested, + networkArrow, ]); // Create a horizontal line using box-drawing characters