feat(tui): add session navigation

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent d094fd7f4d
commit 2b666934ba
11 changed files with 644 additions and 87 deletions
+1 -1
View File
@@ -202,7 +202,7 @@ Use `nanobot gateway --background` for the same direct entry point without keepi
nanobot agent
```
This opens the native terminal client with the same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. It keeps a terminal-specific session, starts a local gateway only when needed, and releases that gateway when you exit. Type `exit` or press `Ctrl+C` when you are done. Use `nanobot agent --classic` for the legacy Python prompt.
This opens the native terminal client with the same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. Use `/sessions` to switch saved conversations or `/new` to start another one. It starts a local gateway only when needed and releases that gateway when you exit. Type `exit` or press `Ctrl+C` when you are done. Use `nanobot agent --classic` for the legacy Python prompt.
For one request and an immediate exit, use:
+1 -1
View File
@@ -118,7 +118,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 recent prompts. Type `/` to discover gateway-provided commands, use the arrow keys to choose one, and press `Tab` to complete it. `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 recent prompts. Type `/` to discover gateway-provided commands, use the arrow keys to choose one, and press `Tab` to complete it. `/sessions` and `/resume` open a searchable conversation picker; `/new` starts a separate conversation without deleting the current one. `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.
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`.
+3
View File
@@ -15,3 +15,6 @@ The renderer uses OpenTUI's retained full-screen layout: the transcript reflows
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.
Use `/sessions` or `/resume` to search and switch persisted conversations without leaving the
terminal. `/new` starts a separate conversation while preserving the current one in history.
+106 -1
View File
@@ -41,7 +41,7 @@ async function waitUntil(predicate: () => boolean, timeout = 1_000): Promise<voi
if (!predicate()) throw new Error(`condition was not met within ${timeout}ms`)
}
function client(sent: string[] = []) {
function client(sent: string[] = [], attached: string[] = [], newChats: string[] = []) {
return {
activeChatId: "chat",
connect() {},
@@ -50,6 +50,12 @@ function client(sent: string[] = []) {
sent.push(content)
return "turn"
},
attach(chatId: string) {
attached.push(chatId)
},
newChat() {
newChats.push("new")
},
}
}
@@ -231,6 +237,105 @@ describe("NanobotTui layout", () => {
expect(sent).toEqual([])
})
test("switches and creates gateway chats without sending agent commands", async () => {
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
const original = globalThis.fetch
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
sessions: [
{
key: "websocket:chat",
title: "Current chat",
preview: "Current work",
updated_at: "2026-08-13T10:00:00Z",
},
{
key: "websocket:other",
title: "Release checklist",
preview: "Prepare stable release",
updated_at: "2026-08-12T10:00:00Z",
},
],
})))) as unknown as typeof fetch
const attached: string[] = []
const newChats: string[] = []
const transport = client([], attached, newChats)
const app = NanobotTui.mount(
setup.renderer,
{ ...options, apiUrl: "http://nanobot.test", apiToken: "secret" },
transport,
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
)
app.accept({ event: "attached", chat_id: "chat" })
await Bun.sleep(1)
const ui = app as unknown as {
composer: TextareaRenderable
sessionMenu: { visible: boolean }
}
try {
ui.composer.setText("/sessions")
ui.composer.submit()
await waitUntil(() => ui.sessionMenu.visible)
expect(ui.composer.placeholder).toBe("Search sessions")
ui.composer.setText("release")
ui.composer.submit()
await waitUntil(() => attached.length === 1)
expect(attached).toEqual(["other"])
app.accept({ event: "attached", chat_id: "other" })
await Bun.sleep(1)
ui.composer.setText("/new")
ui.composer.submit()
await waitUntil(() => newChats.length === 1)
expect(newChats).toEqual(["new"])
} finally {
globalThis.fetch = original
}
})
test("blocks sends and ignores late session results after closing the picker", async () => {
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
const original = globalThis.fetch
let resolveFetch: ((response: Response) => void) | undefined
globalThis.fetch = (() => new Promise<Response>((resolve) => {
resolveFetch = resolve
})) as unknown as typeof fetch
const sent: string[] = []
const app = NanobotTui.mount(
setup.renderer,
{ ...options, apiUrl: "http://nanobot.test", apiToken: "secret" },
client(sent),
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
)
app.accept({ event: "attached", chat_id: "chat" })
await Bun.sleep(1)
const ui = app as unknown as {
composer: TextareaRenderable
sessionMenu: { visible: boolean }
sessionLoading: boolean
}
try {
ui.composer.setText("/sessions")
ui.composer.submit()
await waitUntil(() => ui.sessionLoading)
ui.composer.setText("do not send")
ui.composer.submit()
await Bun.sleep(10)
expect(sent).toEqual([])
setup.mockInput.pressEscape()
await Bun.sleep(10)
resolveFetch?.(new Response(JSON.stringify({ sessions: [] })))
await Bun.sleep(10)
expect(ui.sessionMenu.visible).toBe(false)
expect(ui.composer.plainText).toBe("")
} finally {
globalThis.fetch = original
}
})
test("survives rapid narrow resizes with long CJK and code", async () => {
setup = await createRenderer({ width: 100, height: 30, screenMode: "alternate-screen" })
const app = mount(setup)
+167 -7
View File
@@ -16,11 +16,14 @@ import {
import {
NanobotClient,
fetchHistory,
fetchSessions,
fetchSlashCommands,
type ConnectionStatus,
type InboundEvent,
type SlashCommand,
} from "./protocol"
import { CommandMenu, type CommandMenuTheme } from "./command-menu"
import { SessionMenu } from "./session-menu"
import { Transcript, type TranscriptTheme } from "./transcript"
interface AppOptions {
@@ -40,6 +43,8 @@ interface ChatClient {
connect(): void
close(): void
send(content: string): string
attach(chatId: string): void
newChat(): void
}
interface Palette {
@@ -85,6 +90,29 @@ const LIGHT: Palette = {
}
const COMPOSER_PLACEHOLDER = "Ask nanobot anything"
const LOCAL_COMMANDS: SlashCommand[] = [
{
command: "/sessions",
title: "Sessions",
description: "Find and switch conversations",
argHint: "",
acceptsArgs: false,
},
{
command: "/resume",
title: "Resume",
description: "Find and switch conversations",
argHint: "",
acceptsArgs: false,
},
{
command: "/new",
title: "New chat",
description: "Start a separate conversation",
argHint: "",
acceptsArgs: false,
},
]
function syntaxStyle(palette: Palette): SyntaxStyle {
const color = (value: string) => {
@@ -161,6 +189,7 @@ export class NanobotTui {
private readonly renderer: CliRenderer
private readonly transcript: Transcript
private readonly commandMenu: CommandMenu
private readonly sessionMenu: SessionMenu
private readonly client: ChatClient
private readonly shell: BoxRenderable
private readonly title: TextRenderable
@@ -190,6 +219,8 @@ export class NanobotTui {
private historyDraft = ""
private modelName: string
private quitting = false
private sessionLoadId = 0
private sessionLoading = false
private constructor(
renderer: CliRenderer,
@@ -203,6 +234,7 @@ export class NanobotTui {
this.palette = this.activeThemeMode === "light" ? LIGHT : DARK
this.transcript = new Transcript(renderer, transcriptTheme(this.palette), treeSitterClient)
this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
this.sessionMenu = new SessionMenu(renderer, commandMenuTheme(this.palette))
this.client = client || new NanobotClient({
url: options.wsUrl,
chatId: options.chatId,
@@ -262,7 +294,8 @@ export class NanobotTui {
],
onContentChange: () => {
this.syncComposerPlaceholder()
this.syncCommandMenu()
if (this.sessionMenu.visible) this.syncSessionMenu()
else this.syncCommandMenu()
this.resizeComposer()
},
// IMEs may commit their final composed glyph after Enter. Matching the
@@ -302,6 +335,7 @@ export class NanobotTui {
statusRow.add(this.meta)
this.shell.add(this.transcript.root)
this.shell.add(this.commandMenu.root)
this.shell.add(this.sessionMenu.root)
this.shell.add(this.title)
this.shell.add(this.composerFrame)
this.shell.add(statusRow)
@@ -372,6 +406,15 @@ export class NanobotTui {
private submit(): void {
if (this.quitting || this.composer.isDestroyed) return
const content = this.composer.plainText.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)
return
}
if (!content) return
const completion = this.commandMenu.completion(content)
if (completion) {
@@ -380,6 +423,14 @@ export class NanobotTui {
this.updateMeta()
return
}
if (["/sessions", "/resume", "/continue"].includes(content.toLocaleLowerCase())) {
void this.openSessions()
return
}
if (content.toLocaleLowerCase() === "/new") {
this.startNewChat()
return
}
if (!this.ready) {
this.status.content = "Preparing chat…"
return
@@ -596,6 +647,24 @@ export class NanobotTui {
}
private handleKey = (key: KeyEvent): void => {
if (this.sessionLoading && key.name === "escape") {
this.closeSessions()
this.status.content = "Ready"
key.preventDefault()
return
}
if (this.sessionMenu.visible) {
if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
this.sessionMenu.move(key.name === "up" ? -1 : 1)
key.preventDefault()
return
}
if (key.name === "escape") {
this.closeSessions()
key.preventDefault()
return
}
}
if (this.commandMenu.visible) {
if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
this.commandMenu.move(key.name === "up" ? -1 : 1)
@@ -710,6 +779,7 @@ export class NanobotTui {
this.palette = mode === "light" ? LIGHT : DARK
this.transcript.setTheme(transcriptTheme(this.palette))
this.commandMenu.setTheme(commandMenuTheme(this.palette))
this.sessionMenu.setTheme(commandMenuTheme(this.palette))
this.composerFrame.borderColor = this.palette.border
this.composer.textColor = this.palette.text
this.composer.focusedTextColor = this.palette.text
@@ -736,6 +806,12 @@ export class NanobotTui {
: "tab complete · esc close"
return
}
if (this.sessionMenu.visible) {
this.meta.content = this.renderer.width >= 64
? "type to filter · ↑↓ choose · enter open · esc close"
: "enter open · esc close"
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
@@ -760,7 +836,9 @@ export class NanobotTui {
// OpenTUI normally suppresses placeholder glyphs while the editor is not
// empty. Explicitly removing them also invalidates their old cells, which
// prevents stale placeholder text in differential/embedded terminals.
const placeholder = this.composer.plainText ? null : COMPOSER_PLACEHOLDER
const placeholder = this.composer.plainText
? null
: this.sessionMenu.visible ? "Search sessions" : COMPOSER_PLACEHOLDER
if (this.composer.placeholder !== placeholder) this.composer.placeholder = placeholder
}
@@ -770,22 +848,104 @@ export class NanobotTui {
this.updateMeta()
}
private syncSessionMenu(): void {
const limit = this.renderer.height >= 20 ? 8 : 4
this.sessionMenu.update(this.composer.plainText, limit)
this.updateMeta()
}
private setComposer(content: string): void {
this.composer.setText(content)
this.composer.cursorOffset = content.length
}
private async loadCommands(): Promise<void> {
let discovered: SlashCommand[] = []
try {
this.commandMenu.setCommands(await fetchSlashCommands(
discovered = await fetchSlashCommands(
this.options.apiUrl,
this.options.apiToken,
))
this.syncCommandMenu()
)
} catch {
// Command discovery is an enhancement; chat stays usable if the
// version-matched gateway does not expose the catalog yet.
// Local navigation remains available against older gateways.
}
const commands = new Map(discovered.map((command) => [command.command, command]))
for (const command of LOCAL_COMMANDS) commands.set(command.command, command)
this.commandMenu.setCommands([...commands.values()])
this.syncCommandMenu()
}
private async openSessions(): Promise<void> {
if (this.activeTurn) {
this.status.content = "Wait for the current turn or press Ctrl+C"
return
}
this.commandMenu.hide()
this.composer.setText("")
this.sessionLoading = true
const loadId = ++this.sessionLoadId
this.status.content = "Loading sessions…"
try {
const sessions = await fetchSessions(this.options.apiUrl, this.options.apiToken)
if (this.quitting || loadId !== this.sessionLoadId) return
this.sessionLoading = false
const limit = this.renderer.height >= 20 ? 8 : 4
this.sessionMenu.open(sessions, this.client.activeChatId, limit)
this.syncComposerPlaceholder()
this.updateMeta()
this.status.content = sessions.length ? `${sessions.length} sessions` : "No saved sessions"
} catch (error) {
if (loadId !== this.sessionLoadId) return
this.sessionLoading = false
this.status.content = error instanceof Error ? error.message : String(error)
}
}
private switchSession(chatId: string): void {
if (this.activeTurn) {
this.status.content = "Wait for the current turn or press Ctrl+C"
return
}
this.closeSessions()
if (chatId === this.client.activeChatId) {
this.status.content = "Ready"
return
}
try {
this.ready = false
this.status.content = "Opening session…"
this.client.attach(chatId)
} catch (error) {
this.ready = true
this.status.content = error instanceof Error ? error.message : String(error)
}
}
private startNewChat(): void {
if (this.activeTurn) {
this.status.content = "Wait for the current turn or press Ctrl+C"
return
}
this.commandMenu.hide()
this.sessionMenu.hide()
this.composer.setText("")
try {
this.ready = false
this.status.content = "Starting a new chat…"
this.client.newChat()
} catch (error) {
this.ready = true
this.status.content = error instanceof Error ? error.message : String(error)
}
}
private closeSessions(): void {
this.sessionLoadId += 1
this.sessionLoading = false
this.sessionMenu.hide()
this.composer.setText("")
this.syncComposerPlaceholder()
this.updateMeta()
}
private async copySelection(text: string): Promise<void> {
+22 -77
View File
@@ -1,48 +1,35 @@
import {
BoxRenderable,
RGBA,
TextAttributes,
TextRenderable,
type CliRenderer,
} from "@opentui/core"
import { type BoxRenderable, type CliRenderer } from "@opentui/core"
import type { SlashCommand } from "./protocol"
import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
export interface CommandMenuTheme {
text: string
muted: string
border: string
}
export type CommandMenuTheme = PickerMenuTheme
/** Retained slash-command discovery with one small completion interface. */
export class CommandMenu {
readonly root: BoxRenderable
private readonly picker: PickerMenu<SlashCommand>
private commands: SlashCommand[] = []
private matches: SlashCommand[] = []
private selected = 0
private query = ""
constructor(
private readonly renderer: CliRenderer,
private theme: CommandMenuTheme,
renderer: CliRenderer,
theme: CommandMenuTheme,
) {
this.root = new BoxRenderable(renderer, {
this.picker = new PickerMenu(renderer, theme, {
id: "nanobot-tui-command-menu",
width: "100%",
flexShrink: 0,
flexDirection: "column",
border: true,
borderStyle: "rounded",
borderColor: theme.border,
paddingLeft: 1,
paddingRight: 1,
backgroundColor: RGBA.defaultBackground(),
visible: false,
searchText: (command) => `${command.command} ${command.title}`,
render: (command) => {
const hint = command.argHint ? ` ${command.argHint}` : ""
const detail = (command.description || command.title).replace(/\s+/gu, " ")
return `${command.command}${hint} ${detail}`
},
})
this.root = this.picker.root
}
get visible(): boolean {
return this.root.visible
return this.picker.visible
}
setCommands(commands: SlashCommand[]): void {
@@ -58,76 +45,34 @@ export class CommandMenu {
this.hide()
return
}
const words = token.slice(1)
this.matches = this.commands
.filter((command) => (
command.command.toLocaleLowerCase().startsWith(token)
|| command.title.toLocaleLowerCase().includes(words)
))
.slice(0, Math.max(1, limit))
this.selected = changed ? 0 : Math.min(this.selected, Math.max(0, this.matches.length - 1))
this.render()
if (changed || !this.picker.visible) this.picker.show(this.commands, token.slice(1), limit)
else this.picker.update(token.slice(1), limit)
}
move(direction: -1 | 1): boolean {
if (!this.visible || this.matches.length < 2) return false
this.selected = (this.selected + direction + this.matches.length) % this.matches.length
this.render()
return true
return this.picker.move(direction)
}
completion(input: string): string | null {
if (!this.visible) return null
const command = this.matches[this.selected]
const command = this.picker.current()
if (!command || input.trim() === command.command) return null
return `${command.command}${command.acceptsArgs ? " " : ""}`
}
complete(): string | null {
if (!this.visible) return null
const command = this.matches[this.selected]
const command = this.picker.current()
if (!command) return null
this.hide()
return `${command.command}${command.acceptsArgs ? " " : ""}`
}
hide(): void {
this.matches = []
this.selected = 0
this.root.visible = false
this.clear()
this.picker.hide()
}
setTheme(theme: CommandMenuTheme): void {
this.theme = theme
this.root.borderColor = theme.border
if (this.visible) this.render()
}
private render(): void {
this.clear()
this.root.visible = this.matches.length > 0
for (const [index, command] of this.matches.entries()) {
const selected = index === this.selected
const hint = command.argHint ? ` ${command.argHint}` : ""
const detail = (command.description || command.title).replace(/\s+/gu, " ")
const line = new TextRenderable(this.renderer, {
id: `nanobot-tui-command-${index}`,
content: `${selected ? "" : " "} ${command.command}${hint} ${detail}`,
width: "100%",
height: 1,
wrapMode: "none",
fg: selected ? this.theme.text : this.theme.muted,
attributes: selected ? TextAttributes.BOLD : 0,
})
this.root.add(line)
}
}
private clear(): void {
for (const child of [...this.root.getChildren()]) {
this.root.remove(child)
child.destroyRecursively()
}
this.picker.setTheme(theme)
}
}
+135
View File
@@ -0,0 +1,135 @@
import {
BoxRenderable,
RGBA,
TextAttributes,
TextRenderable,
type CliRenderer,
} from "@opentui/core"
export interface PickerMenuTheme {
text: string
muted: string
border: string
}
interface PickerMenuOptions<T> {
id: string
searchText: (item: T) => string
render: (item: T) => string
emptyText?: string
}
/** Shared retained picker for command discovery and session navigation. */
export class PickerMenu<T> {
readonly root: BoxRenderable
private items: T[] = []
private matches: T[] = []
private selected = 0
private query = ""
private limit = 6
constructor(
private readonly renderer: CliRenderer,
private theme: PickerMenuTheme,
private readonly options: PickerMenuOptions<T>,
) {
this.root = new BoxRenderable(renderer, {
id: options.id,
width: "100%",
flexShrink: 0,
flexDirection: "column",
border: true,
borderStyle: "rounded",
borderColor: theme.border,
paddingLeft: 1,
paddingRight: 1,
backgroundColor: RGBA.defaultBackground(),
visible: false,
})
}
get visible(): boolean {
return this.root.visible
}
show(items: T[], query = "", limit = 6): void {
this.items = items
this.root.visible = true
this.update(query, limit)
}
update(query: string, limit = this.limit): void {
if (!this.visible) return
const changed = query !== this.query
this.query = query
this.limit = Math.max(1, limit)
const words = query.trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean)
this.matches = this.items
.filter((item) => {
const haystack = this.options.searchText(item).toLocaleLowerCase()
return words.every((word) => haystack.includes(word))
})
.slice(0, this.limit)
this.selected = changed ? 0 : Math.min(this.selected, Math.max(0, this.matches.length - 1))
this.render()
}
move(direction: -1 | 1): boolean {
if (!this.visible || this.matches.length < 2) return false
this.selected = (this.selected + direction + this.matches.length) % this.matches.length
this.render()
return true
}
current(): T | null {
return this.visible ? this.matches[this.selected] ?? null : null
}
hide(): void {
this.items = []
this.matches = []
this.selected = 0
this.query = ""
this.root.visible = false
this.clear()
}
setTheme(theme: PickerMenuTheme): void {
this.theme = theme
this.root.borderColor = theme.border
if (this.visible) this.render()
}
private render(): void {
this.clear()
if (this.matches.length === 0) {
this.root.add(new TextRenderable(this.renderer, {
id: `${this.options.id}-empty`,
content: this.options.emptyText || "No matches",
width: "100%",
height: 1,
fg: this.theme.muted,
}))
return
}
for (const [index, item] of this.matches.entries()) {
const selected = index === this.selected
this.root.add(new TextRenderable(this.renderer, {
id: `${this.options.id}-${index}`,
content: `${selected ? "" : " "} ${this.options.render(item)}`,
width: "100%",
height: 1,
wrapMode: "none",
fg: selected ? this.theme.text : this.theme.muted,
attributes: selected ? TextAttributes.BOLD : 0,
}))
}
}
private clear(): void {
for (const child of [...this.root.getChildren()]) {
this.root.remove(child)
child.destroyRecursively()
}
}
}
+36
View File
@@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"
import {
NanobotClient,
fetchHistory,
fetchSessions,
fetchSlashCommands,
type InboundEvent,
} from "./protocol"
@@ -70,12 +71,16 @@ describe("gateway protocol", () => {
})
socket.emit("message", { data: JSON.stringify({ event: "attached", chat_id: "terminal" }) })
client.send("hello")
client.attach("other-chat")
client.newChat()
const outbound = socket.sent.map((value) => JSON.parse(value) as Record<string, unknown>)
expect(outbound[0]).toEqual({ type: "attach", chat_id: "terminal" })
expect(outbound[1]?.type).toBe("message")
expect(outbound[1]?.chat_id).toBe("terminal")
expect(outbound[1]?.content).toBe("hello")
expect(outbound[2]).toEqual({ type: "attach", chat_id: "other-chat" })
expect(outbound[3]).toEqual({ type: "new_chat" })
expect(events.map((event) => event.event)).toEqual(["ready", "attached"])
} finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
@@ -240,4 +245,35 @@ describe("gateway protocol", () => {
globalThis.fetch = original
}
})
test("loads and normalizes WebUI sessions", async () => {
const original = globalThis.fetch
globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({
sessions: [
{
key: "websocket:chat-1",
title: "Release plan",
preview: "Prepare the release",
created_at: "2026-08-12T10:00:00Z",
updated_at: "2026-08-13T10:00:00Z",
run_started_at: 123,
},
{ key: "cli:direct", title: "Not a WebUI session" },
{ key: 42 },
],
})))) as unknown as typeof fetch
try {
expect(await fetchSessions("http://nanobot.test", "secret")).toEqual([{
chatId: "chat-1",
title: "Release plan",
preview: "Prepare the release",
createdAt: "2026-08-12T10:00:00Z",
updatedAt: "2026-08-13T10:00:00Z",
runStartedAt: 123,
}])
} finally {
globalThis.fetch = original
}
})
})
+45
View File
@@ -95,6 +95,15 @@ export interface SlashCommand {
acceptsArgs: boolean
}
export interface SessionSummary {
chatId: string
title: string
preview: string
createdAt: string | null
updatedAt: string | null
runStartedAt: number | null
}
const SLASH_COMMAND_LIFECYCLES = new Set([
"side_channel",
"finalize_active_turn",
@@ -271,6 +280,33 @@ export async function fetchSlashCommands(
})
}
export async function fetchSessions(
apiUrl: string,
apiToken: string,
): Promise<SessionSummary[]> {
if (!apiUrl || !apiToken) return []
const response = await fetch(`${apiUrl}/api/sessions`, {
headers: { Authorization: `Bearer ${apiToken}` },
})
if (!response.ok) throw new Error(`session request failed: HTTP ${response.status}`)
const payload = await response.json() as { sessions?: unknown[] }
return (payload.sessions || []).flatMap((value) => {
if (!isRecord(value) || typeof value.key !== "string" || !value.key.startsWith("websocket:")) {
return []
}
const chatId = value.key.slice("websocket:".length)
if (!chatId) return []
return [{
chatId,
title: typeof value.title === "string" ? value.title : "",
preview: typeof value.preview === "string" ? value.preview : "",
createdAt: typeof value.created_at === "string" ? value.created_at : null,
updatedAt: typeof value.updated_at === "string" ? value.updated_at : null,
runStartedAt: typeof value.run_started_at === "number" ? value.run_started_at : null,
}]
})
}
export class NanobotClient {
private socket: WebSocket | null = null
private chatId = ""
@@ -338,6 +374,15 @@ export class NanobotClient {
return turnId
}
attach(chatId: string): void {
if (!chatId) throw new Error("chat id is required")
this.write({ type: "attach", chat_id: chatId })
}
newChat(): void {
this.write({ type: "new_chat" })
}
private handleMessage(raw: string): void {
let value: unknown
try {
+53
View File
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, test } from "bun:test"
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
import { SessionMenu } from "./session-menu"
import type { SessionSummary } from "./protocol"
const sessions: SessionSummary[] = [
{
chatId: "one",
title: "API migration",
preview: "Move authentication to the new client",
createdAt: "2026-08-12T10:00:00Z",
updatedAt: "2026-08-13T10:00:00Z",
runStartedAt: null,
},
{
chatId: "two",
title: "Release checklist",
preview: "Prepare the stable release",
createdAt: "2026-08-11T10:00:00Z",
updatedAt: "2026-08-12T10:00:00Z",
runStartedAt: null,
},
]
describe("SessionMenu", () => {
let setup: TestRendererSetup | undefined
afterEach(() => {
if (setup && !setup.renderer.isDestroyed) setup.renderer.destroy()
setup = undefined
})
test("marks, filters, and chooses gateway sessions", async () => {
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
const menu = new SessionMenu(setup.renderer, {
text: "#FFFFFF",
muted: "#999999",
border: "#555555",
})
setup.renderer.root.add(menu.root)
menu.open(sessions, "one", 6)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain(" ● API migration")
expect(menu.choose()?.chatId).toBe("one")
menu.update("release stable", 6)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Release checklist")
expect(menu.choose()?.chatId).toBe("two")
})
})
+75
View File
@@ -0,0 +1,75 @@
import { type BoxRenderable, type CliRenderer } from "@opentui/core"
import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
import type { SessionSummary } from "./protocol"
type SessionMenuRow = SessionSummary & { active: boolean }
function sessionLabel(session: SessionSummary): string {
return session.title.trim() || session.preview.trim() || "Untitled chat"
}
function updatedLabel(value: string | null): string {
if (!value) return ""
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return ""
const age = Math.max(0, Date.now() - date.valueOf())
if (age < 60_000) return "now"
if (age < 3_600_000) return `${Math.floor(age / 60_000)}m`
if (age < 86_400_000) return `${Math.floor(age / 3_600_000)}h`
return `${Math.floor(age / 86_400_000)}d`
}
/** Searchable session navigation over the gateway-owned session list. */
export class SessionMenu {
readonly root: BoxRenderable
private readonly picker: PickerMenu<SessionMenuRow>
constructor(renderer: CliRenderer, theme: PickerMenuTheme) {
this.picker = new PickerMenu(renderer, theme, {
id: "nanobot-tui-session-menu",
searchText: (session) => `${sessionLabel(session)} ${session.preview} ${session.chatId}`,
render: (session) => {
const age = updatedLabel(session.updatedAt)
const preview = session.preview.trim()
const detail = [age, preview && preview !== sessionLabel(session) ? preview : ""]
.filter(Boolean)
.join(" · ")
return `${session.active ? "● " : ""}${sessionLabel(session)}${detail ? ` ${detail}` : ""}`
},
emptyText: "No matching sessions",
})
this.root = this.picker.root
}
get visible(): boolean {
return this.picker.visible
}
open(sessions: SessionSummary[], currentChatId: string, limit: number): void {
const rows = sessions
.map((session) => ({ ...session, active: session.chatId === currentChatId }))
.sort((left, right) => Number(right.active) - Number(left.active))
this.picker.show(rows, "", limit)
}
update(query: string, limit: number): void {
this.picker.update(query, limit)
}
move(direction: -1 | 1): boolean {
return this.picker.move(direction)
}
choose(): SessionSummary | null {
return this.picker.current()
}
hide(): void {
this.picker.hide()
}
setTheme(theme: PickerMenuTheme): void {
this.picker.setTheme(theme)
}
}