Skip to content

Commit 4559ec4

Browse files
authored
Fix: esc in the custom-prompt editor cancelled the whole jump instead of returning to Pi's choices (#5)
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.
1 parent 115c55d commit 4559ec4

2 files changed

Lines changed: 59 additions & 2 deletions

File tree

‎src/tui/route.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -900,7 +900,14 @@ export function TreeRoute(props: TreeRouteProps) {
900900
const mine = ++gen
901901
const back = () => {
902902
if (done || mine !== gen) return
903-
openChoices()
903+
// The host's own post-esc dismissal runs right after this handler returns, and it
904+
// targets whatever was on top of the stack when `esc` was pressed — if we `replace`
905+
// synchronously here, that dismissal fires *after* us and wipes the choices dialog
906+
// right back off. Deferring a tick lets the host finish closing first.
907+
setTimeout(() => {
908+
if (done || mine !== gen) return
909+
openChoices()
910+
}, 0)
904911
}
905912
api.ui.dialog.replace(
906913
() =>

‎test/e2e/tui.test.ts‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import { afterAll, beforeAll, describe, expect, test } from "bun:test"
66
import path from "node:path"
77
import { readFileSync, readdirSync } from "node:fs"
8-
import { createProject, installPlugins, REPO_ROOT, runTui, startMock, type StartedMock } from "./harness.js"
8+
import { createProject, installPlugins, REPO_ROOT, runTui, runTuiScreens, startMock, type StartedMock } from "./harness.js"
99

1010
const e2e = process.env["CTREE_E2E"] === "1"
1111

@@ -181,6 +181,56 @@ describe.skipIf(!e2e)("tui e2e: built plugin", () => {
181181
}
182182
}, 320_000)
183183

184+
test("esc in the custom-prompt editor loops back to Pi's choices instead of cancelling the whole jump", async () => {
185+
const m = await startMock({ tool: false })
186+
const proj = await createProject({ mockPort: m.port })
187+
await installPlugins({ projectDir: proj.dir, server: [path.join(REPO_ROOT, "dist", "server.js")], tui: [path.join(REPO_ROOT, "dist", "tui.js")] })
188+
try {
189+
const { screens } = await runTuiScreens({
190+
projectDir: proj.dir,
191+
keys: [
192+
["Ask anything", 1, "first question\r"],
193+
["mock reply", 6, "second question\r"],
194+
["mock reply", 14, "/tree"],
195+
["Context tree", 0.5, "\r"],
196+
["Context tree ·", 2, "gg"],
197+
["Context tree ·", 3, "\r"],
198+
// ↓↓ = "Summarize with a custom prompt"
199+
["Fork & prefill this turn", 1.5, "\x1b[B\x1b[B"],
200+
["Summarize with a custom prompt", 1, "\r"],
201+
// the DialogPrompt's own title never lands as one contiguous run in the raw
202+
// ANSI-stripped stream (its text-cursor widget repaints unlike a plain title), so
203+
// wait on a single word from it instead of the full phrase
204+
["instructions", 2, "focus on x"],
205+
// esc here must return to the 3-choice picker, not cancel the whole jump
206+
["instructions", 1, "\x1b"],
207+
["instructions", 2, "\r"],
208+
// confirms we really landed back on a live picker (not a dangling, unresolved
209+
// promise): finish the flow by picking "No summary" and sending the prefilled turn
210+
["mock reply|Ask anything", 16, "\r"],
211+
["mock reply|Ask anything", 16, "\x03"],
212+
["", 1, "\x03"],
213+
],
214+
timeoutSec: 240,
215+
cols: 130,
216+
rows: 34,
217+
exitWhenDone: true,
218+
})
219+
const afterEsc = screens.find((s) => s.label.includes("conditional key 10"))
220+
expect(afterEsc).toBeDefined()
221+
expect(afterEsc!.screen).toContain("Fork & prefill this turn?")
222+
expect(afterEsc!.screen).not.toContain("Custom summarization instructions")
223+
224+
const dir = path.join(proj.dir, ".opencode", "context-tree")
225+
const lines = readFileSync(path.join(dir, readdirSync(dir).find((f) => f.endsWith(".jsonl"))!), "utf8")
226+
// the detour through the custom-prompt editor changed nothing else: exactly one fork
227+
expect(lines.split('"type":"branch.opened"').length - 1).toBe(1)
228+
} finally {
229+
await m.stop()
230+
await proj.cleanup()
231+
}
232+
}, 320_000)
233+
184234
test("/tree opens the context tree route with rows and a context header", async () => {
185235
const text = await runTui({
186236
projectDir: project.dir,

0 commit comments

Comments
 (0)