feat(tui): surface session activity states
This commit is contained in:
+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 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<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 readonly snapshots = new Map<string, {
|
||||
preview: string
|
||||
runStartedAt: number | null
|
||||
}>()
|
||||
private readonly unreadChatIds = new Set<string>()
|
||||
|
||||
constructor(
|
||||
renderer: CliRenderer,
|
||||
theme: PickerMenuTheme,
|
||||
private theme: PickerMenuTheme,
|
||||
onSelect?: (session: SessionSummary) => void,
|
||||
) {
|
||||
this.picker = new PickerMenu<SessionMenuRow>(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<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) => {
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user