diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index 9bf947907..fa6f52834 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -1049,7 +1049,7 @@ describe("NanobotTui layout", () => { await waitUntil(() => (app as unknown as { ready: boolean }).ready) await setup.renderOnce() - expect(setup.captureCharFrame()).toContain("Task interrupted") + expect(setup.captureCharFrame()).toContain("⚠ Task interrupted") expect(setup.captureCharFrame()).toContain("Tools will not replay automatically") expect(ui.status.plainText).toContain("continue or dismiss") expect(ui.activeTurn).toBe(false) diff --git a/tui/src/app.ts b/tui/src/app.ts index d4e66213c..9b6b23fec 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -125,6 +125,7 @@ interface Palette { accent: string link: string success: string + warning: string error: string user: string userBackground: string @@ -141,6 +142,7 @@ const DARK: Palette = { accent: "#EF8E30", link: "#60A5FA", success: "#5CC489", + warning: "#F5C451", error: "#F87171", user: "#EF8E30", // Codex-style turn anchor: 12% white over the reference dark background. @@ -158,6 +160,7 @@ const LIGHT: Palette = { accent: "#B94D0B", link: "#1D4ED8", success: "#166534", + warning: "#A16207", error: "#B91C1C", user: "#B94D0B", // Codex-style turn anchor: 4% black over the reference light background. @@ -171,6 +174,7 @@ const ACTIVE_COMPOSER_PLACEHOLDER = "Steer this turn…" const SHIMMER_PAUSE = 16 const SHIMMER_BAND = 4 const SHIMMER_INTERVAL_MS = 80 +const SESSION_REFRESH_INTERVAL_MS = 1_000 const LOCAL_COMMANDS: TuiCommand[] = [ { command: "/sessions", @@ -259,6 +263,8 @@ function commandMenuTheme(palette: Palette): CommandMenuTheme { text: palette.text, muted: palette.muted, border: palette.border, + accent: palette.accent, + warning: palette.warning, selectedBackground: palette.userBackground, } } @@ -307,6 +313,7 @@ function recoveryNoticeTheme(palette: Palette): RecoveryNoticeTheme { text: palette.text, muted: palette.muted, accent: palette.accent, + warning: palette.warning, error: palette.error, } } @@ -454,6 +461,8 @@ export class NanobotTui { private quitting = false private sessionLoadId = 0 private sessionLoading = false + private sessionRefreshPending = false + private sessionRefreshTimer: ReturnType | null = null private readonly commandTurns = new Map() private readonly modelCommandTurns = new Set() private readonly silentCommandTurns = new Set() @@ -1995,7 +2004,7 @@ export class NanobotTui { private closeTransientMenus(): void { this.commandMenu.hide() - this.sessionMenu.hide() + this.hideSessionMenu() this.mentionMenu.hide() this.branchMenu.hide() this.contextPanel.hide() @@ -2049,7 +2058,7 @@ export class NanobotTui { return } this.commandMenu.hide() - this.sessionMenu.hide() + this.hideSessionMenu() this.contextPanel.hide() this.clearComposer() this.status.content = "Loading branch points…" @@ -2139,6 +2148,7 @@ export class NanobotTui { } const limit = this.renderer.height >= 20 ? 8 : 4 this.sessionMenu.open(sessions, this.client.activeChatId, limit) + this.startSessionRefresh() this.renderTitleColor() this.sessionMenu.update(this.composer.plainText, limit) this.syncComposerPlaceholder() @@ -2153,6 +2163,7 @@ export class NanobotTui { } private switchSession(session: SessionSummary): void { + this.sessionMenu.markRead(session.chatId) if (session.chatId === this.client.activeChatId) { this.sessionTitle = sessionLabel(session) this.applySessionModel(session) @@ -2199,7 +2210,7 @@ export class NanobotTui { return } this.commandMenu.hide() - this.sessionMenu.hide() + this.hideSessionMenu() this.mentionMenu.hide() this.branchMenu.hide() this.contextPanel.hide() @@ -2307,7 +2318,7 @@ export class NanobotTui { private closeSessions(): void { this.sessionLoadId += 1 this.sessionLoading = false - this.sessionMenu.hide() + this.hideSessionMenu() this.renderTitleColor() this.clearComposer() this.syncComposerPlaceholder() @@ -2317,9 +2328,51 @@ export class NanobotTui { this.updateMeta() } + private hideSessionMenu(): void { + this.stopSessionRefresh() + this.sessionMenu.hide() + } + + private startSessionRefresh(): void { + if (this.sessionRefreshTimer) return + this.sessionRefreshTimer = setInterval(() => { + if (!this.sessionMenu.visible) { + this.stopSessionRefresh() + return + } + void this.refreshSessionMenu() + }, SESSION_REFRESH_INTERVAL_MS) + ;(this.sessionRefreshTimer as unknown as { unref?: () => void }).unref?.() + } + + private stopSessionRefresh(): void { + if (this.sessionRefreshTimer) clearInterval(this.sessionRefreshTimer) + this.sessionRefreshTimer = null + } + + private async refreshSessionMenu(): Promise { + if (this.sessionRefreshPending || !this.sessionMenu.visible || this.quitting) return + this.sessionRefreshPending = true + const loadId = this.sessionLoadId + try { + const sessions = await fetchSessions( + this.options.apiUrl, + this.options.apiToken, + this.apiReauthenticator, + ) + if (this.quitting || loadId !== this.sessionLoadId || !this.sessionMenu.visible) return + this.sessionMenu.replace(sessions, this.client.activeChatId) + this.status.content = sessions.length ? `${sessions.length} sessions` : "No saved sessions" + } catch { + // Keep the existing picker usable during a transient refresh failure. + } finally { + this.sessionRefreshPending = false + } + } + private async openContext(): Promise { this.commandMenu.hide() - this.sessionMenu.hide() + this.hideSessionMenu() this.mentionMenu.hide() this.branchMenu.hide() this.clearComposer() @@ -2391,7 +2444,7 @@ export class NanobotTui { private openDiff(): void { this.commandMenu.hide() - this.sessionMenu.hide() + this.hideSessionMenu() this.mentionMenu.hide() this.branchMenu.hide() this.contextPanel.hide() @@ -2455,6 +2508,7 @@ export class NanobotTui { this.quitting = true this.submitGeneration += 1 this.submitPending = false + this.stopSessionRefresh() this.host.release() this.client.close() this.renderer.destroy() @@ -2465,6 +2519,7 @@ export class NanobotTui { private handleDestroy = (): void => { if (this.shimmerTimer) clearInterval(this.shimmerTimer) + this.stopSessionRefresh() this.transcript.destroy() this.diffViewer.destroy() this.host.release() diff --git a/tui/src/picker-menu.ts b/tui/src/picker-menu.ts index d4625ab3a..62d7b8d23 100644 --- a/tui/src/picker-menu.ts +++ b/tui/src/picker-menu.ts @@ -1,22 +1,26 @@ import { BoxRenderable, RGBA, + StyledText, TextAttributes, TextRenderable, type CliRenderer, + type TextChunk, } from "@opentui/core" export interface PickerMenuTheme { text: string muted: string border: string + accent?: string + warning?: string selectedBackground?: string } interface PickerMenuOptions { id: string searchText: (item: T) => string - render: (item: T) => string + render: (item: T, selected: boolean) => string | TextChunk[] emptyText?: string maxWidth?: number onSelect?: (item: T) => void @@ -68,6 +72,16 @@ export class PickerMenu { this.update(query, limit) } + replace(items: T[]): void { + if (!this.visible) return + this.items = items + this.update(this.query, this.limit) + } + + redraw(): void { + if (this.visible) this.render() + } + update(query: string, limit = this.limit): void { if (!this.visible) return const changed = query !== this.query @@ -125,9 +139,16 @@ export class PickerMenu { } for (const [index, item] of this.matches.entries()) { const selected = index === this.selected + const rendered = this.options.render(item, selected) + const content = typeof rendered === "string" + ? `${selected ? "›" : " "} ${rendered}` + : new StyledText([ + chunk(`${selected ? "›" : " "} `, selected ? this.theme.text : this.theme.muted), + ...rendered, + ]) this.root.add(new TextRenderable(this.renderer, { id: `${this.options.id}-${index}`, - content: `${selected ? "›" : " "} ${this.options.render(item)}`, + content, width: "100%", height: 1, wrapMode: "none", @@ -161,3 +182,11 @@ export class PickerMenu { } } } + +function chunk(text: string, color: string): TextChunk { + return { + __isChunk: true, + text, + fg: RGBA.fromHex(color), + } +} diff --git a/tui/src/recovery-notice.ts b/tui/src/recovery-notice.ts index 1549098b3..09eea1b26 100644 --- a/tui/src/recovery-notice.ts +++ b/tui/src/recovery-notice.ts @@ -14,6 +14,7 @@ export interface RecoveryNoticeTheme { text: string muted: string accent: string + warning: string error: string } @@ -149,7 +150,7 @@ export class RecoveryNotice { ? "This task can’t be resumed safely. Dismiss to start a new message." : "Review the saved context. Tools will not replay automatically." this.title.content = new StyledText([ - chunk("△ ", failed ? this.theme.error : this.theme.accent), + chunk("⚠ ", failed ? this.theme.error : this.theme.warning), chunk(title, this.theme.text, true), ]) this.detail.content = new StyledText([chunk(` ${detail}`, this.theme.muted)]) diff --git a/tui/src/session-menu.test.ts b/tui/src/session-menu.test.ts index afdc27aff..836c10de2 100644 --- a/tui/src/session-menu.test.ts +++ b/tui/src/session-menu.test.ts @@ -48,6 +48,8 @@ describe("SessionMenu", () => { text: "#FFFFFF", muted: "#999999", border: "#555555", + accent: "#FF8A33", + warning: "#F5C451", }) setup.renderer.root.add(menu.root) menu.open(sessions, "one", 6) @@ -58,7 +60,7 @@ describe("SessionMenu", () => { menu.update("release stable", 6) await setup.renderOnce() - expect(setup.captureCharFrame()).toContain("△ Release checklist") + expect(setup.captureCharFrame()).toContain("⚠ Release checklist") expect(menu.choose()?.chatId).toBe("two") }) @@ -68,12 +70,47 @@ describe("SessionMenu", () => { ) }) + test("animates running sessions and marks completed background sessions unread", 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, + } + + menu.open([sessions[0]!, running], "one", 6) + await setup.renderOnce() + expect(setup.captureCharFrame()).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Background task/u) + + menu.replace([{ ...sessions[0]! }, { ...running, runStartedAt: null }], "one") + await setup.renderOnce() + expect(setup.captureCharFrame()).toContain("• Background task") + + menu.markRead("running") + menu.replace([{ ...sessions[0]! }, { ...running, runStartedAt: null }], "running") + await setup.renderOnce() + expect(setup.captureCharFrame()).toContain("● 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, { text: "#FFFFFF", muted: "#999999", border: "#555555", + accent: "#FF8A33", + warning: "#F5C451", }) setup.renderer.root.add(menu.root) menu.open(sessions, "one", 6) @@ -102,6 +139,8 @@ describe("SessionMenu", () => { text: "#FFFFFF", muted: "#999999", border: "#555555", + accent: "#FF8A33", + warning: "#F5C451", }) setup.renderer.root.add(menu.root) const scoped = sessions.map((session) => ({ diff --git a/tui/src/session-menu.ts b/tui/src/session-menu.ts index ee456cc7d..cc74f44e2 100644 --- a/tui/src/session-menu.ts +++ b/tui/src/session-menu.ts @@ -1,9 +1,11 @@ -import { type BoxRenderable, type CliRenderer } from "@opentui/core" +import { RGBA, type BoxRenderable, type CliRenderer, type TextChunk } from "@opentui/core" import { PickerMenu, type PickerMenuTheme } from "./picker-menu" import type { SessionSummary } from "./protocol" -type SessionMenuRow = SessionSummary & { active: boolean } +type SessionMenuRow = SessionSummary & { active: boolean; unread: boolean } + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] export function sessionLabel(session: SessionSummary): string { const label = session.title.trim() || session.preview.trim() || "Untitled chat" @@ -27,10 +29,18 @@ export class SessionMenu { private readonly picker: PickerMenu private readonly workspaceLabels = new Map() private showWorkspaces = false + private spinnerFrame = 0 + private spinnerTimer: ReturnType | null = null + private rows: SessionMenuRow[] = [] + private readonly snapshots = new Map() + private readonly unreadChatIds = new Set() constructor( renderer: CliRenderer, - theme: PickerMenuTheme, + private theme: PickerMenuTheme, onSelect?: (session: SessionSummary) => void, ) { this.picker = new PickerMenu(renderer, theme, { @@ -45,7 +55,7 @@ export class SessionMenu { session.recoveryState?.status || "", session.recoveryState?.reason || "", ].join(" "), - render: (session) => { + render: (session, selected) => { const age = updatedLabel(session.updatedAt) const preview = session.preview.trim() const detail = [ @@ -56,12 +66,12 @@ export class SessionMenu { ] .filter(Boolean) .join(" · ") - const interrupted = session.recoveryState?.status === "awaiting_user" - || session.recoveryState?.status === "failed" - const marker = interrupted - ? "△ " - : session.active ? "● " : session.pinned ? "◆ " : session.archived ? "◇ " : "" - return `${marker}${sessionLabel(session)}${detail ? ` ${detail}` : ""}` + const marker = this.marker(session) + const foreground = selected ? this.theme.text : this.theme.muted + return [ + ...(marker ? [chunk(`${marker.text} `, marker.color)] : []), + chunk(`${sessionLabel(session)}${detail ? ` ${detail}` : ""}`, foreground), + ] }, emptyText: "No matching sessions", onSelect, @@ -74,15 +84,36 @@ export class SessionMenu { } open(sessions: SessionSummary[], currentChatId: string, limit: number): void { + this.observe(sessions, currentChatId) + this.rows = this.prepareRows(sessions, currentChatId) + this.picker.show(this.rows, "", limit) + this.syncSpinner() + } + + replace(sessions: SessionSummary[], currentChatId: string): void { + this.observe(sessions, currentChatId) + this.rows = this.prepareRows(sessions, currentChatId) + this.picker.replace(this.rows) + this.syncSpinner() + } + + markRead(chatId: string): void { + this.unreadChatIds.delete(chatId) + } + + private prepareRows(sessions: SessionSummary[], currentChatId: string): SessionMenuRow[] { this.prepareWorkspaceLabels(sessions) - const rows = sessions - .map((session) => ({ ...session, active: session.chatId === currentChatId })) + return sessions + .map((session) => ({ + ...session, + active: session.chatId === currentChatId, + unread: this.unreadChatIds.has(session.chatId), + })) .sort((left, right) => { return Number(right.active) - Number(left.active) || Number(right.pinned) - Number(left.pinned) || Number(left.archived) - Number(right.archived) }) - this.picker.show(rows, "", limit) } update(query: string, limit: number): void { @@ -98,13 +129,81 @@ export class SessionMenu { } hide(): void { + this.stopSpinner() + this.rows = [] this.picker.hide() } setTheme(theme: PickerMenuTheme): void { + this.theme = theme this.picker.setTheme(theme) } + private marker(session: SessionMenuRow): { text: string; color: string } | null { + const interrupted = session.recoveryState?.status === "awaiting_user" + || session.recoveryState?.status === "failed" + if (interrupted) { + return { + text: "⚠", + color: this.theme.warning || this.theme.accent || this.theme.text, + } + } + if (session.runStartedAt !== null) { + return { + text: SPINNER_FRAMES[this.spinnerFrame % SPINNER_FRAMES.length] || SPINNER_FRAMES[0]!, + color: this.theme.accent || this.theme.text, + } + } + if (session.active) return { text: "●", color: this.theme.text } + if (session.unread) return { text: "•", color: this.theme.accent || this.theme.text } + if (session.pinned) return { text: "◆", color: this.theme.muted } + if (session.archived) return { text: "◇", color: this.theme.muted } + return null + } + + private observe(sessions: SessionSummary[], currentChatId: string): void { + const present = new Set() + for (const session of sessions) { + present.add(session.chatId) + const previous = this.snapshots.get(session.chatId) + const active = session.chatId === currentChatId + const completed = previous !== undefined + && previous.runStartedAt !== null + && session.runStartedAt === null + const receivedContent = previous !== undefined && previous.preview !== session.preview + if (active) this.unreadChatIds.delete(session.chatId) + else if (completed || receivedContent) this.unreadChatIds.add(session.chatId) + this.snapshots.set(session.chatId, { + preview: session.preview, + runStartedAt: session.runStartedAt, + }) + } + for (const chatId of this.snapshots.keys()) { + if (present.has(chatId)) continue + this.snapshots.delete(chatId) + this.unreadChatIds.delete(chatId) + } + } + + private syncSpinner(): void { + if (!this.visible || !this.rows.some((session) => session.runStartedAt !== null)) { + this.stopSpinner() + return + } + if (this.spinnerTimer) return + this.spinnerTimer = setInterval(() => { + this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length + this.picker.redraw() + }, 90) + ;(this.spinnerTimer as unknown as { unref?: () => void }).unref?.() + } + + private stopSpinner(): void { + if (this.spinnerTimer) clearInterval(this.spinnerTimer) + this.spinnerTimer = null + this.spinnerFrame = 0 + } + private prepareWorkspaceLabels(sessions: SessionSummary[]): void { this.workspaceLabels.clear() const scopes = sessions.flatMap((session) => { @@ -130,6 +229,14 @@ export class SessionMenu { } } +function chunk(text: string, color: string): TextChunk { + return { + __isChunk: true, + text, + fg: RGBA.fromHex(color), + } +} + function normalizeWorkspacePath(value: string | undefined): string { return (value || "").trim().replace(/\\/gu, "/").replace(/\/+$/u, "") }