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> {
|
interface PickerMenuOptions<T> {
|
||||||
id: string
|
id: string
|
||||||
|
key?: (item: T) => string
|
||||||
searchText: (item: T) => string
|
searchText: (item: T) => string
|
||||||
render: (item: T, selected: boolean) => string | TextChunk[]
|
render: (item: T, selected: boolean) => string | TextChunk[]
|
||||||
emptyText?: string
|
emptyText?: string
|
||||||
@@ -85,6 +86,8 @@ export class PickerMenu<T> {
|
|||||||
update(query: string, limit = this.limit): void {
|
update(query: string, limit = this.limit): void {
|
||||||
if (!this.visible) return
|
if (!this.visible) return
|
||||||
const changed = query !== this.query
|
const changed = query !== this.query
|
||||||
|
const previous = this.matches[this.selected]
|
||||||
|
const previousKey = previous === undefined ? null : this.options.key?.(previous)
|
||||||
this.query = query
|
this.query = query
|
||||||
this.limit = Math.max(1, limit)
|
this.limit = Math.max(1, limit)
|
||||||
const words = query.trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean)
|
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))
|
return words.every((word) => haystack.includes(word))
|
||||||
})
|
})
|
||||||
.slice(0, this.limit)
|
.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()
|
this.render()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,39 @@ describe("SessionMenu", () => {
|
|||||||
menu.hide()
|
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 () => {
|
test("keeps keyboard selection when the pointer stays over the previous row", async () => {
|
||||||
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
|
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
|
||||||
const menu = new SessionMenu(setup.renderer, {
|
const menu = new SessionMenu(setup.renderer, {
|
||||||
|
|||||||
+26
-4
@@ -45,6 +45,7 @@ export class SessionMenu {
|
|||||||
) {
|
) {
|
||||||
this.picker = new PickerMenu<SessionMenuRow>(renderer, theme, {
|
this.picker = new PickerMenu<SessionMenuRow>(renderer, theme, {
|
||||||
id: "nanobot-tui-session-menu",
|
id: "nanobot-tui-session-menu",
|
||||||
|
key: (session) => session.chatId,
|
||||||
searchText: (session) => [
|
searchText: (session) => [
|
||||||
sessionLabel(session),
|
sessionLabel(session),
|
||||||
session.modelPreset || "",
|
session.modelPreset || "",
|
||||||
@@ -67,7 +68,9 @@ export class SessionMenu {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ")
|
.join(" · ")
|
||||||
const marker = this.marker(session)
|
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 [
|
return [
|
||||||
...(marker ? [chunk(`${marker.text} `, marker.color)] : []),
|
...(marker ? [chunk(`${marker.text} `, marker.color)] : []),
|
||||||
chunk(`${sessionLabel(session)}${detail ? ` ${detail}` : ""}`, foreground),
|
chunk(`${sessionLabel(session)}${detail ? ` ${detail}` : ""}`, foreground),
|
||||||
@@ -111,8 +114,11 @@ export class SessionMenu {
|
|||||||
}))
|
}))
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
return Number(right.active) - Number(left.active)
|
return Number(right.active) - Number(left.active)
|
||||||
|
|| sessionPriority(right) - sessionPriority(left)
|
||||||
|| Number(right.pinned) - Number(left.pinned)
|
|| Number(right.pinned) - Number(left.pinned)
|
||||||
|| Number(left.archived) - Number(right.archived)
|
|| 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 {
|
private marker(session: SessionMenuRow): { text: string; color: string } | null {
|
||||||
const interrupted = session.recoveryState?.status === "awaiting_user"
|
if (this.interrupted(session)) {
|
||||||
|| session.recoveryState?.status === "failed"
|
|
||||||
if (interrupted) {
|
|
||||||
return {
|
return {
|
||||||
text: "⚠",
|
text: "⚠",
|
||||||
color: this.theme.warning || this.theme.accent || this.theme.text,
|
color: this.theme.warning || this.theme.accent || this.theme.text,
|
||||||
@@ -161,6 +165,11 @@ export class SessionMenu {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private interrupted(session: SessionMenuRow): boolean {
|
||||||
|
return session.recoveryState?.status === "awaiting_user"
|
||||||
|
|| session.recoveryState?.status === "failed"
|
||||||
|
}
|
||||||
|
|
||||||
private observe(sessions: SessionSummary[], currentChatId: string): void {
|
private observe(sessions: SessionSummary[], currentChatId: string): void {
|
||||||
const present = new Set<string>()
|
const present = new Set<string>()
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
@@ -249,3 +258,16 @@ function shortPath(path: string): string {
|
|||||||
const parts = path.split("/").filter(Boolean)
|
const parts = path.split("/").filter(Boolean)
|
||||||
return parts.slice(-2).join("/") || path
|
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