feat(runtime): add user-controlled turn recovery

This commit is contained in:
Xubin Ren
2026-08-24 00:58:04 +08:00
parent ffa58aa5ef
commit 12029f8812
60 changed files with 4027 additions and 169 deletions
+119 -1
View File
@@ -7,7 +7,12 @@ import {
} from "@opentui/core/testing"
import { NanobotTui, sessionExitMessage, type AppOptions } from "./app"
import type { MessageOptions, SlashCommand, WorkspaceScopePayload } from "./protocol"
import type {
MessageOptions,
RecoveryState,
SlashCommand,
WorkspaceScopePayload,
} from "./protocol"
import type { HostAgentState, HostMetadata, TuiHost } from "./host"
const options: AppOptions = {
@@ -91,6 +96,13 @@ function client(
setWorkspaceScope(scope: WorkspaceScopePayload) {
scopes.push(scope)
},
updateRecovery(
_action: "continue" | "dismiss",
_chatId: string,
recoveryId: string,
): Promise<RecoveryState> {
return Promise.resolve({ status: "recovered" as const, recovery_id: recoveryId })
},
}
}
@@ -903,6 +915,112 @@ describe("NanobotTui layout", () => {
}
})
test("offers clickable recovery actions without letting a late response revive stale state", async () => {
setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" })
const calls: Array<{ action: string; chatId: string; recoveryId: string }> = []
let deferredResolve: ((state: RecoveryState) => void) | undefined
const recoveryClient = client()
recoveryClient.updateRecovery = (action, chatId, recoveryId) => {
calls.push({ action, chatId, recoveryId })
if (recoveryId === "recovery-1") {
return Promise.resolve({ status: "resuming", recovery_id: recoveryId })
}
if (action === "dismiss") {
return Promise.resolve({ status: "recovered", recovery_id: recoveryId })
}
return new Promise((resolve) => { deferredResolve = resolve })
}
const app = NanobotTui.mount(
setup.renderer,
options,
recoveryClient,
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
)
app.accept({
event: "attached",
chat_id: "chat",
recovery_state: {
status: "awaiting_user",
recovery_id: "recovery-1",
reason: "tool execution interrupted",
},
})
const ui = app as unknown as {
activeTurn: boolean
composer: TextareaRenderable
recoveryNotice: {
visible: boolean
dismiss: TextRenderable
resume: TextRenderable
}
status: TextRenderable
}
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Task interrupted")
expect(ui.activeTurn).toBe(false)
expect(ui.composer.focused).toBe(true)
await setup.mockMouse.click(ui.recoveryNotice.resume.x + 1, ui.recoveryNotice.resume.y)
await waitUntil(() => calls.length === 1 && ui.activeTurn)
expect(calls[0]).toEqual({
action: "continue",
chatId: "chat",
recoveryId: "recovery-1",
})
expect(ui.recoveryNotice.visible).toBe(false)
expect(ui.status.plainText).toContain("Continuing")
app.accept({
event: "recovery_state",
chat_id: "chat",
status: "awaiting_user",
recovery_id: "recovery-2",
})
await setup.renderOnce()
await setup.mockMouse.click(ui.recoveryNotice.resume.x + 1, ui.recoveryNotice.resume.y)
await waitUntil(() => calls.length === 2)
app.accept({
event: "recovery_state",
chat_id: "chat",
status: "recovered",
recovery_id: "recovery-2",
})
deferredResolve?.({ status: "resuming", recovery_id: "recovery-2" })
await Bun.sleep(1)
expect(ui.recoveryNotice.visible).toBe(false)
expect(ui.activeTurn).toBe(false)
expect(ui.composer.focused).toBe(true)
app.accept({
event: "recovery_state",
chat_id: "chat",
status: "awaiting_user",
recovery_id: "recovery-3",
})
await setup.renderOnce()
await setup.mockMouse.click(ui.recoveryNotice.dismiss.x + 1, ui.recoveryNotice.dismiss.y)
await waitUntil(() => calls.length === 3 && !ui.recoveryNotice.visible)
expect(calls[2]).toEqual({
action: "dismiss",
chatId: "chat",
recoveryId: "recovery-3",
})
app.accept({
event: "recovery_state",
chat_id: "chat",
status: "awaiting_user",
recovery_id: "recovery-4",
can_continue: false,
})
await setup.renderOnce()
expect(ui.recoveryNotice.resume.visible).toBe(false)
expect(ui.recoveryNotice.dismiss.visible).toBe(true)
})
test("preserves gateway slash lifecycle while local navigation stays in the same menu", async () => {
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
const sent: string[] = []
+124 -1
View File
@@ -34,6 +34,7 @@ import {
type InboundEvent,
type MentionCandidate,
type MessageOptions,
type RecoveryState,
type SlashCommand,
type SessionSummary,
type TokenUsage,
@@ -70,6 +71,7 @@ import {
} from "./mention-menu"
import { PromptQueue, type QueuedPrompt } from "./prompt-queue"
import { QueuePreview, type QueuePreviewTheme } from "./queue-preview"
import { RecoveryNotice, type RecoveryNoticeTheme } from "./recovery-notice"
import { RuntimeControls } from "./runtime-controls"
import {
contextualFooterHints,
@@ -107,6 +109,11 @@ interface ChatClient {
newChat(scope?: WorkspaceScopePayload): void
forkChat?(sourceChatId: string, beforeUserIndex: number, title?: string): void
setWorkspaceScope(scope: WorkspaceScopePayload): void
updateRecovery(
action: "continue" | "dismiss",
chatId: string,
recoveryId: string,
): Promise<RecoveryState>
}
interface Palette {
@@ -295,6 +302,15 @@ function queuePreviewTheme(palette: Palette): QueuePreviewTheme {
}
}
function recoveryNoticeTheme(palette: Palette): RecoveryNoticeTheme {
return {
text: palette.text,
muted: palette.muted,
accent: palette.accent,
error: palette.error,
}
}
function footerHintTheme(palette: Palette): FooterHintTheme {
return {
accent: palette.accent,
@@ -380,6 +396,7 @@ export class NanobotTui {
private readonly contextPanel: ContextPanel
private readonly diffViewer: DiffViewer
private readonly queuePreview: QueuePreview
private readonly recoveryNotice: RecoveryNotice
private readonly client: ChatClient
private readonly shell: BoxRenderable
private readonly title: BoxRenderable
@@ -444,6 +461,8 @@ export class NanobotTui {
private currentTask = ""
private currentAction = ""
private hostBlocked = false
private recoveryState: RecoveryState | null = null
private recoveryPending = false
private hostWorkspace: string
private hostBranch: string
private readonly apiReauthenticator: ApiReauthenticator | undefined
@@ -495,6 +514,14 @@ export class NanobotTui {
treeSitterClient,
)
this.queuePreview = new QueuePreview(renderer, queuePreviewTheme(this.palette))
this.recoveryNotice = new RecoveryNotice(
renderer,
recoveryNoticeTheme(this.palette),
{
onContinue: () => void this.updateRecovery("continue"),
onDismiss: () => void this.updateRecovery("dismiss"),
},
)
this.client = client || new NanobotClient({
...(options.bootstrapUrl
? {
@@ -723,6 +750,7 @@ export class NanobotTui {
this.shell.add(this.runtimeControls.menuRoot)
if (!host.hosted) this.shell.add(this.title)
this.shell.add(this.queuePreview.root)
this.shell.add(this.recoveryNotice.root)
this.shell.add(this.composerFrame)
this.shell.add(statusRow)
this.shell.add(this.diffViewer.root)
@@ -831,6 +859,12 @@ export class NanobotTui {
this.quit()
return
}
if (["/continue", "/dismiss"].includes(visibleContent.toLowerCase())) {
this.clearComposer()
this.commandMenu.hide()
void this.updateRecovery(visibleContent.toLowerCase() === "/continue" ? "continue" : "dismiss")
return
}
const completion = this.commandMenu.completion(visibleContent)
if (completion) {
this.setComposer(completion)
@@ -946,7 +980,9 @@ export class NanobotTui {
}
const hydrationId = ++this.hydrationId
void this.prepareChat(event.chat_id, restoring, hydrationId).then(() => {
if (hydrationId === this.hydrationId) this.flushPendingEvents()
if (hydrationId !== this.hydrationId) return
this.applyRecoveryState(event.recovery_state ?? null)
this.flushPendingEvents()
})
return
}
@@ -1083,6 +1119,9 @@ export class NanobotTui {
this.applyHostGoalState(event.goal_state)
if (!this.activeTurn) this.reportHostResting()
return
case "recovery_state":
this.applyRecoveryState(event)
return
case "turn_model_updated":
if (typeof event.context_window_tokens === "number") {
this.contextWindowTokens = event.context_window_tokens
@@ -1191,6 +1230,85 @@ export class NanobotTui {
for (const event of events || []) this.accept(event)
}
private clearRecoveryState(): void {
this.recoveryState = null
this.recoveryPending = false
this.recoveryNotice.hide()
}
private applyRecoveryState(state: RecoveryState | null): void {
if (!state) {
this.clearRecoveryState()
return
}
this.recoveryState = state
this.recoveryPending = false
if (state.status === "resuming") {
this.recoveryNotice.hide()
this.hostBlocked = false
this.activeLabel = "Continuing"
this.setCurrentAction("Continuing interrupted task")
this.setActive(true)
this.reportHostWorking()
return
}
if (state.status === "awaiting_user" || state.status === "failed") {
this.activeTurnId = null
this.setActive(false)
this.hostBlocked = true
this.recoveryNotice.show(state)
const detail = state.reason || (state.status === "failed"
? "Recovery failed"
: "Task interrupted")
this.setCurrentAction(detail)
this.status.content = "Waiting for recovery decision"
this.host.reportState("blocked", detail)
this.composer.focus()
return
}
this.clearRecoveryState()
this.activeTurnId = null
this.hostBlocked = false
this.setActive(false)
if (this.ready) this.status.content = this.readyStatus()
this.reportHostResting()
}
private async updateRecovery(action: "continue" | "dismiss"): Promise<void> {
const state = this.recoveryState
if (
!state
|| (state.status !== "awaiting_user" && state.status !== "failed")
|| (action === "continue" && state.can_continue === false)
) {
this.status.content = "No interrupted task"
this.composer.focus()
return
}
if (this.recoveryPending) return
this.recoveryPending = true
this.recoveryNotice.setBusy(true)
this.status.content = action === "continue" ? "Continuing…" : "Dismissing…"
try {
const next = await this.client.updateRecovery(
action,
this.client.activeChatId,
state.recovery_id,
)
if (this.recoveryState?.recovery_id === state.recovery_id) {
this.applyRecoveryState(next)
}
} catch (error) {
if (this.recoveryState?.recovery_id !== state.recovery_id) return
this.recoveryPending = false
this.recoveryNotice.setBusy(false)
this.status.content = error instanceof Error ? error.message : String(error)
this.host.reportState("blocked", state.reason || "Task interrupted")
} finally {
this.composer.focus()
}
}
private updateGatewayApiConnection(apiUrl: string, apiToken: string): void {
this.options.apiUrl = apiUrl
this.options.apiToken = apiToken
@@ -1576,6 +1694,7 @@ export class NanobotTui {
this.contextPanel.setTheme(contextPanelTheme(this.palette))
this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown))
this.queuePreview.setTheme(queuePreviewTheme(this.palette))
this.recoveryNotice.setTheme(recoveryNoticeTheme(this.palette))
this.updateComposerAppearance()
this.composer.textColor = this.palette.text
this.composer.focusedTextColor = this.palette.text
@@ -2001,6 +2120,7 @@ export class NanobotTui {
this.sessionTitle = sessionLabel(current)
this.applySessionModel(current)
this.applySessionScope(current)
this.applyRecoveryState(current.recoveryState ?? null)
this.updateTitle()
}
const limit = this.renderer.height >= 20 ? 8 : 4
@@ -2027,6 +2147,7 @@ export class NanobotTui {
this.sessionTitle = sessionLabel(session)
this.applySessionModel(session)
this.applySessionScope(session)
this.applyRecoveryState(session.recoveryState ?? null)
this.updateTitle()
this.closeSessions()
this.status.content = this.readyStatus()
@@ -2039,6 +2160,7 @@ export class NanobotTui {
this.closeSessions()
try {
this.ready = false
this.clearRecoveryState()
this.clearPromptQueue()
this.sessionMetadataId += 1
this.clearHostContext()
@@ -2073,6 +2195,7 @@ export class NanobotTui {
this.clearComposer()
try {
this.ready = false
this.clearRecoveryState()
this.clearPromptQueue()
this.sessionMetadataId += 1
this.clearHostContext()
+56
View File
@@ -595,6 +595,62 @@ describe("gateway protocol", () => {
}
})
test("sends stale-safe recovery mutations and validates their response", async () => {
const original = globalThis.WebSocket
let socket: FakeSocket | undefined
Object.defineProperty(globalThis, "WebSocket", {
configurable: true,
value: class extends FakeSocket {
constructor() {
super()
socket = this
}
},
})
try {
const statuses: string[] = []
const client = new NanobotClient({
url: "ws://nanobot.test/ws",
onEvent: () => undefined,
onStatus: (status, detail) => statuses.push(`${status}:${detail || ""}`),
})
client.connect()
if (!socket) throw new Error("socket was not created")
const result = client.updateRecovery("continue", "chat", "recovery-1")
const request = JSON.parse(socket.sent.at(-1) || "{}") as {
request_id: string
action: string
payload: Record<string, string>
}
expect(request).toMatchObject({
type: "webui_request",
action: "recovery.continue",
payload: { chat_id: "chat", recovery_id: "recovery-1" },
})
socket.emit("message", { data: JSON.stringify({
event: "webui_response",
request_id: request.request_id,
ok: true,
result: { status: "resuming", recovery_id: "recovery-1", attempts: 1 },
}) })
expect(await result).toEqual({
status: "resuming",
recovery_id: "recovery-1",
attempts: 1,
})
expect(statuses).not.toContain("error:gateway sent an invalid event")
const interrupted = client.updateRecovery("dismiss", "chat", "recovery-2")
socket.emit("close")
await expect(interrupted).rejects.toThrow("gateway connection closed")
client.close()
} finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
}
})
test("reports when the bounded history snapshot omits earlier turns", async () => {
const original = globalThis.fetch
let requested = ""
+132 -1
View File
@@ -54,6 +54,16 @@ export interface RuntimeControls {
canUseFullAccess: boolean
}
export type RecoveryStatus = "resuming" | "awaiting_user" | "recovered" | "failed"
export interface RecoveryState {
status: RecoveryStatus
recovery_id: string
reason?: string
attempts?: number
can_continue?: boolean
}
export type InboundEvent =
| { event: "ready"; chat_id: string; client_id: string }
| {
@@ -61,6 +71,7 @@ export type InboundEvent =
chat_id: string
model_preset?: string | null
usage?: TokenUsage
recovery_state?: RecoveryState
}
| {
event: "message_accepted"
@@ -118,6 +129,7 @@ export type InboundEvent =
turn_id?: string
}
| { event: "goal_state"; chat_id: string; goal_state: Record<string, unknown> }
| ({ event: "recovery_state"; chat_id: string } & RecoveryState)
| {
event: "session_updated"
chat_id: string
@@ -139,6 +151,12 @@ type OutboundEvent =
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
| { type: "attach"; chat_id: string }
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
| {
type: "webui_request"
request_id: string
action: string
payload: Record<string, unknown>
}
| {
type: "message"
chat_id: string
@@ -271,6 +289,7 @@ export interface SessionSummary {
updatedAt: string | null
runStartedAt: number | null
modelPreset: string | null
recoveryState?: RecoveryState | null
workspaceScope?: WorkspaceScopePayload | null
pinned: boolean
archived: boolean
@@ -297,6 +316,7 @@ const CHAT_EVENTS = new Set([
"turn_end",
"goal_status",
"goal_state",
"recovery_state",
"session_updated",
"turn_model_updated",
"error",
@@ -377,6 +397,34 @@ function isWorkspaceScope(value: unknown): value is WorkspaceScopePayload {
&& optional(value.restrict_to_workspace, "boolean")
}
function isRecoveryState(value: unknown): value is RecoveryState {
return isRecord(value)
&& ["resuming", "awaiting_user", "recovered", "failed"].includes(String(value.status))
&& typeof value.recovery_id === "string"
&& optional(value.reason, "string")
&& optional(value.attempts, "number")
&& optional(value.can_continue, "boolean")
}
interface WebUIResponseEvent {
event: "webui_response"
request_id: string
ok: boolean
result?: unknown
error?: { status: number; message: string }
}
function decodeWebUIResponse(value: unknown): WebUIResponseEvent | null | undefined {
if (!isRecord(value) || value.event !== "webui_response") return undefined
if (typeof value.request_id !== "string" || typeof value.ok !== "boolean") return null
if (value.ok) return value as unknown as WebUIResponseEvent
return isRecord(value.error)
&& typeof value.error.status === "number"
&& typeof value.error.message === "string"
? value as unknown as WebUIResponseEvent
: null
}
function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
if (!isRecord(value)) return null
const record = value
@@ -407,7 +455,8 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
&& ((record.model_preset !== undefined
&& record.model_preset !== null
&& typeof record.model_preset !== "string")
|| (record.usage !== undefined && !isTokenUsage(record.usage)))
|| (record.usage !== undefined && !isTokenUsage(record.usage))
|| (record.recovery_state !== undefined && !isRecoveryState(record.recovery_state)))
) return null
if (
["user_message", "message", "delta", "reasoning_delta"].includes(name)
@@ -452,6 +501,7 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
) return null
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") return null
if (name === "goal_state" && !isRecord(record.goal_state)) return null
if (name === "recovery_state" && !isRecoveryState(record)) return null
if (
name === "session_updated"
&& (!optional(record.scope, "string")
@@ -700,6 +750,9 @@ export async function fetchSessions(
modelPreset: typeof value.model_preset === "string" && value.model_preset.trim()
? value.model_preset.trim()
: null,
...(isRecoveryState(value.recovery_state)
? { recoveryState: value.recovery_state }
: {}),
...(isWorkspaceScope(value.workspace_scope) ? { workspaceScope: value.workspace_scope } : {}),
pinned: pinned.has(value.key),
archived: archived.has(value.key),
@@ -855,6 +908,11 @@ export class NanobotClient {
private closedByClient = false
private opening = false
private connectedOnce = false
private readonly pendingMutations = new Map<string, {
resolve: (value: unknown) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}>()
constructor(private readonly options: ClientOptions) {}
@@ -916,6 +974,7 @@ export class NanobotClient {
socket.addEventListener("close", () => {
if (this.socket !== socket) return
this.socket = null
this.rejectPendingMutations("gateway connection closed")
if (this.closedByClient) {
this.options.onStatus("closed")
return
@@ -931,6 +990,7 @@ export class NanobotClient {
const socket = this.socket
this.socket = null
socket?.close()
this.rejectPendingMutations("gateway connection closed")
}
send(content: string, options: MessageOptions = {}): string {
@@ -975,6 +1035,63 @@ export class NanobotClient {
this.write({ type: "set_workspace_scope", chat_id: this.chatId, workspace_scope: scope })
}
updateRecovery(
action: "continue" | "dismiss",
chatId: string,
recoveryId: string,
): Promise<RecoveryState> {
return this.requestMutation<unknown>(`recovery.${action}`, {
chat_id: chatId,
recovery_id: recoveryId,
}).then((result) => {
if (!isRecoveryState(result)) throw new Error("gateway returned an invalid recovery state")
return result
})
}
private requestMutation<T>(
action: string,
payload: Record<string, unknown> = {},
timeoutMs = 20_000,
): Promise<T> {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error("gateway connection is not open"))
}
const requestId = crypto.randomUUID()
const frame = JSON.stringify({
type: "webui_request",
request_id: requestId,
action,
payload,
} satisfies OutboundEvent)
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingMutations.delete(requestId)
reject(new Error(`gateway request timed out after ${timeoutMs}ms`))
}, timeoutMs)
this.pendingMutations.set(requestId, {
resolve: (value) => resolve(value as T),
reject,
timer,
})
try {
this.socket?.send(frame)
} catch {
clearTimeout(timer)
this.pendingMutations.delete(requestId)
reject(new Error("could not send gateway request"))
}
})
}
private rejectPendingMutations(message: string): void {
for (const pending of this.pendingMutations.values()) {
clearTimeout(pending.timer)
pending.reject(new Error(message))
}
this.pendingMutations.clear()
}
private handleMessage(raw: string): void {
let value: unknown
try {
@@ -983,6 +1100,20 @@ export class NanobotClient {
this.options.onStatus("error", "gateway sent invalid JSON")
return
}
const response = decodeWebUIResponse(value)
if (response === null) {
this.options.onStatus("error", "gateway sent an invalid event")
return
}
if (response) {
const pending = this.pendingMutations.get(response.request_id)
if (!pending) return
clearTimeout(pending.timer)
this.pendingMutations.delete(response.request_id)
if (response.ok) pending.resolve(response.result)
else pending.reject(new Error(response.error?.message || "gateway request failed"))
return
}
const event = decodeInboundEvent(value)
if (event === undefined) return
if (event === null) {
+155
View File
@@ -0,0 +1,155 @@
import {
BoxRenderable,
RGBA,
StyledText,
TextAttributes,
TextRenderable,
type CliRenderer,
type TextChunk,
} from "@opentui/core"
import type { RecoveryState } from "./protocol"
export interface RecoveryNoticeTheme {
text: string
muted: string
accent: string
error: string
}
interface RecoveryNoticeOptions {
onContinue: () => void
onDismiss: () => void
}
/** A quiet action surface for a gateway-owned interrupted turn. */
export class RecoveryNotice {
readonly root: BoxRenderable
private readonly message: TextRenderable
private readonly dismiss: TextRenderable
private readonly resume: TextRenderable
private state: RecoveryState | null = null
private busy = false
constructor(
renderer: CliRenderer,
private theme: RecoveryNoticeTheme,
options: RecoveryNoticeOptions,
) {
this.root = new BoxRenderable(renderer, {
id: "nanobot-tui-recovery-notice",
width: "100%",
height: 1,
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
gap: 2,
paddingLeft: 1,
paddingRight: 1,
visible: false,
backgroundColor: RGBA.defaultBackground(),
})
this.message = new TextRenderable(renderer, {
id: "nanobot-tui-recovery-message",
width: "auto",
minWidth: 0,
flexGrow: 1,
height: 1,
truncate: true,
selectable: false,
})
this.dismiss = this.action(renderer, "dismiss", "Dismiss", options.onDismiss)
this.resume = this.action(renderer, "continue", "Continue", options.onContinue, true)
this.root.add(this.message)
this.root.add(this.dismiss)
this.root.add(this.resume)
}
get visible(): boolean {
return this.root.visible
}
show(state: RecoveryState): void {
this.state = state
this.busy = false
this.root.visible = true
this.render()
}
hide(): void {
this.state = null
this.busy = false
this.root.visible = false
}
setBusy(busy: boolean): void {
this.busy = busy
if (this.visible) this.render()
}
setTheme(theme: RecoveryNoticeTheme): void {
this.theme = theme
if (this.visible) this.render()
}
private action(
renderer: CliRenderer,
id: string,
label: string,
callback: () => void,
primary = false,
): TextRenderable {
return new TextRenderable(renderer, {
id: `nanobot-tui-recovery-${id}`,
content: label,
width: label.length,
height: 1,
flexShrink: 0,
selectable: false,
onMouseOver: () => {
if (this.busy) return
const target = primary ? this.resume : this.dismiss
target.attributes = TextAttributes.BOLD | TextAttributes.UNDERLINE
},
onMouseOut: () => this.render(),
onMouseDown: (event) => {
if (event.button !== 0 || this.busy) return
event.preventDefault()
event.stopPropagation()
renderer.clearSelection()
callback()
},
})
}
private render(): void {
if (!this.state) return
const failed = this.state.status === "failed"
const contextUnavailable = this.state.can_continue === false
const title = failed ? "Recovery failed" : "Task interrupted"
const detail = failed
? "Review the task before continuing"
: contextUnavailable
? "Saved context unavailable"
: "Tools will not replay automatically"
this.message.content = new StyledText([
chunk("△ ", failed ? this.theme.error : this.theme.accent),
chunk(title, this.theme.text, true),
chunk(` · ${detail}`, this.theme.muted),
])
this.dismiss.fg = RGBA.fromHex(this.busy ? this.theme.muted : this.theme.text)
this.resume.visible = !contextUnavailable
this.resume.fg = RGBA.fromHex(this.busy ? this.theme.muted : this.theme.accent)
this.dismiss.attributes = 0
this.resume.attributes = TextAttributes.BOLD
}
}
function chunk(text: string, color: string, bold = false): TextChunk {
return {
__isChunk: true,
text,
fg: RGBA.fromHex(color),
attributes: bold ? TextAttributes.BOLD : 0,
}
}
+6 -1
View File
@@ -24,6 +24,11 @@ const sessions: SessionSummary[] = [
updatedAt: "2026-08-12T10:00:00Z",
runStartedAt: null,
modelPreset: null,
recoveryState: {
status: "awaiting_user",
recovery_id: "recovery-two",
reason: "tool execution interrupted",
},
pinned: false,
archived: false,
},
@@ -53,7 +58,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")
})
+7 -1
View File
@@ -42,6 +42,8 @@ export class SessionMenu {
session.chatId,
session.workspaceScope?.project_name || "",
session.workspaceScope?.project_path || "",
session.recoveryState?.status || "",
session.recoveryState?.reason || "",
].join(" "),
render: (session) => {
const age = updatedLabel(session.updatedAt)
@@ -54,7 +56,11 @@ export class SessionMenu {
]
.filter(Boolean)
.join(" · ")
const marker = session.active ? "● " : session.pinned ? "◆ " : session.archived ? "◇ " : ""
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}` : ""}`
},
emptyText: "No matching sessions",