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
+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}`