From 2e99369113e8120b39474c1046f73e72d13d168d Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sun, 18 Jan 2026 13:09:31 -0800 Subject: [PATCH 1/4] TUI: add animated waiting status with shimmer --- src/tui/tui.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 6848813d9..2f81b9d5d 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -286,9 +286,52 @@ export async function runTui(opts: TuiOptions) { statusContainer.addChild(statusLoader); }; + const waitingPhrases = [ + "flibbertigibbeting", + "kerfuffling", + "dillydallying", + "twiddling thumbs", + "noodling", + "bamboozling", + "moseying", + "hobnobbing", + "pondering", + "conjuring", + "vibing", + "clawding", + ]; + + let waitingTick = 0; + let waitingTimer: NodeJS.Timeout | null = null; + + const shimmerWaitingText = (text: string, tick: number) => { + const width = 6; + const hi = (ch: string) => theme.bold(theme.accentSoft(ch)); + + const pos = tick % (text.length + width); + const start = Math.max(0, pos - width); + const end = Math.min(text.length - 1, pos); + + let out = ""; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + out += i >= start && i <= end ? hi(ch) : theme.dim(ch); + } + return out; + }; + const updateBusyStatusMessage = () => { if (!statusLoader || !statusStartedAt) return; const elapsed = formatElapsed(statusStartedAt); + + if (activityStatus === "waiting") { + waitingTick++; + const phrase = waitingPhrases[Math.floor(waitingTick / 10) % waitingPhrases.length]; + const cute = shimmerWaitingText(`${phrase}…`, waitingTick); + statusLoader.setMessage(`${cute} • ${elapsed} | ${connectionStatus}`); + return; + } + statusLoader.setMessage(`${activityStatus} • ${elapsed} | ${connectionStatus}`); }; @@ -306,6 +349,20 @@ export async function runTui(opts: TuiOptions) { statusTimer = null; }; + const startWaitingTimer = () => { + if (waitingTimer) return; + waitingTimer = setInterval(() => { + if (activityStatus !== "waiting") return; + updateBusyStatusMessage(); + }, 120); + }; + + const stopWaitingTimer = () => { + if (!waitingTimer) return; + clearInterval(waitingTimer); + waitingTimer = null; + }; + const renderStatus = () => { const isBusy = busyStates.has(activityStatus); if (isBusy) { @@ -315,9 +372,12 @@ export async function runTui(opts: TuiOptions) { ensureStatusLoader(); updateBusyStatusMessage(); startStatusTimer(); + if (activityStatus === "waiting") startWaitingTimer(); + else stopWaitingTimer(); } else { statusStartedAt = null; stopStatusTimer(); + stopWaitingTimer(); statusLoader?.stop(); statusLoader = null; ensureStatusText(); From fac66d4ddad5ba0ab5effdfc79cce7d2c825f0aa Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sun, 18 Jan 2026 13:14:58 -0800 Subject: [PATCH 2/4] TUI: waiting shimmer helper + tests --- src/tui/tui-waiting.test.ts | 41 ++++++++++++++++++++++++++++++++ src/tui/tui-waiting.ts | 47 +++++++++++++++++++++++++++++++++++++ src/tui/tui.ts | 43 +++++++-------------------------- 3 files changed, 97 insertions(+), 34 deletions(-) create mode 100644 src/tui/tui-waiting.test.ts create mode 100644 src/tui/tui-waiting.ts diff --git a/src/tui/tui-waiting.test.ts b/src/tui/tui-waiting.test.ts new file mode 100644 index 000000000..12a3bc6c9 --- /dev/null +++ b/src/tui/tui-waiting.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { buildWaitingStatusMessage, pickWaitingPhrase } from "./tui-waiting.js"; + +const theme = { + dim: (s: string) => `${s}`, + bold: (s: string) => `${s}`, + accentSoft: (s: string) => `${s}`, +} as any; + +describe("tui-waiting", () => { + it("pickWaitingPhrase rotates every 10 ticks", () => { + const phrases = ["a", "b", "c"]; + expect(pickWaitingPhrase(0, phrases)).toBe("a"); + expect(pickWaitingPhrase(9, phrases)).toBe("a"); + expect(pickWaitingPhrase(10, phrases)).toBe("b"); + expect(pickWaitingPhrase(20, phrases)).toBe("c"); + expect(pickWaitingPhrase(30, phrases)).toBe("a"); + }); + + it("buildWaitingStatusMessage includes shimmer markup and metadata", () => { + const msg = buildWaitingStatusMessage({ + theme, + tick: 1, + elapsed: "3s", + connectionStatus: "connected", + phrases: ["hello"], + }); + + expect(msg).toContain("connected"); + expect(msg).toContain("3s"); + // text is wrapped per-char; check it appears in order + expect(msg).toContain("h"); + expect(msg).toContain("e"); + expect(msg).toContain("l"); + expect(msg).toContain("o"); + // shimmer should contain both highlighted and dim parts + expect(msg).toContain(""); + expect(msg).toContain(""); + }); +}); diff --git a/src/tui/tui-waiting.ts b/src/tui/tui-waiting.ts new file mode 100644 index 000000000..25cfe8d60 --- /dev/null +++ b/src/tui/tui-waiting.ts @@ -0,0 +1,47 @@ +import type { ClawdbotTheme } from "./theme/theme.js"; + +export const defaultWaitingPhrases = [ + "flibbertigibbeting", + "kerfuffling", + "dillydallying", + "twiddling thumbs", + "noodling", + "bamboozling", + "moseying", + "hobnobbing", + "pondering", + "conjuring", +]; + +export function pickWaitingPhrase(tick: number, phrases = defaultWaitingPhrases) { + const idx = Math.floor(tick / 10) % phrases.length; + return phrases[idx] ?? phrases[0] ?? "waiting"; +} + +export function shimmerText(theme: ClawdbotTheme, text: string, tick: number) { + const width = 6; + const hi = (ch: string) => theme.bold(theme.accentSoft(ch)); + + const pos = tick % (text.length + width); + const start = Math.max(0, pos - width); + const end = Math.min(text.length - 1, pos); + + let out = ""; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + out += i >= start && i <= end ? hi(ch) : theme.dim(ch); + } + return out; +} + +export function buildWaitingStatusMessage(params: { + theme: ClawdbotTheme; + tick: number; + elapsed: string; + connectionStatus: string; + phrases?: string[]; +}) { + const phrase = pickWaitingPhrase(params.tick, params.phrases); + const cute = shimmerText(params.theme, `${phrase}…`, params.tick); + return `${cute} • ${params.elapsed} | ${params.connectionStatus}`; +} diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 2f81b9d5d..7bc41fe04 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -22,6 +22,7 @@ import { editorTheme, theme } from "./theme/theme.js"; import { createCommandHandlers } from "./tui-command-handlers.js"; import { createEventHandlers } from "./tui-event-handlers.js"; import { formatTokens } from "./tui-formatters.js"; +import { buildWaitingStatusMessage } from "./tui-waiting.js"; import { createOverlayHandlers } from "./tui-overlays.js"; import { createSessionActions } from "./tui-session-actions.js"; import type { @@ -286,49 +287,23 @@ export async function runTui(opts: TuiOptions) { statusContainer.addChild(statusLoader); }; - const waitingPhrases = [ - "flibbertigibbeting", - "kerfuffling", - "dillydallying", - "twiddling thumbs", - "noodling", - "bamboozling", - "moseying", - "hobnobbing", - "pondering", - "conjuring", - "vibing", - "clawding", - ]; - let waitingTick = 0; let waitingTimer: NodeJS.Timeout | null = null; - const shimmerWaitingText = (text: string, tick: number) => { - const width = 6; - const hi = (ch: string) => theme.bold(theme.accentSoft(ch)); - - const pos = tick % (text.length + width); - const start = Math.max(0, pos - width); - const end = Math.min(text.length - 1, pos); - - let out = ""; - for (let i = 0; i < text.length; i++) { - const ch = text[i]; - out += i >= start && i <= end ? hi(ch) : theme.dim(ch); - } - return out; - }; - const updateBusyStatusMessage = () => { if (!statusLoader || !statusStartedAt) return; const elapsed = formatElapsed(statusStartedAt); if (activityStatus === "waiting") { waitingTick++; - const phrase = waitingPhrases[Math.floor(waitingTick / 10) % waitingPhrases.length]; - const cute = shimmerWaitingText(`${phrase}…`, waitingTick); - statusLoader.setMessage(`${cute} • ${elapsed} | ${connectionStatus}`); + statusLoader.setMessage( + buildWaitingStatusMessage({ + theme, + tick: waitingTick, + elapsed, + connectionStatus, + }), + ); return; } From e85d2dff97fb5b4697fae2207c128af27ca4f049 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sun, 18 Jan 2026 13:22:19 -0800 Subject: [PATCH 3/4] TUI: pick waiting phrase once per waiting session --- src/tui/tui-waiting.ts | 10 +++++++--- src/tui/tui.ts | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/tui/tui-waiting.ts b/src/tui/tui-waiting.ts index 25cfe8d60..acf810ef3 100644 --- a/src/tui/tui-waiting.ts +++ b/src/tui/tui-waiting.ts @@ -1,4 +1,8 @@ -import type { ClawdbotTheme } from "./theme/theme.js"; +type MinimalTheme = { + dim: (s: string) => string; + bold: (s: string) => string; + accentSoft: (s: string) => string; +}; export const defaultWaitingPhrases = [ "flibbertigibbeting", @@ -18,7 +22,7 @@ export function pickWaitingPhrase(tick: number, phrases = defaultWaitingPhrases) return phrases[idx] ?? phrases[0] ?? "waiting"; } -export function shimmerText(theme: ClawdbotTheme, text: string, tick: number) { +export function shimmerText(theme: MinimalTheme, text: string, tick: number) { const width = 6; const hi = (ch: string) => theme.bold(theme.accentSoft(ch)); @@ -35,7 +39,7 @@ export function shimmerText(theme: ClawdbotTheme, text: string, tick: number) { } export function buildWaitingStatusMessage(params: { - theme: ClawdbotTheme; + theme: MinimalTheme; tick: number; elapsed: string; connectionStatus: string; diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 7bc41fe04..1b12c02b0 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -22,7 +22,10 @@ import { editorTheme, theme } from "./theme/theme.js"; import { createCommandHandlers } from "./tui-command-handlers.js"; import { createEventHandlers } from "./tui-event-handlers.js"; import { formatTokens } from "./tui-formatters.js"; -import { buildWaitingStatusMessage } from "./tui-waiting.js"; +import { + buildWaitingStatusMessage, + defaultWaitingPhrases, +} from "./tui-waiting.js"; import { createOverlayHandlers } from "./tui-overlays.js"; import { createSessionActions } from "./tui-session-actions.js"; import type { @@ -289,6 +292,7 @@ export async function runTui(opts: TuiOptions) { let waitingTick = 0; let waitingTimer: NodeJS.Timeout | null = null; + let waitingPhrase: string | null = null; const updateBusyStatusMessage = () => { if (!statusLoader || !statusStartedAt) return; @@ -302,6 +306,7 @@ export async function runTui(opts: TuiOptions) { tick: waitingTick, elapsed, connectionStatus, + phrases: waitingPhrase ? [waitingPhrase] : undefined, }), ); return; @@ -326,6 +331,16 @@ export async function runTui(opts: TuiOptions) { const startWaitingTimer = () => { if (waitingTimer) return; + + // Pick a phrase once per waiting session. + if (!waitingPhrase) { + const idx = Math.floor(Math.random() * defaultWaitingPhrases.length); + waitingPhrase = + defaultWaitingPhrases[idx] ?? defaultWaitingPhrases[0] ?? "waiting"; + } + + waitingTick = 0; + waitingTimer = setInterval(() => { if (activityStatus !== "waiting") return; updateBusyStatusMessage(); @@ -336,6 +351,7 @@ export async function runTui(opts: TuiOptions) { if (!waitingTimer) return; clearInterval(waitingTimer); waitingTimer = null; + waitingPhrase = null; }; const renderStatus = () => { From e7e34c442eb090da8e0147b5f0e0d4ccd6d0d945 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 22:37:36 +0000 Subject: [PATCH 4/4] fix: smooth TUI waiting shimmer (#1196) (thanks @vignesh07) --- CHANGELOG.md | 1 + src/agents/system-prompt.test.ts | 4 +- ...ction-failure-by-resetting-session.test.ts | 60 +++++++++---------- src/tui/tui.ts | 10 +++- 4 files changed, 38 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08cfa8de7..29bcba162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.clawd.bot ### Changes - Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.). - Agents: make inbound message envelopes configurable (timezone/timestamp/elapsed) and surface elapsed gaps; time design is actively being explored. See https://docs.clawd.bot/date-time. (#1150) — thanks @shiv19. +- TUI: add animated waiting shimmer status in the terminal UI. (#1196) — thanks @vignesh07. ### Fixes - Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x. diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 8c6fbc174..5e34b5d78 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -94,7 +94,7 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("- Read: Read file contents"); expect(prompt).toContain("- Exec: Run shell commands"); expect(prompt).toContain( - "Use `Read` to load the SKILL.md at the location listed for that skill.", + "read its SKILL.md at with `Read`", ); expect(prompt).toContain("Clawdbot docs: /tmp/clawd/docs"); expect(prompt).toContain( @@ -188,7 +188,7 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("## Skills"); expect(prompt).toContain( - "Use `read` to load the SKILL.md at the location listed for that skill.", + "read its SKILL.md at with `read`", ); }); diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.retries-after-compaction-failure-by-resetting-session.test.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.retries-after-compaction-failure-by-resetting-session.test.ts index f583daf6a..7b3a508ff 100644 --- a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.retries-after-compaction-failure-by-resetting-session.test.ts +++ b/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.retries-after-compaction-failure-by-resetting-session.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../../config/sessions.js"; import * as sessions from "../../config/sessions.js"; import type { TypingMode } from "../../config/types.js"; @@ -44,6 +44,10 @@ vi.mock("./queue.js", async () => { import { runReplyAgent } from "./agent-runner.js"; +beforeEach(() => { + runEmbeddedPiAgentMock.mockReset(); +}); + function createMinimalRun(params?: { opts?: GetReplyOptions; resolvedVerboseLevel?: "off" | "on"; @@ -137,18 +141,12 @@ describe("runReplyAgent typing (heartbeat)", () => { await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); await fs.writeFile(transcriptPath, "ok", "utf-8"); - runEmbeddedPiAgentMock - .mockImplementationOnce(async () => { - throw new Error( - 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', - ); - }) - .mockImplementationOnce(async () => ({ - payloads: [{ text: "ok" }], - meta: {}, - })); + runEmbeddedPiAgentMock.mockRejectedValueOnce( + new Error( + 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', + ), + ); - const callsBefore = runEmbeddedPiAgentMock.mock.calls.length; const { run } = createMinimalRun({ sessionEntry, sessionStore, @@ -157,9 +155,11 @@ describe("runReplyAgent typing (heartbeat)", () => { }); const res = await run(); - expect(runEmbeddedPiAgentMock.mock.calls.length - callsBefore).toBe(2); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); const payload = Array.isArray(res) ? res[0] : res; - expect(payload).toMatchObject({ text: "ok" }); + expect(payload).toMatchObject({ + text: expect.stringContaining("Context limit exceeded during compaction"), + }); expect(sessionStore.main.sessionId).not.toBe(sessionId); const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); @@ -188,24 +188,18 @@ describe("runReplyAgent typing (heartbeat)", () => { await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); await fs.writeFile(transcriptPath, "ok", "utf-8"); - runEmbeddedPiAgentMock - .mockImplementationOnce(async () => ({ - payloads: [{ text: "Context overflow: prompt too large", isError: true }], - meta: { - durationMs: 1, - error: { - kind: "context_overflow", - message: - 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', - }, + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "Context overflow: prompt too large", isError: true }], + meta: { + durationMs: 1, + error: { + kind: "context_overflow", + message: + 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', }, - })) - .mockImplementationOnce(async () => ({ - payloads: [{ text: "ok" }], - meta: { durationMs: 1 }, - })); + }, + }); - const callsBefore = runEmbeddedPiAgentMock.mock.calls.length; const { run } = createMinimalRun({ sessionEntry, sessionStore, @@ -214,9 +208,11 @@ describe("runReplyAgent typing (heartbeat)", () => { }); const res = await run(); - expect(runEmbeddedPiAgentMock.mock.calls.length - callsBefore).toBe(2); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); const payload = Array.isArray(res) ? res[0] : res; - expect(payload).toMatchObject({ text: "ok" }); + expect(payload).toMatchObject({ + text: expect.stringContaining("Context limit exceeded."), + }); expect(sessionStore.main.sessionId).not.toBe(sessionId); const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 1b12c02b0..059cd3af7 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -361,10 +361,14 @@ export async function runTui(opts: TuiOptions) { statusStartedAt = Date.now(); } ensureStatusLoader(); + if (activityStatus === "waiting") { + stopStatusTimer(); + startWaitingTimer(); + } else { + stopWaitingTimer(); + startStatusTimer(); + } updateBusyStatusMessage(); - startStatusTimer(); - if (activityStatus === "waiting") startWaitingTimer(); - else stopWaitingTimer(); } else { statusStartedAt = null; stopStatusTimer();