mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
refactor(tui): modernize active status animation
This commit is contained in:
+17
-2
@@ -1030,8 +1030,22 @@ describe("NanobotTui layout", () => {
|
|||||||
await setup.renderOnce()
|
await setup.renderOnce()
|
||||||
let frame = setup.captureCharFrame()
|
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")
|
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({
|
app.accept({
|
||||||
event: "message",
|
event: "message",
|
||||||
@@ -1044,7 +1058,8 @@ describe("NanobotTui layout", () => {
|
|||||||
await setup.renderOnce()
|
await setup.renderOnce()
|
||||||
frame = setup.captureCharFrame()
|
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")
|
expect(frame).toContain("› exec")
|
||||||
app.accept({ event: "turn_end", chat_id: "chat" })
|
app.accept({ event: "turn_end", chat_id: "chat" })
|
||||||
})
|
})
|
||||||
|
|||||||
+43
-4
@@ -2,6 +2,7 @@ import {
|
|||||||
BoxRenderable,
|
BoxRenderable,
|
||||||
CliRenderEvents,
|
CliRenderEvents,
|
||||||
RGBA,
|
RGBA,
|
||||||
|
StyledText,
|
||||||
SyntaxStyle,
|
SyntaxStyle,
|
||||||
TextareaRenderable,
|
TextareaRenderable,
|
||||||
TextRenderable,
|
TextRenderable,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
type CliRenderer,
|
type CliRenderer,
|
||||||
type KeyEvent,
|
type KeyEvent,
|
||||||
type PasteEvent,
|
type PasteEvent,
|
||||||
|
type TextChunk,
|
||||||
type ThemeMode,
|
type ThemeMode,
|
||||||
type TreeSitterClient,
|
type TreeSitterClient,
|
||||||
} from "@opentui/core"
|
} from "@opentui/core"
|
||||||
@@ -122,6 +124,9 @@ const LIGHT: Palette = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const COMPOSER_PLACEHOLDER = "Ask nanobot anything"
|
const COMPOSER_PLACEHOLDER = "Ask nanobot anything"
|
||||||
|
const SHIMMER_PAUSE = 16
|
||||||
|
const SHIMMER_BAND = 4
|
||||||
|
const SHIMMER_INTERVAL_MS = 80
|
||||||
const LOCAL_COMMANDS: TuiCommand[] = [
|
const LOCAL_COMMANDS: TuiCommand[] = [
|
||||||
{
|
{
|
||||||
command: "/sessions",
|
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 {
|
function formatElapsed(milliseconds: number): string {
|
||||||
const seconds = Math.max(0, Math.floor(milliseconds / 1000))
|
const seconds = Math.max(0, Math.floor(milliseconds / 1000))
|
||||||
if (seconds < 60) return `${seconds}s`
|
if (seconds < 60) return `${seconds}s`
|
||||||
@@ -800,7 +836,7 @@ export class NanobotTui {
|
|||||||
this.shimmerTimer = setInterval(() => {
|
this.shimmerTimer = setInterval(() => {
|
||||||
this.shimmerFrame += 1
|
this.shimmerFrame += 1
|
||||||
this.renderActiveStatus()
|
this.renderActiveStatus()
|
||||||
}, 120)
|
}, SHIMMER_INTERVAL_MS)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.shimmerTimer) clearInterval(this.shimmerTimer)
|
if (this.shimmerTimer) clearInterval(this.shimmerTimer)
|
||||||
@@ -810,14 +846,17 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderActiveStatus(): void {
|
private renderActiveStatus(): void {
|
||||||
const frames = ["◐", "◓", "◑", "◒"]
|
|
||||||
const frame = frames[this.shimmerFrame % frames.length]
|
|
||||||
const elapsed = formatElapsed(Date.now() - this.activeStartedAt)
|
const elapsed = formatElapsed(Date.now() - this.activeStartedAt)
|
||||||
const progress = this.lastProgress
|
const progress = this.lastProgress
|
||||||
? ` · ${this.lastProgress.replace(/^\s*[·›✓×]\s*/u, "")}`
|
? ` · ${this.lastProgress.replace(/^\s*[·›✓×]\s*/u, "")}`
|
||||||
: ""
|
: ""
|
||||||
const navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : ""
|
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 {
|
private readyStatus(detail = ""): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user