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-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..acf810ef3 --- /dev/null +++ b/src/tui/tui-waiting.ts @@ -0,0 +1,51 @@ +type MinimalTheme = { + dim: (s: string) => string; + bold: (s: string) => string; + accentSoft: (s: string) => string; +}; + +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: MinimalTheme, 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: MinimalTheme; + 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 6848813d9..059cd3af7 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -22,6 +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, + defaultWaitingPhrases, +} from "./tui-waiting.js"; import { createOverlayHandlers } from "./tui-overlays.js"; import { createSessionActions } from "./tui-session-actions.js"; import type { @@ -286,9 +290,28 @@ export async function runTui(opts: TuiOptions) { statusContainer.addChild(statusLoader); }; + let waitingTick = 0; + let waitingTimer: NodeJS.Timeout | null = null; + let waitingPhrase: string | null = null; + const updateBusyStatusMessage = () => { if (!statusLoader || !statusStartedAt) return; const elapsed = formatElapsed(statusStartedAt); + + if (activityStatus === "waiting") { + waitingTick++; + statusLoader.setMessage( + buildWaitingStatusMessage({ + theme, + tick: waitingTick, + elapsed, + connectionStatus, + phrases: waitingPhrase ? [waitingPhrase] : undefined, + }), + ); + return; + } + statusLoader.setMessage(`${activityStatus} • ${elapsed} | ${connectionStatus}`); }; @@ -306,6 +329,31 @@ export async function runTui(opts: TuiOptions) { statusTimer = null; }; + 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(); + }, 120); + }; + + const stopWaitingTimer = () => { + if (!waitingTimer) return; + clearInterval(waitingTimer); + waitingTimer = null; + waitingPhrase = null; + }; + const renderStatus = () => { const isBusy = busyStates.has(activityStatus); if (isBusy) { @@ -313,11 +361,18 @@ export async function runTui(opts: TuiOptions) { statusStartedAt = Date.now(); } ensureStatusLoader(); + if (activityStatus === "waiting") { + stopStatusTimer(); + startWaitingTimer(); + } else { + stopWaitingTimer(); + startStatusTimer(); + } updateBusyStatusMessage(); - startStatusTimer(); } else { statusStartedAt = null; stopStatusTimer(); + stopWaitingTimer(); statusLoader?.stop(); statusLoader = null; ensureStatusText();