mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +03:00
feat(tui): make follow-up queue explicit
This commit is contained in:
+5
-2
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user