mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
feat(tui): surface session activity states
This commit is contained in:
+1
-1
@@ -1049,7 +1049,7 @@ describe("NanobotTui layout", () => {
|
|||||||
|
|
||||||
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
|
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
|
||||||
await setup.renderOnce()
|
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(setup.captureCharFrame()).toContain("Tools will not replay automatically")
|
||||||
expect(ui.status.plainText).toContain("continue or dismiss")
|
expect(ui.status.plainText).toContain("continue or dismiss")
|
||||||
expect(ui.activeTurn).toBe(false)
|
expect(ui.activeTurn).toBe(false)
|
||||||
|
|||||||
+61
-6
@@ -125,6 +125,7 @@ interface Palette {
|
|||||||
accent: string
|
accent: string
|
||||||
link: string
|
link: string
|
||||||
success: string
|
success: string
|
||||||
|
warning: string
|
||||||
error: string
|
error: string
|
||||||
user: string
|
user: string
|
||||||
userBackground: string
|
userBackground: string
|
||||||
@@ -141,6 +142,7 @@ const DARK: Palette = {
|
|||||||
accent: "#EF8E30",
|
accent: "#EF8E30",
|
||||||
link: "#60A5FA",
|
link: "#60A5FA",
|
||||||
success: "#5CC489",
|
success: "#5CC489",
|
||||||
|
warning: "#F5C451",
|
||||||
error: "#F87171",
|
error: "#F87171",
|
||||||
user: "#EF8E30",
|
user: "#EF8E30",
|
||||||
// Codex-style turn anchor: 12% white over the reference dark background.
|
// Codex-style turn anchor: 12% white over the reference dark background.
|
||||||
@@ -158,6 +160,7 @@ const LIGHT: Palette = {
|
|||||||
accent: "#B94D0B",
|
accent: "#B94D0B",
|
||||||
link: "#1D4ED8",
|
link: "#1D4ED8",
|
||||||
success: "#166534",
|
success: "#166534",
|
||||||
|
warning: "#A16207",
|
||||||
error: "#B91C1C",
|
error: "#B91C1C",
|
||||||
user: "#B94D0B",
|
user: "#B94D0B",
|
||||||
// Codex-style turn anchor: 4% black over the reference light background.
|
// 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_PAUSE = 16
|
||||||
const SHIMMER_BAND = 4
|
const SHIMMER_BAND = 4
|
||||||
const SHIMMER_INTERVAL_MS = 80
|
const SHIMMER_INTERVAL_MS = 80
|
||||||
|
const SESSION_REFRESH_INTERVAL_MS = 1_000
|
||||||
const LOCAL_COMMANDS: TuiCommand[] = [
|
const LOCAL_COMMANDS: TuiCommand[] = [
|
||||||
{
|
{
|
||||||
command: "/sessions",
|
command: "/sessions",
|
||||||
@@ -259,6 +263,8 @@ function commandMenuTheme(palette: Palette): CommandMenuTheme {
|
|||||||
text: palette.text,
|
text: palette.text,
|
||||||
muted: palette.muted,
|
muted: palette.muted,
|
||||||
border: palette.border,
|
border: palette.border,
|
||||||
|
accent: palette.accent,
|
||||||
|
warning: palette.warning,
|
||||||
selectedBackground: palette.userBackground,
|
selectedBackground: palette.userBackground,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,6 +313,7 @@ function recoveryNoticeTheme(palette: Palette): RecoveryNoticeTheme {
|
|||||||
text: palette.text,
|
text: palette.text,
|
||||||
muted: palette.muted,
|
muted: palette.muted,
|
||||||
accent: palette.accent,
|
accent: palette.accent,
|
||||||
|
warning: palette.warning,
|
||||||
error: palette.error,
|
error: palette.error,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -454,6 +461,8 @@ export class NanobotTui {
|
|||||||
private quitting = false
|
private quitting = false
|
||||||
private sessionLoadId = 0
|
private sessionLoadId = 0
|
||||||
private sessionLoading = false
|
private sessionLoading = false
|
||||||
|
private sessionRefreshPending = false
|
||||||
|
private sessionRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
private readonly commandTurns = new Map<string, ResolvedSlashCommandLifecycle>()
|
private readonly commandTurns = new Map<string, ResolvedSlashCommandLifecycle>()
|
||||||
private readonly modelCommandTurns = new Set<string>()
|
private readonly modelCommandTurns = new Set<string>()
|
||||||
private readonly silentCommandTurns = new Set<string>()
|
private readonly silentCommandTurns = new Set<string>()
|
||||||
@@ -1995,7 +2004,7 @@ export class NanobotTui {
|
|||||||
|
|
||||||
private closeTransientMenus(): void {
|
private closeTransientMenus(): void {
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.sessionMenu.hide()
|
this.hideSessionMenu()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
this.branchMenu.hide()
|
this.branchMenu.hide()
|
||||||
this.contextPanel.hide()
|
this.contextPanel.hide()
|
||||||
@@ -2049,7 +2058,7 @@ export class NanobotTui {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.sessionMenu.hide()
|
this.hideSessionMenu()
|
||||||
this.contextPanel.hide()
|
this.contextPanel.hide()
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
this.status.content = "Loading branch points…"
|
this.status.content = "Loading branch points…"
|
||||||
@@ -2139,6 +2148,7 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
const limit = this.renderer.height >= 20 ? 8 : 4
|
const limit = this.renderer.height >= 20 ? 8 : 4
|
||||||
this.sessionMenu.open(sessions, this.client.activeChatId, limit)
|
this.sessionMenu.open(sessions, this.client.activeChatId, limit)
|
||||||
|
this.startSessionRefresh()
|
||||||
this.renderTitleColor()
|
this.renderTitleColor()
|
||||||
this.sessionMenu.update(this.composer.plainText, limit)
|
this.sessionMenu.update(this.composer.plainText, limit)
|
||||||
this.syncComposerPlaceholder()
|
this.syncComposerPlaceholder()
|
||||||
@@ -2153,6 +2163,7 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private switchSession(session: SessionSummary): void {
|
private switchSession(session: SessionSummary): void {
|
||||||
|
this.sessionMenu.markRead(session.chatId)
|
||||||
if (session.chatId === this.client.activeChatId) {
|
if (session.chatId === this.client.activeChatId) {
|
||||||
this.sessionTitle = sessionLabel(session)
|
this.sessionTitle = sessionLabel(session)
|
||||||
this.applySessionModel(session)
|
this.applySessionModel(session)
|
||||||
@@ -2199,7 +2210,7 @@ export class NanobotTui {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.sessionMenu.hide()
|
this.hideSessionMenu()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
this.branchMenu.hide()
|
this.branchMenu.hide()
|
||||||
this.contextPanel.hide()
|
this.contextPanel.hide()
|
||||||
@@ -2307,7 +2318,7 @@ export class NanobotTui {
|
|||||||
private closeSessions(): void {
|
private closeSessions(): void {
|
||||||
this.sessionLoadId += 1
|
this.sessionLoadId += 1
|
||||||
this.sessionLoading = false
|
this.sessionLoading = false
|
||||||
this.sessionMenu.hide()
|
this.hideSessionMenu()
|
||||||
this.renderTitleColor()
|
this.renderTitleColor()
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
this.syncComposerPlaceholder()
|
this.syncComposerPlaceholder()
|
||||||
@@ -2317,9 +2328,51 @@ export class NanobotTui {
|
|||||||
this.updateMeta()
|
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<void> {
|
||||||
|
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<void> {
|
private async openContext(): Promise<void> {
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.sessionMenu.hide()
|
this.hideSessionMenu()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
this.branchMenu.hide()
|
this.branchMenu.hide()
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
@@ -2391,7 +2444,7 @@ export class NanobotTui {
|
|||||||
|
|
||||||
private openDiff(): void {
|
private openDiff(): void {
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.sessionMenu.hide()
|
this.hideSessionMenu()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
this.branchMenu.hide()
|
this.branchMenu.hide()
|
||||||
this.contextPanel.hide()
|
this.contextPanel.hide()
|
||||||
@@ -2455,6 +2508,7 @@ export class NanobotTui {
|
|||||||
this.quitting = true
|
this.quitting = true
|
||||||
this.submitGeneration += 1
|
this.submitGeneration += 1
|
||||||
this.submitPending = false
|
this.submitPending = false
|
||||||
|
this.stopSessionRefresh()
|
||||||
this.host.release()
|
this.host.release()
|
||||||
this.client.close()
|
this.client.close()
|
||||||
this.renderer.destroy()
|
this.renderer.destroy()
|
||||||
@@ -2465,6 +2519,7 @@ export class NanobotTui {
|
|||||||
|
|
||||||
private handleDestroy = (): void => {
|
private handleDestroy = (): void => {
|
||||||
if (this.shimmerTimer) clearInterval(this.shimmerTimer)
|
if (this.shimmerTimer) clearInterval(this.shimmerTimer)
|
||||||
|
this.stopSessionRefresh()
|
||||||
this.transcript.destroy()
|
this.transcript.destroy()
|
||||||
this.diffViewer.destroy()
|
this.diffViewer.destroy()
|
||||||
this.host.release()
|
this.host.release()
|
||||||
|
|||||||
+31
-2
@@ -1,22 +1,26 @@
|
|||||||
import {
|
import {
|
||||||
BoxRenderable,
|
BoxRenderable,
|
||||||
RGBA,
|
RGBA,
|
||||||
|
StyledText,
|
||||||
TextAttributes,
|
TextAttributes,
|
||||||
TextRenderable,
|
TextRenderable,
|
||||||
type CliRenderer,
|
type CliRenderer,
|
||||||
|
type TextChunk,
|
||||||
} from "@opentui/core"
|
} from "@opentui/core"
|
||||||
|
|
||||||
export interface PickerMenuTheme {
|
export interface PickerMenuTheme {
|
||||||
text: string
|
text: string
|
||||||
muted: string
|
muted: string
|
||||||
border: string
|
border: string
|
||||||
|
accent?: string
|
||||||
|
warning?: string
|
||||||
selectedBackground?: string
|
selectedBackground?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PickerMenuOptions<T> {
|
interface PickerMenuOptions<T> {
|
||||||
id: string
|
id: string
|
||||||
searchText: (item: T) => string
|
searchText: (item: T) => string
|
||||||
render: (item: T) => string
|
render: (item: T, selected: boolean) => string | TextChunk[]
|
||||||
emptyText?: string
|
emptyText?: string
|
||||||
maxWidth?: number
|
maxWidth?: number
|
||||||
onSelect?: (item: T) => void
|
onSelect?: (item: T) => void
|
||||||
@@ -68,6 +72,16 @@ export class PickerMenu<T> {
|
|||||||
this.update(query, limit)
|
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 {
|
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
|
||||||
@@ -125,9 +139,16 @@ export class PickerMenu<T> {
|
|||||||
}
|
}
|
||||||
for (const [index, item] of this.matches.entries()) {
|
for (const [index, item] of this.matches.entries()) {
|
||||||
const selected = index === this.selected
|
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, {
|
this.root.add(new TextRenderable(this.renderer, {
|
||||||
id: `${this.options.id}-${index}`,
|
id: `${this.options.id}-${index}`,
|
||||||
content: `${selected ? "›" : " "} ${this.options.render(item)}`,
|
content,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: 1,
|
height: 1,
|
||||||
wrapMode: "none",
|
wrapMode: "none",
|
||||||
@@ -161,3 +182,11 @@ export class PickerMenu<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chunk(text: string, color: string): TextChunk {
|
||||||
|
return {
|
||||||
|
__isChunk: true,
|
||||||
|
text,
|
||||||
|
fg: RGBA.fromHex(color),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface RecoveryNoticeTheme {
|
|||||||
text: string
|
text: string
|
||||||
muted: string
|
muted: string
|
||||||
accent: string
|
accent: string
|
||||||
|
warning: string
|
||||||
error: string
|
error: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +150,7 @@ export class RecoveryNotice {
|
|||||||
? "This task can’t be resumed safely. Dismiss to start a new message."
|
? "This task can’t be resumed safely. Dismiss to start a new message."
|
||||||
: "Review the saved context. Tools will not replay automatically."
|
: "Review the saved context. Tools will not replay automatically."
|
||||||
this.title.content = new StyledText([
|
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),
|
chunk(title, this.theme.text, true),
|
||||||
])
|
])
|
||||||
this.detail.content = new StyledText([chunk(` ${detail}`, this.theme.muted)])
|
this.detail.content = new StyledText([chunk(` ${detail}`, this.theme.muted)])
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ describe("SessionMenu", () => {
|
|||||||
text: "#FFFFFF",
|
text: "#FFFFFF",
|
||||||
muted: "#999999",
|
muted: "#999999",
|
||||||
border: "#555555",
|
border: "#555555",
|
||||||
|
accent: "#FF8A33",
|
||||||
|
warning: "#F5C451",
|
||||||
})
|
})
|
||||||
setup.renderer.root.add(menu.root)
|
setup.renderer.root.add(menu.root)
|
||||||
menu.open(sessions, "one", 6)
|
menu.open(sessions, "one", 6)
|
||||||
@@ -58,7 +60,7 @@ describe("SessionMenu", () => {
|
|||||||
|
|
||||||
menu.update("release stable", 6)
|
menu.update("release stable", 6)
|
||||||
await setup.renderOnce()
|
await setup.renderOnce()
|
||||||
expect(setup.captureCharFrame()).toContain("△ Release checklist")
|
expect(setup.captureCharFrame()).toContain("⚠ Release checklist")
|
||||||
expect(menu.choose()?.chatId).toBe("two")
|
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 () => {
|
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, {
|
||||||
text: "#FFFFFF",
|
text: "#FFFFFF",
|
||||||
muted: "#999999",
|
muted: "#999999",
|
||||||
border: "#555555",
|
border: "#555555",
|
||||||
|
accent: "#FF8A33",
|
||||||
|
warning: "#F5C451",
|
||||||
})
|
})
|
||||||
setup.renderer.root.add(menu.root)
|
setup.renderer.root.add(menu.root)
|
||||||
menu.open(sessions, "one", 6)
|
menu.open(sessions, "one", 6)
|
||||||
@@ -102,6 +139,8 @@ describe("SessionMenu", () => {
|
|||||||
text: "#FFFFFF",
|
text: "#FFFFFF",
|
||||||
muted: "#999999",
|
muted: "#999999",
|
||||||
border: "#555555",
|
border: "#555555",
|
||||||
|
accent: "#FF8A33",
|
||||||
|
warning: "#F5C451",
|
||||||
})
|
})
|
||||||
setup.renderer.root.add(menu.root)
|
setup.renderer.root.add(menu.root)
|
||||||
const scoped = sessions.map((session) => ({
|
const scoped = sessions.map((session) => ({
|
||||||
|
|||||||
+120
-13
@@ -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 { PickerMenu, type PickerMenuTheme } from "./picker-menu"
|
||||||
import type { SessionSummary } from "./protocol"
|
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 {
|
export function sessionLabel(session: SessionSummary): string {
|
||||||
const label = session.title.trim() || session.preview.trim() || "Untitled chat"
|
const label = session.title.trim() || session.preview.trim() || "Untitled chat"
|
||||||
@@ -27,10 +29,18 @@ export class SessionMenu {
|
|||||||
private readonly picker: PickerMenu<SessionMenuRow>
|
private readonly picker: PickerMenu<SessionMenuRow>
|
||||||
private readonly workspaceLabels = new Map<string, string>()
|
private readonly workspaceLabels = new Map<string, string>()
|
||||||
private showWorkspaces = false
|
private showWorkspaces = false
|
||||||
|
private spinnerFrame = 0
|
||||||
|
private spinnerTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
private rows: SessionMenuRow[] = []
|
||||||
|
private readonly snapshots = new Map<string, {
|
||||||
|
preview: string
|
||||||
|
runStartedAt: number | null
|
||||||
|
}>()
|
||||||
|
private readonly unreadChatIds = new Set<string>()
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
renderer: CliRenderer,
|
renderer: CliRenderer,
|
||||||
theme: PickerMenuTheme,
|
private theme: PickerMenuTheme,
|
||||||
onSelect?: (session: SessionSummary) => void,
|
onSelect?: (session: SessionSummary) => void,
|
||||||
) {
|
) {
|
||||||
this.picker = new PickerMenu<SessionMenuRow>(renderer, theme, {
|
this.picker = new PickerMenu<SessionMenuRow>(renderer, theme, {
|
||||||
@@ -45,7 +55,7 @@ export class SessionMenu {
|
|||||||
session.recoveryState?.status || "",
|
session.recoveryState?.status || "",
|
||||||
session.recoveryState?.reason || "",
|
session.recoveryState?.reason || "",
|
||||||
].join(" "),
|
].join(" "),
|
||||||
render: (session) => {
|
render: (session, selected) => {
|
||||||
const age = updatedLabel(session.updatedAt)
|
const age = updatedLabel(session.updatedAt)
|
||||||
const preview = session.preview.trim()
|
const preview = session.preview.trim()
|
||||||
const detail = [
|
const detail = [
|
||||||
@@ -56,12 +66,12 @@ export class SessionMenu {
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ")
|
.join(" · ")
|
||||||
const interrupted = session.recoveryState?.status === "awaiting_user"
|
const marker = this.marker(session)
|
||||||
|| session.recoveryState?.status === "failed"
|
const foreground = selected ? this.theme.text : this.theme.muted
|
||||||
const marker = interrupted
|
return [
|
||||||
? "△ "
|
...(marker ? [chunk(`${marker.text} `, marker.color)] : []),
|
||||||
: session.active ? "● " : session.pinned ? "◆ " : session.archived ? "◇ " : ""
|
chunk(`${sessionLabel(session)}${detail ? ` ${detail}` : ""}`, foreground),
|
||||||
return `${marker}${sessionLabel(session)}${detail ? ` ${detail}` : ""}`
|
]
|
||||||
},
|
},
|
||||||
emptyText: "No matching sessions",
|
emptyText: "No matching sessions",
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -74,15 +84,36 @@ export class SessionMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
open(sessions: SessionSummary[], currentChatId: string, limit: number): void {
|
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)
|
this.prepareWorkspaceLabels(sessions)
|
||||||
const rows = sessions
|
return sessions
|
||||||
.map((session) => ({ ...session, active: session.chatId === currentChatId }))
|
.map((session) => ({
|
||||||
|
...session,
|
||||||
|
active: session.chatId === currentChatId,
|
||||||
|
unread: this.unreadChatIds.has(session.chatId),
|
||||||
|
}))
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
return Number(right.active) - Number(left.active)
|
return Number(right.active) - Number(left.active)
|
||||||
|| Number(right.pinned) - Number(left.pinned)
|
|| Number(right.pinned) - Number(left.pinned)
|
||||||
|| Number(left.archived) - Number(right.archived)
|
|| Number(left.archived) - Number(right.archived)
|
||||||
})
|
})
|
||||||
this.picker.show(rows, "", limit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
update(query: string, limit: number): void {
|
update(query: string, limit: number): void {
|
||||||
@@ -98,13 +129,81 @@ export class SessionMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hide(): void {
|
hide(): void {
|
||||||
|
this.stopSpinner()
|
||||||
|
this.rows = []
|
||||||
this.picker.hide()
|
this.picker.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
setTheme(theme: PickerMenuTheme): void {
|
setTheme(theme: PickerMenuTheme): void {
|
||||||
|
this.theme = theme
|
||||||
this.picker.setTheme(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<string>()
|
||||||
|
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 {
|
private prepareWorkspaceLabels(sessions: SessionSummary[]): void {
|
||||||
this.workspaceLabels.clear()
|
this.workspaceLabels.clear()
|
||||||
const scopes = sessions.flatMap((session) => {
|
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 {
|
function normalizeWorkspacePath(value: string | undefined): string {
|
||||||
return (value || "").trim().replace(/\\/gu, "/").replace(/\/+$/u, "")
|
return (value || "").trim().replace(/\\/gu, "/").replace(/\/+$/u, "")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user