Merge pull request #1196 from vignesh07/feat/tui-waiting-shimmer-clean
feat(tui): animated waiting status with shimmer effect ✨
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 <location> 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 <location> with `read`",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
41
src/tui/tui-waiting.test.ts
Normal file
41
src/tui/tui-waiting.test.ts
Normal 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
51
src/tui/tui-waiting.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user