Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/tui/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
() =>
Expand Down
52 changes: 51 additions & 1 deletion test/e2e/tui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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,
Expand Down
Loading