From 1f0771c5553cae4ff0f65a11dd8a6b826ad49c08 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:28:01 +0800 Subject: [PATCH] feat(tui): refine session status navigation --- tui/src/picker-menu.ts | 16 +++++++++++++++- tui/src/session-menu.test.ts | 33 +++++++++++++++++++++++++++++++++ tui/src/session-menu.ts | 30 ++++++++++++++++++++++++++---- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/tui/src/picker-menu.ts b/tui/src/picker-menu.ts index 62d7b8d23..75890ea4d 100644 --- a/tui/src/picker-menu.ts +++ b/tui/src/picker-menu.ts @@ -19,6 +19,7 @@ export interface PickerMenuTheme { interface PickerMenuOptions { 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 { 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 { 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() } diff --git a/tui/src/session-menu.test.ts b/tui/src/session-menu.test.ts index 836c10de2..c1d59dfb7 100644 --- a/tui/src/session-menu.test.ts +++ b/tui/src/session-menu.test.ts @@ -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, { diff --git a/tui/src/session-menu.ts b/tui/src/session-menu.ts index cc74f44e2..e13237309 100644 --- a/tui/src/session-menu.ts +++ b/tui/src/session-menu.ts @@ -45,6 +45,7 @@ export class SessionMenu { ) { this.picker = new PickerMenu(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() 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 +}