mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 10:11:46 +03:00
feat(tui): refine quiet terminal interactions
This commit is contained in:
+56
-4
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`
|
||||
|
||||
Reference in New Issue
Block a user