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
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
79 changes: 56 additions & 23 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`

Expand Down Expand Up @@ -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**, `<worktree>/.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. |

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 21 additions & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
Expand Down
64 changes: 62 additions & 2 deletions src/core/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -52,3 +53,62 @@ export function planJump(row: Row, ctx: { transcripts: Record<string, Transcript
if (!next) return { kind: "switch", sessionID: row.sessionID }
return { kind: "fork", sessionID: row.sessionID, messageID: next.id, mode: "continue" }
}

/** What a jump leaves behind: the current session's messages below the point being
* jumped to — Pi's "entries from the old leaf back to the common ancestor", which is
* exactly what its branch summary covers (DESIGN.md §6.2). */
export type AbandonedTail = {
/** Oldest first. Empty when the jump abandons nothing (a switch onto your own path). */
messages: TranscriptMessage[]
/** User turns among them — how the fork dialog counts what you are leaving. */
turns: number
/** Rough token weight of the tail; always an estimate. */
tokens: number
}

const EMPTY_TAIL: AbandonedTail = { messages: [], turns: 0, tokens: 0 }

/**
* The part of the current session that a jump plan drops out of the context path.
*
* Both sides are reduced to their **spine** — the ordered `sessionID:messageID` path from
* the root, where an ancestor's copied prefix keeps the ancestor's own IDs (`core/tree.ts`
* `spineOf`). The deepest entry present in both is the common ancestor; everything after it
* in the current session's transcript is abandoned. A `fork` plan cuts the target spine
* *before* its fork boundary, because `session.fork` copies messages strictly before it.
*
* Examples, against the trunk `m1 a1 m2 a2 m3 a3`:
* - redo `m2` from the trunk → `m2 a2 m3 a3` (everything below the selected row);
* - switch from an open branch to a sibling forked at the same anchor → the branch's own turns;
* - switch to a branch of the session you are on → the trunk turns past the fork point.
*/
export function abandonedTail(o: {
state: TreeState
transcripts: Record<string, Transcript>
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) }
}
9 changes: 7 additions & 2 deletions src/core/decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading