From cf82b89307811f8637b6ba98f41671abb1f10c0f Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:47:56 +0800 Subject: [PATCH] feat(tui): make session title interactive --- tui/src/app.test.ts | 61 +++++++++++++++++++++++++++++++++++++++++ tui/src/app.ts | 38 +++++++++++++++++++++++-- tui/src/session-menu.ts | 9 ++++-- 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index 97f328c30..35813dd10 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -764,6 +764,67 @@ describe("NanobotTui layout", () => { } }) + test("opens and switches sessions from the clickable title", async () => { + const original = globalThis.fetch + globalThis.fetch = ((input: string | URL | Request) => { + const url = String(input) + if (url.endsWith("/api/webui/sidebar-state")) { + return Promise.resolve(new Response(JSON.stringify({}))) + } + return Promise.resolve(new Response(JSON.stringify({ + sessions: [ + { key: "websocket:chat", title: "Current chat", preview: "Current work" }, + { key: "websocket:other", title: "Release checklist", preview: "Ship it" }, + ], + }))) + }) as typeof fetch + const attached: string[] = [] + setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" }) + const app = NanobotTui.mount( + setup.renderer, + { ...options, apiUrl: "http://nanobot.test", apiToken: "secret" }, + client([], attached), + new MockTreeSitterClient({ autoResolveTimeout: 0 }), + ) + app.accept({ event: "attached", chat_id: "chat" }) + const ui = app as unknown as { + composer: TextareaRenderable + sessionMenu: { visible: boolean; root: { getChildren(): unknown[] } } + titleText: TextRenderable + status: TextRenderable + } + + try { + await waitUntil(() => (app as unknown as { ready: boolean }).ready) + await setup.renderOnce() + await setup.mockMouse.click(ui.titleText.x + 2, ui.titleText.y) + await waitUntil(() => ui.sessionMenu.visible) + await setup.flush() + expect(ui.composer.placeholder).toBe("Search sessions") + + const rows = ui.sessionMenu.root.getChildren() as TextRenderable[] + const other = rows.find((row) => row.plainText.includes("Release checklist")) + if (!other) throw new Error("other session row was not rendered") + ui.composer.blur() + await setup.mockMouse.click(other.x + 2, other.y) + await waitUntil(() => attached.length === 1) + expect(attached).toEqual(["other"]) + expect(ui.sessionMenu.visible).toBe(false) + expect(ui.composer.focused).toBe(true) + expect(ui.titleText.plainText).toContain("Release checklist") + + app.accept({ event: "attached", chat_id: "other" }) + await setup.mockMouse.click(ui.titleText.x + 2, ui.titleText.y) + await waitUntil(() => ui.sessionMenu.visible) + ui.composer.blur() + await setup.mockMouse.click(ui.status.x, ui.status.y) + expect(ui.sessionMenu.visible).toBe(false) + expect(ui.composer.focused).toBe(true) + } finally { + globalThis.fetch = original + } + }) + test("preserves gateway slash lifecycle while local navigation stays in the same menu", async () => { setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) const sent: string[] = [] diff --git a/tui/src/app.ts b/tui/src/app.ts index 5cce93ff1..53983821a 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -457,7 +457,11 @@ export class NanobotTui { ) this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette)) this.commandMenu.setCommands([], this.localCommands) - this.sessionMenu = new SessionMenu(renderer, commandMenuTheme(this.palette)) + this.sessionMenu = new SessionMenu( + renderer, + commandMenuTheme(this.palette), + (session) => this.switchSession(session), + ) this.mentionMenu = new MentionMenu(renderer, commandMenuTheme(this.palette)) this.branchMenu = new BranchMenu(renderer, commandMenuTheme(this.palette)) this.contextPanel = new ContextPanel(renderer, contextPanelTheme(this.palette)) @@ -492,6 +496,9 @@ export class NanobotTui { // outside their trigger or body dismisses them; hide() restores the // composer focus through the shared visibility callback. this.dismissRuntimeControls() + if (this.sessionLoading || this.sessionMenu.visible) { + this.closeSessions() + } // Selection belongs to transcript/input content, never to empty chrome. // Clearing it here prevents default-background cells from lingering as // opaque blocks in terminals with differential repainting. @@ -517,6 +524,21 @@ export class NanobotTui { truncate: true, fg: this.palette.muted, selectable: false, + ...(host.hosted ? {} : { + onMouseOver: () => { this.titleText.fg = this.palette.accent }, + onMouseOut: () => this.renderTitleColor(), + onMouseDown: (event) => { + if (event.button !== 0) return + event.preventDefault() + event.stopPropagation() + this.renderer.clearSelection() + if (this.sessionLoading || this.sessionMenu.visible) { + this.closeSessions() + return + } + void this.openSessions() + }, + }), }) this.runtimeControls = new RuntimeControls( renderer, @@ -1458,7 +1480,7 @@ export class NanobotTui { this.composer.textColor = this.palette.text this.composer.focusedTextColor = this.palette.text this.composer.cursorColor = this.palette.accent - this.titleText.fg = this.palette.muted + this.renderTitleColor() this.status.fg = this.palette.muted this.meta.fg = this.palette.faint this.updateMeta() @@ -1558,6 +1580,12 @@ export class NanobotTui { this.syncHostMetadata() } + private renderTitleColor(): void { + this.titleText.fg = !this.host.hosted && (this.sessionLoading || this.sessionMenu.visible) + ? this.palette.accent + : this.palette.muted + } + private setCurrentTask(task: string): void { const next = singleLine(task) if (!next || next === this.currentTask) return @@ -1848,11 +1876,13 @@ export class NanobotTui { return } this.commandMenu.hide() + this.dismissRuntimeControls() this.mentionMenu.hide() this.branchMenu.hide() this.contextPanel.hide() this.clearComposer() this.sessionLoading = true + this.renderTitleColor() const loadId = ++this.sessionLoadId this.status.content = "Loading sessions…" try { @@ -1868,6 +1898,7 @@ export class NanobotTui { } const limit = this.renderer.height >= 20 ? 8 : 4 this.sessionMenu.open(sessions, this.client.activeChatId, limit) + this.renderTitleColor() this.sessionMenu.update(this.composer.plainText, limit) this.syncComposerPlaceholder() this.updateMeta() @@ -1875,6 +1906,7 @@ export class NanobotTui { } catch (error) { if (loadId !== this.sessionLoadId) return this.sessionLoading = false + this.renderTitleColor() this.status.content = error instanceof Error ? error.message : String(error) } } @@ -2035,8 +2067,10 @@ export class NanobotTui { this.sessionLoadId += 1 this.sessionLoading = false this.sessionMenu.hide() + this.renderTitleColor() this.clearComposer() this.syncComposerPlaceholder() + this.composer.focus() if (!this.activeTurn && this.ready) this.status.content = this.readyStatus() this.updateMeta() } diff --git a/tui/src/session-menu.ts b/tui/src/session-menu.ts index 6d53d30e4..e6ccc4e7c 100644 --- a/tui/src/session-menu.ts +++ b/tui/src/session-menu.ts @@ -26,8 +26,12 @@ export class SessionMenu { readonly root: BoxRenderable private readonly picker: PickerMenu - constructor(renderer: CliRenderer, theme: PickerMenuTheme) { - this.picker = new PickerMenu(renderer, theme, { + constructor( + renderer: CliRenderer, + theme: PickerMenuTheme, + onSelect?: (session: SessionSummary) => void, + ) { + this.picker = new PickerMenu(renderer, theme, { id: "nanobot-tui-session-menu", searchText: (session) => [ sessionLabel(session), @@ -49,6 +53,7 @@ export class SessionMenu { return `${marker}${sessionLabel(session)}${detail ? ` ${detail}` : ""}` }, emptyText: "No matching sessions", + onSelect, }) this.root = this.picker.root }