From d22890921a54edd8675033538cac5b8db67e5bc1 Mon Sep 17 00:00:00 2001 From: Naveen Kumar Date: Fri, 4 Sep 2026 14:25:10 +0800 Subject: [PATCH] Fix: esc in the custom-prompt editor cancelled the whole jump instead of returning to Pi's choices The host's dialog stack runs its own post-esc dismissal right after our onCancel handler returns, targeting whatever was on top when esc fired. Replacing the dialog synchronously inside that handler meant the host's cleanup wiped the freshly-opened choices dialog straight back off, silently stranding the askJump promise. Deferring the reopen by one tick lets the host finish first. --- src/tui/route.tsx | 9 +++++++- test/e2e/tui.test.ts | 52 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/tui/route.tsx b/src/tui/route.tsx index e46e418..7808a94 100644 --- a/src/tui/route.tsx +++ b/src/tui/route.tsx @@ -900,7 +900,14 @@ export function TreeRoute(props: TreeRouteProps) { const mine = ++gen const back = () => { if (done || mine !== gen) return - openChoices() + // The host's own post-esc dismissal runs right after this handler returns, and it + // targets whatever was on top of the stack when `esc` was pressed — if we `replace` + // synchronously here, that dismissal fires *after* us and wipes the choices dialog + // right back off. Deferring a tick lets the host finish closing first. + setTimeout(() => { + if (done || mine !== gen) return + openChoices() + }, 0) } api.ui.dialog.replace( () => diff --git a/test/e2e/tui.test.ts b/test/e2e/tui.test.ts index ce148a7..cbc0da4 100644 --- a/test/e2e/tui.test.ts +++ b/test/e2e/tui.test.ts @@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test" import path from "node:path" import { readFileSync, readdirSync } from "node:fs" -import { createProject, installPlugins, REPO_ROOT, runTui, startMock, type StartedMock } from "./harness.js" +import { createProject, installPlugins, REPO_ROOT, runTui, runTuiScreens, startMock, type StartedMock } from "./harness.js" const e2e = process.env["CTREE_E2E"] === "1" @@ -181,6 +181,56 @@ describe.skipIf(!e2e)("tui e2e: built plugin", () => { } }, 320_000) + test("esc in the custom-prompt editor loops back to Pi's choices instead of cancelling the whole jump", async () => { + const m = await startMock({ tool: false }) + const proj = await createProject({ mockPort: m.port }) + await installPlugins({ projectDir: proj.dir, server: [path.join(REPO_ROOT, "dist", "server.js")], tui: [path.join(REPO_ROOT, "dist", "tui.js")] }) + try { + const { screens } = await runTuiScreens({ + projectDir: proj.dir, + keys: [ + ["Ask anything", 1, "first question\r"], + ["mock reply", 6, "second question\r"], + ["mock reply", 14, "/tree"], + ["Context tree", 0.5, "\r"], + ["Context tree ·", 2, "gg"], + ["Context tree ·", 3, "\r"], + // ↓↓ = "Summarize with a custom prompt" + ["Fork & prefill this turn", 1.5, "\x1b[B\x1b[B"], + ["Summarize with a custom prompt", 1, "\r"], + // the DialogPrompt's own title never lands as one contiguous run in the raw + // ANSI-stripped stream (its text-cursor widget repaints unlike a plain title), so + // wait on a single word from it instead of the full phrase + ["instructions", 2, "focus on x"], + // esc here must return to the 3-choice picker, not cancel the whole jump + ["instructions", 1, "\x1b"], + ["instructions", 2, "\r"], + // confirms we really landed back on a live picker (not a dangling, unresolved + // promise): finish the flow by picking "No summary" and sending the prefilled turn + ["mock reply|Ask anything", 16, "\r"], + ["mock reply|Ask anything", 16, "\x03"], + ["", 1, "\x03"], + ], + timeoutSec: 240, + cols: 130, + rows: 34, + exitWhenDone: true, + }) + const afterEsc = screens.find((s) => s.label.includes("conditional key 10")) + expect(afterEsc).toBeDefined() + expect(afterEsc!.screen).toContain("Fork & prefill this turn?") + expect(afterEsc!.screen).not.toContain("Custom summarization instructions") + + const dir = path.join(proj.dir, ".opencode", "context-tree") + const lines = readFileSync(path.join(dir, readdirSync(dir).find((f) => f.endsWith(".jsonl"))!), "utf8") + // the detour through the custom-prompt editor changed nothing else: exactly one fork + expect(lines.split('"type":"branch.opened"').length - 1).toBe(1) + } finally { + await m.stop() + await proj.cleanup() + } + }, 320_000) + test("/tree opens the context tree route with rows and a context header", async () => { const text = await runTui({ projectDir: project.dir,