Skip to content

Commit 686a78f

Browse files
authored
Ask OpenCode for a frame when the status line changes (#14)
OpenCode's renderer draws on demand and nothing here ever called requestRender(), so a signal changed inside an await reached the render buffer and waited there for something else to repaint. While a draft runs the route now also holds the renderer's refcounted requestLive(). This is the one difference between 0.3.0-beta.1 — which carries the progress line and reads as no feedback — and this build; on the OpenCode versions the harness can drive (1.18.26, 1.18.29) the line was already painting without it, so it is unconfirmed against the report rather than reproduced and cured. The suite can now watch a draft while it runs: startMock({ slowSummaryMs }) holds back the summary request only, and a new test samples the tree 0.3s after the choice and three times half a second apart. Also removes a stray `1` file that CTREE_DUMP=1 created, and dates the shipped 0.3.0-beta.1 changelog section.
1 parent 6445940 commit 686a78f

5 files changed

Lines changed: 167 additions & 13 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,30 @@
11
# Changelog
22

3-
## Unreleased
3+
## 0.3.0-beta.2 — 2026-09-09
4+
5+
- **The status line now asks OpenCode for a frame when it changes.** OpenCode's renderer draws
6+
on demand, and nothing here ever called `requestRender()`: a signal changed from inside an
7+
`await` — every progress update, and every notice raised by a slow action — reached the
8+
render buffer and then waited there for something else to trigger a repaint. While a draft
9+
runs the route also holds the renderer's own refcounted `requestLive()`, the same thing an
10+
animation asks for, and drops it when the draft ends.
11+
12+
This is the one difference between 0.3.0-beta.1 and the branch summary reading as "no
13+
feedback at all": beta.1 carries the progress line but never asks to be drawn. On the two
14+
OpenCode versions the e2e suite can drive (1.18.26 and 1.18.29) the line was already being
15+
painted promptly without it, so the fix is unconfirmed against a report rather than proven —
16+
but nothing should depend on another component's render loop to be visible.
17+
18+
- **The e2e suite can now watch a draft while it runs.** `startMock({ slowSummaryMs })` holds
19+
back the branch-summary request only (the setup turns stay fast), and a new test samples the
20+
tree 0.3s after you choose *Summarize* and three more times half a second apart: the line
21+
must be up on all four, and the spinner must have turned between them. Every earlier test
22+
used a mock that answered instantly, so nothing had ever looked at the screen mid-draft.
23+
24+
- `CTREE_OPENCODE_VERSION` picks the OpenCode the e2e suite installs and drives (default
25+
1.18.26), so a report against a newer release can be reproduced without editing the harness.
26+
27+
## 0.3.0-beta.1 — 2026-09-08
428

529
### Breaking: the keymap is now vim-aligned
630

‎harness/mock-provider.mjs‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,23 @@ const server = http.createServer((req, res) => {
2929
if (req.method === "POST" && url.startsWith("/v1/chat/completions")) {
3030
let json = {};
3131
try { json = JSON.parse(body || "{}"); } catch {}
32+
// MOCK_SLOW_MS: hold back the *summary* request only (the context-tree helper session's
33+
// system prompt names it), so a test can watch the tree while a model is answering
34+
// without slowing the turns that set the test up.
35+
const slow = Number(process.env.MOCK_SLOW_MS || 0);
36+
const isSummary = (json.messages || []).some((m) => typeof m.content === "string" && m.content.includes("context summarization assistant"));
37+
if (slow > 0 && isSummary) {
38+
return setTimeout(() => respond(req, res, url, body, json), slow);
39+
}
40+
return respond(req, res, url, body, json);
41+
}
42+
res.writeHead(404); res.end("not found");
43+
});
44+
});
45+
46+
function respond(req, res, url, body, json) {
47+
{
48+
{
3249
fs.appendFileSync(LOG, JSON.stringify({ ts: Date.now(), url, model: json.model, stream: !!json.stream, body: json }) + "\n");
3350
const id = "chatcmpl-" + Date.now();
3451
const created = Math.floor(Date.now() / 1000);
@@ -53,7 +70,6 @@ const server = http.createServer((req, res) => {
5370
res.writeHead(200, { "content-type": "application/json" });
5471
return res.end(JSON.stringify({ id, object: "chat.completion", created, model: json.model, choices: [{ index: 0, message: { role: "assistant", content: REPLY }, finish_reason: "stop" }], usage: { prompt_tokens: 100, completion_tokens: 5, total_tokens: 105 } }));
5572
}
56-
res.writeHead(404); res.end("not found");
57-
});
58-
});
73+
}
74+
}
5975
server.listen(PORT, "127.0.0.1", () => console.log(`mock provider on http://127.0.0.1:${PORT}/v1 -> ${LOG}`));

