mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
feat(tui): refine session status navigation
This commit is contained in:
+15
-1
@@ -19,6 +19,7 @@ export interface PickerMenuTheme {
|
||||
|
||||
interface PickerMenuOptions<T> {
|
||||
id: string
|
||||
key?: (item: T) => string
|
||||
searchText: (item: T) => string
|
||||
render: (item: T, selected: boolean) => string | TextChunk[]
|
||||
emptyText?: string
|
||||
@@ -85,6 +86,8 @@ export class PickerMenu<T> {
|
||||
update(query: string, limit = this.limit): void {
|
||||
if (!this.visible) return
|
||||
const changed = query !== this.query
|
||||
const previous = this.matches[this.selected]
|
||||
const previousKey = previous === undefined ? null : this.options.key?.(previous)
|
||||
this.query = query
|
||||
this.limit = Math.max(1, limit)
|
||||
const words = query.trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean)
|
||||
@@ -94,7 +97,18 @@ export class PickerMenu<T> {
|
||||
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))
|
||||
if (changed) {
|
||||
this.selected = 0
|
||||
} else {
|
||||
const preserved = previous === undefined
|
||||
? -1
|
||||
: previousKey === null || previousKey === undefined
|
||||
? this.matches.indexOf(previous)
|
||||
: this.matches.findIndex((item) => this.options.key?.(item) === previousKey)
|
||||
this.selected = preserved >= 0
|
||||
? preserved
|
||||
: Math.min(this.selected, Math.max(0, this.matches.length - 1))
|
||||
}
|
||||
this.render()
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,39 @@ describe("SessionMenu", () => {
|
||||
menu.hide()
|
||||
})
|
||||
|
||||
test("prioritizes actionable sessions without moving keyboard selection on refresh", async () => {
|
||||
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
|
||||
const menu = new SessionMenu(setup.renderer, {
|
||||
text: "#FFFFFF",
|
||||
muted: "#999999",
|
||||
border: "#555555",
|
||||
accent: "#FF8A33",
|
||||
warning: "#F5C451",
|
||||
})
|
||||
setup.renderer.root.add(menu.root)
|
||||
const running = {
|
||||
...sessions[0]!,
|
||||
chatId: "running",
|
||||
title: "Background task",
|
||||
runStartedAt: Date.now(),
|
||||
pinned: false,
|
||||
}
|
||||
|
||||
const idle = { ...sessions[1]!, recoveryState: null }
|
||||
menu.open([sessions[0]!, running, idle], "one", 6)
|
||||
expect(menu.choose()?.chatId).toBe("one")
|
||||
expect(menu.move(1)).toBe(true)
|
||||
expect(menu.choose()?.chatId).toBe("running")
|
||||
|
||||
menu.replace([sessions[0]!, running, sessions[1]!], "one")
|
||||
await setup.renderOnce()
|
||||
|
||||
expect(menu.choose()?.chatId).toBe("running")
|
||||
const frame = setup.captureCharFrame()
|
||||
expect(frame.indexOf("Release checklist")).toBeLessThan(frame.indexOf("Background task"))
|
||||
menu.hide()
|
||||
})
|
||||
|
||||
test("keeps keyboard selection when the pointer stays over the previous row", async () => {
|
||||
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
|
||||
const menu = new SessionMenu(setup.renderer, {
|
||||
|
||||
+26
-4
@@ -45,6 +45,7 @@ export class SessionMenu {
|
||||
) {
|
||||
this.picker = new PickerMenu<SessionMenuRow>(renderer, theme, {
|
||||
id: "nanobot-tui-session-menu",
|
||||
key: (session) => session.chatId,
|
||||
searchText: (session) => [
|
||||
sessionLabel(session),
|
||||
session.modelPreset || "",
|
||||
@@ -67,7 +68,9 @@ export class SessionMenu {
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
const marker = this.marker(session)
|
||||
const foreground = selected ? this.theme.text : this.theme.muted
|
||||
const foreground = this.interrupted(session)
|
||||
? this.theme.warning || this.theme.accent || this.theme.text
|
||||
: selected ? this.theme.text : this.theme.muted
|
||||
return [
|
||||
...(marker ? [chunk(`${marker.text} `, marker.color)] : []),
|
||||
chunk(`${sessionLabel(session)}${detail ? ` ${detail}` : ""}`, foreground),
|
||||
@@ -111,8 +114,11 @@ export class SessionMenu {
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
return Number(right.active) - Number(left.active)
|
||||
|| sessionPriority(right) - sessionPriority(left)
|
||||
|| Number(right.pinned) - Number(left.pinned)
|
||||
|| Number(left.archived) - Number(right.archived)
|
||||
|| timestamp(right.updatedAt) - timestamp(left.updatedAt)
|
||||
|| sessionLabel(left).localeCompare(sessionLabel(right))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -140,9 +146,7 @@ export class SessionMenu {
|
||||
}
|
||||
|
||||
private marker(session: SessionMenuRow): { text: string; color: string } | null {
|
||||
const interrupted = session.recoveryState?.status === "awaiting_user"
|
||||
|| session.recoveryState?.status === "failed"
|
||||
if (interrupted) {
|
||||
if (this.interrupted(session)) {
|
||||
return {
|
||||
text: "⚠",
|
||||
color: this.theme.warning || this.theme.accent || this.theme.text,
|
||||
@@ -161,6 +165,11 @@ export class SessionMenu {
|
||||
return null
|
||||
}
|
||||
|
||||
private interrupted(session: SessionMenuRow): boolean {
|
||||
return session.recoveryState?.status === "awaiting_user"
|
||||
|| session.recoveryState?.status === "failed"
|
||||
}
|
||||
|
||||
private observe(sessions: SessionSummary[], currentChatId: string): void {
|
||||
const present = new Set<string>()
|
||||
for (const session of sessions) {
|
||||
@@ -249,3 +258,16 @@ function shortPath(path: string): string {
|
||||
const parts = path.split("/").filter(Boolean)
|
||||
return parts.slice(-2).join("/") || path
|
||||
}
|
||||
|
||||
function sessionPriority(session: SessionMenuRow): number {
|
||||
if (session.recoveryState?.status === "awaiting_user"
|
||||
|| session.recoveryState?.status === "failed") return 3
|
||||
if (session.runStartedAt !== null) return 2
|
||||
return session.unread ? 1 : 0
|
||||
}
|
||||
|
||||
function timestamp(value: string | null): number {
|
||||
if (!value) return 0
|
||||
const parsed = Date.parse(value)
|
||||
return Number.isNaN(parsed) ? 0 : parsed
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user