mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
290 lines
9.6 KiB
TypeScript
290 lines
9.6 KiB
TypeScript
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; unread: boolean }
|
|
|
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
|
|
export function sessionLabel(session: SessionSummary): string {
|
|
const label = session.title.trim() || session.preview.trim() || "Untitled chat"
|
|
return label.replace(/\s+/gu, " ")
|
|
}
|
|
|
|
function updatedLabel(value: string | null): string {
|
|
if (!value) return ""
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.valueOf())) return ""
|
|
const age = Math.max(0, Date.now() - date.valueOf())
|
|
if (age < 60_000) return "now"
|
|
if (age < 3_600_000) return `${Math.floor(age / 60_000)}m`
|
|
if (age < 86_400_000) return `${Math.floor(age / 3_600_000)}h`
|
|
return `${Math.floor(age / 86_400_000)}d`
|
|
}
|
|
|
|
/** Searchable session navigation over the gateway-owned session list. */
|
|
export class SessionMenu {
|
|
readonly root: BoxRenderable
|
|
private readonly picker: PickerMenu<SessionMenuRow>
|
|
private readonly workspaceLabels = new Map<string, string>()
|
|
private showWorkspaces = false
|
|
private spinnerFrame = 0
|
|
private spinnerTimer: ReturnType<typeof setInterval> | null = null
|
|
private rows: SessionMenuRow[] = []
|
|
private defaultModelPreset = ""
|
|
private readonly snapshots = new Map<string, {
|
|
preview: string
|
|
runStartedAt: number | null
|
|
}>()
|
|
private readonly unreadChatIds = new Set<string>()
|
|
|
|
constructor(
|
|
renderer: CliRenderer,
|
|
private theme: PickerMenuTheme,
|
|
onSelect?: (session: SessionSummary) => void,
|
|
) {
|
|
this.picker = new PickerMenu<SessionMenuRow>(renderer, theme, {
|
|
id: "nanobot-tui-session-menu",
|
|
key: (session) => session.chatId,
|
|
searchText: (session) => [
|
|
sessionLabel(session),
|
|
session.modelPreset || "",
|
|
session.preview,
|
|
session.chatId,
|
|
session.workspaceScope?.project_name || "",
|
|
session.workspaceScope?.project_path || "",
|
|
session.recoveryState?.status || "",
|
|
session.recoveryState?.reason || "",
|
|
].join(" "),
|
|
render: (session, selected) => {
|
|
const age = updatedLabel(session.updatedAt)
|
|
const detail = [
|
|
this.showWorkspaces ? this.workspaceLabel(session) : "",
|
|
this.modelOverride(session),
|
|
]
|
|
.filter(Boolean)
|
|
.join(" · ")
|
|
const marker = this.marker(session)
|
|
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), foreground),
|
|
...(detail ? [chunk(` ${detail}`, this.theme.muted)] : []),
|
|
...(age ? [chunk(` ${age}`, this.theme.muted)] : []),
|
|
]
|
|
},
|
|
emptyText: "No matching sessions",
|
|
onSelect,
|
|
})
|
|
this.root = this.picker.root
|
|
}
|
|
|
|
get visible(): boolean {
|
|
return this.picker.visible
|
|
}
|
|
|
|
open(
|
|
sessions: SessionSummary[],
|
|
currentChatId: string,
|
|
limit: number,
|
|
defaultModelPreset = "",
|
|
): void {
|
|
this.defaultModelPreset = defaultModelPreset
|
|
this.observe(sessions, currentChatId)
|
|
this.rows = this.prepareRows(sessions, currentChatId)
|
|
this.picker.show(this.rows, "", limit)
|
|
this.syncSpinner()
|
|
}
|
|
|
|
replace(
|
|
sessions: SessionSummary[],
|
|
currentChatId: string,
|
|
defaultModelPreset = this.defaultModelPreset,
|
|
): void {
|
|
this.defaultModelPreset = defaultModelPreset
|
|
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)
|
|
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)
|
|
|| 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))
|
|
})
|
|
}
|
|
|
|
update(query: string, limit: number): void {
|
|
this.picker.update(query, limit)
|
|
}
|
|
|
|
move(direction: -1 | 1): boolean {
|
|
return this.picker.move(direction)
|
|
}
|
|
|
|
choose(): SessionSummary | null {
|
|
return this.picker.current()
|
|
}
|
|
|
|
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 {
|
|
if (this.interrupted(session)) {
|
|
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 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) {
|
|
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) => {
|
|
const path = normalizeWorkspacePath(session.workspaceScope?.project_path)
|
|
return path ? [{ path, name: session.workspaceScope?.project_name?.trim() || pathName(path) }] : []
|
|
})
|
|
const paths = new Set(scopes.map(({ path }) => path))
|
|
this.showWorkspaces = paths.size > 1
|
|
if (!this.showWorkspaces) return
|
|
const namePaths = new Map<string, Set<string>>()
|
|
for (const { path, name } of scopes) {
|
|
const pathsForName = namePaths.get(name) || new Set<string>()
|
|
pathsForName.add(path)
|
|
namePaths.set(name, pathsForName)
|
|
}
|
|
for (const { path, name } of scopes) {
|
|
this.workspaceLabels.set(path, namePaths.get(name)?.size === 1 ? name : shortPath(path))
|
|
}
|
|
}
|
|
|
|
private workspaceLabel(session: SessionSummary): string {
|
|
return this.workspaceLabels.get(normalizeWorkspacePath(session.workspaceScope?.project_path)) || ""
|
|
}
|
|
|
|
private modelOverride(session: SessionSummary): string {
|
|
const preset = session.modelPreset?.trim() || ""
|
|
return preset && preset !== this.defaultModelPreset ? preset : ""
|
|
}
|
|
}
|
|
|
|
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, "")
|
|
}
|
|
|
|
function pathName(path: string): string {
|
|
return path.split("/").filter(Boolean).at(-1) || path
|
|
}
|
|
|
|
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
|
|
}
|