‎src/tui/route.tsx‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -324,10 +324,22 @@ export function TreeRoute(props: TreeRouteProps) {
324324
// palette (actions.ts) take this as `ctx.notify`, falling back to `api.ui.toast` without one.
325325
const [notice, setNotice] = createSignal<string | undefined>()
326326
let noticeTimer: ReturnType<typeof setTimeout> | undefined
327+
/**
328+
* Ask OpenCode's renderer for a frame. It renders **on demand**, so a signal we change from
329+
* inside an `await` reaches the buffer and then sits there invisibly until something else
330+
* repaints — which is why a status line set while a model was answering could stay dark for
331+
* the whole call (`test/e2e/tui.test.ts` pins this). Every feedback path below asks.
332+
*/
333+
const paint = () => (api.renderer as { requestRender?: () => void } | undefined)?.requestRender?.()
334+
327335
const notify = (message: string, ms = 4000) => {
328336
setNotice(message)
329337
clearTimeout(noticeTimer)
330-
noticeTimer = setTimeout(() => setNotice(undefined), ms)
338+
noticeTimer = setTimeout(() => {
339+
setNotice(undefined)
340+
paint()
341+
}, ms)
342+
paint()
331343
}
332344
onCleanup(() => clearTimeout(noticeTimer))
333345

@@ -337,21 +349,34 @@ export function TreeRoute(props: TreeRouteProps) {
337349
if (!state) {
338350
setProgress(undefined)
339351
setCancelling(undefined)
352+
paint()
340353
return
341354
}
342355
const open = progress()
343356
setProgress({ ...state, startedAt: open && open.label === state.label ? open.startedAt : Date.now() })
357+
paint()
344358
}
345359
const ctx: ActionContext = { api, store, directory, notify, progress: reportProgress }
346360

347-
// one interval for as long as something is running, rather than a permanently ticking route
361+
// One interval for as long as something is running, rather than a permanently ticking route.
362+
// `requestLive` is the renderer's own refcounted "keep drawing frames" request — the same
363+
// thing an animation asks for — so the spinner turns without depending on anything else
364+
// happening; the ticks below are what give it something new to draw.
348365
createEffect(
349366
on(
350367
() => progress() !== undefined,
351368
(running) => {
352369
if (!running) return
353-
const timer = setInterval(() => setFrame((n) => n + 1), SPINNER_MS)
354-
onCleanup(() => clearInterval(timer))
370+
const renderer = api.renderer as { requestLive?: () => void; dropLive?: () => void } | undefined
371+
renderer?.requestLive?.()
372+
const timer = setInterval(() => {
373+
setFrame((n) => n + 1)
374+
paint()
375+
}, SPINNER_MS)
376+
onCleanup(() => {
377+
clearInterval(timer)
378+
renderer?.dropLive?.()
379+
})
355380
},
356381
),
357382
)

‎test/e2e/harness.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,25 +40,38 @@ async function run(cmd: string[], cwd: string, env?: Record<string, string | und
4040

4141
/**
4242
* Ensures a local `opencode` binary exists at harness/node_modules/.bin/opencode,
43-
* installing it with `npm i opencode-ai@1.18.26` in harness/ if missing. Writes a
43+
* installing it with `npm i opencode-ai@$OPENCODE_VERSION` in harness/ if missing or if a
44+
* different version is installed. Writes a
4445
* minimal harness/package.json first if one isn't there yet (harness/package*.json
4546
* is gitignored) — otherwise a cwd-less `npm i` walks up to the repo root and
4647
* pollutes the root package.json instead of installing locally.
4748
*/
49+
/** The OpenCode the suite runs against. `CTREE_OPENCODE_VERSION` overrides it, so a report
50+
* against a newer release can be reproduced without editing the harness. */
51+
export const OPENCODE_VERSION = process.env["CTREE_OPENCODE_VERSION"] || "1.18.26"
52+
53+
function installedOpencodeVersion(): string | undefined {
54+
try {
55+
return JSON.parse(readFileSync(path.join(HARNESS_DIR, "node_modules/opencode-ai/package.json"), "utf8")).version as string
56+
} catch {
57+
return undefined
58+
}
59+
}
60+
4861
export async function ensureOpencode(): Promise<string> {
4962
const bin = path.join(HARNESS_DIR, "node_modules/.bin/opencode")
50-
if (!existsSync(bin)) {
63+
if (!existsSync(bin) || installedOpencodeVersion() !== OPENCODE_VERSION) {
5164
const pkgJson = path.join(HARNESS_DIR, "package.json")
5265
if (!existsSync(pkgJson)) {
5366
writeFileSync(pkgJson, JSON.stringify({ name: "harness", version: "1.0.0", private: true }, null, 2) + "\n")
5467
}
5568
const proc = Bun.spawn({
56-
cmd: ["npm", "i", "opencode-ai@1.18.26"],
69+
cmd: ["npm", "i", `opencode-ai@${OPENCODE_VERSION}`],
5770
cwd: HARNESS_DIR,
5871
stdio: ["inherit", "inherit", "inherit"],
5972
})
6073
const code = await proc.exited
61-
if (code !== 0) throw new Error(`npm i opencode-ai@1.18.26 failed in ${HARNESS_DIR} (exit ${code})`)
74+
if (code !== 0) throw new Error(`npm i opencode-ai@${OPENCODE_VERSION} failed in ${HARNESS_DIR} (exit ${code})`)
6275
}
6376
if (!existsSync(bin)) throw new Error(`opencode binary still missing at ${bin} after install`)
6477
return bin
@@ -87,6 +100,9 @@ export interface StartMockOptions {
87100
port?: number
88101
/** MOCK_REPLY override for the assistant's canned text reply. */
89102
reply?: string
103+
/** MOCK_SLOW_MS: hold back the branch-summary request this long, so a test can watch the
104+
* tree while a model is answering. Only the summary is delayed; setup turns stay fast. */
105+
slowSummaryMs?: number
90106
}
91107

92108
export async function startMock(opts: StartMockOptions = {}): Promise<StartedMock> {
@@ -103,6 +119,7 @@ export async function startMock(opts: StartMockOptions = {}): Promise<StartedMoc
103119
MOCK_LOG: logFile,
104120
MOCK_TOOL: opts.tool ? "1" : "0",
105121
...(opts.reply ? { MOCK_REPLY: opts.reply } : {}),
122+
...(opts.slowSummaryMs ? { MOCK_SLOW_MS: String(opts.slowSummaryMs) } : {}),
106123
},
107124
stdio: ["ignore", "pipe", "pipe"],
108125
})

‎test/e2e/tui.test.ts‎

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,23 @@
44
*/
55
import { afterAll, beforeAll, describe, expect, test } from "bun:test"
66
import path from "node:path"
7+
import { tmpdir } from "node:os"
78
import { existsSync, readFileSync, readdirSync } from "node:fs"
89
import { createProject, installPlugins, REPO_ROOT, runTui, runTuiScreens, startMock, type StartedMock } from "./harness.js"
910

1011
const e2e = process.env["CTREE_E2E"] === "1"
1112

13+
/** `CTREE_DUMP=<path>` writes the pyte-rendered screens for eyeballing a failure. A value that
14+
* is not a path (`CTREE_DUMP=1`, the obvious thing to type) lands in the temp dir rather than
15+
* creating a file called `1` in the repo root — which is exactly how one got committed. */
16+
async function dumpScreens(screens: { label: string; screen: string }[]): Promise<void> {
17+
const want = process.env["CTREE_DUMP"]
18+
if (!want) return
19+
const file = want.includes("/") ? want : path.join(tmpdir(), "ctree-e2e-screens.txt")
20+
await Bun.write(file, screens.map((x) => `=== ${x.label}\n${x.screen}`).join("\n"))
21+
console.log(`screens → ${file}`)
22+
}
23+
1224
describe.skipIf(!e2e)("tui e2e: built plugin", () => {
1325
let mock: StartedMock
1426
let project: Awaited<ReturnType<typeof createProject>>
@@ -308,7 +320,7 @@ describe.skipIf(!e2e)("tui e2e: built plugin", () => {
308320
rows: 34,
309321
exitWhenDone: true,
310322
})
311-
if (process.env["CTREE_DUMP"]) await Bun.write(process.env["CTREE_DUMP"]!, screens.map((x) => `=== ${x.label}\n${x.screen}`).join("\n"))
323+
await dumpScreens(screens)
312324
// screens are captured *before* each key, so key N's screen is the state key N-1 left:
313325
// assert the state itself rather than the notice, which has a lifetime of its own
314326
const before = (key: number) => {
@@ -336,6 +348,66 @@ describe.skipIf(!e2e)("tui e2e: built plugin", () => {
336348
}
337349
}, 300_000)
338350

351+
test("a summary that takes seconds shows a live progress line while it runs", async () => {
352+
// The mock answers instantly, which is why no earlier test watched the tree *during* a
353+
// draft at all. Holding the summary request back pins the two things a user reports when
354+
// they say "no feedback": that the line is up straight away (0.3s in, not whenever
355+
// something else repaints), and that it is still moving while the model thinks.
356+
const m = await startMock({ tool: false, slowSummaryMs: 20000 })
357+
const proj = await createProject({ mockPort: m.port })
358+
await installPlugins({ projectDir: proj.dir, server: [path.join(REPO_ROOT, "dist", "server.js")], tui: [path.join(REPO_ROOT, "dist", "tui.js")] })
359+
try {
360+
const { screens } = await runTuiScreens({
361+
projectDir: proj.dir,
362+
keys: [
363+
["Ask anything", 1, "first question\r"],
364+
["mock reply", 8, "second question\r"],
365+
["mock reply", 16, "/tree"],
366+
["Context tree", 0.5, "\r"],
367+
["Context tree ·", 2, "gg"],
368+
["Context tree ·", 3, "\r"],
369+
// ↓ once = "Summarize everything below this point"
370+
["Fork & prefill this turn", 1.5, "\x1b[B"],
371+
["Summarize everything below", 1, "\r"],
372+
// four screens half a second apart while the summary is still in flight: close
373+
// enough that only a *live* line changes between them (the spinner turns every
374+
// 120ms), which is the thing OpenCode's on-demand renderer does not do by itself
375+
["Summarize everything below", 0.3, ""],
376+
["Summarize everything below", 0.5, ""],
377+
["Summarize everything below", 0.5, ""],
378+
["Summarize everything below", 0.5, ""],
379+
["mock reply|Ask anything", 20, "\x03"],
380+
["", 1, "\x03"],
381+
],
382+
timeoutSec: 240,
383+
cols: 130,
384+
rows: 34,
385+
exitWhenDone: true,
386+
})
387+
await dumpScreens(screens)
388+
// the first sample is 0.3s after the choice: the line has to be up *immediately*, not
389+
// whenever something else happens to repaint the screen
390+
const inFlight = screens.filter((x) => x.label.includes("conditional key 8") || x.label.includes("conditional key 9") || x.label.includes("conditional key 10") || x.label.includes("conditional key 11"))
391+
expect(inFlight.length).toBe(4)
392+
const during = inFlight.filter((x) => x.screen.includes("summarizing"))
393+
if (during.length < 4) throw new Error(`the progress line was missing from ${4 - during.length} of the 4 in-flight samples: ${inFlight.map((x) => (x.screen.includes("summarizing") ? "line" : "NOTHING")).join(" ")}`)
394+
expect(during.every((x) => x.screen.includes("esc cancels"))).toBe(true)
395+
396+
// It is *live*: the spinner has turned between these half-second samples. Without a
397+
// frame request the line is drawn once and then sits frozen until something unrelated
398+
// repaints — which is exactly what "no feedback" looked like.
399+
const frames = during.map((x) => /([⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]) summarizing/.exec(x.screen)?.[1]).filter((v): v is string => v !== undefined)
400+
if (process.env["CTREE_DUMP"]) console.log(`spinner frames across ${during.length} samples: ${frames.join(" ")}`)
401+
expect(frames.length).toBe(during.length)
402+
// two of four is enough to prove it moved: at 8 frames a second, samples half a second
403+
// apart alias against the 10-frame cycle, so "all four differ" would be flaky
404+
expect(new Set(frames).size).toBeGreaterThan(1)
405+
} finally {
406+
await m.stop()
407+
await proj.cleanup()
408+
}
409+
}, 300_000)
410+
339411
test("the g-prefixed verbs fire alongside gg", async () => {
340412
// `gg` was the only sequence in the keymap; the realignment put every plugin verb behind
341413
// `g`, so this proves the sequence tree branches rather than `gg` shadowing them

0 commit comments

Comments
 (0)