mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
feat(tui): make session title interactive
This commit is contained in:
@@ -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[] = []
|
||||
|
||||
+36
-2
@@ -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()
|
||||
}
|
||||
|
||||
@@ -26,8 +26,12 @@ export class SessionMenu {
|
||||
readonly root: BoxRenderable
|
||||
private readonly picker: PickerMenu<SessionMenuRow>
|
||||
|
||||
constructor(renderer: CliRenderer, theme: PickerMenuTheme) {
|
||||
this.picker = new PickerMenu(renderer, theme, {
|
||||
constructor(
|
||||
renderer: CliRenderer,
|
||||
theme: PickerMenuTheme,
|
||||
onSelect?: (session: SessionSummary) => void,
|
||||
) {
|
||||
this.picker = new PickerMenu<SessionMenuRow>(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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user