@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
34
src/cli/helpers/format.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user