|
5 | 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test" |
6 | 6 | import path from "node:path" |
7 | 7 | 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" |
9 | 9 |
|
10 | 10 | const e2e = process.env["CTREE_E2E"] === "1" |
11 | 11 |
|
@@ -181,6 +181,56 @@ describe.skipIf(!e2e)("tui e2e: built plugin", () => { |
181 | 181 | } |
182 | 182 | }, 320_000) |
183 | 183 |
|
| 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 | + |
184 | 234 | test("/tree opens the context tree route with rows and a context header", async () => { |
185 | 235 | const text = await runTui({ |
186 | 236 | projectDir: project.dir, |
|
0 commit comments