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. 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`. 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. 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 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. 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") 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 () => { test("recalls submitted prompts without stealing multiline cursor movement", async () => {
const sent: string[] = [] const sent: string[] = []
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
@@ -266,6 +289,7 @@ describe("NanobotTui layout", () => {
const ui = app as unknown as { const ui = app as unknown as {
composer: TextareaRenderable composer: TextareaRenderable
sessionMenu: { visible: boolean } sessionMenu: { visible: boolean }
titleText: { plainText: string }
} }
try { try {
@@ -278,6 +302,7 @@ describe("NanobotTui layout", () => {
ui.composer.submit() ui.composer.submit()
await waitUntil(() => attached.length === 1) await waitUntil(() => attached.length === 1)
expect(attached).toEqual(["other"]) expect(attached).toEqual(["other"])
expect(ui.titleText.plainText).toContain("Release checklist")
app.accept({ event: "attached", chat_id: "other" }) app.accept({ event: "attached", chat_id: "other" })
await Bun.sleep(1) await Bun.sleep(1)
@@ -285,6 +310,7 @@ describe("NanobotTui layout", () => {
ui.composer.submit() ui.composer.submit()
await waitUntil(() => newChats.length === 1) await waitUntil(() => newChats.length === 1)
expect(newChats).toEqual(["new"]) expect(newChats).toEqual(["new"])
expect(ui.titleText.plainText).toContain("New chat")
} finally { } finally {
globalThis.fetch = original globalThis.fetch = original
} }
@@ -429,7 +455,7 @@ describe("NanobotTui layout", () => {
await setup.flush() await setup.flush()
const frame = setup.captureCharFrame() const frame = setup.captureCharFrame()
expect(frame).toContain("Release checklist") expect(frame).toContain("Release checklist")
expect(frame).not.toContain("Current chat") expect(occurrences(frame, "Current chat")).toBe(1)
} finally { } finally {
globalThis.fetch = original globalThis.fetch = original
} }
@@ -461,6 +487,7 @@ describe("NanobotTui layout", () => {
const ui = app as unknown as { const ui = app as unknown as {
composer: TextareaRenderable composer: TextareaRenderable
contextPanel: { visible: boolean } contextPanel: { visible: boolean }
modelText: { plainText: string }
} }
try { try {
@@ -468,6 +495,7 @@ describe("NanobotTui layout", () => {
ui.composer.submit() ui.composer.submit()
await waitUntil(() => ui.contextPanel.visible) await waitUntil(() => ui.contextPanel.visible)
await setup.flush() await setup.flush()
expect(ui.modelText.plainText).toContain("~2.2k ctx")
const frame = setup.captureCharFrame() const frame = setup.captureCharFrame()
expect(frame).toContain("Agent context") expect(frame).toContain("Agent context")
@@ -1056,14 +1084,25 @@ describe("NanobotTui layout", () => {
test("supports keyboard transcript navigation without rebuilding the layout", async () => { test("supports keyboard transcript navigation without rebuilding the layout", async () => {
setup = await createRenderer({ width: 64, height: 16, screenMode: "alternate-screen" }) setup = await createRenderer({ width: 64, height: 16, screenMode: "alternate-screen" })
const app = mount(setup) 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) { for (let index = 0; index < 24; index += 1) {
app.accept({ event: "delta", chat_id: "chat", text: `answer ${index}` }) app.accept({ event: "delta", chat_id: "chat", text: `answer ${index}` })
app.accept({ event: "stream_end", chat_id: "chat" }) app.accept({ event: "stream_end", chat_id: "chat" })
} }
await setup.flush() await setup.flush()
const scroll = (app as unknown as { const internals = app as unknown as {
transcript: { root: { scrollTop: number; scrollHeight: number; height: number } } status: { plainText: string }
}).transcript.root transcript: {
root: {
scrollTop: number
scrollHeight: number
height: number
verticalScrollBar: { visible: boolean }
}
}
}
const scroll = internals.transcript.root
setup.mockInput.pressKey("HOME", { ctrl: true }) setup.mockInput.pressKey("HOME", { ctrl: true })
await setup.renderOnce() await setup.renderOnce()
@@ -1073,6 +1112,8 @@ describe("NanobotTui layout", () => {
app.accept({ event: "stream_end", chat_id: "chat" }) app.accept({ event: "stream_end", chat_id: "chat" })
await setup.renderOnce() await setup.renderOnce()
expect(scroll.scrollTop).toBe(0) expect(scroll.scrollTop).toBe(0)
expect(scroll.verticalScrollBar.visible).toBe(true)
expect(internals.status.plainText).toContain("Ctrl+End latest")
setup.mockInput.pressKey("\u001B[6~") setup.mockInput.pressKey("\u001B[6~")
await setup.renderOnce() await setup.renderOnce()
@@ -1081,6 +1122,17 @@ describe("NanobotTui layout", () => {
setup.mockInput.pressKey("END", { ctrl: true }) setup.mockInput.pressKey("END", { ctrl: true })
await setup.renderOnce() await setup.renderOnce()
expect(scroll.scrollTop).toBeGreaterThanOrEqual(scroll.scrollHeight - scroll.height) 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 () => { test("reconciles active state from attach hydration after reconnect", async () => {
+183 -37
View File
@@ -6,9 +6,12 @@ import {
TextareaRenderable, TextareaRenderable,
TextRenderable, TextRenderable,
createCliRenderer, createCliRenderer,
decodePasteBytes,
getTreeSitterClient, getTreeSitterClient,
stripAnsiSequences,
type CliRenderer, type CliRenderer,
type KeyEvent, type KeyEvent,
type PasteEvent,
type ThemeMode, type ThemeMode,
type TreeSitterClient, type TreeSitterClient,
} from "@opentui/core" } from "@opentui/core"
@@ -24,6 +27,7 @@ import {
type HistoryMessage, type HistoryMessage,
type InboundEvent, type InboundEvent,
type SlashCommand, type SlashCommand,
type SessionSummary,
} from "./protocol" } from "./protocol"
import { import {
CommandMenu, CommandMenu,
@@ -32,16 +36,21 @@ import {
type ResolvedSlashCommandLifecycle, type ResolvedSlashCommandLifecycle,
type TuiCommand, type TuiCommand,
} from "./command-menu" } from "./command-menu"
import { SessionMenu } from "./session-menu" import { SessionMenu, sessionLabel } from "./session-menu"
import { ContextPanel, type ContextPanelTheme } from "./context-panel" import { ContextPanel, formatTokenCount, type ContextPanelTheme } from "./context-panel"
import { import {
DiffViewer, DiffViewer,
latestTurnFileEdits, latestTurnFileEdits,
mergeFileEdits, mergeFileEdits,
type DiffViewerTheme, type DiffViewerTheme,
} from "./diff-viewer" } from "./diff-viewer"
import { Transcript, type TranscriptTheme } from "./transcript" import {
Transcript,
type TranscriptNavigation,
type TranscriptTheme,
} from "./transcript"
import { rememberChat } from "./session-state" import { rememberChat } from "./session-state"
import { ComposerDraft } from "./composer-draft"
interface AppOptions { interface AppOptions {
wsUrl: string wsUrl: string
@@ -251,6 +260,7 @@ export class NanobotTui {
private readonly composer: TextareaRenderable private readonly composer: TextareaRenderable
private readonly status: TextRenderable private readonly status: TextRenderable
private readonly meta: TextRenderable private readonly meta: TextRenderable
private readonly draft = new ComposerDraft()
private palette: Palette private palette: Palette
private activeThemeMode: ThemeMode private activeThemeMode: ThemeMode
private backgroundKnown: boolean private backgroundKnown: boolean
@@ -276,6 +286,13 @@ export class NanobotTui {
private historyCursor = 0 private historyCursor = 0
private historyDraft = "" private historyDraft = ""
private modelName: string private modelName: string
private sessionTitle = ""
private sessionMetadataId = 0
private contextTokens: number | null = null
private transcriptNavigation: TranscriptNavigation = {
awayFromBottom: false,
unseenOutput: false,
}
private quitting = false private quitting = false
private sessionLoadId = 0 private sessionLoadId = 0
private sessionLoading = false private sessionLoading = false
@@ -298,6 +315,7 @@ export class NanobotTui {
renderer, renderer,
transcriptTheme(this.palette, this.backgroundKnown), transcriptTheme(this.palette, this.backgroundKnown),
treeSitterClient, treeSitterClient,
(state) => this.handleTranscriptNavigation(state),
) )
this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette)) this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
this.commandMenu.setCommands([], LOCAL_COMMANDS) this.commandMenu.setCommands([], LOCAL_COMMANDS)
@@ -339,14 +357,15 @@ export class NanobotTui {
}) })
this.titleText = new TextRenderable(renderer, { this.titleText = new TextRenderable(renderer, {
id: "nanobot-tui-title-text", id: "nanobot-tui-title-text",
content: "nanobot · ", content: "nanobot",
height: 1, height: 1,
flexShrink: 0, flexShrink: 0,
truncate: true,
fg: this.palette.muted, fg: this.palette.muted,
}) })
this.modelText = new TextRenderable(renderer, { this.modelText = new TextRenderable(renderer, {
id: "nanobot-tui-model-text", id: "nanobot-tui-model-text",
content: this.modelName, content: ` · ${this.modelName}`,
height: 1, height: 1,
flexShrink: 1, flexShrink: 1,
fg: this.palette.muted, fg: this.palette.muted,
@@ -384,6 +403,7 @@ export class NanobotTui {
{ name: "return", meta: true, action: "newline" }, { name: "return", meta: true, action: "newline" },
], ],
onContentChange: () => { onContentChange: () => {
this.draft.prune(this.composer.plainText)
if (this.contextPanel.visible && this.composer.plainText) this.contextPanel.hide() if (this.contextPanel.visible && this.composer.plainText) this.contextPanel.hide()
this.syncComposerPlaceholder() this.syncComposerPlaceholder()
if (this.sessionMenu.visible) this.syncSessionMenu() if (this.sessionMenu.visible) this.syncSessionMenu()
@@ -393,6 +413,7 @@ export class NanobotTui {
// IMEs may commit their final composed glyph after Enter. Matching the // IMEs may commit their final composed glyph after Enter. Matching the
// OpenCode/OpenTUI integration, defer twice before reading plainText. // OpenCode/OpenTUI integration, defer twice before reading plainText.
onSubmit: () => this.deferSubmit(), onSubmit: () => this.deferSubmit(),
onPaste: (event) => this.handlePaste(event),
}) })
this.status = new TextRenderable(renderer, { this.status = new TextRenderable(renderer, {
id: "nanobot-tui-status", id: "nanobot-tui-status",
@@ -499,25 +520,26 @@ export class NanobotTui {
private submit(): void { private submit(): void {
if (this.quitting || this.composer.isDestroyed) return 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) { if (this.sessionLoading) {
this.status.content = "Loading sessions…" this.status.content = "Loading sessions…"
return return
} }
if (this.sessionMenu.visible) { if (this.sessionMenu.visible) {
const session = this.sessionMenu.choose() const session = this.sessionMenu.choose()
if (session) this.switchSession(session.chatId) if (session) this.switchSession(session)
return return
} }
if (!content) return if (!visibleContent) return
const completion = this.commandMenu.completion(content) const completion = this.commandMenu.completion(visibleContent)
if (completion) { if (completion) {
this.setComposer(completion) this.setComposer(completion)
this.commandMenu.hide() this.commandMenu.hide()
this.updateMeta() this.updateMeta()
return return
} }
const command = this.commandMenu.resolve(content) const command = this.commandMenu.resolve(visibleContent)
if (command?.source === "tui") { if (command?.source === "tui") {
if (command.command.action === "sessions") void this.openSessions() if (command.command.action === "sessions") void this.openSessions()
else if (command.command.action === "context") void this.openContext() else if (command.command.action === "context") void this.openContext()
@@ -526,15 +548,15 @@ export class NanobotTui {
return return
} }
if (command?.source === "gateway") { if (command?.source === "gateway") {
const lifecycle = resolveSlashCommandLifecycle(content, command.command) const lifecycle = resolveSlashCommandLifecycle(visibleContent, command.command)
if (lifecycle) this.sendGatewayCommand(content, lifecycle) if (lifecycle) this.sendGatewayCommand(visibleContent, lifecycle)
return return
} }
if (!this.ready) { if (!this.ready) {
this.status.content = "Preparing chat…" this.status.content = "Preparing chat…"
return return
} }
if (["exit", "quit", "/exit", "/quit", ":q"].includes(content.toLowerCase())) { if (["exit", "quit", "/exit", "/quit", ":q"].includes(visibleContent.toLowerCase())) {
this.quit() this.quit()
return return
} }
@@ -548,7 +570,7 @@ export class NanobotTui {
this.status.content = error instanceof Error ? error.message : String(error) this.status.content = error instanceof Error ? error.message : String(error)
return return
} }
this.composer.setText("") this.clearComposer()
this.commandMenu.hide() this.commandMenu.hide()
this.recordPrompt(content) this.recordPrompt(content)
this.transcript.user(content) this.transcript.user(content)
@@ -648,8 +670,9 @@ export class NanobotTui {
this.turnHadAnswer = false this.turnHadAnswer = false
this.setActive(false) this.setActive(false)
if (typeof event.latency_ms === "number") { 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 return
case "goal_status": case "goal_status":
if (event.status === "running") { if (event.status === "running") {
@@ -667,6 +690,16 @@ export class NanobotTui {
case "runtime_model_updated": case "runtime_model_updated":
this.setModel(event.model_name) this.setModel(event.model_name)
return 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": case "error":
const commandLifecycle = event.turn_id ? this.commandTurns.get(event.turn_id) : undefined const commandLifecycle = event.turn_id ? this.commandTurns.get(event.turn_id) : undefined
if (event.turn_id) this.commandTurns.delete(event.turn_id) if (event.turn_id) this.commandTurns.delete(event.turn_id)
@@ -719,7 +752,7 @@ export class NanobotTui {
if (hydrationId !== this.hydrationId) return if (hydrationId !== this.hydrationId) return
this.ready = true this.ready = true
if (!this.activeTurn) { 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) { if (active) {
this.activeStartedAt = startedAt ?? Date.now() this.activeStartedAt = startedAt ?? Date.now()
this.shimmerFrame = 0 this.shimmerFrame = 0
this.renderActiveStatus()
this.shimmerTimer = setInterval(() => { this.shimmerTimer = setInterval(() => {
const frames = ["◐", "◓", "◑", "◒"] this.shimmerFrame += 1
const frame = frames[this.shimmerFrame++ % frames.length] this.renderActiveStatus()
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}`
}, 120) }, 120)
return return
} }
if (this.shimmerTimer) clearInterval(this.shimmerTimer) if (this.shimmerTimer) clearInterval(this.shimmerTimer)
this.shimmerTimer = null this.shimmerTimer = null
this.lastProgress = "" 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 => { private handleKey = (key: KeyEvent): void => {
@@ -785,7 +844,7 @@ export class NanobotTui {
if (selected) void this.copySelection(selected) if (selected) void this.copySelection(selected)
} else if (this.diffViewer.handleKey(key) && !this.diffViewer.visible) { } else if (this.diffViewer.handleKey(key) && !this.diffViewer.visible) {
this.composer.focus() this.composer.focus()
this.status.content = "Ready" this.status.content = this.readyStatus()
this.updateMeta() this.updateMeta()
} }
key.preventDefault() key.preventDefault()
@@ -793,13 +852,14 @@ export class NanobotTui {
} }
if (this.contextPanel.visible && key.name === "escape") { if (this.contextPanel.visible && key.name === "escape") {
this.contextPanel.hide() this.contextPanel.hide()
this.status.content = this.readyStatus()
this.updateMeta() this.updateMeta()
key.preventDefault() key.preventDefault()
return return
} }
if (this.sessionLoading && key.name === "escape") { if (this.sessionLoading && key.name === "escape") {
this.closeSessions() this.closeSessions()
this.status.content = "Ready" this.status.content = this.readyStatus()
key.preventDefault() key.preventDefault()
return return
} }
@@ -874,7 +934,8 @@ export class NanobotTui {
this.setActive(false) this.setActive(false)
} }
} else if (this.composer.plainText) { } else if (this.composer.plainText) {
this.composer.setText("") this.clearComposer()
this.status.content = this.readyStatus()
} else { } else {
this.quit() this.quit()
} }
@@ -952,12 +1013,15 @@ export class NanobotTui {
this.contextPanel.resize(this.renderer.height) this.contextPanel.resize(this.renderer.height)
this.diffViewer.resize(this.renderer.width) this.diffViewer.resize(this.renderer.width)
this.title.visible = this.renderer.height >= 14 this.title.visible = this.renderer.height >= 14
this.updateTitle()
this.updateMeta() this.updateMeta()
} }
private updateMeta(): void { private updateMeta(): void {
if (this.activeTurn) { 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 return
} }
if (this.commandMenu.visible) { if (this.commandMenu.visible) {
@@ -976,6 +1040,12 @@ export class NanobotTui {
this.meta.content = "esc close · pgup/pgdn scroll" this.meta.content = "esc close · pgup/pgdn scroll"
return 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 this.meta.content = this.renderer.width >= 112
? "enter send · alt+enter newline · pgup/pgdn scroll · ctrl+o tools · ctrl+c stop" ? "enter send · alt+enter newline · pgup/pgdn scroll · ctrl+o tools · ctrl+c stop"
: this.renderer.width >= 72 : this.renderer.width >= 72
@@ -987,7 +1057,15 @@ export class NanobotTui {
private setModel(model: string): void { private setModel(model: string): void {
this.modelName = model 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 { private resizeComposer(): void {
@@ -1019,10 +1097,27 @@ export class NanobotTui {
} }
private setComposer(content: string): void { private setComposer(content: string): void {
this.draft.clear()
this.composer.setText(content) this.composer.setText(content)
this.composer.cursorOffset = content.length 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> { private async loadCommands(): Promise<void> {
let discovered: SlashCommand[] = [] let discovered: SlashCommand[] = []
try { try {
@@ -1045,7 +1140,7 @@ export class NanobotTui {
} }
this.commandMenu.hide() this.commandMenu.hide()
this.contextPanel.hide() this.contextPanel.hide()
this.composer.setText("") this.clearComposer()
this.sessionLoading = true this.sessionLoading = true
const loadId = ++this.sessionLoadId const loadId = ++this.sessionLoadId
this.status.content = "Loading sessions…" this.status.content = "Loading sessions…"
@@ -1053,6 +1148,11 @@ export class NanobotTui {
const sessions = await fetchSessions(this.options.apiUrl, this.options.apiToken) const sessions = await fetchSessions(this.options.apiUrl, this.options.apiToken)
if (this.quitting || loadId !== this.sessionLoadId) return if (this.quitting || loadId !== this.sessionLoadId) return
this.sessionLoading = false 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 const limit = this.renderer.height >= 20 ? 8 : 4
this.sessionMenu.open(sessions, this.client.activeChatId, limit) this.sessionMenu.open(sessions, this.client.activeChatId, limit)
this.sessionMenu.update(this.composer.plainText, 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) { if (this.activeTurn) {
this.status.content = "Wait for the current turn or press Ctrl+C" this.status.content = "Wait for the current turn or press Ctrl+C"
return return
} }
if (chatId === this.client.activeChatId) { if (session.chatId === this.client.activeChatId) {
this.sessionTitle = sessionLabel(session)
this.updateTitle()
this.closeSessions() this.closeSessions()
this.status.content = "Ready" this.status.content = this.readyStatus()
return return
} }
if (!this.ready) { if (!this.ready) {
@@ -1083,8 +1185,12 @@ export class NanobotTui {
this.closeSessions() this.closeSessions()
try { try {
this.ready = false this.ready = false
this.sessionMetadataId += 1
this.sessionTitle = sessionLabel(session)
this.contextTokens = null
this.updateTitle()
this.status.content = "Opening session…" this.status.content = "Opening session…"
this.client.attach(chatId) this.client.attach(session.chatId)
} catch (error) { } catch (error) {
this.status.content = error instanceof Error ? error.message : String(error) this.status.content = error instanceof Error ? error.message : String(error)
} }
@@ -1102,9 +1208,13 @@ export class NanobotTui {
this.commandMenu.hide() this.commandMenu.hide()
this.sessionMenu.hide() this.sessionMenu.hide()
this.contextPanel.hide() this.contextPanel.hide()
this.composer.setText("") this.clearComposer()
try { try {
this.ready = false this.ready = false
this.sessionMetadataId += 1
this.sessionTitle = "New chat"
this.contextTokens = null
this.updateTitle()
this.status.content = "Starting a new chat…" this.status.content = "Starting a new chat…"
this.client.newChat() this.client.newChat()
} catch (error) { } catch (error) {
@@ -1132,7 +1242,7 @@ export class NanobotTui {
return return
} }
this.commandTurns.set(turnId, lifecycle) this.commandTurns.set(turnId, lifecycle)
this.composer.setText("") this.clearComposer()
this.commandMenu.hide() this.commandMenu.hide()
if (lifecycle !== "stop_active_turn") this.transcript.user(content) if (lifecycle !== "stop_active_turn") this.transcript.user(content)
this.recordPrompt(content) this.recordPrompt(content)
@@ -1182,15 +1292,16 @@ export class NanobotTui {
this.sessionLoadId += 1 this.sessionLoadId += 1
this.sessionLoading = false this.sessionLoading = false
this.sessionMenu.hide() this.sessionMenu.hide()
this.composer.setText("") this.clearComposer()
this.syncComposerPlaceholder() this.syncComposerPlaceholder()
if (!this.activeTurn && this.ready) this.status.content = this.readyStatus()
this.updateMeta() this.updateMeta()
} }
private async openContext(): Promise<void> { private async openContext(): Promise<void> {
this.commandMenu.hide() this.commandMenu.hide()
this.sessionMenu.hide() this.sessionMenu.hide()
this.composer.setText("") this.clearComposer()
this.status.content = "Reading agent context…" this.status.content = "Reading agent context…"
try { try {
const context = await fetchSessionContext( const context = await fetchSessionContext(
@@ -1202,6 +1313,8 @@ export class NanobotTui {
this.status.content = "Context unavailable · new session or older gateway" this.status.content = "Context unavailable · new session or older gateway"
return return
} }
this.contextTokens = context.estimatedSessionTokens
this.updateTitle()
this.contextPanel.show(context) this.contextPanel.show(context)
this.status.content = "Context snapshot" this.status.content = "Context snapshot"
this.updateMeta() 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 { private openDiff(): void {
this.commandMenu.hide() this.commandMenu.hide()
this.sessionMenu.hide() this.sessionMenu.hide()
this.contextPanel.hide() this.contextPanel.hide()
this.composer.setText("") this.clearComposer()
this.composer.blur() this.composer.blur()
const edits = this.currentFileEdits.length ? this.currentFileEdits : this.lastFileEdits const edits = this.currentFileEdits.length ? this.currentFileEdits : this.lastFileEdits
this.diffViewer.show(edits) 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 accent: string
} }
function tokens(value: number): string { export function formatTokenCount(value: number): string {
if (value < 1_000) return String(value) if (value < 1_000) return String(value)
const compact = value >= 10_000 ? Math.round(value / 1_000) : Math.round(value / 100) / 10 const compact = value >= 10_000 ? Math.round(value / 1_000) : Math.round(value / 100) / 10
return `${compact}k` return `${compact}k`
@@ -86,7 +86,7 @@ export class ContextPanel {
const archived = context.archivedMessages > 0 const archived = context.archivedMessages > 0
? `${context.archivedMessages} archived · summary ${context.archivedSummary ? "active" : "unavailable"}` ? `${context.archivedMessages} archived · summary ${context.archivedSummary ? "active" : "unavailable"}`
: "No archived messages" : "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 this.summary.content = context.archivedSummary
? `Summary\n${context.archivedSummary}` ? `Summary\n${context.archivedSummary}`
: "The agent is currently replaying raw session messages; no compacted summary exists yet." : "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", { socket.emit("message", {
data: JSON.stringify({ event: "stream_end", chat_id: "one", resuming: "yes" }), 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: "future_gateway_event" }) })
socket.emit("message", { data: JSON.stringify({ event: "error", detail: "global failure" }) }) socket.emit("message", { data: JSON.stringify({ event: "error", detail: "global failure" }) })
expect(statuses).toContain("error:gateway sent an invalid event") 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" }) expect(events).toContainEqual({ event: "error", detail: "global failure" })
} finally { } finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original }) Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
+3
View File
@@ -70,6 +70,7 @@ export type InboundEvent =
turn_id?: string turn_id?: string
} }
| { event: "goal_state"; chat_id: string; goal_state: Record<string, unknown> } | { 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: "runtime_model_updated"; model_name: string; model_preset?: string | null }
| { event: "turn_model_updated"; chat_id: string; model_name: string } | { event: "turn_model_updated"; chat_id: string; model_name: string }
| { event: "error"; chat_id?: string; detail?: string; reason?: string; turn_id?: string } | { event: "error"; chat_id?: string; detail?: string; reason?: string; turn_id?: string }
@@ -158,6 +159,7 @@ const CHAT_EVENTS = new Set([
"turn_end", "turn_end",
"goal_status", "goal_status",
"goal_state", "goal_state",
"session_updated",
"turn_model_updated", "turn_model_updated",
"error", "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 === "turn_end" && !optional(record.latency_ms, "number")) return null
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") 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 === "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 if (name === "turn_model_updated" && typeof record.model_name !== "string") return null
return value as InboundEvent return value as InboundEvent
} }
+7 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing" import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
import { SessionMenu } from "./session-menu" import { SessionMenu, sessionLabel } from "./session-menu"
import type { SessionSummary } from "./protocol" import type { SessionSummary } from "./protocol"
const sessions: SessionSummary[] = [ const sessions: SessionSummary[] = [
@@ -54,4 +54,10 @@ describe("SessionMenu", () => {
expect(setup.captureCharFrame()).toContain("Release checklist") expect(setup.captureCharFrame()).toContain("Release checklist")
expect(menu.choose()?.chatId).toBe("two") 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 } type SessionMenuRow = SessionSummary & { active: boolean }
function sessionLabel(session: SessionSummary): string { export function sessionLabel(session: SessionSummary): string {
return session.title.trim() || session.preview.trim() || "Untitled chat" const label = session.title.trim() || session.preview.trim() || "Untitled chat"
return label.replace(/\s+/gu, " ")
} }
function updatedLabel(value: string | null): string { function updatedLabel(value: string | null): string {
+58
View File
@@ -29,6 +29,11 @@ export interface TranscriptHeader {
access: string access: string
} }
export interface TranscriptNavigation {
awayFromBottom: boolean
unseenOutput: boolean
}
interface Activity { interface Activity {
text: TextRenderable text: TextRenderable
lines: string[] lines: string[]
@@ -53,11 +58,14 @@ export class Transcript {
private readonly userRows = new Set<BoxRenderable>() private readonly userRows = new Set<BoxRenderable>()
private wrote = false private wrote = false
private nextId = 0 private nextId = 0
private navigation: TranscriptNavigation = { awayFromBottom: false, unseenOutput: false }
private navigationTimer: ReturnType<typeof setTimeout> | null = null
constructor( constructor(
private readonly renderer: CliRenderer, private readonly renderer: CliRenderer,
private theme: TranscriptTheme, private theme: TranscriptTheme,
private readonly treeSitterClient: TreeSitterClient, private readonly treeSitterClient: TreeSitterClient,
private readonly onNavigationChange?: (state: TranscriptNavigation) => void,
) { ) {
this.root = new ScrollBoxRenderable(renderer, { this.root = new ScrollBoxRenderable(renderer, {
id: "nanobot-tui-transcript", id: "nanobot-tui-transcript",
@@ -78,6 +86,7 @@ export class Transcript {
}, },
verticalScrollbarOptions: { visible: false }, verticalScrollbarOptions: { visible: false },
horizontalScrollbarOptions: { visible: false }, horizontalScrollbarOptions: { visible: false },
onMouseScroll: () => this.scheduleNavigationUpdate(),
}) })
this.root.verticalScrollBar.visible = false this.root.verticalScrollBar.visible = false
this.root.horizontalScrollBar.visible = false this.root.horizontalScrollBar.visible = false
@@ -126,6 +135,8 @@ export class Transcript {
} }
reset(header: TranscriptHeader): void { reset(header: TranscriptHeader): void {
if (this.navigationTimer) clearTimeout(this.navigationTimer)
this.navigationTimer = null
for (const child of [...this.root.getChildren()]) { for (const child of [...this.root.getChildren()]) {
this.root.remove(child) this.root.remove(child)
child.destroyRecursively() child.destroyRecursively()
@@ -139,7 +150,10 @@ export class Transcript {
this.userRows.clear() this.userRows.clear()
this.wrote = false this.wrote = false
this.nextId = 0 this.nextId = 0
this.navigation = { awayFromBottom: false, unseenOutput: false }
this.root.verticalScrollBar.visible = false
this.header(header) this.header(header)
this.emitNavigation()
} }
history(messages: HistoryMessage[]): void { history(messages: HistoryMessage[]): void {
@@ -185,23 +199,27 @@ export class Transcript {
} }
user(content: string): void { user(content: string): void {
this.noteOutput()
this.finishActivity() this.finishActivity()
this.writeRole("", content, "user") this.writeRole("", content, "user")
} }
assistant(content: string): void { assistant(content: string): void {
if (!content.trim()) return if (!content.trim()) return
this.noteOutput()
this.finishActivity() this.finishActivity()
this.writeMarkdown(content, false) this.writeMarkdown(content, false)
} }
notice(content: string, error = false): void { notice(content: string, error = false): void {
this.noteOutput()
this.finishActivity() this.finishActivity()
this.writeRole(error ? "×" : "·", content, error ? "error" : "muted") this.writeRole(error ? "×" : "·", content, error ? "error" : "muted")
} }
stream(delta: string): void { stream(delta: string): void {
if (!delta) return if (!delta) return
this.noteOutput()
if (!this.live) { if (!this.live) {
this.finishActivity() this.finishActivity()
const markdown = this.createMarkdown("", true, "assistant-stream") const markdown = this.createMarkdown("", true, "assistant-stream")
@@ -233,6 +251,7 @@ export class Transcript {
progress(content: string, events: ToolProgressEvent[] = []): string { progress(content: string, events: ToolProgressEvent[] = []): string {
if (events.length === 0 && !content.split("\n").some((line) => cleanProgress(line))) return "" if (events.length === 0 && !content.split("\n").some((line) => cleanProgress(line))) return ""
this.noteOutput()
if (!this.activity) this.activity = this.createActivity() if (!this.activity) this.activity = this.createActivity()
return this.updateActivity(this.activity, content, events) return this.updateActivity(this.activity, content, events)
} }
@@ -262,13 +281,17 @@ export class Transcript {
scrollByPage(direction: -1 | 1): void { scrollByPage(direction: -1 | 1): void {
this.root.scrollBy(direction * Math.max(3, Math.floor(this.root.height * 0.7))) this.root.scrollBy(direction * Math.max(3, Math.floor(this.root.height * 0.7)))
this.scheduleNavigationUpdate()
} }
scrollToEdge(edge: "top" | "bottom"): void { scrollToEdge(edge: "top" | "bottom"): void {
this.root.scrollTo(edge === "top" ? 0 : this.root.scrollHeight) this.root.scrollTo(edge === "top" ? 0 : this.root.scrollHeight)
if (edge === "bottom") this.updateNavigation(false, true)
else this.scheduleNavigationUpdate()
} }
destroy(): void { destroy(): void {
if (this.navigationTimer) clearTimeout(this.navigationTimer)
this.live = null this.live = null
this.activity = null this.activity = null
this.frames.clear() this.frames.clear()
@@ -276,6 +299,41 @@ export class Transcript {
this.theme.syntax.destroy() 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 { private id(prefix: string): string {
this.nextId += 1 this.nextId += 1
return `${prefix}-${this.nextId}` return `${prefix}-${this.nextId}`