mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
feat(tui): add session navigation
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user