refactor(tui): modernize active status animation

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 5415e68014
commit 6eba62606c
2 changed files with 60 additions and 6 deletions
+17 -2
View File
@@ -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" })
})
+43 -4
View File
@@ -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 {