mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +03:00
feat(tui): add session navigation
This commit is contained in:
@@ -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