From 1d06743c3b5f4c7d16da8a6215a04a195894e747 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Mon, 22 Dec 2025 00:07:13 -0800 Subject: [PATCH] fix: misc UI fixes (#337) Co-authored-by: Letta --- src/cli/components/InputRich.tsx | 60 ++++++++++++++++++++++++----- src/cli/components/SessionStats.tsx | 3 +- src/cli/helpers/format.ts | 34 ++++++++++++++++ src/constants.ts | 8 ++++ 4 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 src/cli/helpers/format.ts diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index 1da8d42..7f4f9ce 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -2,14 +2,20 @@ import { EventEmitter } from "node:events"; import { stdin } from "node:process"; +import chalk from "chalk"; import { Box, Text, useInput } from "ink"; import SpinnerLib from "ink-spinner"; import { type ComponentType, useEffect, useRef, useState } from "react"; import { LETTA_CLOUD_API_URL } from "../../auth/oauth"; +import { + ELAPSED_DISPLAY_THRESHOLD_MS, + TOKEN_DISPLAY_THRESHOLD, +} from "../../constants"; import type { PermissionMode } from "../../permissions/mode"; import { permissionMode } from "../../permissions/mode"; import { settingsManager } from "../../settings-manager"; import { getVersion } from "../../version"; +import { charsToTokens, formatCompact } from "../helpers/format"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; import { InputAssist } from "./InputAssist"; @@ -21,8 +27,6 @@ import { ShimmerText } from "./ShimmerText"; const Spinner = SpinnerLib as ComponentType<{ type?: string }>; const appVersion = getVersion(); -// Only show token count when it exceeds this threshold -const COUNTER_VISIBLE_THRESHOLD = 1000; // Window for double-escape to clear input const ESC_CLEAR_WINDOW_MS = 2500; @@ -118,6 +122,8 @@ export function Input({ // Shimmer animation state const [shimmerOffset, setShimmerOffset] = useState(-3); + const [elapsedMs, setElapsedMs] = useState(0); + const streamStartRef = useRef(null); // Terminal width (reactive to window resizing) const columns = useTerminalWidth(); @@ -406,6 +412,25 @@ export function Input({ return () => clearInterval(id); }, [streaming, thinkingMessage, visible, agentName]); + // Elapsed time tracking + useEffect(() => { + if (streaming && visible) { + // Start tracking when streaming begins + if (streamStartRef.current === null) { + streamStartRef.current = Date.now(); + } + const id = setInterval(() => { + if (streamStartRef.current !== null) { + setElapsedMs(Date.now() - streamStartRef.current); + } + }, 1000); + return () => clearInterval(id); + } + // Reset when streaming stops + streamStartRef.current = null; + setElapsedMs(0); + }, [streaming, visible]); + const handleSubmit = async () => { // Don't submit if autocomplete is active with matches if (isAutocompleteActive) { @@ -506,8 +531,28 @@ export function Input({ const modeInfo = getModeInfo(); + const estimatedTokens = charsToTokens(tokenCount); const shouldShowTokenCount = - streaming && tokenCount > COUNTER_VISIBLE_THRESHOLD; + streaming && estimatedTokens > TOKEN_DISPLAY_THRESHOLD; + const shouldShowElapsed = + streaming && elapsedMs > ELAPSED_DISPLAY_THRESHOLD_MS; + const elapsedMinutes = Math.floor(elapsedMs / 60000); + + // Build the status hint text (esc to interrupt · 2m · 1.2k ↑) + const statusHintText = (() => { + const hintColor = chalk.hex(colors.subagent.hint); + const hintBold = hintColor.bold; + const suffix = + (shouldShowElapsed ? ` · ${elapsedMinutes}m` : "") + + (shouldShowTokenCount ? ` · ${formatCompact(estimatedTokens)} ↑` : "") + + ")"; + if (interruptRequested) { + return hintColor(` (interrupting${suffix}`); + } + return ( + hintColor(" (") + hintBold("esc") + hintColor(` to interrupt${suffix}`) + ); + })(); // Create a horizontal line using box-drawing characters const horizontalLine = "─".repeat(columns); @@ -527,18 +572,13 @@ export function Input({ - + - - {" ("} - {interruptRequested ? "interrupting" : "esc to interrupt"} - {shouldShowTokenCount && ` · ${tokenCount} ↑`} - {")"} - + {statusHintText} )} diff --git a/src/cli/components/SessionStats.tsx b/src/cli/components/SessionStats.tsx index 8af6dba..ff522d9 100644 --- a/src/cli/components/SessionStats.tsx +++ b/src/cli/components/SessionStats.tsx @@ -1,4 +1,5 @@ import type { SessionStatsSnapshot } from "../../agent/stats"; +import { formatCompact } from "../helpers/format"; export function formatDuration(ms: number): string { if (ms < 1000) { @@ -45,7 +46,7 @@ export function formatUsageStats({ const outputLines = [ `Total duration (API): ${formatDuration(stats.totalApiMs)}`, `Total duration (wall): ${formatDuration(stats.totalWallMs)}`, - `Session usage: ${stats.usage.stepCount} steps, ${formatNumber(stats.usage.promptTokens)} input, ${formatNumber(stats.usage.completionTokens)} output`, + `Session usage: ${stats.usage.stepCount} steps, ${formatCompact(stats.usage.promptTokens)} input, ${formatCompact(stats.usage.completionTokens)} output`, "", ]; diff --git a/src/cli/helpers/format.ts b/src/cli/helpers/format.ts new file mode 100644 index 0000000..9bbe501 --- /dev/null +++ b/src/cli/helpers/format.ts @@ -0,0 +1,34 @@ +/** + * Format a number compactly with k/M suffix + * Examples: 500 -> "500", 5000 -> "5k", 5200 -> "5.2k", 52000 -> "52k" + * Uses at most 2 significant figures for the decimal part + */ +export function formatCompact(n: number): string { + if (n < 1000) { + return String(n); + } + if (n < 1_000_000) { + const k = n / 1000; + // Show 1 decimal place if < 10k, otherwise round to whole number + if (k < 10) { + const rounded = Math.round(k * 10) / 10; + return `${rounded}k`; + } + return `${Math.round(k)}k`; + } + // Millions + const m = n / 1_000_000; + if (m < 10) { + const rounded = Math.round(m * 10) / 10; + return `${rounded}M`; + } + return `${Math.round(m)}M`; +} + +/** + * Rough approximation of tokens from character count. + * Uses ~4 chars per token as a rough average for English text. + */ +export function charsToTokens(chars: number): number { + return Math.round(chars / 4); +} diff --git a/src/constants.ts b/src/constants.ts index ec4c6e5..542fb29 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -16,3 +16,11 @@ export const DEFAULT_AGENT_NAME = "Nameless Agent"; * Message displayed when user interrupts tool execution */ export const INTERRUPTED_BY_USER = "Interrupted by user"; + +/** + * Status bar thresholds - only show indicators when values exceed these + */ +// Show token count after 1000 estimated tokens +export const TOKEN_DISPLAY_THRESHOLD = 1000; +// Show elapsed time after 2 minutes (in ms) +export const ELAPSED_DISPLAY_THRESHOLD_MS = 2 * 60 * 1000;