Merge pull request #1196 from vignesh07/feat/tui-waiting-shimmer-clean

feat(tui): animated waiting status with shimmer effect 
This commit is contained in:
Peter Steinberger 2026-01-18 22:38:08 +00:00 committed by GitHub
commit a86d7a2f35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 179 additions and 35 deletions

View File

@ -7,6 +7,7 @@ Docs: https://docs.clawd.bot
### Changes ### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.). - 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. - 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 ### Fixes
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x. - Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.

View File

@ -94,7 +94,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("- Read: Read file contents"); expect(prompt).toContain("- Read: Read file contents");
expect(prompt).toContain("- Exec: Run shell commands"); expect(prompt).toContain("- Exec: Run shell commands");
expect(prompt).toContain( expect(prompt).toContain(
"Use `Read` to load the SKILL.md at the location listed for that skill.", "read its SKILL.md at <location> with `Read`",
); );
expect(prompt).toContain("Clawdbot docs: /tmp/clawd/docs"); expect(prompt).toContain("Clawdbot docs: /tmp/clawd/docs");
expect(prompt).toContain( expect(prompt).toContain(
@ -188,7 +188,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("## Skills"); expect(prompt).toContain("## Skills");
expect(prompt).toContain( expect(prompt).toContain(
"Use `read` to load the SKILL.md at the location listed for that skill.", "read its SKILL.md at <location> with `read`",
); );
}); });

View File

@ -1,7 +1,7 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; 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 type { SessionEntry } from "../../config/sessions.js";
import * as sessions from "../../config/sessions.js"; import * as sessions from "../../config/sessions.js";
import type { TypingMode } from "../../config/types.js"; import type { TypingMode } from "../../config/types.js";
@ -44,6 +44,10 @@ vi.mock("./queue.js", async () => {
import { runReplyAgent } from "./agent-runner.js"; import { runReplyAgent } from "./agent-runner.js";
beforeEach(() => {
runEmbeddedPiAgentMock.mockReset();
});
function createMinimalRun(params?: { function createMinimalRun(params?: {
opts?: GetReplyOptions; opts?: GetReplyOptions;
resolvedVerboseLevel?: "off" | "on"; resolvedVerboseLevel?: "off" | "on";
@ -137,18 +141,12 @@ describe("runReplyAgent typing (heartbeat)", () => {
await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
await fs.writeFile(transcriptPath, "ok", "utf-8"); await fs.writeFile(transcriptPath, "ok", "utf-8");
runEmbeddedPiAgentMock runEmbeddedPiAgentMock.mockRejectedValueOnce(
.mockImplementationOnce(async () => { new Error(
throw new Error( 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', ),
); );
})
.mockImplementationOnce(async () => ({
payloads: [{ text: "ok" }],
meta: {},
}));
const callsBefore = runEmbeddedPiAgentMock.mock.calls.length;
const { run } = createMinimalRun({ const { run } = createMinimalRun({
sessionEntry, sessionEntry,
sessionStore, sessionStore,
@ -157,9 +155,11 @@ describe("runReplyAgent typing (heartbeat)", () => {
}); });
const res = await run(); const res = await run();
expect(runEmbeddedPiAgentMock.mock.calls.length - callsBefore).toBe(2); expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
const payload = Array.isArray(res) ? res[0] : res; 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); expect(sessionStore.main.sessionId).not.toBe(sessionId);
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); 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.mkdir(path.dirname(transcriptPath), { recursive: true });
await fs.writeFile(transcriptPath, "ok", "utf-8"); await fs.writeFile(transcriptPath, "ok", "utf-8");
runEmbeddedPiAgentMock runEmbeddedPiAgentMock.mockResolvedValueOnce({
.mockImplementationOnce(async () => ({ payloads: [{ text: "Context overflow: prompt too large", isError: true }],
payloads: [{ text: "Context overflow: prompt too large", isError: true }], meta: {
meta: { durationMs: 1,
durationMs: 1, error: {
error: { kind: "context_overflow",
kind: "context_overflow", message:
message: 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
'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({ const { run } = createMinimalRun({
sessionEntry, sessionEntry,
sessionStore, sessionStore,
@ -214,9 +208,11 @@ describe("runReplyAgent typing (heartbeat)", () => {
}); });
const res = await run(); const res = await run();
expect(runEmbeddedPiAgentMock.mock.calls.length - callsBefore).toBe(2); expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
const payload = Array.isArray(res) ? res[0] : res; 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); expect(sessionStore.main.sessionId).not.toBe(sessionId);
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));

View File

@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { buildWaitingStatusMessage, pickWaitingPhrase } from "./tui-waiting.js";
const theme = {
dim: (s: string) => `<d>${s}</d>`,
bold: (s: string) => `<b>${s}</b>`,
accentSoft: (s: string) => `<a>${s}</a>`,
} 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("<b><a>");
expect(msg).toContain("<d>");
});
});

51
src/tui/tui-waiting.ts Normal file
View File

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

View File

@ -22,6 +22,10 @@ import { editorTheme, theme } from "./theme/theme.js";
import { createCommandHandlers } from "./tui-command-handlers.js"; import { createCommandHandlers } from "./tui-command-handlers.js";
import { createEventHandlers } from "./tui-event-handlers.js"; import { createEventHandlers } from "./tui-event-handlers.js";
import { formatTokens } from "./tui-formatters.js"; import { formatTokens } from "./tui-formatters.js";
import {
buildWaitingStatusMessage,
defaultWaitingPhrases,
} from "./tui-waiting.js";
import { createOverlayHandlers } from "./tui-overlays.js"; import { createOverlayHandlers } from "./tui-overlays.js";
import { createSessionActions } from "./tui-session-actions.js"; import { createSessionActions } from "./tui-session-actions.js";
import type { import type {
@ -286,9 +290,28 @@ export async function runTui(opts: TuiOptions) {
statusContainer.addChild(statusLoader); statusContainer.addChild(statusLoader);
}; };
let waitingTick = 0;
let waitingTimer: NodeJS.Timeout | null = null;
let waitingPhrase: string | null = null;
const updateBusyStatusMessage = () => { const updateBusyStatusMessage = () => {
if (!statusLoader || !statusStartedAt) return; if (!statusLoader || !statusStartedAt) return;
const elapsed = formatElapsed(statusStartedAt); 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}`); statusLoader.setMessage(`${activityStatus}${elapsed} | ${connectionStatus}`);
}; };
@ -306,6 +329,31 @@ export async function runTui(opts: TuiOptions) {
statusTimer = null; 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 renderStatus = () => {
const isBusy = busyStates.has(activityStatus); const isBusy = busyStates.has(activityStatus);
if (isBusy) { if (isBusy) {
@ -313,11 +361,18 @@ export async function runTui(opts: TuiOptions) {
statusStartedAt = Date.now(); statusStartedAt = Date.now();
} }
ensureStatusLoader(); ensureStatusLoader();
if (activityStatus === "waiting") {
stopStatusTimer();
startWaitingTimer();
} else {
stopWaitingTimer();
startStatusTimer();
}
updateBusyStatusMessage(); updateBusyStatusMessage();
startStatusTimer();
} else { } else {
statusStartedAt = null; statusStartedAt = null;
stopStatusTimer(); stopStatusTimer();
stopWaitingTimer();
statusLoader?.stop(); statusLoader?.stop();
statusLoader = null; statusLoader = null;
ensureStatusText(); ensureStatusText();