feat(tui): refine session status navigation

This commit is contained in:
Xubin Ren
2026-08-24 00:58:04 +08:00
parent 2850114eab
commit 1f0771c555
3 changed files with 74 additions and 5 deletions
+15 -1
View File
@@ -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()
}
+33
View File
@@ -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
View File
@@ -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
}