fix(tui): clarify interrupted task actions

This commit is contained in:
Xubin Ren
2026-08-24 00:58:04 +08:00
parent 2cdfba38b2
commit 7e66375f59
3 changed files with 49 additions and 16 deletions
+15
View File
@@ -1050,6 +1050,8 @@ describe("NanobotTui layout", () => {
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Task interrupted")
expect(setup.captureCharFrame()).toContain("Tools will not replay automatically")
expect(ui.status.plainText).toContain("continue or dismiss")
expect(ui.activeTurn).toBe(false)
expect(ui.composer.focused).toBe(true)
@@ -1085,6 +1087,19 @@ describe("NanobotTui layout", () => {
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-unavailable",
can_continue: false,
})
await setup.renderOnce()
const unavailableFrame = setup.captureCharFrame()
expect(unavailableFrame).toContain("cant be resumed safely")
expect(unavailableFrame).not.toContain("Continue")
expect(ui.status.plainText).toContain("dismiss to start a new message")
app.accept({
event: "recovery_state",
chat_id: "chat",
+3 -1
View File
@@ -1266,7 +1266,9 @@ export class NanobotTui {
? "Recovery failed"
: "Task interrupted")
this.setCurrentAction(detail)
this.status.content = "Waiting for recovery decision"
this.status.content = state.can_continue === false
? "Interrupted · dismiss to start a new message"
: "Interrupted · continue or dismiss"
this.host.reportState("blocked", detail)
this.composer.focus()
return
+31 -15
View File
@@ -25,7 +25,8 @@ interface RecoveryNoticeOptions {
/** A quiet action surface for a gateway-owned interrupted turn. */
export class RecoveryNotice {
readonly root: BoxRenderable
private readonly message: TextRenderable
private readonly title: TextRenderable
private readonly detail: TextRenderable
private readonly dismiss: TextRenderable
private readonly resume: TextRenderable
private state: RecoveryState | null = null
@@ -39,18 +40,24 @@ export class RecoveryNotice {
this.root = new BoxRenderable(renderer, {
id: "nanobot-tui-recovery-notice",
width: "100%",
height: 1,
height: 2,
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
gap: 2,
flexDirection: "column",
paddingLeft: 1,
paddingRight: 1,
visible: false,
backgroundColor: RGBA.defaultBackground(),
})
this.message = new TextRenderable(renderer, {
id: "nanobot-tui-recovery-message",
const header = new BoxRenderable(renderer, {
id: "nanobot-tui-recovery-header",
width: "100%",
height: 1,
flexDirection: "row",
alignItems: "center",
gap: 2,
})
this.title = new TextRenderable(renderer, {
id: "nanobot-tui-recovery-title",
width: "auto",
minWidth: 0,
flexGrow: 1,
@@ -58,11 +65,20 @@ export class RecoveryNotice {
truncate: true,
selectable: false,
})
this.detail = new TextRenderable(renderer, {
id: "nanobot-tui-recovery-detail",
width: "100%",
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)
header.add(this.title)
header.add(this.dismiss)
header.add(this.resume)
this.root.add(header)
this.root.add(this.detail)
}
get visible(): boolean {
@@ -128,15 +144,15 @@ export class RecoveryNotice {
const contextUnavailable = this.state.can_continue === false
const title = failed ? "Recovery failed" : "Task interrupted"
const detail = failed
? "Review the task before continuing"
? "Review the saved task before continuing."
: contextUnavailable
? "Saved context unavailable"
: "Tools will not replay automatically"
this.message.content = new StyledText([
? "This task cant be resumed safely. Dismiss to start a new message."
: "Review the saved context. Tools will not replay automatically."
this.title.content = new StyledText([
chunk("△ ", failed ? this.theme.error : this.theme.accent),
chunk(title, this.theme.text, true),
chunk(` · ${detail}`, this.theme.muted),
])
this.detail.content = new StyledText([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)