feat(tui): make follow-up queue explicit

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 2f7355426d
commit 2f78f7fbc5
14 changed files with 495 additions and 93 deletions
+5 -2
View File
@@ -21,8 +21,11 @@ Type `/` to discover slash commands published by the connected gateway. Use the
to move, `Tab` to complete, and `Esc` to close the menu.
Type `@` to complete installed CLI apps, configured MCP servers, or saved sessions through the
same gateway metadata used by the WebUI. While nanobot is working, `Enter` queues a follow-up;
press `Enter` again on an empty composer to steer the current turn with the newest queued prompt.
same gateway metadata used by the WebUI. While nanobot is working, `Enter` steers the current
turn, `Tab` queues a follow-up for the next turn, and `Alt+Up` returns the latest queued message
to the composer for editing. The pending queue stays visible above the composer.
Use `Ctrl+J` for a newline; `Shift+Enter`, `Alt+Enter`, and `Ctrl+Enter` are also accepted when
the terminal can distinguish them.
Unsent prompts return to the composer if the turn stops or fails.
Use `/sessions` to search and switch persisted conversations without leaving the terminal.
+57 -6
View File
@@ -167,6 +167,32 @@ describe("NanobotTui layout", () => {
expect(sent).toEqual(["你好"])
})
test("inserts a newline without sending and gives the composer breathing room", async () => {
const sent: string[] = []
setup = await createRenderer({
width: 72,
height: 20,
screenMode: "alternate-screen",
kittyKeyboard: true,
})
const app = mount(setup, sent)
app.accept({ event: "attached", chat_id: "chat" })
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
const ui = app as unknown as {
composer: TextareaRenderable
composerFrame: { height: number }
}
await setup.mockInput.typeText("first")
setup.mockInput.pressKey("j", { ctrl: true })
await setup.mockInput.typeText("second")
await setup.flush()
expect(ui.composer.plainText).toBe("first\nsecond")
expect(sent).toEqual([])
expect(ui.composerFrame.height).toBeGreaterThanOrEqual(3)
})
test("clears the placeholder on the first typed character", async () => {
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
const app = mount(setup)
@@ -214,7 +240,7 @@ describe("NanobotTui layout", () => {
expect(ui.composer.plainText).toBe("")
})
test("queues follow-ups and promotes the armed prompt to steering", async () => {
test("steers with Enter, queues with Tab, and restores queued text with Alt+Up", async () => {
const sent: string[] = []
const sentOptions: MessageOptions[] = []
setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" })
@@ -229,6 +255,7 @@ describe("NanobotTui layout", () => {
ready: boolean
composer: TextareaRenderable
mentionCandidates: Array<Record<string, unknown>>
queuePreview: { root: { visible: boolean } }
}
await waitUntil(() => ui.ready)
ui.mentionCandidates = [{
@@ -243,10 +270,6 @@ describe("NanobotTui layout", () => {
await waitUntil(() => sent.length === 1)
ui.composer.setText("ask @github next")
ui.composer.submit()
await waitUntil(() => ui.composer.plainText === "")
expect(sent).toEqual(["first"])
ui.composer.submit()
await waitUntil(() => sent.length === 2)
expect(sentOptions[1]).toEqual({
@@ -256,8 +279,17 @@ describe("NanobotTui layout", () => {
})
ui.composer.setText("after this turn")
ui.composer.submit()
setup.mockInput.pressTab()
await waitUntil(() => ui.composer.plainText === "")
expect(sent).toHaveLength(2)
expect(ui.queuePreview.root.visible).toBeTrue()
setup.mockInput.pressArrow("up", { meta: true })
expect(ui.composer.plainText).toBe("after this turn")
expect(ui.queuePreview.root.visible).toBeFalse()
setup.mockInput.pressTab()
await waitUntil(() => ui.composer.plainText === "")
app.accept({
event: "error",
chat_id: "chat",
@@ -272,6 +304,7 @@ describe("NanobotTui layout", () => {
app.accept({ event: "turn_end", chat_id: "chat", turn_id: "turn" })
await waitUntil(() => sent.length === 3)
expect(sent[2]).toBe("after this turn")
expect(ui.queuePreview.root.visible).toBeFalse()
app.accept({ event: "goal_status", chat_id: "chat", status: "idle", turn_id: "prior" })
expect((app as unknown as { activeTurn: boolean }).activeTurn).toBeTrue()
})
@@ -1558,6 +1591,24 @@ describe("NanobotTui layout", () => {
expect(closed).toBe(true)
expect(setup.renderer.isDestroyed).toBe(true)
})
test("exits immediately when Ctrl+C is pressed on an idle empty composer", async () => {
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
let closed = false
const transport = client()
transport.close = () => { closed = true }
NanobotTui.mount(
setup.renderer,
options,
transport,
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
)
setup.mockInput.pressCtrlC()
expect(closed).toBe(true)
expect(setup.renderer.isDestroyed).toBe(true)
})
})
if (process.platform !== "win32") {
+109 -69
View File
@@ -66,6 +66,12 @@ import {
type MentionQuery,
} from "./mention-menu"
import { PromptQueue, type QueuedPrompt } from "./prompt-queue"
import { QueuePreview, type QueuePreviewTheme } from "./queue-preview"
import {
contextualFooterHints,
type FooterMode,
type FooterHintTheme,
} from "./footer-hints"
interface AppOptions {
wsUrl: string
@@ -248,6 +254,23 @@ function diffViewerTheme(palette: Palette, backgroundKnown: boolean): DiffViewer
}
}
function queuePreviewTheme(palette: Palette): QueuePreviewTheme {
return {
accent: palette.accent,
muted: palette.muted,
faint: palette.faint,
}
}
function footerHintTheme(palette: Palette): FooterHintTheme {
return {
accent: palette.accent,
danger: palette.error,
muted: palette.muted,
separator: palette.faint,
}
}
function shimmerStatus(
label: string,
suffix: string,
@@ -329,6 +352,7 @@ export class NanobotTui {
private readonly branchMenu: BranchMenu
private readonly contextPanel: ContextPanel
private readonly diffViewer: DiffViewer
private readonly queuePreview: QueuePreview
private readonly client: ChatClient
private readonly shell: BoxRenderable
private readonly title: BoxRenderable
@@ -422,6 +446,7 @@ export class NanobotTui {
diffViewerTheme(this.palette, this.backgroundKnown),
treeSitterClient,
)
this.queuePreview = new QueuePreview(renderer, queuePreviewTheme(this.palette))
this.client = client || new NanobotClient({
url: options.wsUrl,
chatId: options.chatId,
@@ -494,8 +519,12 @@ export class NanobotTui {
cursorColor: this.palette.accent,
showCursor: true,
keyBindings: [
{ name: "return", action: "submit" },
{ name: "return", shift: true, action: "newline" },
{ name: "return", meta: true, action: "newline" },
{ name: "return", ctrl: true, action: "newline" },
{ name: "j", ctrl: true, action: "newline" },
{ name: "linefeed", action: "newline" },
{ name: "return", action: "submit" },
],
onContentChange: () => {
this.draft.prune(this.composer.plainText)
@@ -523,7 +552,7 @@ export class NanobotTui {
})
this.meta = new TextRenderable(renderer, {
id: "nanobot-tui-meta",
content: "enter send · alt+enter newline · ctrl+c stop",
content: "",
fg: this.palette.faint,
height: 1,
width: "auto",
@@ -549,6 +578,7 @@ export class NanobotTui {
this.shell.add(this.branchMenu.root)
this.shell.add(this.contextPanel.root)
this.shell.add(this.title)
this.shell.add(this.queuePreview.root)
this.shell.add(this.composerFrame)
this.shell.add(statusRow)
this.shell.add(this.diffViewer.root)
@@ -640,13 +670,7 @@ export class NanobotTui {
if (candidate) this.chooseMention(candidate, this.activeMentionQuery)
return
}
if (!visibleContent) {
if (this.activeTurn) {
const steering = this.promptQueue.takeSteering()
if (steering) this.sendPrompt(steering, true)
}
return
}
if (!visibleContent) return
const completion = this.commandMenu.completion(visibleContent)
if (completion) {
this.setComposer(completion)
@@ -678,13 +702,7 @@ export class NanobotTui {
}
const prompt = { content, options: mentionOptions(content, this.availableMentions()) }
if (this.activeTurn) {
this.promptQueue.enqueue(prompt)
this.clearComposer()
this.commandMenu.hide()
this.mentionMenu.hide()
this.recordPrompt(content)
this.renderActiveStatus()
this.updateMeta()
this.sendPrompt(prompt, true)
return
}
this.sendPrompt(prompt)
@@ -1014,16 +1032,56 @@ export class NanobotTui {
if (!this.ready || this.activeTurn || this.quitting) return
const prompt = this.promptQueue.takeFollowUp()
if (!prompt) return
this.syncQueuePreview()
this.sendPrompt(prompt)
}
private restoreQueuedPrompts(): void {
const queued = this.promptQueue.restore()
if (!queued.length) return
this.syncQueuePreview()
const current = this.draft.expand(this.composer.plainText).trim()
this.setComposer([current, ...queued.map((prompt) => prompt.content)].filter(Boolean).join("\n\n"))
}
private queueFollowUp(): void {
if (!this.activeTurn || !this.ready) return
const visibleContent = this.composer.plainText.trim()
const content = this.draft.expand(visibleContent).trim()
if (!content) return
this.promptQueue.enqueue({
content,
options: mentionOptions(content, this.availableMentions()),
})
this.clearComposer()
this.commandMenu.hide()
this.mentionMenu.hide()
this.recordPrompt(content)
this.syncQueuePreview()
this.renderActiveStatus()
this.updateMeta()
}
private editLastFollowUp(): boolean {
const prompt = this.promptQueue.takeLast()
if (!prompt) return false
const current = this.draft.expand(this.composer.plainText).trim()
this.setComposer([prompt.content, current].filter(Boolean).join("\n\n"))
this.syncQueuePreview()
this.renderActiveStatus()
this.updateMeta()
return true
}
private clearPromptQueue(): void {
this.promptQueue.clear()
this.syncQueuePreview()
}
private syncQueuePreview(): void {
this.queuePreview.update(this.promptQueue.snapshot().map(({ content }) => content))
}
private handleTranscriptNavigation(state: TranscriptNavigation): void {
this.transcriptNavigation = state
if (this.activeTurn) this.renderActiveStatus()
@@ -1124,6 +1182,15 @@ export class NanobotTui {
return
}
}
if (this.activeTurn && !key.ctrl && !key.meta && key.name === "tab") {
this.queueFollowUp()
key.preventDefault()
return
}
if (this.activeTurn && key.meta && key.name === "up") {
if (this.editLastFollowUp()) key.preventDefault()
return
}
if (key.ctrl && key.name === "o") {
const expanded = this.transcript.toggleActivityDetails()
if (expanded === null) return
@@ -1227,6 +1294,7 @@ export class NanobotTui {
this.branchMenu.setTheme(commandMenuTheme(this.palette))
this.contextPanel.setTheme(contextPanelTheme(this.palette))
this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown))
this.queuePreview.setTheme(queuePreviewTheme(this.palette))
this.updateComposerAppearance()
this.composer.textColor = this.palette.text
this.composer.focusedTextColor = this.palette.text
@@ -1235,6 +1303,7 @@ export class NanobotTui {
this.modelText.fg = this.palette.muted
this.status.fg = this.palette.muted
this.meta.fg = this.palette.faint
this.updateMeta()
}
private handleResize = (): void => {
@@ -1247,53 +1316,19 @@ export class NanobotTui {
}
private updateMeta(): void {
if (this.mentionMenu.visible) {
this.meta.content = this.renderer.width >= 64
? "↑↓ choose · tab/enter insert · esc close"
: "enter insert · esc close"
return
}
if (this.activeTurn) {
this.meta.content = this.renderer.width >= 96
? "enter queue · enter again steer · ctrl+c stop"
: this.renderer.width >= 64 ? "enter queue · ctrl+c stop" : ""
return
}
if (this.branchMenu.visible) {
this.meta.content = this.renderer.width >= 64
? "type to filter · ↑↓ choose · enter branch · esc close"
: "enter branch · esc close"
return
}
if (this.commandMenu.visible) {
this.meta.content = this.renderer.width >= 72
? "↑↓ choose · tab complete · esc close"
: "tab complete · esc close"
return
}
if (this.sessionMenu.visible) {
this.meta.content = this.renderer.width >= 64
? "type to filter · ↑↓ choose · enter open · esc close"
: "enter open · esc close"
return
}
if (this.contextPanel.visible) {
this.meta.content = "esc close · pgup/pgdn scroll"
return
}
if (this.transcriptNavigation.awayFromBottom) {
this.meta.content = this.renderer.width >= 72
? "ctrl+end latest · pgup/pgdn scroll"
: this.renderer.width >= 48 ? "ctrl+end latest" : ""
return
}
this.meta.content = this.renderer.width >= 112
? "enter send · alt+enter newline · pgup/pgdn scroll · ctrl+o tools · ctrl+c stop"
: this.renderer.width >= 72
? "enter send · alt+enter newline · ctrl+c stop"
: this.renderer.width >= 48
? "enter send · alt+enter newline"
: ""
const mode: FooterMode = this.mentionMenu.visible ? "mention"
: this.activeTurn ? "active"
: this.branchMenu.visible ? "branch"
: this.commandMenu.visible ? "command"
: this.sessionMenu.visible ? "session"
: this.contextPanel.visible ? "context"
: this.transcriptNavigation.awayFromBottom ? "history"
: "ready"
this.meta.content = contextualFooterHints(
mode,
this.renderer.width,
footerHintTheme(this.palette),
)
}
private setTurnModel(model: string, preset?: string | null): void {
@@ -1342,9 +1377,14 @@ export class NanobotTui {
}
private resizeComposer(): void {
const maxHeight = Math.max(1, Math.min(12, Math.floor(this.renderer.height / 3)))
this.composer.maxHeight = maxHeight
this.composerFrame.maxHeight = maxHeight
const verticalPadding = this.renderer.height >= 12 ? 1 : 0
const maxContentHeight = Math.max(1, Math.min(12, Math.floor(this.renderer.height / 3)))
this.composer.minHeight = 1
this.composer.maxHeight = maxContentHeight
this.composerFrame.paddingTop = verticalPadding
this.composerFrame.paddingBottom = verticalPadding
this.composerFrame.minHeight = 1 + verticalPadding * 2
this.composerFrame.maxHeight = maxContentHeight + verticalPadding * 2
}
private composerSurface(): RGBA {
@@ -1513,7 +1553,7 @@ export class NanobotTui {
try {
if (!this.client.forkChat) throw new Error("branching is unavailable")
this.ready = false
this.promptQueue.clear()
this.clearPromptQueue()
this.sessionMetadataId += 1
this.sessionTitle = `Fork · ${preview.slice(0, 48)}`
this.contextTokens = null
@@ -1596,7 +1636,7 @@ export class NanobotTui {
this.closeSessions()
try {
this.ready = false
this.promptQueue.clear()
this.clearPromptQueue()
this.sessionMetadataId += 1
this.sessionTitle = sessionLabel(session)
this.applySessionModel(session)
@@ -1628,7 +1668,7 @@ export class NanobotTui {
this.clearComposer()
try {
this.ready = false
this.promptQueue.clear()
this.clearPromptQueue()
this.sessionMetadataId += 1
this.sessionTitle = "New chat"
this.sessionModelPreset = null
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test"
import { contextualFooterHints, footerHints } from "./footer-hints"
const theme = {
accent: "#EF8E30",
danger: "#F87171",
muted: "#A1A1AA",
separator: "#71717A",
}
describe("footerHints", () => {
test("separates normal and destructive shortcuts semantically", () => {
const result = footerHints([
{ key: "enter", label: "steer" },
{ key: "ctrl+c", label: "stop", tone: "danger" },
], theme)
expect(result.chunks.map(({ text }) => text).join("")).toBe("enter steer · ctrl+c stop")
expect(result.chunks[0]?.fg?.toInts().slice(0, 3)).toEqual([239, 142, 48])
expect(result.chunks[3]?.fg?.toInts().slice(0, 3)).toEqual([248, 113, 113])
})
test("adapts the active-turn vocabulary to available width", () => {
const wide = contextualFooterHints("active", 100, theme)
const compact = contextualFooterHints("active", 72, theme)
expect(wide.chunks.map(({ text }) => text).join(""))
.toBe("enter steer · tab queue · alt+↑ edit · ctrl+c stop")
expect(compact.chunks.map(({ text }) => text).join(""))
.toBe("enter steer · tab queue · ctrl+c stop")
})
})
+92
View File
@@ -0,0 +1,92 @@
import { RGBA, StyledText, TextAttributes, type TextChunk } from "@opentui/core"
export interface FooterHint {
key: string
label: string
tone?: "normal" | "danger"
}
export interface FooterHintTheme {
accent: string
danger: string
muted: string
separator: string
}
export type FooterMode =
| "mention"
| "active"
| "branch"
| "command"
| "session"
| "context"
| "history"
| "ready"
export function contextualFooterHints(
mode: FooterMode,
width: number,
theme: FooterHintTheme,
): StyledText {
return footerHints(hintsFor(mode, width), theme)
}
/** Give shortcuts visual hierarchy without turning the footer into a toolbar. */
export function footerHints(hints: readonly FooterHint[], theme: FooterHintTheme): StyledText {
const chunks: TextChunk[] = []
hints.forEach((hint, index) => {
if (index) chunks.push(chunk(" · ", theme.separator))
const color = hint.tone === "danger" ? theme.danger : theme.accent
chunks.push(chunk(hint.key, color, true))
chunks.push(chunk(` ${hint.label}`, theme.muted))
})
return new StyledText(chunks)
}
function hintsFor(mode: FooterMode, width: number): FooterHint[] {
if (mode === "mention") return width >= 64
? [hint("↑↓", "choose"), hint("tab/enter", "insert"), hint("esc", "close")]
: [hint("enter", "insert"), hint("esc", "close")]
if (mode === "active") return width >= 96
? [hint("enter", "steer"), hint("tab", "queue"), hint("alt+↑", "edit"), stopHint()]
: width >= 64 ? [hint("enter", "steer"), hint("tab", "queue"), stopHint()] : []
if (mode === "branch") return width >= 64
? [hint("type", "filter"), hint("↑↓", "choose"), hint("enter", "branch"), hint("esc", "close")]
: [hint("enter", "branch"), hint("esc", "close")]
if (mode === "command") return width >= 72
? [hint("↑↓", "choose"), hint("tab", "complete"), hint("esc", "close")]
: [hint("tab", "complete"), hint("esc", "close")]
if (mode === "session") return width >= 64
? [hint("type", "filter"), hint("↑↓", "choose"), hint("enter", "open"), hint("esc", "close")]
: [hint("enter", "open"), hint("esc", "close")]
if (mode === "context") return [hint("esc", "close"), hint("pgup/pgdn", "scroll")]
if (mode === "history") return width >= 72
? [hint("ctrl+end", "latest"), hint("pgup/pgdn", "scroll")]
: width >= 48 ? [hint("ctrl+end", "latest")] : []
if (width >= 112) return [
hint("enter", "send"),
hint("ctrl+j", "newline"),
hint("pgup/pgdn", "scroll"),
hint("ctrl+o", "tools"),
stopHint(),
]
if (width >= 72) return [hint("enter", "send"), hint("ctrl+j", "newline"), stopHint()]
return width >= 48 ? [hint("enter", "send"), hint("ctrl+j", "newline")] : []
}
function hint(key: string, label: string): FooterHint {
return { key, label }
}
function stopHint(): FooterHint {
return { key: "ctrl+c", label: "stop", tone: "danger" }
}
function chunk(text: string, color: string, bold = false): TextChunk {
return {
__isChunk: true,
text,
fg: RGBA.fromHex(color),
attributes: bold ? TextAttributes.BOLD : 0,
}
}
+3 -3
View File
@@ -5,13 +5,13 @@ import { PromptQueue } from "./prompt-queue"
const prompt = (content: string) => ({ content, options: {} })
describe("PromptQueue", () => {
test("promotes the newest armed prompt to steering", () => {
test("returns the latest follow-up for editing", () => {
const queue = new PromptQueue()
queue.enqueue(prompt("next one"))
queue.enqueue(prompt("steer now"))
expect(queue.takeSteering()?.content).toBe("steer now")
expect(queue.takeSteering()).toBeNull()
expect(queue.takeLast()?.content).toBe("steer now")
expect(queue.snapshot().map(({ content }) => content)).toEqual(["next one"])
expect(queue.takeFollowUp()?.content).toBe("next one")
})
+6 -10
View File
@@ -5,10 +5,9 @@ export interface QueuedPrompt {
options: MessageOptions
}
/** Owns the difference between steering the active turn and starting the next one. */
/** Owns prompts that should start after the active turn finishes. */
export class PromptQueue {
private prompts: QueuedPrompt[] = []
private armed = false
get length(): number {
return this.prompts.length
@@ -16,30 +15,27 @@ export class PromptQueue {
enqueue(prompt: QueuedPrompt): void {
this.prompts.push(prompt)
this.armed = true
}
/** A second Enter immediately promotes the newest queued prompt to steering. */
takeSteering(): QueuedPrompt | null {
if (!this.armed) return null
this.armed = false
takeLast(): QueuedPrompt | null {
return this.prompts.pop() ?? null
}
takeFollowUp(): QueuedPrompt | null {
this.armed = false
return this.prompts.shift() ?? null
}
snapshot(): readonly QueuedPrompt[] {
return this.prompts
}
restore(): QueuedPrompt[] {
const prompts = this.prompts
this.prompts = []
this.armed = false
return prompts
}
clear(): void {
this.prompts = []
this.armed = false
}
}
+36
View File
@@ -0,0 +1,36 @@
import { afterEach, describe, expect, test } from "bun:test"
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
import { QueuePreview } from "./queue-preview"
describe("QueuePreview", () => {
let setup: TestRendererSetup | undefined
afterEach(() => {
if (setup && !setup.renderer.isDestroyed) setup.renderer.destroy()
setup = undefined
})
test("shows the latest follow-ups without becoming another card", async () => {
setup = await createTestRenderer({ width: 64, height: 12, screenMode: "alternate-screen" })
const preview = new QueuePreview(setup.renderer, {
accent: "#EF8E30",
muted: "#A1A1AA",
faint: "#71717A",
})
setup.renderer.root.add(preview.root)
preview.update(["first\nline", "second", "third", "fourth"])
await setup.renderOnce()
const frame = setup.captureCharFrame()
expect(frame).toContain("Queued next 4")
expect(frame).not.toContain("first line")
expect(frame).toContain("↳ second")
expect(frame).toContain("↳ fourth")
preview.update([])
await setup.renderOnce()
expect(setup.captureCharFrame()).not.toContain("Queued next")
})
})
+95
View File
@@ -0,0 +1,95 @@
import {
BoxRenderable,
RGBA,
StyledText,
TextAttributes,
TextRenderable,
type CliRenderer,
type TextChunk,
} from "@opentui/core"
export interface QueuePreviewTheme {
accent: string
muted: string
faint: string
}
const MAX_VISIBLE = 3
/** A compact, retained projection of follow-ups waiting behind the active turn. */
export class QueuePreview {
readonly root: BoxRenderable
private readonly header: TextRenderable
private readonly rows: TextRenderable[]
private theme: QueuePreviewTheme
private messages: readonly string[] = []
constructor(renderer: CliRenderer, theme: QueuePreviewTheme) {
this.theme = theme
this.root = new BoxRenderable(renderer, {
id: "nanobot-tui-queue-preview",
width: "100%",
height: 1,
flexShrink: 0,
flexDirection: "column",
paddingLeft: 1,
paddingRight: 1,
visible: false,
backgroundColor: RGBA.defaultBackground(),
})
this.header = new TextRenderable(renderer, {
id: "nanobot-tui-queue-header",
width: "100%",
height: 1,
flexShrink: 0,
truncate: true,
})
this.rows = Array.from({ length: MAX_VISIBLE }, (_, index) => new TextRenderable(renderer, {
id: `nanobot-tui-queue-row-${index}`,
width: "100%",
height: 1,
flexShrink: 0,
truncate: true,
visible: false,
}))
this.root.add(this.header)
for (const row of this.rows) this.root.add(row)
}
update(messages: readonly string[]): void {
this.messages = [...messages]
const visible = messages.slice(-MAX_VISIBLE)
this.root.visible = messages.length > 0
this.root.height = messages.length ? visible.length + 1 : 1
this.header.content = new StyledText([
chunk("Queued next", this.theme.accent, true),
chunk(` ${messages.length}`, this.theme.faint),
chunk(" · alt+↑ edit last", this.theme.faint),
])
this.rows.forEach((row, index) => {
const message = visible[index]
row.visible = Boolean(message)
row.content = message ? `${oneLine(message)}` : ""
row.fg = this.theme.muted
})
}
setTheme(theme: QueuePreviewTheme): void {
this.theme = theme
this.update(this.messages)
}
}
function oneLine(value: string): string {
const preview = value.slice(0, 240).replace(/\s+/gu, " ").trim()
return value.length > 240 ? `${preview}` : preview
}
function chunk(text: string, color: string, bold = false): TextChunk {
return {
__isChunk: true,
text,
fg: RGBA.fromHex(color),
attributes: bold ? TextAttributes.BOLD : 0,
}
}