feat(tui): refine quiet terminal interactions

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 7ed37e5b70
commit 8f9bdb210e
12 changed files with 401 additions and 48 deletions
+1 -1
View File
@@ -124,7 +124,7 @@ Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the s
The default `--theme auto` mode probes the terminal's real foreground and background colors before first paint and follows supported live appearance changes. Use `--theme light` or `--theme dark` when a terminal or multiplexer does not report its colors reliably.
`Enter` sends the current message. Press `Alt+Enter` to add a newline and use `Up`/`Down` at the composer edge to recall prompts from the current saved session. Type `/` to discover nanobot commands and terminal navigation in one palette, use the arrow keys to choose one, and press `Tab` to complete it. `/sessions` opens a searchable conversation picker, while `/new-chat` preserves the current conversation and starts another one. `/diff` opens a read-only unified diff for the newest turn; use `Left`/`Right` to switch edits and `Esc` to close it. The core `/new` command retains its cross-channel behavior and resets the current chat. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen.
`Enter` sends the current message. Press `Alt+Enter` to add a newline and use `Up`/`Down` at the composer edge to recall prompts from the current saved session. Large pastes appear as a compact placeholder in the composer but are sent unchanged. Type `/` to discover nanobot commands and terminal navigation in one palette, use the arrow keys to choose one, and press `Tab` to complete it. `/sessions` opens a searchable conversation picker, while `/new-chat` preserves the current conversation and starts another one. `/diff` opens a read-only unified diff for the newest turn; use `Left`/`Right` to switch edits and `Esc` to close it. The core `/new` command retains its cross-channel behavior and resets the current chat. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. When you leave the bottom, the TUI shows a scrollbar and a `Ctrl+End` hint until you return. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen.
Packaged releases fetch a version-matched, checksummed terminal binary for macOS (Apple Silicon and Intel), Linux (x64 and ARM64), or Windows x64 on first use and cache it under the nanobot data directory. Windows ARM64 currently falls back to the classic prompt because the Bun runtime disables the FFI required by OpenTUI on that platform. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. A source checkout can run the client with Bun after `bun install --cwd tui`.
+4
View File
@@ -13,6 +13,10 @@ bun run --cwd tui build
The renderer uses OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen.
When you scroll away from the latest output, the scrollbar and `Ctrl+End` hint appear only until
you return to the bottom. Large pastes are represented by a short editable placeholder in the
composer; nanobot sends the original text unchanged.
Type `/` to discover slash commands published by the connected gateway. Use the arrow keys
to move, `Tab` to complete, and `Esc` to close the menu.
+56 -4
View File
@@ -168,6 +168,29 @@ describe("NanobotTui layout", () => {
expect(setup.captureCharFrame()).toContain("Ask nanobot anything")
})
test("compacts large pastes in the composer without changing the sent text", async () => {
const sent: string[] = []
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
const app = mount(setup, sent)
app.accept({ event: "attached", chat_id: "chat" })
await Bun.sleep(1)
const ui = app as unknown as {
composer: TextareaRenderable
status: { plainText: string }
}
const pasted = Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n")
await setup.mockInput.pasteBracketedText(pasted)
await setup.flush()
expect(ui.composer.plainText).toBe("[Pasted 12 lines] ")
expect(ui.status.plainText).toContain("Pasted 12 lines")
ui.composer.submit()
await waitUntil(() => sent.length === 1)
expect(sent).toEqual([pasted])
expect(ui.composer.plainText).toBe("")
})
test("recalls submitted prompts without stealing multiline cursor movement", async () => {
const sent: string[] = []
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
@@ -266,6 +289,7 @@ describe("NanobotTui layout", () => {
const ui = app as unknown as {
composer: TextareaRenderable
sessionMenu: { visible: boolean }
titleText: { plainText: string }
}
try {
@@ -278,6 +302,7 @@ describe("NanobotTui layout", () => {
ui.composer.submit()
await waitUntil(() => attached.length === 1)
expect(attached).toEqual(["other"])
expect(ui.titleText.plainText).toContain("Release checklist")
app.accept({ event: "attached", chat_id: "other" })
await Bun.sleep(1)
@@ -285,6 +310,7 @@ describe("NanobotTui layout", () => {
ui.composer.submit()
await waitUntil(() => newChats.length === 1)
expect(newChats).toEqual(["new"])
expect(ui.titleText.plainText).toContain("New chat")
} finally {
globalThis.fetch = original
}
@@ -429,7 +455,7 @@ describe("NanobotTui layout", () => {
await setup.flush()
const frame = setup.captureCharFrame()
expect(frame).toContain("Release checklist")
expect(frame).not.toContain("Current chat")
expect(occurrences(frame, "Current chat")).toBe(1)
} finally {
globalThis.fetch = original
}
@@ -461,6 +487,7 @@ describe("NanobotTui layout", () => {
const ui = app as unknown as {
composer: TextareaRenderable
contextPanel: { visible: boolean }
modelText: { plainText: string }
}
try {
@@ -468,6 +495,7 @@ describe("NanobotTui layout", () => {
ui.composer.submit()
await waitUntil(() => ui.contextPanel.visible)
await setup.flush()
expect(ui.modelText.plainText).toContain("~2.2k ctx")
const frame = setup.captureCharFrame()
expect(frame).toContain("Agent context")
@@ -1056,14 +1084,25 @@ describe("NanobotTui layout", () => {
test("supports keyboard transcript navigation without rebuilding the layout", async () => {
setup = await createRenderer({ width: 64, height: 16, screenMode: "alternate-screen" })
const app = mount(setup)
app.accept({ event: "attached", chat_id: "chat" })
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
for (let index = 0; index < 24; index += 1) {
app.accept({ event: "delta", chat_id: "chat", text: `answer ${index}` })
app.accept({ event: "stream_end", chat_id: "chat" })
}
await setup.flush()
const scroll = (app as unknown as {
transcript: { root: { scrollTop: number; scrollHeight: number; height: number } }
}).transcript.root
const internals = app as unknown as {
status: { plainText: string }
transcript: {
root: {
scrollTop: number
scrollHeight: number
height: number
verticalScrollBar: { visible: boolean }
}
}
}
const scroll = internals.transcript.root
setup.mockInput.pressKey("HOME", { ctrl: true })
await setup.renderOnce()
@@ -1073,6 +1112,8 @@ describe("NanobotTui layout", () => {
app.accept({ event: "stream_end", chat_id: "chat" })
await setup.renderOnce()
expect(scroll.scrollTop).toBe(0)
expect(scroll.verticalScrollBar.visible).toBe(true)
expect(internals.status.plainText).toContain("Ctrl+End latest")
setup.mockInput.pressKey("\u001B[6~")
await setup.renderOnce()
@@ -1081,6 +1122,17 @@ describe("NanobotTui layout", () => {
setup.mockInput.pressKey("END", { ctrl: true })
await setup.renderOnce()
expect(scroll.scrollTop).toBeGreaterThanOrEqual(scroll.scrollHeight - scroll.height)
expect(scroll.verticalScrollBar.visible).toBe(false)
expect(internals.status.plainText).not.toContain("Ctrl+End latest")
setup.mockInput.pressKey("HOME", { ctrl: true })
await waitUntil(() => scroll.verticalScrollBar.visible)
expect(scroll.verticalScrollBar.visible).toBe(true)
app.accept({ event: "attached", chat_id: "chat" })
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
await setup.renderOnce()
expect(scroll.verticalScrollBar.visible).toBe(false)
expect(internals.status.plainText).not.toContain("Ctrl+End latest")
})
test("reconciles active state from attach hydration after reconnect", async () => {
+183 -37
View File
@@ -6,9 +6,12 @@ import {
TextareaRenderable,
TextRenderable,
createCliRenderer,
decodePasteBytes,
getTreeSitterClient,
stripAnsiSequences,
type CliRenderer,
type KeyEvent,
type PasteEvent,
type ThemeMode,
type TreeSitterClient,
} from "@opentui/core"
@@ -24,6 +27,7 @@ import {
type HistoryMessage,
type InboundEvent,
type SlashCommand,
type SessionSummary,
} from "./protocol"
import {
CommandMenu,
@@ -32,16 +36,21 @@ import {
type ResolvedSlashCommandLifecycle,
type TuiCommand,
} from "./command-menu"
import { SessionMenu } from "./session-menu"
import { ContextPanel, type ContextPanelTheme } from "./context-panel"
import { SessionMenu, sessionLabel } from "./session-menu"
import { ContextPanel, formatTokenCount, type ContextPanelTheme } from "./context-panel"
import {
DiffViewer,
latestTurnFileEdits,
mergeFileEdits,
type DiffViewerTheme,
} from "./diff-viewer"
import { Transcript, type TranscriptTheme } from "./transcript"
import {
Transcript,
type TranscriptNavigation,
type TranscriptTheme,
} from "./transcript"
import { rememberChat } from "./session-state"
import { ComposerDraft } from "./composer-draft"
interface AppOptions {
wsUrl: string
@@ -251,6 +260,7 @@ export class NanobotTui {
private readonly composer: TextareaRenderable
private readonly status: TextRenderable
private readonly meta: TextRenderable
private readonly draft = new ComposerDraft()
private palette: Palette
private activeThemeMode: ThemeMode
private backgroundKnown: boolean
@@ -276,6 +286,13 @@ export class NanobotTui {
private historyCursor = 0
private historyDraft = ""
private modelName: string
private sessionTitle = ""
private sessionMetadataId = 0
private contextTokens: number | null = null
private transcriptNavigation: TranscriptNavigation = {
awayFromBottom: false,
unseenOutput: false,
}
private quitting = false
private sessionLoadId = 0
private sessionLoading = false
@@ -298,6 +315,7 @@ export class NanobotTui {
renderer,
transcriptTheme(this.palette, this.backgroundKnown),
treeSitterClient,
(state) => this.handleTranscriptNavigation(state),
)
this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
this.commandMenu.setCommands([], LOCAL_COMMANDS)
@@ -339,14 +357,15 @@ export class NanobotTui {
})
this.titleText = new TextRenderable(renderer, {
id: "nanobot-tui-title-text",
content: "nanobot · ",
content: "nanobot",
height: 1,
flexShrink: 0,
truncate: true,
fg: this.palette.muted,
})
this.modelText = new TextRenderable(renderer, {
id: "nanobot-tui-model-text",
content: this.modelName,
content: ` · ${this.modelName}`,
height: 1,
flexShrink: 1,
fg: this.palette.muted,
@@ -384,6 +403,7 @@ export class NanobotTui {
{ name: "return", meta: true, action: "newline" },
],
onContentChange: () => {
this.draft.prune(this.composer.plainText)
if (this.contextPanel.visible && this.composer.plainText) this.contextPanel.hide()
this.syncComposerPlaceholder()
if (this.sessionMenu.visible) this.syncSessionMenu()
@@ -393,6 +413,7 @@ export class NanobotTui {
// IMEs may commit their final composed glyph after Enter. Matching the
// OpenCode/OpenTUI integration, defer twice before reading plainText.
onSubmit: () => this.deferSubmit(),
onPaste: (event) => this.handlePaste(event),
})
this.status = new TextRenderable(renderer, {
id: "nanobot-tui-status",
@@ -499,25 +520,26 @@ export class NanobotTui {
private submit(): void {
if (this.quitting || this.composer.isDestroyed) return
const content = this.composer.plainText.trim()
const visibleContent = this.composer.plainText.trim()
const content = this.draft.expand(visibleContent).trim()
if (this.sessionLoading) {
this.status.content = "Loading sessions…"
return
}
if (this.sessionMenu.visible) {
const session = this.sessionMenu.choose()
if (session) this.switchSession(session.chatId)
if (session) this.switchSession(session)
return
}
if (!content) return
const completion = this.commandMenu.completion(content)
if (!visibleContent) return
const completion = this.commandMenu.completion(visibleContent)
if (completion) {
this.setComposer(completion)
this.commandMenu.hide()
this.updateMeta()
return
}
const command = this.commandMenu.resolve(content)
const command = this.commandMenu.resolve(visibleContent)
if (command?.source === "tui") {
if (command.command.action === "sessions") void this.openSessions()
else if (command.command.action === "context") void this.openContext()
@@ -526,15 +548,15 @@ export class NanobotTui {
return
}
if (command?.source === "gateway") {
const lifecycle = resolveSlashCommandLifecycle(content, command.command)
if (lifecycle) this.sendGatewayCommand(content, lifecycle)
const lifecycle = resolveSlashCommandLifecycle(visibleContent, command.command)
if (lifecycle) this.sendGatewayCommand(visibleContent, lifecycle)
return
}
if (!this.ready) {
this.status.content = "Preparing chat…"
return
}
if (["exit", "quit", "/exit", "/quit", ":q"].includes(content.toLowerCase())) {
if (["exit", "quit", "/exit", "/quit", ":q"].includes(visibleContent.toLowerCase())) {
this.quit()
return
}
@@ -548,7 +570,7 @@ export class NanobotTui {
this.status.content = error instanceof Error ? error.message : String(error)
return
}
this.composer.setText("")
this.clearComposer()
this.commandMenu.hide()
this.recordPrompt(content)
this.transcript.user(content)
@@ -648,8 +670,9 @@ export class NanobotTui {
this.turnHadAnswer = false
this.setActive(false)
if (typeof event.latency_ms === "number") {
this.status.content = `Ready · ${(event.latency_ms / 1000).toFixed(1)}s`
this.status.content = this.readyStatus(`${(event.latency_ms / 1000).toFixed(1)}s`)
}
if (this.contextTokens !== null) void this.refreshContextEstimate(event.chat_id)
return
case "goal_status":
if (event.status === "running") {
@@ -667,6 +690,16 @@ export class NanobotTui {
case "runtime_model_updated":
this.setModel(event.model_name)
return
case "session_updated":
if (
!this.sessionTitle
|| this.sessionTitle === "New chat"
|| this.sessionTitle === "Untitled chat"
|| event.scope === "metadata"
) {
void this.refreshSessionMetadata(event.chat_id)
}
return
case "error":
const commandLifecycle = event.turn_id ? this.commandTurns.get(event.turn_id) : undefined
if (event.turn_id) this.commandTurns.delete(event.turn_id)
@@ -719,7 +752,7 @@ export class NanobotTui {
if (hydrationId !== this.hydrationId) return
this.ready = true
if (!this.activeTurn) {
this.status.content = this.historyHasMore ? "Ready · PageUp for earlier history" : "Ready"
this.status.content = this.readyStatus()
}
}
}
@@ -763,19 +796,45 @@ export class NanobotTui {
if (active) {
this.activeStartedAt = startedAt ?? Date.now()
this.shimmerFrame = 0
this.renderActiveStatus()
this.shimmerTimer = setInterval(() => {
const frames = ["◐", "◓", "◑", "◒"]
const frame = frames[this.shimmerFrame++ % frames.length]
const elapsed = formatElapsed(Date.now() - this.activeStartedAt)
const detail = this.lastProgress ? ` · ${this.lastProgress.replace(/^\s*[·×]\s*/u, "")}` : ""
this.status.content = `${frame} ${this.activeLabel} ${elapsed}${detail}`
this.shimmerFrame += 1
this.renderActiveStatus()
}, 120)
return
}
if (this.shimmerTimer) clearInterval(this.shimmerTimer)
this.shimmerTimer = null
this.lastProgress = ""
this.status.content = "Ready"
this.status.content = this.readyStatus()
}
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}`
}
private readyStatus(detail = ""): string {
if (this.transcriptNavigation.awayFromBottom) {
return this.transcriptNavigation.unseenOutput
? "New output · Ctrl+End latest"
: "History · Ctrl+End latest"
}
if (detail) return `Ready · ${detail}`
return this.historyHasMore ? "Ready · PageUp for earlier history" : "Ready"
}
private handleTranscriptNavigation(state: TranscriptNavigation): void {
this.transcriptNavigation = state
if (this.activeTurn) this.renderActiveStatus()
else if (this.ready) this.status.content = this.readyStatus()
this.updateMeta()
}
private handleKey = (key: KeyEvent): void => {
@@ -785,7 +844,7 @@ export class NanobotTui {
if (selected) void this.copySelection(selected)
} else if (this.diffViewer.handleKey(key) && !this.diffViewer.visible) {
this.composer.focus()
this.status.content = "Ready"
this.status.content = this.readyStatus()
this.updateMeta()
}
key.preventDefault()
@@ -793,13 +852,14 @@ export class NanobotTui {
}
if (this.contextPanel.visible && key.name === "escape") {
this.contextPanel.hide()
this.status.content = this.readyStatus()
this.updateMeta()
key.preventDefault()
return
}
if (this.sessionLoading && key.name === "escape") {
this.closeSessions()
this.status.content = "Ready"
this.status.content = this.readyStatus()
key.preventDefault()
return
}
@@ -874,7 +934,8 @@ export class NanobotTui {
this.setActive(false)
}
} else if (this.composer.plainText) {
this.composer.setText("")
this.clearComposer()
this.status.content = this.readyStatus()
} else {
this.quit()
}
@@ -952,12 +1013,15 @@ export class NanobotTui {
this.contextPanel.resize(this.renderer.height)
this.diffViewer.resize(this.renderer.width)
this.title.visible = this.renderer.height >= 14
this.updateTitle()
this.updateMeta()
}
private updateMeta(): void {
if (this.activeTurn) {
this.meta.content = this.renderer.width >= 48 ? "ctrl+c stop" : ""
this.meta.content = this.renderer.width >= 72 && this.transcriptNavigation.awayFromBottom
? "ctrl+end latest · ctrl+c stop"
: this.renderer.width >= 48 ? "ctrl+c stop" : ""
return
}
if (this.commandMenu.visible) {
@@ -976,6 +1040,12 @@ export class NanobotTui {
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
@@ -987,7 +1057,15 @@ export class NanobotTui {
private setModel(model: string): void {
this.modelName = model
this.modelText.content = model
this.updateTitle()
}
private updateTitle(): void {
const identity = this.sessionTitle.trim() || "nanobot"
this.titleText.maxWidth = Math.max(8, Math.floor(this.renderer.width * 0.38))
this.titleText.content = identity
const context = this.contextTokens === null ? "" : ` · ~${formatTokenCount(this.contextTokens)} ctx`
this.modelText.content = ` · ${this.modelName}${context}`
}
private resizeComposer(): void {
@@ -1019,10 +1097,27 @@ export class NanobotTui {
}
private setComposer(content: string): void {
this.draft.clear()
this.composer.setText(content)
this.composer.cursorOffset = content.length
}
private clearComposer(): void {
this.draft.clear()
this.composer.setText("")
}
private handlePaste(event: PasteEvent): void {
event.preventDefault()
const value = stripAnsiSequences(decodePasteBytes(event.bytes))
const insertion = this.draft.paste(value)
if (!insertion.text) return
this.composer.insertText(insertion.text)
if (insertion.compacted) {
this.status.content = `Pasted ${insertion.description} · review before sending`
}
}
private async loadCommands(): Promise<void> {
let discovered: SlashCommand[] = []
try {
@@ -1045,7 +1140,7 @@ export class NanobotTui {
}
this.commandMenu.hide()
this.contextPanel.hide()
this.composer.setText("")
this.clearComposer()
this.sessionLoading = true
const loadId = ++this.sessionLoadId
this.status.content = "Loading sessions…"
@@ -1053,6 +1148,11 @@ export class NanobotTui {
const sessions = await fetchSessions(this.options.apiUrl, this.options.apiToken)
if (this.quitting || loadId !== this.sessionLoadId) return
this.sessionLoading = false
const current = sessions.find((session) => session.chatId === this.client.activeChatId)
if (current) {
this.sessionTitle = sessionLabel(current)
this.updateTitle()
}
const limit = this.renderer.height >= 20 ? 8 : 4
this.sessionMenu.open(sessions, this.client.activeChatId, limit)
this.sessionMenu.update(this.composer.plainText, limit)
@@ -1066,14 +1166,16 @@ export class NanobotTui {
}
}
private switchSession(chatId: string): void {
private switchSession(session: SessionSummary): void {
if (this.activeTurn) {
this.status.content = "Wait for the current turn or press Ctrl+C"
return
}
if (chatId === this.client.activeChatId) {
if (session.chatId === this.client.activeChatId) {
this.sessionTitle = sessionLabel(session)
this.updateTitle()
this.closeSessions()
this.status.content = "Ready"
this.status.content = this.readyStatus()
return
}
if (!this.ready) {
@@ -1083,8 +1185,12 @@ export class NanobotTui {
this.closeSessions()
try {
this.ready = false
this.sessionMetadataId += 1
this.sessionTitle = sessionLabel(session)
this.contextTokens = null
this.updateTitle()
this.status.content = "Opening session…"
this.client.attach(chatId)
this.client.attach(session.chatId)
} catch (error) {
this.status.content = error instanceof Error ? error.message : String(error)
}
@@ -1102,9 +1208,13 @@ export class NanobotTui {
this.commandMenu.hide()
this.sessionMenu.hide()
this.contextPanel.hide()
this.composer.setText("")
this.clearComposer()
try {
this.ready = false
this.sessionMetadataId += 1
this.sessionTitle = "New chat"
this.contextTokens = null
this.updateTitle()
this.status.content = "Starting a new chat…"
this.client.newChat()
} catch (error) {
@@ -1132,7 +1242,7 @@ export class NanobotTui {
return
}
this.commandTurns.set(turnId, lifecycle)
this.composer.setText("")
this.clearComposer()
this.commandMenu.hide()
if (lifecycle !== "stop_active_turn") this.transcript.user(content)
this.recordPrompt(content)
@@ -1182,15 +1292,16 @@ export class NanobotTui {
this.sessionLoadId += 1
this.sessionLoading = false
this.sessionMenu.hide()
this.composer.setText("")
this.clearComposer()
this.syncComposerPlaceholder()
if (!this.activeTurn && this.ready) this.status.content = this.readyStatus()
this.updateMeta()
}
private async openContext(): Promise<void> {
this.commandMenu.hide()
this.sessionMenu.hide()
this.composer.setText("")
this.clearComposer()
this.status.content = "Reading agent context…"
try {
const context = await fetchSessionContext(
@@ -1202,6 +1313,8 @@ export class NanobotTui {
this.status.content = "Context unavailable · new session or older gateway"
return
}
this.contextTokens = context.estimatedSessionTokens
this.updateTitle()
this.contextPanel.show(context)
this.status.content = "Context snapshot"
this.updateMeta()
@@ -1210,11 +1323,44 @@ export class NanobotTui {
}
}
private async refreshSessionMetadata(chatId: string): Promise<void> {
if (!this.options.apiUrl || !this.options.apiToken) return
const requestId = ++this.sessionMetadataId
try {
const sessions = await fetchSessions(this.options.apiUrl, this.options.apiToken)
if (
requestId !== this.sessionMetadataId
|| chatId !== this.client.activeChatId
) return
const session = sessions.find((candidate) => candidate.chatId === chatId)
if (!session) return
this.sessionTitle = sessionLabel(session)
this.updateTitle()
} catch {
// Session metadata is decorative; conversation transport stays authoritative.
}
}
private async refreshContextEstimate(chatId: string): Promise<void> {
try {
const context = await fetchSessionContext(
this.options.apiUrl,
this.options.apiToken,
chatId,
)
if (!context || chatId !== this.client.activeChatId || this.contextTokens === null) return
this.contextTokens = context.estimatedSessionTokens
this.updateTitle()
} catch {
// Keep the last known estimate; it is intentionally informational.
}
}
private openDiff(): void {
this.commandMenu.hide()
this.sessionMenu.hide()
this.contextPanel.hide()
this.composer.setText("")
this.clearComposer()
this.composer.blur()
const edits = this.currentFileEdits.length ? this.currentFileEdits : this.lastFileEdits
this.diffViewer.show(edits)
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"
import { ComposerDraft } from "./composer-draft"
describe("ComposerDraft", () => {
test("keeps ordinary pastes editable as ordinary text", () => {
const draft = new ComposerDraft()
const insertion = draft.paste("first\r\nsecond")
expect(insertion).toEqual({ text: "first\nsecond", compacted: false, description: "" })
expect(draft.expand(`before ${insertion.text} after`)).toBe("before first\nsecond after")
})
test("compacts large pastes and expands only placeholders that remain", () => {
const draft = new ComposerDraft()
const content = Array.from({ length: 12 }, (_, index) => `line ${index}`).join("\n")
const first = draft.paste(content)
const second = draft.paste(content)
expect(first.text).toBe("[Pasted 12 lines] ")
expect(second.text).toBe("[Pasted 12 lines #2] ")
expect(draft.expand(`review ${first.text.trim()}`)).toBe(`review ${content}`)
draft.prune(second.text)
expect(draft.expand(first.text.trim())).toBe(first.text.trim())
expect(draft.expand(second.text.trim())).toBe(content)
})
})
+44
View File
@@ -0,0 +1,44 @@
const LARGE_PASTE_CHARS = 1_000
const LARGE_PASTE_LINES = 10
export interface PasteInsertion {
text: string
compacted: boolean
description: string
}
/** Keeps large pasted text out of the editor without changing what is sent. */
export class ComposerDraft {
private readonly pastes = new Map<string, string>()
paste(value: string): PasteInsertion {
const text = value.replace(/\r\n/gu, "\n").replace(/\r/gu, "\n")
const lines = text.split("\n").length
if (text.length < LARGE_PASTE_CHARS && lines < LARGE_PASTE_LINES) {
return { text, compacted: false, description: "" }
}
const description = lines > 1 ? `${lines} lines` : `${text.length} characters`
const base = `[Pasted ${description}]`
let label = base
for (let index = 2; this.pastes.has(label); index += 1) label = `${base.slice(0, -1)} #${index}]`
this.pastes.set(label, text)
return { text: `${label} `, compacted: true, description }
}
expand(visible: string): string {
let expanded = visible
for (const [label, content] of this.pastes) expanded = expanded.split(label).join(content)
return expanded
}
prune(visible: string): void {
for (const label of this.pastes.keys()) {
if (!visible.includes(label)) this.pastes.delete(label)
}
}
clear(): void {
this.pastes.clear()
}
}
+2 -2
View File
@@ -14,7 +14,7 @@ export interface ContextPanelTheme {
accent: string
}
function tokens(value: number): string {
export function formatTokenCount(value: number): string {
if (value < 1_000) return String(value)
const compact = value >= 10_000 ? Math.round(value / 1_000) : Math.round(value / 100) / 10
return `${compact}k`
@@ -86,7 +86,7 @@ export class ContextPanel {
const archived = context.archivedMessages > 0
? `${context.archivedMessages} archived · summary ${context.archivedSummary ? "active" : "unavailable"}`
: "No archived messages"
this.stats.content = `~${tokens(context.estimatedSessionTokens)} session tokens · ${context.replayMessages} replay messages · ${archived}`
this.stats.content = `~${formatTokenCount(context.estimatedSessionTokens)} session tokens · ${context.replayMessages} replay messages · ${archived}`
this.summary.content = context.archivedSummary
? `Summary\n${context.archivedSummary}`
: "The agent is currently replaying raw session messages; no compacted summary exists yet."
+12 -1
View File
@@ -124,10 +124,21 @@ describe("gateway protocol", () => {
socket.emit("message", {
data: JSON.stringify({ event: "stream_end", chat_id: "one", resuming: "yes" }),
})
socket.emit("message", {
data: JSON.stringify({ event: "session_updated", chat_id: "one", scope: 42 }),
})
socket.emit("message", {
data: JSON.stringify({ event: "session_updated", chat_id: "one", scope: "metadata" }),
})
socket.emit("message", { data: JSON.stringify({ event: "future_gateway_event" }) })
socket.emit("message", { data: JSON.stringify({ event: "error", detail: "global failure" }) })
expect(statuses).toContain("error:gateway sent an invalid event")
expect(statuses.filter((status) => status.includes("invalid event"))).toHaveLength(4)
expect(statuses.filter((status) => status.includes("invalid event"))).toHaveLength(5)
expect(events).toContainEqual({
event: "session_updated",
chat_id: "one",
scope: "metadata",
})
expect(events).toContainEqual({ event: "error", detail: "global failure" })
} finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
+3
View File
@@ -70,6 +70,7 @@ export type InboundEvent =
turn_id?: string
}
| { event: "goal_state"; chat_id: string; goal_state: Record<string, unknown> }
| { event: "session_updated"; chat_id: string; scope?: string }
| { event: "runtime_model_updated"; model_name: string; model_preset?: string | null }
| { event: "turn_model_updated"; chat_id: string; model_name: string }
| { event: "error"; chat_id?: string; detail?: string; reason?: string; turn_id?: string }
@@ -158,6 +159,7 @@ const CHAT_EVENTS = new Set([
"turn_end",
"goal_status",
"goal_state",
"session_updated",
"turn_model_updated",
"error",
])
@@ -250,6 +252,7 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
if (name === "turn_end" && !optional(record.latency_ms, "number")) return null
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") return null
if (name === "goal_state" && (!record.goal_state || typeof record.goal_state !== "object")) return null
if (name === "session_updated" && !optional(record.scope, "string")) return null
if (name === "turn_model_updated" && typeof record.model_name !== "string") return null
return value as InboundEvent
}
+7 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test"
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
import { SessionMenu } from "./session-menu"
import { SessionMenu, sessionLabel } from "./session-menu"
import type { SessionSummary } from "./protocol"
const sessions: SessionSummary[] = [
@@ -54,4 +54,10 @@ describe("SessionMenu", () => {
expect(setup.captureCharFrame()).toContain("Release checklist")
expect(menu.choose()?.chatId).toBe("two")
})
test("keeps generated multi-line titles on one terminal row", () => {
expect(sessionLabel({ ...sessions[0]!, title: "Release\n checklist" })).toBe(
"Release checklist",
)
})
})
+3 -2
View File
@@ -5,8 +5,9 @@ import type { SessionSummary } from "./protocol"
type SessionMenuRow = SessionSummary & { active: boolean }
function sessionLabel(session: SessionSummary): string {
return session.title.trim() || session.preview.trim() || "Untitled chat"
export function sessionLabel(session: SessionSummary): string {
const label = session.title.trim() || session.preview.trim() || "Untitled chat"
return label.replace(/\s+/gu, " ")
}
function updatedLabel(value: string | null): string {
+58
View File
@@ -29,6 +29,11 @@ export interface TranscriptHeader {
access: string
}
export interface TranscriptNavigation {
awayFromBottom: boolean
unseenOutput: boolean
}
interface Activity {
text: TextRenderable
lines: string[]
@@ -53,11 +58,14 @@ export class Transcript {
private readonly userRows = new Set<BoxRenderable>()
private wrote = false
private nextId = 0
private navigation: TranscriptNavigation = { awayFromBottom: false, unseenOutput: false }
private navigationTimer: ReturnType<typeof setTimeout> | null = null
constructor(
private readonly renderer: CliRenderer,
private theme: TranscriptTheme,
private readonly treeSitterClient: TreeSitterClient,
private readonly onNavigationChange?: (state: TranscriptNavigation) => void,
) {
this.root = new ScrollBoxRenderable(renderer, {
id: "nanobot-tui-transcript",
@@ -78,6 +86,7 @@ export class Transcript {
},
verticalScrollbarOptions: { visible: false },
horizontalScrollbarOptions: { visible: false },
onMouseScroll: () => this.scheduleNavigationUpdate(),
})
this.root.verticalScrollBar.visible = false
this.root.horizontalScrollBar.visible = false
@@ -126,6 +135,8 @@ export class Transcript {
}
reset(header: TranscriptHeader): void {
if (this.navigationTimer) clearTimeout(this.navigationTimer)
this.navigationTimer = null
for (const child of [...this.root.getChildren()]) {
this.root.remove(child)
child.destroyRecursively()
@@ -139,7 +150,10 @@ export class Transcript {
this.userRows.clear()
this.wrote = false
this.nextId = 0
this.navigation = { awayFromBottom: false, unseenOutput: false }
this.root.verticalScrollBar.visible = false
this.header(header)
this.emitNavigation()
}
history(messages: HistoryMessage[]): void {
@@ -185,23 +199,27 @@ export class Transcript {
}
user(content: string): void {
this.noteOutput()
this.finishActivity()
this.writeRole("", content, "user")
}
assistant(content: string): void {
if (!content.trim()) return
this.noteOutput()
this.finishActivity()
this.writeMarkdown(content, false)
}
notice(content: string, error = false): void {
this.noteOutput()
this.finishActivity()
this.writeRole(error ? "×" : "·", content, error ? "error" : "muted")
}
stream(delta: string): void {
if (!delta) return
this.noteOutput()
if (!this.live) {
this.finishActivity()
const markdown = this.createMarkdown("", true, "assistant-stream")
@@ -233,6 +251,7 @@ export class Transcript {
progress(content: string, events: ToolProgressEvent[] = []): string {
if (events.length === 0 && !content.split("\n").some((line) => cleanProgress(line))) return ""
this.noteOutput()
if (!this.activity) this.activity = this.createActivity()
return this.updateActivity(this.activity, content, events)
}
@@ -262,13 +281,17 @@ export class Transcript {
scrollByPage(direction: -1 | 1): void {
this.root.scrollBy(direction * Math.max(3, Math.floor(this.root.height * 0.7)))
this.scheduleNavigationUpdate()
}
scrollToEdge(edge: "top" | "bottom"): void {
this.root.scrollTo(edge === "top" ? 0 : this.root.scrollHeight)
if (edge === "bottom") this.updateNavigation(false, true)
else this.scheduleNavigationUpdate()
}
destroy(): void {
if (this.navigationTimer) clearTimeout(this.navigationTimer)
this.live = null
this.activity = null
this.frames.clear()
@@ -276,6 +299,41 @@ export class Transcript {
this.theme.syntax.destroy()
}
private noteOutput(): void {
this.updateNavigation(true)
}
private scheduleNavigationUpdate(): void {
if (this.navigationTimer) clearTimeout(this.navigationTimer)
this.navigationTimer = setTimeout(() => {
this.navigationTimer = null
this.updateNavigation(false)
}, 0)
}
private updateNavigation(output: boolean, forceBottom = false): void {
const awayFromBottom = forceBottom ? false : !this.isAtBottom()
const unseenOutput = awayFromBottom
? this.navigation.unseenOutput || output
: false
if (
awayFromBottom === this.navigation.awayFromBottom
&& unseenOutput === this.navigation.unseenOutput
) return
this.navigation = { awayFromBottom, unseenOutput }
this.root.verticalScrollBar.visible = awayFromBottom
this.emitNavigation()
}
private isAtBottom(): boolean {
const bottom = Math.max(0, this.root.scrollHeight - this.root.height)
return this.root.scrollTop >= bottom - 1
}
private emitNavigation(): void {
this.onNavigationChange?.({ ...this.navigation })
}
private id(prefix: string): string {
this.nextId += 1
return `${prefix}-${this.nextId}`