diff --git a/CHANGELOG.md b/CHANGELOG.md index b104362e2..5cf8ee14d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor. - UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor. - TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank. +- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states. - Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`). - macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis. - Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver. diff --git a/src/tui/tui.ts b/src/tui/tui.ts index d43fcaeb0..979540e80 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -1,4 +1,11 @@ -import { CombinedAutocompleteProvider, Container, ProcessTerminal, Text, TUI } from "@mariozechner/pi-tui"; +import { + CombinedAutocompleteProvider, + Container, + Loader, + ProcessTerminal, + Text, + TUI, +} from "@mariozechner/pi-tui"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { loadConfig } from "../config/config.js"; import { @@ -53,6 +60,9 @@ export async function runTui(opts: TuiOptions) { let activityStatus = "idle"; let connectionStatus = "connecting"; let statusTimeout: NodeJS.Timeout | null = null; + let statusTimer: NodeJS.Timeout | null = null; + let statusStartedAt: number | null = null; + let lastActivityStatus = activityStatus; const state: TuiStateAccess = { get agentDefaultId() { @@ -178,14 +188,14 @@ export async function runTui(opts: TuiOptions) { }); const header = new Text("", 1, 0); - const status = new Text("", 1, 0); + const statusContainer = new Container(); const footer = new Text("", 1, 0); const chatLog = new ChatLog(); const editor = new CustomEditor(editorTheme); const root = new Container(); root.addChild(header); root.addChild(chatLog); - root.addChild(status); + root.addChild(statusContainer); root.addChild(footer); root.addChild(editor); @@ -242,13 +252,79 @@ export async function runTui(opts: TuiOptions) { ); }; - const setStatus = (text: string) => { - status.setText(theme.dim(text)); + const busyStates = new Set(["sending", "waiting", "streaming", "running"]); + let statusText: Text | null = null; + let statusLoader: Loader | null = null; + + const formatElapsed = (startMs: number) => { + const totalSeconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000)); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}m ${seconds}s`; + }; + + const ensureStatusText = () => { + if (statusText) return; + statusContainer.clear(); + statusLoader?.stop(); + statusLoader = null; + statusText = new Text("", 1, 0); + statusContainer.addChild(statusText); + }; + + const ensureStatusLoader = () => { + if (statusLoader) return; + statusContainer.clear(); + statusText = null; + statusLoader = new Loader( + tui, + (spinner) => theme.accent(spinner), + (text) => theme.bold(theme.accentSoft(text)), + "", + ); + statusContainer.addChild(statusLoader); + }; + + const updateBusyStatusMessage = () => { + if (!statusLoader || !statusStartedAt) return; + const elapsed = formatElapsed(statusStartedAt); + statusLoader.setMessage(`${activityStatus} • ${elapsed} | ${connectionStatus}`); + }; + + const startStatusTimer = () => { + if (statusTimer) return; + statusTimer = setInterval(() => { + if (!busyStates.has(activityStatus)) return; + updateBusyStatusMessage(); + }, 1000); + }; + + const stopStatusTimer = () => { + if (!statusTimer) return; + clearInterval(statusTimer); + statusTimer = null; }; const renderStatus = () => { - const text = activityStatus ? `${connectionStatus} | ${activityStatus}` : connectionStatus; - setStatus(text); + const isBusy = busyStates.has(activityStatus); + if (isBusy) { + if (!statusStartedAt || lastActivityStatus !== activityStatus) { + statusStartedAt = Date.now(); + } + ensureStatusLoader(); + updateBusyStatusMessage(); + startStatusTimer(); + } else { + statusStartedAt = null; + stopStatusTimer(); + statusLoader?.stop(); + statusLoader = null; + ensureStatusText(); + const text = activityStatus ? `${connectionStatus} | ${activityStatus}` : connectionStatus; + statusText?.setText(theme.dim(text)); + } + lastActivityStatus = activityStatus; }; const setConnectionStatus = (text: string, ttlMs?: number) => {