diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a95b33..df6a08e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,31 @@ # Changelog -## 0.2.2 (unreleased) +## 0.2.3 (unreleased) + +Pi's fork-from-an-earlier-message flow, ported whole: + +- `⏎` on a row above where you are now opens Pi's tree-selector question — **No summary** / + **Summarize everything below this point** / **Summarize with a custom prompt** — and that + question is the confirmation, so the fork is one dialog instead of a yes/no followed by a + second one. The option lines say how much you are leaving (`drop the 3 turns · ~14k below + this point`). +- The summary now covers **what the jump abandons**, not the whole session: the turns from + where you are back to the point your path and the target's path last shared, computed across + sessions from their spines (Pi's "old leaf → common ancestor"). Redoing trunk turn 2 + summarizes turns 2–3; switching from a branch to a sibling summarizes the branch's own turns + and not the shared trunk. +- `esc` in the picker now really cancels: it puts you back on the same row with nothing forked, + where before it moved you anyway without a summary. Cancelling the custom-prompt editor goes + back to the three choices instead of quietly meaning "no summary". +- Nothing moves until the summary exists. The draft runs first, so `esc` while it is being + written aborts the draft *and* the jump (the helper session's reply is aborted too) and + leaves you where you were. A summary that fails outright still lets the move through, with a + notice. A streaming reply on the session you are leaving is aborted before the draft, so the + summary covers the reply as it actually ended. +- A jump with nothing below the selected point, and `jumpSummary: "never"`, skip the question + and show the plain confirm. + +## 0.2.2 — 2026-09-03 Found by a long driven session on a real model (13 tool-using turns, three fork paths, three merge paths, result and turn crops, undo, resume) and 50/100/200-turn scale runs: diff --git a/DESIGN.md b/DESIGN.md index b417ebc..08ac7dc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -275,36 +275,69 @@ happens to the session. `q` returns to the chat exactly as it was. ### 6.2 Jump ("go here") — the Pi `/tree` move -Select any row, press `⏎`. +Select any row, press `⏎`. What the row *is* decides the move, exactly as Pi's +`agent-session.ts#navigateTree` decides it from the entry type: - Row is the **tip of a branch** (its last message) → switch to that session (`route.navigate("session")`). No fork. This is Pi's "move the leaf to an existing leaf". -- Row is a **user message** in the middle → confirm dialog *"Redo this turn on a new - branch?"* → `session.fork({ messageID })` (copies everything *before* it) → - `branch.opened{kind:"jump"}` → open the new session → the user text is pre-filled in - the prompt (`tui.appendPrompt`). Identical to Pi's user-message semantics and to - OpenCode's own `/fork`. +- Row is a **user message** in the middle → `session.fork({ messageID })` (copies everything + *before* it) → `branch.opened{kind:"redo"}` → open the new session → the user text is + pre-filled in the prompt (`tui.appendPrompt`). Pi does the same thing by moving the leaf to + the message's *parent* and putting its text in the editor. - Row is an **assistant/tool step** → fork at the *next* message (so the step is included) with an empty prompt: "continue from here". - Row is a **branch header** → same as its tip. -If the current session is streaming, we abort it first (`session.abort`) and say so, -as Pi does since #7022. The old branch is untouched and stays visible. Undo: `x` -closes the jump branch as `abandoned` and returns to where you were. - -After a jump that leaves an open path behind, the plugin asks Pi's question: -**"Summarize the branch you are leaving? No / Summarize / Summarize with custom -prompt"** (option `jumpSummary`, default `"ask"`; `"never"` for the pure -`pi-context-tree` stance). *Summarize* generates the Pi-format branch summary (Goal / -Constraints / Progress / Key decisions / Next steps) in a throw-away helper session, -deletes it, and injects the text into the destination session with -`session.prompt({ noReply: true })` prefixed by "The user explored a different -conversation branch before returning here", tagged `metadata.ctree.kind = "summary"`, -exactly as `opencode-tree` does. `Esc` in the picker returns to the tree at the same -row. The summary is journalled (`summary.recorded`) so `/undo` can hide it and the -decisions view can distinguish ◆ confirmed records from ◇ auto summaries. `/merge` -remains the reviewed path; a summary is never written when a merge closes the branch. +**One question, three answers.** `⏎` opens Pi's tree-selector question, and that question +*is* the confirmation — there is no separate yes/no step: + +``` +┌ Fork & prefill this turn? ───────────────────────────────────────────────┐ +│ No summary start clean · nothing carried over│ +│ Summarize everything below this point carry the 3 turns · ~14k over… │ +│ Summarize with a custom prompt the same, with your own focus │ +└──────────────────────────────────────────────────────────────────────────┘ + +(A **switch** to another branch has no picked point, so its middle answer reads +"Summarize what you are leaving"; everything else is the same.) +``` + +Pi's order and Pi's escape hatches: `esc` on the choices puts you back on the same row with +nothing done, and cancelling the custom-prompt editor loops back to the three choices rather +than quietly meaning "no summary" (`interactive-mode.ts#showTreeSelector`). Option +`jumpSummary`, default `"ask"`; `"never"` (the pure `pi-context-tree` stance) degrades it to +a plain confirm, as does a jump with nothing below the selected point to summarize. + +**What "everything below that point" is.** Pi collects the entries from the old leaf back to +the **common ancestor** with the target and summarizes those. We compute the same set across +sessions: both sides are reduced to their *spine* (`core/tree.ts#spineOf`) — the ordered +`sessionID:messageID` path from the root, where an ancestor's copied prefix keeps the +ancestor's own IDs — the deepest entry present in both is the common ancestor, and everything +after it in the current session is the abandoned tail (`core/actions.ts#abandonedTail`, unit +tested). A `fork` plan cuts the target spine *before* its boundary, because `session.fork` +copies messages strictly before it. So redoing trunk turn 2 summarizes turns 2–3; switching +from a branch to a sibling summarizes the branch's own turns and not the shared trunk. + +**Order of operations**, matching `navigateTree`: abort a streaming response first +(`session.abort`, Pi #7022, so the summary covers the reply as it actually ended) → draft the +summary while *nothing has moved yet* → fork or switch → inject. Drafting first is what makes +`esc` meaningful: it aborts the helper session's reply and the whole jump, leaving you on the +row you started from. A summary that *fails* (rather than being aborted) never blocks the +move — we say so and go anyway, because the alternative is stranding you on the session you +asked to leave. + +*Summarize* generates the Pi-format branch summary (Goal / Constraints / Progress / Key +decisions / Next steps) in a throw-away helper session, deletes it, and injects the text into +the **destination** session with `session.prompt({ noReply: true })` prefixed by "The user +explored a different conversation branch before returning here", tagged +`metadata.ctree.kind = "summary"` — Pi likewise attaches its `branch_summary` entry at the new +leaf, not on the branch it left. The summary is journalled (`summary.recorded`) so `/undo` can +hide it and the decisions view can distinguish ◆ confirmed records from ◇ auto summaries. +`/merge` remains the reviewed path; a summary is never written when a merge closes the branch. + +The old branch is untouched and stays visible. Undo: `x` closes the jump branch as +`abandoned` and returns to where you were. ### 6.3 `/branch fix-flaky-test [haiku-4.5]` @@ -658,7 +691,7 @@ packages/ |---|---|---| | 1 | Package and slash names | **`opencode-context-tree`**; slash `/tree` with alias `/ctree`; headless server commands are `/ctree …`. The `/tree` name only collides if `@ishaksebsib/opencode-tree` is installed alongside. | | 2 | Journal location | **Local**, `/.opencode/context-tree/`, gitignored by default. `storage: "global"` remains an option. | -| 3 | Summarize on jump | **Ask every time** (Pi behaviour): No / Summarize / Custom prompt. `jumpSummary: "never"` opts out. Summaries are journalled as ◇ unreviewed and are distinct from ◆ merge records. | +| 3 | Summarize on jump | **Ask every time** (Pi behaviour), as the one dialog `⏎` opens: No summary / Summarize everything below this point / Summarize with a custom prompt. The summary covers the abandoned tail (§6.2), not the whole session. `jumpSummary: "never"` opts out. Summaries are journalled as ◇ unreviewed and are distinct from ◆ merge records. | | 4 | Undo of a squash | **Hide the ◆ record from the model, keep it on screen.** Journal marks it inactive; the hook drops it; OpenCode storage untouched. No delete path in v1. | | 5 | Code base | **From scratch, spec-driven.** Port the `pi-context-tree` *semantics* (merge modes, crop protections, undo rules, gauge bands, decision template) and its *method* (pure `core` reducers, journal fold, golden fixtures, table-driven view-model tests), but write all code against OpenCode's message/part/session model. Do not fork `@ishaksebsib/opencode-tree` (unmaintained) or copy Pi entry-based code; read both only as API references. Reliability rule: every OpenCode API the plugin depends on gets an integration test against `opencode serve` with a mock provider before it is used by a feature. | diff --git a/README.md b/README.md index eb9f7e3..a1740a8 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,10 @@ elsewhere — timing, inspector) in one screen. Branch, jump, labels, filters, search, crop + undo, squash/discard/tournament merge with a `$EDITOR` gate, the timeline lanes, inspector and consumers views, the gauge, and the headless `/ctree` commands all work against OpenCode 1.18 and are covered by pty-driven e2e tests -(`bun run test:e2e`). Read [DESIGN.md](./DESIGN.md) — it contains the research +(`bun run test:e2e`). Pressing `⏎` on an earlier message is Pi's fork flow whole — one +question with Pi's three answers (no summary · summarize everything below that point · +summarize with your own prompt), and the summary covers exactly the turns the move leaves +behind. Read [DESIGN.md](./DESIGN.md) — it contains the research (Pi, `pi-context-tree`, OpenCode plugin/SDK surface, existing plugins, DSH trajectory), the end-user flows, the combined tree + trajectory mockup, the data model, architecture, edge cases, and the roadmap. diff --git a/docs/USAGE.md b/docs/USAGE.md index c05e927..569fa55 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -66,6 +66,26 @@ c … space … ⏎ crop a fat tool result (double space for protected ones u undo the last crop / branch / merge on this path (alias x) ``` +`⏎` on any row above your current position asks Pi's tree-selector question, and the answer +is also the confirmation — there is no separate yes/no step: + +| option | what it does | +|---|---| +| **No summary** | fork (or switch) clean; everything below the row you picked stays behind on the old session, out of the model's context | +| **Summarize everything below this point** (a switch reads "Summarize what you are leaving") | one model call drafts a Goal / Constraints / Progress / Key decisions / Next steps summary of exactly the turns the move abandons, and it lands at the destination as one `≣` message the model reads | +| **Summarize with a custom prompt** | the same, with your own focus ("just the API decisions", "keep the stack traces") | + +The option lines say how much you are leaving — `drop the 3 turns · ~14k below this point`. +"Everything below this point" means what Pi means: the turns from where you are now back to +the point the two paths share, so redoing trunk turn 2 summarizes turns 2–3, while switching +from a branch to a sibling summarizes the branch's own turns and not the shared trunk. + +`esc` on the choices puts you back on the same row with nothing done; `esc` while the summary +is being drafted cancels the draft *and* the move — nothing is forked until the summary is +ready. A summary that fails outright never blocks the move: you get a notice and go anyway. +Set `jumpSummary: "never"` for a plain confirm instead (the pure `pi-context-tree` stance); +a jump with nothing below the selected point skips the question too. + `/merge` asks how to close the branch: | option | what it does | @@ -85,7 +105,7 @@ appended to the trunk as a normal message.* | `↑↓` `j k` · `J K` (20) · `ctrl+d` `ctrl+u` · `gg` `G` | move · half page · top / bottom | | `[` `]` | previous / next branch row | | `← →` `h l` · `Tab` (or `e`) | fold / unfold a branch inline | -| `⏎` | go here — the footer names what it will do for the row you are on: switch to a `⎇` branch, fork & prefill a user turn, fork after a step. Confirms first, then asks "Summarize the branch you are leaving?" (Pi); `u` undoes it | +| `⏎` | go here — the footer names what it will do for the row you are on: switch to a `⎇` branch, fork & prefill a user turn, fork after a step. Opens Pi's one question (below), which is also the confirmation; `u` undoes it | | `b` | branch here: name it, then "Model for this branch" (Enter keeps the current one) | | `m` | merge: Squash / Squash without LLM / Discard / Tournament (siblings only) | | `c` `space` `a` `t` `⏎` | crop mode: mark (`space` alone enters it on a croppable row), auto-mark (≥10k tokens, older than 2 turns), result⇄turn, apply | diff --git a/src/core/actions.ts b/src/core/actions.ts index 4851d11..75dec77 100644 --- a/src/core/actions.ts +++ b/src/core/actions.ts @@ -5,8 +5,9 @@ * * Pure, no OpenCode/opentui/solid-js imports — see test/core-purity.test.ts. */ -import type { Row } from "./tree.js" -import type { Transcript } from "./transcript.js" +import type { TreeState } from "./journal.js" +import { aggregateTokens, spineOf, type Row } from "./tree.js" +import type { Transcript, TranscriptMessage } from "./transcript.js" export type JumpPlan = | { kind: "noop"; reason: string } @@ -52,3 +53,62 @@ export function planJump(row: Row, ctx: { transcripts: Record + currentSessionID: string + plan: JumpPlan +}): AbandonedTail { + const plan = o.plan + const current = o.transcripts[o.currentSessionID] + if (!current || plan.kind === "noop") return EMPTY_TAIL + + const mine = spineOf(o.state, o.transcripts, o.currentSessionID) + let theirs = spineOf(o.state, o.transcripts, plan.sessionID) + if (plan.kind === "fork") { + const cut = theirs.findIndex((e) => e.sessionID === plan.sessionID && e.messageID === plan.messageID) + if (cut !== -1) theirs = theirs.slice(0, cut) + } + const keep = new Set(theirs.map((e) => `${e.sessionID}:${e.messageID}`)) + + let common = -1 + for (let i = mine.length - 1; i >= 0; i--) { + const e = mine[i]! + if (keep.has(`${e.sessionID}:${e.messageID}`)) { + common = i + break + } + } + + const messages = current.messages.slice(Math.min(common + 1, current.messages.length)) + return { messages, turns: messages.filter((m) => m.role === "user").length, tokens: aggregateTokens(messages) } +} diff --git a/src/core/decision.ts b/src/core/decision.ts index a139f03..5292505 100644 --- a/src/core/decision.ts +++ b/src/core/decision.ts @@ -59,8 +59,13 @@ export function branchTranscriptText(transcript: Transcript, anchor: { messageID anchorIndex = anchor.parentMessageIDs.indexOf(anchor.messageID) if (anchorIndex === -1) throw new Error(`anchor message ${anchor.messageID} is no longer in the parent session — cannot tell this branch's own turns from the shared prefix`) } - const msgs = transcript.messages.slice(anchorIndex + 1) - return msgs + return transcriptText(transcript.messages.slice(anchorIndex + 1), toolChars) +} + +/** `[User]: …` / `[Assistant]: …` lines for a run of messages, tool results truncated — + * what both the merge drafter and the branch summarizer hand to the model. */ +export function transcriptText(messages: readonly TranscriptMessage[], toolChars = 2000): string { + return messages .map((m) => messageText(m, toolChars)) .filter(Boolean) .join("\n\n") diff --git a/src/core/tree.ts b/src/core/tree.ts index 52a60f1..454f3ec 100644 --- a/src/core/tree.ts +++ b/src/core/tree.ts @@ -227,7 +227,7 @@ function tokenFieldsOf(message: TranscriptMessage): StepRow["tokenFields"] { return { input: tk.input ?? 0, output: tk.output, reasoning: tk.reasoning ?? 0, cacheRead: tk.cache?.read ?? 0, cacheWrite: tk.cache?.write ?? 0 } } -function aggregateTokens(messages: TranscriptMessage[]): number { +export function aggregateTokens(messages: TranscriptMessage[]): number { let total = 0 for (const m of messages) { if (m.role === "user") total += estimateTokens(userText(m)) @@ -775,6 +775,40 @@ export function buildTreeView(o: BuildOptions): TreeView { // Positional map between spine rows and the current session's copied prefix. // --------------------------------------------------------------------------- +export type SpineEntry = { sessionID: string; messageID: string } + +/** + * Every message on a session's context path, root-first: each on-path ancestor's copied + * prefix named by *its own* message IDs, then the session's own tail. Position `i` is the + * index of that message in `transcripts[sessionID].messages`, so two sessions' spines can be + * compared entry-by-entry (`sessionID:messageID`) to find where they diverge — see + * `abandonedTail` in `core/actions.ts`. + * + * An ancestor whose transcript is not loaded contributes nothing and its share falls to the + * session itself, exactly as `prefixLengthOf` and `buildSpineMap` treat it. + */ +export function spineOf(state: TreeState, transcripts: Record, sessionID: string): SpineEntry[] { + const chain = currentChainOf(state, sessionID) + const out: SpineEntry[] = [] + let from = 0 + for (let s = 0; s < chain.length; s++) { + const sid = chain[s]! + const own = transcripts[sid] + const child = chain[s + 1] + if (child !== undefined) { + const anchorID = state.sessions[child]?.anchorMessageID + const anchorIndex = own && anchorID !== undefined ? own.messages.findIndex((m) => m.id === anchorID) : -1 + if (own && anchorIndex !== -1) { + for (const m of own.messages.slice(from, anchorIndex + 1)) out.push({ sessionID: sid, messageID: m.id }) + from = anchorIndex + 1 + } + continue + } + if (own) for (const m of own.messages.slice(from)) out.push({ sessionID: sid, messageID: m.id }) + } + return out +} + export type SpineMap = { /** `${sessionID}:${messageID}` of any spine message → index into the current transcript */ index: Map @@ -793,32 +827,8 @@ export type SpineMap = { export function buildSpineMap(o: Pick): SpineMap { const current = o.transcripts[o.currentSessionID] const index = new Map() - const owner: { sessionID: string; messageID: string }[] = [] - if (current) { - const spine = [...ancestorChainOf(o.state, o.currentSessionID), o.currentSessionID] - let from = 0 - for (let s = 0; s < spine.length; s++) { - const sessionID = spine[s]! - const own = o.transcripts[sessionID] - const child = spine[s + 1] - const childBranch = child ? o.state.sessions[child] : undefined - if (s < spine.length - 1) { - const anchorIndex = own ? own.messages.findIndex((m) => m.id === childBranch?.anchorMessageID) : -1 - if (own && anchorIndex !== -1) { - own.messages.slice(from, anchorIndex + 1).forEach((m, i) => { - index.set(`${sessionID}:${m.id}`, from + i) - owner[from + i] = { sessionID, messageID: m.id } - }) - from = anchorIndex + 1 - } - continue - } - current.messages.slice(from).forEach((m, i) => { - index.set(`${sessionID}:${m.id}`, from + i) - owner[from + i] = { sessionID, messageID: m.id } - }) - } - } + const owner: SpineEntry[] = current ? spineOf(o.state, o.transcripts, o.currentSessionID) : [] + owner.forEach((e, i) => index.set(`${e.sessionID}:${e.messageID}`, i)) const messageAt = (i: number) => current?.messages[i] const toCurrent = (sessionID: string, messageID: string) => { const i = index.get(`${sessionID}:${messageID}`) diff --git a/src/tui/actions.ts b/src/tui/actions.ts index 0219a04..83bde25 100644 --- a/src/tui/actions.ts +++ b/src/tui/actions.ts @@ -11,8 +11,9 @@ import type { JournalStore } from "../shared/store.js" import { withBranchLabel, type CropAppliedData, type JournalEntry, type TreeState } from "../core/journal.js" import type { UndoPlan } from "../core/undo.js" import type { TranscriptMessage } from "../core/transcript.js" +import type { AbandonedTail } from "../core/actions.js" import { contextSizeOf, formatK, type MinimalMessage } from "../core/tokens.js" -import { DECISION_SYSTEM, branchTranscriptText, buildDecisionDraftPrompt, decisionMessageText, decisionRecord, decisionTemplate, openSiblings, templatePlaceholders } from "../core/decision.js" +import { DECISION_SYSTEM, branchTranscriptText, transcriptText, buildDecisionDraftPrompt, decisionMessageText, decisionRecord, decisionTemplate, openSiblings, templatePlaceholders } from "../core/decision.js" import { editInExternalEditor, hasEditor } from "./editor.js" import { debug } from "../shared/debug.js" import { fetchTranscript } from "./transcripts.js" @@ -33,6 +34,10 @@ export type ActionContext = { export type SummaryChoice = { kind: "none" } | { kind: "summarize"; customInstructions?: string } +/** Where a jump ended up: `target` when it moved, `aborted` when the user pressed `esc` + * during the summary draft and nothing was changed at all. */ +export type JumpOutcome = { target?: string; aborted?: boolean } + const [revision, setRevision] = createSignal(0) /** The journal is plain files, so views built from `store.stateFor*` subscribe to this * counter to notice writes made by this TUI (the sidebar card, the route). */ @@ -184,32 +189,65 @@ export async function createNamedBranch( return forkedID } -/** Execute a jump plan (DESIGN.md §6.2). Returns the session we ended up in. */ +/** + * Execute a jump plan (DESIGN.md §6.2), Pi's `navigateTree` order: + * + * 1. abort a streaming response on the session we are leaving (Pi #7022), so the summary + * covers the reply as it actually ended; + * 2. draft the branch summary **before** anything moves, so `esc` (an aborted `signal`) + * leaves you exactly where you were — `{ aborted: true }`, no fork, no switch; + * 3. fork or switch; + * 4. inject the summary at the destination, the way Pi attaches its `branch_summary` entry + * at the new leaf rather than on the branch it left. + * + * A summary that *fails* (as opposed to being aborted) never blocks the move: we say so and + * go anyway, because the alternative is stranding the user on the session they asked to leave. + */ export async function executeJump( ctx: ActionContext, plan: JumpPlan, - opts: { currentSessionID: string; summary: SummaryChoice }, -): Promise { - debug("jump.plan", { plan, current: opts.currentSessionID, summary: opts.summary.kind }) + opts: { currentSessionID: string; summary: SummaryChoice; abandoned?: readonly TranscriptMessage[]; signal?: AbortSignal }, +): Promise { + debug("jump.plan", { plan, current: opts.currentSessionID, summary: opts.summary.kind, abandoned: opts.abandoned?.length ?? 0 }) if (plan.kind === "noop") { notify(ctx, { message: plan.reason }) - return undefined + return {} } + + // Pi stops the active response as soon as the user commits to navigating, before it + // summarizes, so the summary covers the reply as it actually ended (interactive-mode.ts). await abortIfBusy(ctx, opts.currentSessionID) const leavingTip = ctx.api.state.session.messages(opts.currentSessionID).at(-1)?.id + const wanted = opts.summary.kind === "summarize" && (opts.abandoned?.length ?? 0) > 0 + + let summary: string | undefined + let summaryNotice: TuiToast | undefined + if (wanted) { + try { + summary = await draftBranchSummary(ctx, { messages: opts.abandoned ?? [], customInstructions: opts.summary.kind === "summarize" ? opts.summary.customInstructions : undefined, signal: opts.signal }) + } catch (e) { + if (opts.signal?.aborted) { + debug("jump.summary.aborted", {}) + return { aborted: true } + } + summaryNotice = { variant: "error", message: `summary failed: ${e instanceof Error ? e.message : String(e)} — moved without it` } + } + if (opts.signal?.aborted) return { aborted: true } + } + let target: string if (plan.kind === "switch") { target = plan.sessionID } else { target = await forkBranch(ctx, { sessionID: plan.sessionID, messageID: plan.messageID, kind: plan.mode === "redo" ? "redo" : "jump" }) } - let summaryNotice: TuiToast | undefined - if (opts.summary.kind === "summarize" && leavingTip && target !== opts.currentSessionID) { - // the fork already exists; a failed summary must not strand the user on the old session - await summarizeInto(ctx, { fromSessionID: opts.currentSessionID, fromMessageID: leavingTip, targetSessionID: target, customInstructions: opts.summary.customInstructions }) + + if (summary && target !== opts.currentSessionID) { + await injectBranchSummary(ctx, { summary, targetSessionID: target, fromSessionID: opts.currentSessionID, fromMessageID: leavingTip ?? "" }) .then(() => (summaryNotice = { variant: "success", message: "Branch summary added" })) .catch((e) => (summaryNotice = { variant: "error", message: `summary failed: ${e instanceof Error ? e.message : String(e)} — moved without it` })) } + debug("jump.navigate", { target }) navigateToSession(ctx, target) // the route has unmounted by now, so this always goes through the toast, not ctx.notify @@ -218,31 +256,61 @@ export async function executeJump( await new Promise((r) => setTimeout(r, 0)) await ctx.api.client.tui.appendPrompt({ text: plan.prefill, directory: ctx.directory }).catch(() => undefined) } - return target + return { target } +} + +/** What `⏎` is about to do to the selected row — Pi asks only "Summarize branch?", but the + * choice is also the confirmation here, so the action belongs in the title. */ +export function jumpDialogTitle(plan: JumpPlan, label: (sessionID: string) => string): string { + if (plan.kind === "noop") return "Nothing to go to" + if (plan.kind === "switch") return `Switch to ${label(plan.sessionID)}?` + return plan.mode === "redo" ? "Fork & prefill this turn?" : "Fork after this step?" +} + +/** `3 turns · ~14k` — the size of what a jump leaves behind, for the dialog and the notice. */ +export function describeTail(tail: AbandonedTail): string { + const what = tail.turns > 0 ? plural(tail.turns, "turn") : plural(tail.messages.length, "message") + return `${what} · ~${formatK(tail.tokens)}` +} + +export type JumpChoice = "none" | "summarize" | "custom" + +/** + * Pi's three answers to `⏎` on an earlier row, in Pi's order — "No summary" first, so `⏎⏎` + * stays the safe move (`interactive-mode.ts#showTreeSelector`). A fork names the point you + * picked ("everything below this point"); a switch has no such point, only the path it + * leaves. Descriptions render on the option's own line and truncate past ~50 columns, so + * they stay short. + */ +export function jumpDialogOptions(tail: AbandonedTail, kind: "fork" | "switch"): { title: string; value: JumpChoice; description: string }[] { + const leaving = describeTail(tail) + return [ + { title: "No summary", value: "none", description: "start clean · nothing carried over" }, + { title: kind === "fork" ? "Summarize everything below this point" : "Summarize what you are leaving", value: "summarize", description: `carry the ${leaving} over as one ≣ summary` }, + { title: "Summarize with a custom prompt", value: "custom", description: "the same, with your own focus" }, + ] } -/** Pi-style summary of the branch we are leaving, generated in a throw-away helper session and - * injected into the destination with `noReply` (DESIGN.md §6.2, journal `summary.recorded`). */ -export async function summarizeInto( +/** + * Draft the Pi-format summary of the turns a jump leaves behind, in a throw-away helper + * session that is deleted again (no provider keys of our own; DESIGN.md §6.2). + * + * `signal` is honoured between steps and aborts the helper session's in-flight reply, so + * `esc` in the tree gets out of a slow summarizer. + */ +export async function draftBranchSummary( ctx: ActionContext, - input: { fromSessionID: string; fromMessageID: string; targetSessionID: string; customInstructions?: string; signal?: AbortSignal }, -): Promise { - const msgs = await ctx.api.client.session.messages({ sessionID: input.fromSessionID, directory: ctx.directory }) - const transcript = ((msgs.data as any[]) ?? []) - .map((m) => { - const role = m.info.role === "user" ? "[User]" : "[Assistant]" - const text = (m.parts as any[]) - .map((p) => (p.type === "text" ? p.text : p.type === "tool" ? `(tool ${p.tool}: ${JSON.stringify(p.state?.input ?? {}).slice(0, 200)} → ${String(p.state?.output ?? "").slice(0, 400)})` : "")) - .filter(Boolean) - .join("\n") - return text ? `${role}: ${text}` : "" - }) - .filter(Boolean) - .join("\n\n") - debug("summary.start", { from: input.fromSessionID, target: input.targetSessionID, chars: transcript.length }) + input: { messages: readonly TranscriptMessage[]; customInstructions?: string; signal?: AbortSignal }, +): Promise { + const transcript = transcriptText(input.messages, 400) + if (!transcript.trim()) throw new Error("nothing to summarize") + debug("summary.start", { messages: input.messages.length, chars: transcript.length }) + if (input.signal?.aborted) throw new Error("aborted") const helper = await ctx.api.client.session.create({ directory: ctx.directory, title: "Context tree: branch summary" }) const helperID = (helper.data as any)?.id as string | undefined if (!helperID) throw new Error("could not create helper session") + const stop = () => void ctx.api.client.session.abort({ sessionID: helperID, directory: ctx.directory }).catch(() => undefined) + input.signal?.addEventListener("abort", stop, { once: true }) try { const instructions = input.customInstructions ? `${SUMMARY_INSTRUCTIONS}\n\nAdditional focus from the user:\n${input.customInstructions}` : SUMMARY_INSTRUCTIONS const reply = await ctx.api.client.session.prompt({ @@ -251,6 +319,7 @@ export async function summarizeInto( system: SUMMARY_SYSTEM, parts: [{ type: "text", text: `\n${transcript}\n\n\n${instructions}` }], }) + if (input.signal?.aborted) throw new Error("aborted") const summary = ((reply.data as any)?.parts as any[] | undefined) ?.filter((p) => p.type === "text" && !p.synthetic && !p.ignored) .map((p) => p.text) @@ -258,21 +327,30 @@ export async function summarizeInto( .trim() debug("summary.generated", { chars: summary?.length ?? 0 }) if (!summary) throw new Error("summary model returned no text") - const injected = await ctx.api.client.session.prompt({ - sessionID: input.targetSessionID, - directory: ctx.directory, - noReply: true, - parts: [{ type: "text", text: SUMMARY_PREAMBLE + summary, metadata: { ctree: { kind: "summary", fromSessionID: input.fromSessionID } } }], - }) - const messageID = (injected.data as any)?.info?.id ?? (injected.data as any)?.id - const treeId = ctx.store.ensureTree(input.targetSessionID, "tui") - record(ctx, treeId, "summary.recorded", { sessionID: input.targetSessionID, messageID: String(messageID ?? ""), fromSessionID: input.fromSessionID, fromMessageID: input.fromMessageID }) return summary } finally { + input.signal?.removeEventListener("abort", stop) await ctx.api.client.session.delete({ sessionID: helperID, directory: ctx.directory }).catch(() => undefined) } } +/** Land a drafted summary at the destination as a `noReply` user message, journalled as + * `summary.recorded` so `/undo` and the ◇ decisions view can tell it from a ◆ merge. */ +export async function injectBranchSummary( + ctx: ActionContext, + input: { summary: string; targetSessionID: string; fromSessionID: string; fromMessageID: string }, +): Promise { + const injected = await ctx.api.client.session.prompt({ + sessionID: input.targetSessionID, + directory: ctx.directory, + noReply: true, + parts: [{ type: "text", text: SUMMARY_PREAMBLE + input.summary, metadata: { ctree: { kind: "summary", fromSessionID: input.fromSessionID } } }], + }) + const messageID = (injected.data as any)?.info?.id ?? (injected.data as any)?.id + const treeId = ctx.store.ensureTree(input.targetSessionID, "tui") + record(ctx, treeId, "summary.recorded", { sessionID: input.targetSessionID, messageID: String(messageID ?? ""), fromSessionID: input.fromSessionID, fromMessageID: input.fromMessageID }) +} + export function setLabel(ctx: ActionContext, input: { sessionID: string; messageID: string; label: string | null }): void { const treeId = ctx.store.ensureTree(input.sessionID, "tui") record(ctx, treeId, "label.set", { sessionID: input.sessionID, messageID: input.messageID, label: input.label }) diff --git a/src/tui/route.tsx b/src/tui/route.tsx index 963f951..5e68abc 100644 --- a/src/tui/route.tsx +++ b/src/tui/route.tsx @@ -6,7 +6,7 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import { PLUGIN_VERSION } from "../shared/version.js" import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" -import { planJump } from "../core/actions.js" +import { abandonedTail, planJump, type AbandonedTail, type JumpPlan } from "../core/actions.js" import { foldJournal, type TreeState } from "../core/journal.js" import { firstIndex, lastIndex, moveSelection, nextBranchIndex, resolveSelection, toggleExpanded } from "../core/navigation.js" import { contextSizeOf, formatContext, formatK, type MinimalMessage } from "../core/tokens.js" @@ -14,7 +14,7 @@ import { buildSpineMap, buildTreeView, currentChainOf, type Filter, type Row, ty import { ContextGauge } from "./gauge.js" import type { Transcript } from "../core/transcript.js" import type { JournalStore } from "../shared/store.js" -import { applyCrop, branchLabel, BRANCH_DIALOG, clip as clipTo, copyText, createNamedBranch, executeJump, executeUndo, mergeBranch, mergeDialogOptions, mergeDialogTitle, mergePickerFigures, MERGE_TRUST, setLabel, UNDO_KEY, type ActionContext, type MergeMode, type SummaryChoice } from "./actions.js" +import { applyCrop, branchLabel, BRANCH_DIALOG, clip as clipTo, copyText, createNamedBranch, describeTail, executeJump, executeUndo, jumpDialogOptions, jumpDialogTitle, mergeBranch, mergeDialogOptions, mergeDialogTitle, mergePickerFigures, MERGE_TRUST, setLabel, UNDO_KEY, type ActionContext, type MergeMode, type SummaryChoice } from "./actions.js" import { decisionSummary, exportDecisions, renderDecision } from "../core/decision.js" import { layoutEventStrip, overviewTrack, stripIndexFor, windowFor, type LaneMode, type StripCell } from "../core/lanes.js" import { bar, consumers, type Consumer, type ConsumerEntry } from "../core/consumers.js" @@ -201,6 +201,7 @@ const HELP = [ " h l ← → fold/unfold a branch · Tab (or e) toggle · / live search · n N next/prev match", "Act", " ⏎ go — a ⎇ header switches to it · a user turn forks & prefills it · a step forks after it", + " then: no summary · summarize everything below that point · summarize with your own prompt (esc stays put)", " b branch · m merge · c crop mode (space mark · a auto · t result⇄turn · ⏎ apply · esc leave)", " u undo (alias x) · L label · y copy · E export decisions", "Views", @@ -248,6 +249,9 @@ export function TreeRoute(props: TreeRouteProps) { const [selected, setSelected] = createSignal(0) const [others, setOthers] = createSignal>({}) const [busy, setBusy] = createSignal() + /** Set while a jump is drafting its branch summary: `esc` cancels the draft, and with it the + * jump — nothing has been forked or switched yet (Pi's `abortBranchSummary`). */ + const [summaryAbort, setSummaryAbort] = createSignal() const [cropMode, setCropMode] = createSignal<"result" | "turn" | undefined>() const [panel, setPanel] = createSignal<"tree" | "decisions" | "consumers" | "help">(props.initialView ?? "tree") const [laneMode, setLaneMode] = createSignal(api.kv.get("ctree.lanes", "turns")) @@ -816,44 +820,76 @@ export function TreeRoute(props: TreeRouteProps) { else api.route.navigate("home") } - function askSummary(): Promise { - if (props.options.jumpSummary === "never") return Promise.resolve({ kind: "none" }) + /** + * Pi's one question on `⏎` (its tree selector's "Summarize branch?"): the choice *is* the + * confirmation, so pressing enter on a row offers "start the fork clean", "summarize + * everything below this point" or "summarize with a prompt" in a single step. + * + * `esc` on the choices puts you back on the same row with nothing done, and cancelling the + * custom-prompt editor loops back to the choices rather than silently meaning "no summary" + * (`interactive-mode.ts#showTreeSelector`). With `jumpSummary: "never"`, or when the jump + * abandons nothing to summarize, it degrades to the plain confirm. + * + * Resolves `undefined` for "changed my mind, stay in the tree". + */ + function askJump(plan: JumpPlan & { kind: "switch" | "fork" }, tail: AbandonedTail, title: string): Promise { + const note = + plan.kind === "switch" + ? `The session you are on now stays exactly as it is. ${UNDO_KEY} undoes this.` + : `A new OpenCode session forks from ${sessionLabel(plan.sessionID)} at this point; nothing is deleted. ${UNDO_KEY} undoes this.` + if (props.options.jumpSummary === "never" || tail.messages.length === 0) return confirm(title, note).then((ok) => (ok ? { kind: "none" } : undefined)) + return new Promise((resolve) => { - api.ui.dialog.replace( - () => - api.ui.DialogSelect({ - title: "Summarize the branch you are leaving?", - options: [ - { title: "No summary", value: "none", description: "just move" }, - { title: "Summarize", value: "summarize", description: "Pi-style Goal / Progress / Decisions / Next steps" }, - { title: "Summarize with custom prompt", value: "custom" }, - ], - onSelect: (o) => { - if (o.value === "custom") { - api.ui.dialog.replace( - () => - api.ui.DialogPrompt({ - title: "Custom summarization instructions", - placeholder: "focus on…", - onConfirm: (value) => { - resolve({ kind: "summarize", customInstructions: value || undefined }) - api.ui.dialog.clear() - }, - onCancel: () => { - resolve({ kind: "none" }) - api.ui.dialog.clear() - }, - }), - () => resolve({ kind: "none" }), - ) - return - } - resolve(o.value === "summarize" ? { kind: "summarize" } : { kind: "none" }) - api.ui.dialog.clear() - }, - }), - () => resolve({ kind: "none" }), - ) + let done = false + // a `replace` closes the dialog under it, and that close fires the handler we passed for + // `esc`: the token makes every superseded handler a no-op, so only a real `esc` acts + let gen = 0 + const settle = (value: SummaryChoice | undefined) => { + if (done) return + done = true + api.ui.dialog.clear() + resolve(value) + } + const openCustom = () => { + const mine = ++gen + const back = () => { + if (done || mine !== gen) return + openChoices() + } + api.ui.dialog.replace( + () => + api.ui.DialogPrompt({ + title: "Custom summarization instructions", + placeholder: "focus on…", + onConfirm: (value) => settle({ kind: "summarize", customInstructions: value.trim() || undefined }), + onCancel: back, + }), + back, + ) + } + const openChoices = () => { + const mine = ++gen + const cancel = () => { + if (done || mine !== gen) return + settle(undefined) + } + api.ui.dialog.replace( + () => + api.ui.DialogSelect({ + title, + options: jumpDialogOptions(tail, plan.kind), + onSelect: (o) => { + if (o.value === "custom") { + openCustom() + return + } + settle(o.value === "summarize" ? { kind: "summarize" } : { kind: "none" }) + }, + }), + cancel, + ) + } + openChoices() }) } @@ -927,20 +963,32 @@ export function TreeRoute(props: TreeRouteProps) { if (!row || !sessionID) return const plan = planJump(row, { transcripts: transcripts(), currentSessionID: sessionID }) debug("route.jump", { row: { kind: row.kind, id: row.id }, plan }) + if (plan.kind === "noop") { + notify(plan.reason) + return + } + // what the model would stop seeing: Pi's "entries from the old leaf to the common ancestor" + const tail = abandonedTail({ state: state(), transcripts: transcripts(), currentSessionID: sessionID, plan }) + const title = jumpDialogTitle(plan, sessionLabel) + const choice = await askJump(plan, tail, title) + if (!choice) return await guarded("jump", async () => { - if (plan.kind === "noop") { - notify(plan.reason) - return + const controller = new AbortController() + const summarizing = choice.kind === "summarize" + if (summarizing) { + setSummaryAbort(controller) + notify(`summarizing ${describeTail(tail)} below this point — esc to skip`, 120_000) + } + try { + const out = await executeJump(ctx, plan, { currentSessionID: sessionID!, summary: choice, abandoned: tail.messages, signal: controller.signal }) + if (out.aborted) { + notify("summary cancelled — nothing moved") + return + } + if (out.target) api.ui.toast({ message: `moved to ${sessionLabel(out.target)} · ${UNDO_KEY} undoes it` }) + } finally { + setSummaryAbort(undefined) } - const from = sessionLabel(plan.sessionID) - const ok = await confirm( - plan.kind === "switch" ? `Switch to ${from}?` : plan.mode === "redo" ? "Fork & prefill this turn?" : "Fork after this step?", - plan.kind === "switch" ? `The session you are on now stays exactly as it is. ${UNDO_KEY} undoes this.` : `A new OpenCode session forks from ${from} at this point; nothing is deleted. ${UNDO_KEY} undoes this.`, - ) - if (!ok) return - const summary = await askSummary() - const target = await executeJump(ctx, plan, { currentSessionID: sessionID, summary }) - if (target) api.ui.toast({ message: `moved to ${sessionLabel(target)} · ${UNDO_KEY} undoes it` }) }) } @@ -1264,6 +1312,13 @@ export function TreeRoute(props: TreeRouteProps) { name: "ctree.back", hidden: true, run: () => { + const draft = summaryAbort() + if (draft) { + draft.abort() + setSummaryAbort(undefined) + notify("cancelling the branch summary…") + return + } if (panel() !== "tree") { setPanel("tree") return diff --git a/test/abandoned.test.ts b/test/abandoned.test.ts new file mode 100644 index 0000000..dc63f33 --- /dev/null +++ b/test/abandoned.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test" +import { abandonedTail, planJump } from "../src/core/actions.js" +import { buildTreeView, type Row } from "../src/core/tree.js" +import { describeTail, jumpDialogOptions, jumpDialogTitle } from "../src/tui/actions.js" +import { buildFixture, OPEN, SQUASHED, TRUNK } from "./fixtures/tree.js" + +const f = buildFixture() +const view = (currentSessionID: string) => + buildTreeView({ state: f.state, transcripts: f.transcripts, currentSessionID, expanded: new Set([TRUNK, OPEN, SQUASHED]), filter: "all" }) +const row = (currentSessionID: string, pick: (r: Row) => boolean) => view(currentSessionID).rows.find(pick)! + +const ids = (currentSessionID: string, r: Row) => + abandonedTail({ state: f.state, transcripts: f.transcripts, currentSessionID, plan: planJump(r, { transcripts: f.transcripts, currentSessionID }) }).messages.map((m) => m.id) + +describe("abandonedTail — Pi's 'everything below that point'", () => { + test("redoing a trunk turn abandons that turn and everything after it", () => { + const m2 = row(TRUNK, (r) => r.kind === "turn" && r.messageID === "m2") + expect(ids(TRUNK, m2)).toEqual(["m2", "a2", "m3", "a3"]) + }) + + test("continuing from a step abandons everything after that step's message", () => { + const step = row(TRUNK, (r) => r.kind === "step" && r.messageID === "a1") + expect(ids(TRUNK, step)).toEqual(["m2", "a2", "m3", "a3"]) + }) + + test("switching from a branch to a sibling abandons only the branch's own turns", () => { + const sib = row(OPEN, (r) => r.kind === "branch" && r.sessionID === SQUASHED) + expect(ids(OPEN, sib)).toEqual(["om1", "oa1", "om2", "oa2"]) + }) + + test("switching from the trunk into a branch abandons the trunk turns past the fork point", () => { + const branch = row(TRUNK, (r) => r.kind === "branch" && r.sessionID === OPEN) + expect(ids(TRUNK, branch)).toEqual(["m3", "a3"]) + }) + + test("forking an ancestor from inside a branch abandons the whole branch path below it", () => { + const m1 = row(OPEN, (r) => r.kind === "turn" && r.sessionID === TRUNK && r.messageID === "m1") + expect(ids(OPEN, m1)).toEqual(["o-m1", "o-a1", "o-m2", "o-a2", "om1", "oa1", "om2", "oa2"]) + }) + + test("a noop plan abandons nothing", () => { + expect(abandonedTail({ state: f.state, transcripts: f.transcripts, currentSessionID: TRUNK, plan: { kind: "noop", reason: "already here" } })).toEqual({ messages: [], turns: 0, tokens: 0 }) + }) + + test("turns and tokens describe the tail", () => { + const m2 = row(TRUNK, (r) => r.kind === "turn" && r.messageID === "m2") + const tail = abandonedTail({ state: f.state, transcripts: f.transcripts, currentSessionID: TRUNK, plan: planJump(m2, { transcripts: f.transcripts, currentSessionID: TRUNK }) }) + expect(tail.turns).toBe(2) + expect(tail.tokens).toBeGreaterThan(0) + }) +}) + +describe("the ⏎ dialog (Pi's tree-selector question)", () => { + const label = (id: string) => (id === OPEN ? "⎇ fix-flaky-test" : id) + + test("the title says what ⏎ will do, so the choice is also the confirmation", () => { + expect(jumpDialogTitle({ kind: "switch", sessionID: OPEN }, label)).toBe("Switch to ⎇ fix-flaky-test?") + expect(jumpDialogTitle({ kind: "fork", sessionID: TRUNK, messageID: "m2", mode: "redo" }, label)).toBe("Fork & prefill this turn?") + expect(jumpDialogTitle({ kind: "fork", sessionID: TRUNK, messageID: "a2", mode: "continue" }, label)).toBe("Fork after this step?") + expect(jumpDialogTitle({ kind: "noop", reason: "already here" }, label)).toBe("Nothing to go to") + }) + + test("three answers in Pi's order, 'No summary' first", () => { + const opts = jumpDialogOptions({ messages: [], turns: 3, tokens: 14_200 }, "fork") + expect(opts.map((o) => o.value)).toEqual(["none", "summarize", "custom"]) + expect(opts[0]!.description).toBe("start clean · nothing carried over") + expect(opts[1]!.description).toBe("carry the 3 turns · ~14.2k over as one ≣ summary") + expect(opts[1]!.title).toBe("Summarize everything below this point") + }) + + test("a switch names the path it leaves, not a point in it", () => { + expect(jumpDialogOptions({ messages: [], turns: 2, tokens: 800 }, "switch")[1]!.title).toBe("Summarize what you are leaving") + }) + + test("a tail with no user turn is counted in messages", () => { + expect(describeTail({ messages: [{} as never, {} as never], turns: 0, tokens: 900 })).toBe("2 messages · ~900") + expect(describeTail({ messages: [{} as never], turns: 1, tokens: 1_000 })).toBe("1 turn · ~1k") + }) +}) diff --git a/test/e2e/tui.test.ts b/test/e2e/tui.test.ts index 11dd246..ce148a7 100644 --- a/test/e2e/tui.test.ts +++ b/test/e2e/tui.test.ts @@ -126,6 +126,61 @@ describe.skipIf(!e2e)("tui e2e: built plugin", () => { } }, 320_000) + test("⏎ on an earlier turn offers Pi's three fork choices; summarize lands a ≣ summary in the fork", 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 text = await runTui({ + 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"], + // the first user turn, three turns above the tip: ⏎ there asks Pi's question + ["Context tree ·", 2, "gg"], + ["Context tree ·", 3, "\r"], + // esc on the choices goes back to the row with nothing done (Pi's showTreeSelector) + ["Fork & prefill this turn", 1.5, "\x1b"], + ["Context tree ·", 3, "\r"], + // ↓ once = "Summarize everything below this point" + ["Fork & prefill this turn", 1.5, "\x1b[B"], + ["Summarize everything below", 1.5, "\r"], + // the fork opens with the turn pre-filled: send it, so the model request that + // follows is the proof the injected summary is really in the fork's context + ["mock reply|Ask anything", 16, "\r"], + ["mock reply|Ask anything", 16, "\x03"], + ["", 1, "\x03"], + ], + timeoutSec: 240, + cols: 130, + rows: 34, + exitWhenDone: true, + }) + // all three Pi answers, in Pi's order, from the one dialog ⏎ opens + expect(text).toContain("Fork & prefill this turn") + expect(text).toContain("No summary") + expect(text).toContain("Summarize everything below this point") + expect(text).toContain("Summarize with a custom prompt") + + const dir = path.join(proj.dir, ".opencode", "context-tree") + const lines = readFileSync(path.join(dir, readdirSync(dir).find((f) => f.endsWith(".jsonl"))!), "utf8") + // the escape round changed nothing: exactly one fork, from the one ⏎ we went through with + expect(lines.split('"type":"branch.opened"').length - 1).toBe(1) + expect(lines).toContain('"kind":"redo"') + expect(lines).toContain('"type":"summary.recorded"') + // the summary was drafted from the abandoned turns, and the fork's next model request + // carries it: injected with noReply, it only reaches the provider on the following turn + const users = m.requests().map((r) => (r.body.messages as { role: string; content: unknown }[]).filter((x) => x.role === "user").map((x) => String(x.content))) + expect(users.some((u) => u.some((c) => c.includes("Create a structured summary of this conversation branch")))).toBe(true) + expect(users.some((u) => u.some((c) => c.startsWith("The user explored a different conversation branch")))).toBe(true) + } 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,