156 lines
4.0 KiB
TypeScript
156 lines
4.0 KiB
TypeScript
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,
|
|
}
|
|
}
|