From e329127722a2f64272afb3c37a95e084fd1ccbb1 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:52:32 +0800 Subject: [PATCH] fix(tui): clarify interrupted task actions --- tui/src/app.test.ts | 15 +++++++++++++ tui/src/app.ts | 4 +++- tui/src/recovery-notice.ts | 46 +++++++++++++++++++++++++------------- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index 8d52a4fbf..9bf947907 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -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("can’t 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", diff --git a/tui/src/app.ts b/tui/src/app.ts index 389548bce..d4e66213c 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -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 diff --git a/tui/src/recovery-notice.ts b/tui/src/recovery-notice.ts index 8403526a3..1549098b3 100644 --- a/tui/src/recovery-notice.ts +++ b/tui/src/recovery-notice.ts @@ -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 can’t 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)