fix: misc UI fixes (#337)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2025-12-22 00:07:13 -08:00
committed by GitHub
parent fb60c1d8b7
commit 1d06743c3b
4 changed files with 94 additions and 11 deletions

View File

@@ -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<number | null>(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({
<Spinner type="layer" />
</Text>
</Box>
<Box flexGrow={1}>
<Box flexGrow={1} flexDirection="row">
<ShimmerText
boldPrefix={agentName || undefined}
message={thinkingMessage}
shimmerOffset={shimmerOffset}
/>
<Text dimColor>
{" ("}
{interruptRequested ? "interrupting" : "esc to interrupt"}
{shouldShowTokenCount && ` · ${tokenCount}`}
{")"}
</Text>
<Text>{statusHintText}</Text>
</Box>
</Box>
)}

View File

@@ -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`,
"",
];

34
src/cli/helpers/format.ts Normal file
View File

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

View File

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