mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 10:11:46 +03:00
feat(runtime): add user-controlled turn recovery
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user