fix(tui): clarify interrupted task actions

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