From 6eba62606c461b0f6fde4c76312848be6886b178 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:20:53 +0800 Subject: [PATCH] refactor(tui): modernize active status animation --- tui/src/app.test.ts | 19 ++++++++++++++++-- tui/src/app.ts | 47 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index 1ecce3a4e..e0af6614a 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -1030,8 +1030,22 @@ describe("NanobotTui layout", () => { await setup.renderOnce() let frame = setup.captureCharFrame() - expect(frame).toMatch(/[◐◓◑◒] Thinking\s+0s/u) + expect(frame).toMatch(/Thinking\s+0s/u) + expect(frame).not.toMatch(/[◐◓◑◒⠋⠙⠹⠸]/u) expect(frame).not.toContain("hidden reasoning") + const status = (app as unknown as { + status: { + content: { chunks: Array<{ fg?: { toInts(): number[] } }> } + plainText: string + } + }).status + expect(status.plainText).toMatch(/^Thinking\s+0s/u) + const shimmerColors = new Set( + status.content.chunks + .slice(0, "Thinking".length) + .map((chunk) => chunk.fg?.toInts().join(",")), + ) + expect(shimmerColors.size).toBeGreaterThan(1) app.accept({ event: "message", @@ -1044,7 +1058,8 @@ describe("NanobotTui layout", () => { await setup.renderOnce() frame = setup.captureCharFrame() - expect(frame).toMatch(/[◐◓◑◒] Working\s+0s/u) + expect(frame).toMatch(/Working\s+0s/u) + expect(frame).not.toMatch(/[◐◓◑◒⠋⠙⠹⠸]/u) expect(frame).toContain("› exec") app.accept({ event: "turn_end", chat_id: "chat" }) }) diff --git a/tui/src/app.ts b/tui/src/app.ts index 20d9e5894..4039e48be 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -2,6 +2,7 @@ import { BoxRenderable, CliRenderEvents, RGBA, + StyledText, SyntaxStyle, TextareaRenderable, TextRenderable, @@ -12,6 +13,7 @@ import { type CliRenderer, type KeyEvent, type PasteEvent, + type TextChunk, type ThemeMode, type TreeSitterClient, } from "@opentui/core" @@ -122,6 +124,9 @@ const LIGHT: Palette = { } const COMPOSER_PLACEHOLDER = "Ask nanobot anything" +const SHIMMER_PAUSE = 16 +const SHIMMER_BAND = 4 +const SHIMMER_INTERVAL_MS = 80 const LOCAL_COMMANDS: TuiCommand[] = [ { command: "/sessions", @@ -219,6 +224,37 @@ function diffViewerTheme(palette: Palette, backgroundKnown: boolean): DiffViewer } } +function shimmerStatus( + label: string, + suffix: string, + frame: number, + palette: Palette, +): StyledText { + const chars = Array.from(label) + const base = RGBA.fromHex(palette.muted).toInts() + const highlight = RGBA.fromHex(palette.text).toInts() + // Sweep immediately, then leave a quiet pause before repeating. The text + // keeps a constant width throughout, so the footer never jitters. + const position = frame % (chars.length + SHIMMER_PAUSE) + const chunks: TextChunk[] = chars.map((text, index) => { + const distance = Math.abs(index - position) + const intensity = distance > SHIMMER_BAND + ? 0 + : (1 + Math.cos(Math.PI * distance / SHIMMER_BAND)) / 2 + return { + __isChunk: true, + text, + fg: RGBA.fromInts( + Math.round(base[0] + (highlight[0] - base[0]) * intensity), + Math.round(base[1] + (highlight[1] - base[1]) * intensity), + Math.round(base[2] + (highlight[2] - base[2]) * intensity), + ), + } + }) + chunks.push({ __isChunk: true, text: suffix, fg: RGBA.fromHex(palette.muted) }) + return new StyledText(chunks) +} + function formatElapsed(milliseconds: number): string { const seconds = Math.max(0, Math.floor(milliseconds / 1000)) if (seconds < 60) return `${seconds}s` @@ -800,7 +836,7 @@ export class NanobotTui { this.shimmerTimer = setInterval(() => { this.shimmerFrame += 1 this.renderActiveStatus() - }, 120) + }, SHIMMER_INTERVAL_MS) return } if (this.shimmerTimer) clearInterval(this.shimmerTimer) @@ -810,14 +846,17 @@ export class NanobotTui { } private renderActiveStatus(): void { - const frames = ["◐", "◓", "◑", "◒"] - const frame = frames[this.shimmerFrame % frames.length] const elapsed = formatElapsed(Date.now() - this.activeStartedAt) const progress = this.lastProgress ? ` · ${this.lastProgress.replace(/^\s*[·›✓×]\s*/u, "")}` : "" const navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : "" - this.status.content = `${frame} ${this.activeLabel} ${elapsed}${progress}${navigation}` + this.status.content = shimmerStatus( + this.activeLabel, + ` ${elapsed}${progress}${navigation}`, + this.shimmerFrame, + this.palette, + ) } private readyStatus(detail = ""): string {