mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
feat(tui): refine quiet terminal interactions
This commit is contained in:
@@ -124,7 +124,7 @@ Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the s
|
||||
|
||||
The default `--theme auto` mode probes the terminal's real foreground and background colors before first paint and follows supported live appearance changes. Use `--theme light` or `--theme dark` when a terminal or multiplexer does not report its colors reliably.
|
||||
|
||||
`Enter` sends the current message. Press `Alt+Enter` to add a newline and use `Up`/`Down` at the composer edge to recall prompts from the current saved session. Type `/` to discover nanobot commands and terminal navigation in one palette, use the arrow keys to choose one, and press `Tab` to complete it. `/sessions` opens a searchable conversation picker, while `/new-chat` preserves the current conversation and starts another one. `/diff` opens a read-only unified diff for the newest turn; use `Left`/`Right` to switch edits and `Esc` to close it. The core `/new` command retains its cross-channel behavior and resets the current chat. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen.
|
||||
`Enter` sends the current message. Press `Alt+Enter` to add a newline and use `Up`/`Down` at the composer edge to recall prompts from the current saved session. Large pastes appear as a compact placeholder in the composer but are sent unchanged. Type `/` to discover nanobot commands and terminal navigation in one palette, use the arrow keys to choose one, and press `Tab` to complete it. `/sessions` opens a searchable conversation picker, while `/new-chat` preserves the current conversation and starts another one. `/diff` opens a read-only unified diff for the newest turn; use `Left`/`Right` to switch edits and `Esc` to close it. The core `/new` command retains its cross-channel behavior and resets the current chat. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. When you leave the bottom, the TUI shows a scrollbar and a `Ctrl+End` hint until you return. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen.
|
||||
|
||||
Packaged releases fetch a version-matched, checksummed terminal binary for macOS (Apple Silicon and Intel), Linux (x64 and ARM64), or Windows x64 on first use and cache it under the nanobot data directory. Windows ARM64 currently falls back to the classic prompt because the Bun runtime disables the FFI required by OpenTUI on that platform. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. A source checkout can run the client with Bun after `bun install --cwd tui`.
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ bun run --cwd tui build
|
||||
|
||||
The renderer uses OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen.
|
||||
|
||||
When you scroll away from the latest output, the scrollbar and `Ctrl+End` hint appear only until
|
||||
you return to the bottom. Large pastes are represented by a short editable placeholder in the
|
||||
composer; nanobot sends the original text unchanged.
|
||||
|
||||
Type `/` to discover slash commands published by the connected gateway. Use the arrow keys
|
||||
to move, `Tab` to complete, and `Esc` to close the menu.
|
||||
|
||||
|
||||
+56
-4
@@ -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