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
+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)
}
}