opencode-context-tree 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,447 @@
1
+ /**
2
+ * TUI-side actions (DESIGN.md §6.2, §6.3, §4.2). Everything here talks to OpenCode
3
+ * through `api.client` (SDK v2) and writes journal lines through the shared store.
4
+ * Pure planning lives in core; this file only executes plans.
5
+ */
6
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
7
+ import { createSignal } from "solid-js"
8
+ import type { JournalStore } from "../shared/store.js"
9
+ import type { CropAppliedData, JournalEntry } from "../core/journal.js"
10
+ import type { UndoPlan } from "../core/undo.js"
11
+ import { DECISION_SYSTEM, branchTranscriptText, buildDecisionDraftPrompt, decisionMessageText, decisionTemplate, openSiblings } from "../core/decision.js"
12
+ import { editInExternalEditor, hasEditor } from "./editor.js"
13
+ import { debug } from "../shared/debug.js"
14
+ import { fetchTranscript } from "./transcripts.js"
15
+
16
+ export type JumpPlan =
17
+ | { kind: "noop"; reason: string }
18
+ | { kind: "switch"; sessionID: string }
19
+ | { kind: "fork"; sessionID: string; messageID: string; prefill?: string; mode: "redo" | "continue" }
20
+
21
+ export type ActionContext = {
22
+ api: TuiPluginApi
23
+ store: JournalStore
24
+ directory: string
25
+ }
26
+
27
+ export type SummaryChoice = { kind: "none" } | { kind: "summarize"; customInstructions?: string }
28
+
29
+ const [revision, setRevision] = createSignal(0)
30
+ /** The journal is plain files, so views built from `store.stateFor*` subscribe to this
31
+ * counter to notice writes made by this TUI (the sidebar card, the route). */
32
+ export const journalRevision = revision
33
+ export function bumpJournal(): void {
34
+ setRevision((n) => n + 1)
35
+ }
36
+
37
+ /** Every journal write from the TUI goes through here, so none can forget the bump. */
38
+ function record<T extends JournalEntry["type"]>(ctx: ActionContext, treeId: string, type: T, data: Extract<JournalEntry, { type: T }>["data"]): JournalEntry {
39
+ // generic forwarding trips TS's intersection of all data shapes, as in JournalStore.record
40
+ const entry = ctx.store.record(treeId, type, data as never, "tui")
41
+ bumpJournal()
42
+ return entry
43
+ }
44
+
45
+ const SUMMARY_SYSTEM =
46
+ "You are a context summarization assistant. Read a conversation between a user and an AI coding assistant and produce a structured summary in the exact format requested. Do NOT continue the conversation; ONLY output the summary."
47
+
48
+ const SUMMARY_INSTRUCTIONS = `Create a structured summary of this conversation branch for context when returning later.
49
+
50
+ Use this EXACT format:
51
+
52
+ ## Goal
53
+ [What was the user trying to accomplish in this branch?]
54
+
55
+ ## Constraints & Preferences
56
+ - [Any constraints, preferences, or requirements mentioned, or "(none)"]
57
+
58
+ ## Progress
59
+ ### Done
60
+ - [x] [Completed tasks/changes]
61
+ ### In Progress
62
+ - [ ] [Work started but not finished]
63
+ ### Blocked
64
+ - [Issues preventing progress, if any]
65
+
66
+ ## Key Decisions
67
+ - **[Decision]**: [Brief rationale]
68
+
69
+ ## Next Steps
70
+ 1. [What should happen next]
71
+
72
+ Keep each section concise. Preserve exact file paths, function names, and error messages.`
73
+
74
+ export const SUMMARY_PREAMBLE = "The user explored a different conversation branch before returning here.\nSummary of that exploration:\n\n"
75
+
76
+ /** Mirror the tree linkage into `session.metadata.ctree` (DESIGN.md §4.2). Best effort. */
77
+ export async function mirrorMetadata(
78
+ ctx: ActionContext,
79
+ sessionID: string,
80
+ ctree: Record<string, unknown>,
81
+ ): Promise<void> {
82
+ const existing = await ctx.api.client.session.get({ sessionID, directory: ctx.directory }).catch(() => undefined)
83
+ const metadata = { ...((existing?.data as any)?.metadata ?? {}), ctree: { ...(((existing?.data as any)?.metadata ?? {}).ctree ?? {}), ...ctree } }
84
+ await ctx.api.client.session.update({ sessionID, directory: ctx.directory, metadata }).catch(() => undefined)
85
+ }
86
+
87
+ export function navigateToSession(ctx: ActionContext, sessionID: string): void {
88
+ ctx.api.route.navigate("session", { sessionID })
89
+ }
90
+
91
+ /** Abort a streaming session before we leave it (DESIGN.md §9, Pi #7022). */
92
+ async function abortIfBusy(ctx: ActionContext, sessionID: string): Promise<boolean> {
93
+ const status = ctx.api.state.session.status(sessionID)
94
+ if (!status || (status as any).type === "idle") return false
95
+ await ctx.api.client.session.abort({ sessionID, directory: ctx.directory }).catch(() => undefined)
96
+ ctx.api.ui.toast({ variant: "warning", message: "Aborted the running response before switching" })
97
+ return true
98
+ }
99
+
100
+ /** Fork `sessionID` at `messageID` (exclusive, OpenCode semantics), journal it, mirror metadata. */
101
+ export async function forkBranch(
102
+ ctx: ActionContext,
103
+ input: { sessionID: string; messageID: string; name?: string; kind: "explicit" | "jump" | "redo"; branchModel?: string; trunkModel?: string; title?: string },
104
+ ): Promise<string> {
105
+ const treeId = ctx.store.ensureTree(input.sessionID, "tui")
106
+ // journal anchors are the last *shared* message (inclusive); OpenCode's fork boundary is exclusive.
107
+ // The owning session may not be loaded in this TUI (jumping into an ancestor), so read it via the SDK.
108
+ const parentMsgs = (await fetchTranscript(ctx.api, input.sessionID, ctx.directory)).messages
109
+ const boundary = parentMsgs.findIndex((m) => m.id === input.messageID)
110
+ if (boundary === -1) throw new Error("fork point not found in the session")
111
+ const anchorMessageID = boundary > 0 ? parentMsgs[boundary - 1]!.id : ""
112
+ const forked = await ctx.api.client.session.fork({ sessionID: input.sessionID, messageID: input.messageID, directory: ctx.directory })
113
+ const forkedID = (forked.data as any)?.id as string | undefined
114
+ if (!forkedID) throw new Error("fork did not return a session id")
115
+ ctx.store.registerSession(forkedID, treeId)
116
+ record(ctx, treeId, "branch.opened", { sessionID: forkedID, parentSessionID: input.sessionID, anchorMessageID, name: input.name, kind: input.kind, branchModel: input.branchModel, trunkModel: input.trunkModel })
117
+ if (input.title) await ctx.api.client.session.update({ sessionID: forkedID, directory: ctx.directory, title: input.title }).catch(() => undefined)
118
+ await mirrorMetadata(ctx, forkedID, { treeId, parentSessionID: input.sessionID, anchorMessageID, name: input.name, status: "open" })
119
+ await mirrorMetadata(ctx, input.sessionID, { treeId })
120
+ return forkedID
121
+ }
122
+
123
+ /** `/branch <name> [model]` from the tip of the current session (DESIGN.md §6.3). */
124
+ export async function createNamedBranch(
125
+ ctx: ActionContext,
126
+ input: { sessionID: string; name: string; model?: string; trunkModel?: string },
127
+ ): Promise<string> {
128
+ const msgs = ctx.api.state.session.messages(input.sessionID)
129
+ const last = msgs[msgs.length - 1]
130
+ debug("branch.start", { sessionID: input.sessionID, name: input.name, messages: msgs.length, status: ctx.api.state.session.status(input.sessionID) })
131
+ if (!last) throw new Error("nothing to branch from yet")
132
+ await abortIfBusy(ctx, input.sessionID)
133
+ debug("branch.afterAbort")
134
+ // Fork "after the tip": OpenCode copies messages strictly before messageID, so we pass a
135
+ // sentinel by forking without messageID (full copy) — the SDK accepts messageID undefined.
136
+ const treeId = ctx.store.ensureTree(input.sessionID, "tui")
137
+ debug("branch.tree", { treeId })
138
+ const forked = await ctx.api.client.session.fork({ sessionID: input.sessionID, directory: ctx.directory })
139
+ debug("branch.forked", { data: forked.data, error: forked.error })
140
+ const forkedID = (forked.data as any)?.id as string | undefined
141
+ if (!forkedID) throw new Error("fork did not return a session id")
142
+ ctx.store.registerSession(forkedID, treeId)
143
+ record(ctx, treeId, "branch.opened", { sessionID: forkedID, parentSessionID: input.sessionID, anchorMessageID: last.id, name: input.name, kind: "explicit", branchModel: input.model, trunkModel: input.trunkModel })
144
+ debug("branch.recorded")
145
+ record(ctx, treeId, "label.set", { sessionID: input.sessionID, messageID: last.id, label: `⎇ ${input.name}` })
146
+ await ctx.api.client.session.update({ sessionID: forkedID, directory: ctx.directory, title: `⎇ ${input.name}` }).catch(() => undefined)
147
+ await mirrorMetadata(ctx, forkedID, { treeId, parentSessionID: input.sessionID, anchorMessageID: last.id, name: input.name, status: "open" })
148
+ await mirrorMetadata(ctx, input.sessionID, { treeId })
149
+ debug("branch.mirrored")
150
+ navigateToSession(ctx, forkedID)
151
+ ctx.api.ui.toast({ variant: "success", message: `⎇ ${input.name} opened${input.model ? ` on ${input.model}` : ""}` })
152
+ debug("branch.done", { forkedID })
153
+ return forkedID
154
+ }
155
+
156
+ /** Execute a jump plan (DESIGN.md §6.2). Returns the session we ended up in. */
157
+ export async function executeJump(
158
+ ctx: ActionContext,
159
+ plan: JumpPlan,
160
+ opts: { currentSessionID: string; summary: SummaryChoice },
161
+ ): Promise<string | undefined> {
162
+ debug("jump.plan", { plan, current: opts.currentSessionID, summary: opts.summary.kind })
163
+ if (plan.kind === "noop") {
164
+ ctx.api.ui.toast({ message: plan.reason })
165
+ return undefined
166
+ }
167
+ await abortIfBusy(ctx, opts.currentSessionID)
168
+ const leavingTip = ctx.api.state.session.messages(opts.currentSessionID).at(-1)?.id
169
+ let target: string
170
+ if (plan.kind === "switch") {
171
+ target = plan.sessionID
172
+ } else {
173
+ target = await forkBranch(ctx, { sessionID: plan.sessionID, messageID: plan.messageID, kind: plan.mode === "redo" ? "redo" : "jump" })
174
+ }
175
+ if (opts.summary.kind === "summarize" && leavingTip && target !== opts.currentSessionID) {
176
+ // the fork already exists; a failed summary must not strand the user on the old session
177
+ await summarizeInto(ctx, { fromSessionID: opts.currentSessionID, fromMessageID: leavingTip, targetSessionID: target, customInstructions: opts.summary.customInstructions }).catch((e) =>
178
+ ctx.api.ui.toast({ variant: "error", message: `summary failed: ${e instanceof Error ? e.message : String(e)} — moved without it` }),
179
+ )
180
+ }
181
+ debug("jump.navigate", { target })
182
+ navigateToSession(ctx, target)
183
+ if (plan.kind === "fork" && plan.prefill) {
184
+ await new Promise((r) => setTimeout(r, 0))
185
+ await ctx.api.client.tui.appendPrompt({ text: plan.prefill, directory: ctx.directory }).catch(() => undefined)
186
+ }
187
+ return target
188
+ }
189
+
190
+ /** Pi-style summary of the branch we are leaving, generated in a throw-away helper session and
191
+ * injected into the destination with `noReply` (DESIGN.md §6.2, journal `summary.recorded`). */
192
+ export async function summarizeInto(
193
+ ctx: ActionContext,
194
+ input: { fromSessionID: string; fromMessageID: string; targetSessionID: string; customInstructions?: string; signal?: AbortSignal },
195
+ ): Promise<string | undefined> {
196
+ const msgs = await ctx.api.client.session.messages({ sessionID: input.fromSessionID, directory: ctx.directory })
197
+ const transcript = ((msgs.data as any[]) ?? [])
198
+ .map((m) => {
199
+ const role = m.info.role === "user" ? "[User]" : "[Assistant]"
200
+ const text = (m.parts as any[])
201
+ .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)})` : ""))
202
+ .filter(Boolean)
203
+ .join("\n")
204
+ return text ? `${role}: ${text}` : ""
205
+ })
206
+ .filter(Boolean)
207
+ .join("\n\n")
208
+ debug("summary.start", { from: input.fromSessionID, target: input.targetSessionID, chars: transcript.length })
209
+ const helper = await ctx.api.client.session.create({ directory: ctx.directory, title: "Context tree: branch summary" })
210
+ const helperID = (helper.data as any)?.id as string | undefined
211
+ if (!helperID) throw new Error("could not create helper session")
212
+ try {
213
+ const instructions = input.customInstructions ? `${SUMMARY_INSTRUCTIONS}\n\nAdditional focus from the user:\n${input.customInstructions}` : SUMMARY_INSTRUCTIONS
214
+ const reply = await ctx.api.client.session.prompt({
215
+ sessionID: helperID,
216
+ directory: ctx.directory,
217
+ system: SUMMARY_SYSTEM,
218
+ parts: [{ type: "text", text: `<conversation>\n${transcript}\n</conversation>\n\n${instructions}` }],
219
+ })
220
+ const summary = ((reply.data as any)?.parts as any[] | undefined)
221
+ ?.filter((p) => p.type === "text" && !p.synthetic && !p.ignored)
222
+ .map((p) => p.text)
223
+ .join("")
224
+ .trim()
225
+ debug("summary.generated", { chars: summary?.length ?? 0 })
226
+ if (!summary) throw new Error("summary model returned no text")
227
+ const injected = await ctx.api.client.session.prompt({
228
+ sessionID: input.targetSessionID,
229
+ directory: ctx.directory,
230
+ noReply: true,
231
+ parts: [{ type: "text", text: SUMMARY_PREAMBLE + summary, metadata: { ctree: { kind: "summary", fromSessionID: input.fromSessionID } } }],
232
+ })
233
+ const messageID = (injected.data as any)?.info?.id ?? (injected.data as any)?.id
234
+ const treeId = ctx.store.ensureTree(input.targetSessionID, "tui")
235
+ record(ctx, treeId, "summary.recorded", { sessionID: input.targetSessionID, messageID: String(messageID ?? ""), fromSessionID: input.fromSessionID, fromMessageID: input.fromMessageID })
236
+ ctx.api.ui.toast({ variant: "success", message: "Branch summary added" })
237
+ return summary
238
+ } finally {
239
+ await ctx.api.client.session.delete({ sessionID: helperID, directory: ctx.directory }).catch(() => undefined)
240
+ }
241
+ }
242
+
243
+ export function setLabel(ctx: ActionContext, input: { sessionID: string; messageID: string; label: string | null }): void {
244
+ const treeId = ctx.store.ensureTree(input.sessionID, "tui")
245
+ record(ctx, treeId, "label.set", { sessionID: input.sessionID, messageID: input.messageID, label: input.label })
246
+ }
247
+
248
+ /** Record a crop (the server half applies it on the next turn). With `hard`, result crops
249
+ * additionally set OpenCode's own `state.time.compacted` flag on the tool part, so the
250
+ * TUI renders "[Old tool result content cleared]" and hides the text (DESIGN.md §6.5). */
251
+ export async function applyCrop(ctx: ActionContext, data: CropAppliedData, opts: { hard?: boolean } = {}): Promise<string> {
252
+ const treeId = ctx.store.ensureTree(data.sessionID, "tui")
253
+ const entry = record(ctx, treeId, "crop.applied", data)
254
+ if (opts.hard && data.mode === "result") await setCompacted(ctx, data.sessionID, data.targets.map((t) => ({ messageID: t.messageID, partID: t.partID })), Date.now())
255
+ return entry.id
256
+ }
257
+
258
+ async function setCompacted(ctx: ActionContext, sessionID: string, targets: { messageID: string; partID?: string }[], value: number | undefined): Promise<void> {
259
+ for (const t of targets) {
260
+ if (!t.partID) continue
261
+ const part = (ctx.api.state.part(t.messageID) as unknown as any[]).find((p) => p.id === t.partID)
262
+ if (!part || part.type !== "tool" || part.state?.status !== "completed") continue
263
+ const next = { ...part, state: { ...part.state, time: { ...(part.state.time ?? {}), compacted: value } } }
264
+ if (value === undefined) delete next.state.time.compacted
265
+ await ctx.api.client.part.update({ sessionID, messageID: t.messageID, partID: t.partID, directory: ctx.directory, part: next } as any).catch((e: unknown) => debug("hardcrop.error", { error: String(e) }))
266
+ }
267
+ }
268
+
269
+ /** Execute an undo plan (DESIGN.md §6.6). Returns the session to show afterwards. */
270
+ export async function executeUndo(ctx: ActionContext, sessionID: string, plan: UndoPlan): Promise<string | undefined> {
271
+ const treeId = ctx.store.ensureTree(sessionID, "tui")
272
+ debug("undo.plan", { plan })
273
+ switch (plan.kind) {
274
+ case "nothing":
275
+ ctx.api.ui.toast({ message: "nothing to undo on this path" })
276
+ return undefined
277
+ case "restore-crop": {
278
+ record(ctx, treeId, "crop.restored", { cropID: plan.cropID })
279
+ const crop = ctx.store.stateFor(treeId).crops[plan.cropID]
280
+ if (crop && crop.mode === "result") await setCompacted(ctx, sessionID, crop.targets.map((t) => ({ messageID: t.messageID, partID: t.partID })), undefined)
281
+ ctx.api.ui.toast({ variant: "success", message: `↶ restored ${plan.mode === "turn" ? "dropped turn" : "cropped result"} (~${Math.round(plan.estTokens / 100) / 10}k tokens back in context)` })
282
+ return undefined
283
+ }
284
+ case "abandon-branch": {
285
+ await abortIfBusy(ctx, sessionID)
286
+ record(ctx, treeId, "branch.closed", { sessionID: plan.sessionID, status: "abandoned" })
287
+ await mirrorMetadata(ctx, plan.sessionID, { status: "abandoned" })
288
+ navigateToSession(ctx, plan.parentSessionID)
289
+ ctx.api.ui.toast({ variant: "success", message: `↶ back on the trunk; ⎇ ${plan.name ?? "branch"} kept as abandoned` })
290
+ return plan.parentSessionID
291
+ }
292
+ case "reopen-branch": {
293
+ const branch = ctx.store.stateFor(treeId).sessions[plan.sessionID]
294
+ if (!branch) return undefined
295
+ record(ctx, treeId, "branch.opened", { sessionID: branch.sessionID, parentSessionID: branch.parentSessionID, anchorMessageID: branch.anchorMessageID, name: branch.name, kind: branch.kind, branchModel: branch.branchModel, trunkModel: branch.trunkModel })
296
+ await mirrorMetadata(ctx, plan.sessionID, { status: "open" })
297
+ navigateToSession(ctx, plan.sessionID)
298
+ ctx.api.ui.toast({ variant: "success", message: `↶ re-opened ⎇ ${branch.name ?? "branch"}${plan.decisionMessageID ? " (its decision record is hidden from the model)" : ""}` })
299
+ return plan.sessionID
300
+ }
301
+ }
302
+ }
303
+
304
+ /** Run one prompt in a throw-away helper session and return the assistant text. */
305
+ export async function draftWithHelper(ctx: ActionContext, input: { title: string; system: string; prompt: string; model?: { providerID: string; modelID: string } }): Promise<string> {
306
+ const helper = await ctx.api.client.session.create({ directory: ctx.directory, title: input.title })
307
+ const helperID = (helper.data as any)?.id as string | undefined
308
+ if (!helperID) throw new Error("could not create helper session")
309
+ try {
310
+ const reply = await ctx.api.client.session.prompt({ sessionID: helperID, directory: ctx.directory, system: input.system, model: input.model, parts: [{ type: "text", text: input.prompt }] })
311
+ const text = ((reply.data as any)?.parts as any[] | undefined)
312
+ ?.filter((p) => p.type === "text" && !p.synthetic && !p.ignored)
313
+ .map((p) => p.text)
314
+ .join("")
315
+ .trim()
316
+ if (!text) throw new Error("the model returned no text")
317
+ return text
318
+ } finally {
319
+ await ctx.api.client.session.delete({ sessionID: helperID, directory: ctx.directory }).catch(() => undefined)
320
+ }
321
+ }
322
+
323
+ export type MergeMode = "squash" | "squash-no-llm" | "discard" | "tournament"
324
+
325
+ /** The promise every merge confirmation repeats — the reason a merge is safe to try. */
326
+ export const MERGE_TRUST = "Your transcript is never rewritten; the record is appended to the trunk as a normal message."
327
+
328
+ /** What the $EDITOR gate opens with: the draft is a proposal, saving is the confirmation. */
329
+ export const MERGE_GATE_NOTICE = `Edit the ◆ decision record, then save to confirm (empty file or a non-zero exit aborts the merge).\n${MERGE_TRUST}`
330
+
331
+ /** Shared copy for the merge picker (palette and route). */
332
+ export function mergeDialogTitle(branchName: string, trunkTitle?: string): string {
333
+ return `Merge ⎇ ${branchName} → ${trunkTitle ? clip(trunkTitle, 28) : "the trunk"}`
334
+ }
335
+
336
+ /** Tournament only exists when there is something to compare against. Descriptions render on
337
+ * the option's own line, which truncates past ~50 columns — keep them short; the full promise
338
+ * is repeated at the confirmation step (`MERGE_TRUST`). */
339
+ export function mergeDialogOptions(input: { siblings: number }): { title: string; value: MergeMode; description: string }[] {
340
+ return [
341
+ { title: "Squash", value: "squash" as const, description: "drafts a ◆ decision record you confirm" },
342
+ { title: "Squash without LLM", value: "squash-no-llm" as const, description: "you write the record yourself" },
343
+ { title: "Discard", value: "discard" as const, description: "rejected; nothing lands in the trunk" },
344
+ ...(input.siblings > 0 ? [{ title: "Tournament", value: "tournament" as const, description: "compare sibling branches and keep one" }] : []),
345
+ ]
346
+ }
347
+
348
+ export type MergeInput = {
349
+ sessionID: string
350
+ mode: MergeMode
351
+ note?: string
352
+ /** how to confirm the record: external editor (default) or a pre-confirmed text */
353
+ confirm?: (draft: string) => Promise<string | undefined>
354
+ }
355
+
356
+ /** Close the branch the session lives on (DESIGN.md §6.4). Returns the parent session id. */
357
+ export async function mergeBranch(ctx: ActionContext, input: MergeInput): Promise<string | undefined> {
358
+ const treeId = ctx.store.ensureTree(input.sessionID, "tui")
359
+ const state = ctx.store.stateFor(treeId)
360
+ const branch = state.sessions[input.sessionID]
361
+ if (!branch || branch.status !== "open") throw new Error("this session is not an open branch")
362
+ const parentID = branch.parentSessionID
363
+ const name = branch.name ?? "branch"
364
+ debug("merge.start", { mode: input.mode, sessionID: input.sessionID, parentID })
365
+ await abortIfBusy(ctx, input.sessionID)
366
+
367
+ if (input.mode === "discard") {
368
+ record(ctx, treeId, "branch.closed", { sessionID: input.sessionID, status: "rejected", note: input.note })
369
+ await mirrorMetadata(ctx, input.sessionID, { status: "rejected" })
370
+ navigateToSession(ctx, parentID)
371
+ ctx.api.ui.toast({ variant: "success", message: `⎇ ${name} discarded — back on the trunk` })
372
+ return parentID
373
+ }
374
+
375
+ // --- draft ---------------------------------------------------------------
376
+ const parentMsgs = await ctx.api.client.session.messages({ sessionID: parentID, directory: ctx.directory })
377
+ const parentMessageIDs = ((parentMsgs.data as any[]) ?? []).map((m) => String(m.info.id))
378
+ const own = await fetchOwnTranscript(ctx, input.sessionID)
379
+ const transcript = branchTranscriptText(own, { messageID: branch.anchorMessageID, parentMessageIDs })
380
+ const model = branch.branchModel ?? branch.trunkModel
381
+ const modelRef = model ? { providerID: model.split("/")[0]!, modelID: model.split("/").slice(1).join("/") } : undefined
382
+ const siblingIDs = input.mode === "tournament" ? openSiblings(state, input.sessionID) : []
383
+ const siblings = await Promise.all(
384
+ siblingIDs.map(async (id) => {
385
+ const tr = await fetchOwnTranscript(ctx, id)
386
+ const b = state.sessions[id]!
387
+ return { name: b.name ?? id, transcript: branchTranscriptText(tr, { messageID: b.anchorMessageID, parentMessageIDs }, 800) }
388
+ }),
389
+ )
390
+ let draft: string
391
+ if (input.mode === "squash-no-llm") {
392
+ draft = decisionTemplate(name, model)
393
+ } else {
394
+ ctx.api.ui.toast({ message: `drafting the decision record for ⎇ ${name}…` })
395
+ draft = await draftWithHelper(ctx, { title: `Context tree: draft for ${name}`, system: DECISION_SYSTEM, prompt: buildDecisionDraftPrompt({ branchName: name, model, transcript, siblings }), model: modelRef })
396
+ }
397
+ debug("merge.drafted", { chars: draft.length })
398
+
399
+ // --- gate ----------------------------------------------------------------
400
+ const confirm = input.confirm ?? ((d: string) => editInExternalEditor(ctx.api.renderer as any, d, ctx.directory, MERGE_GATE_NOTICE))
401
+ if (!input.confirm && !hasEditor()) throw new Error("no $EDITOR configured — set VISUAL/EDITOR, or use the in-app confirm")
402
+ const confirmed = await confirm(draft)
403
+ if (!confirmed) {
404
+ ctx.api.ui.toast({ variant: "warning", message: "merge aborted — nothing written" })
405
+ return undefined
406
+ }
407
+
408
+ // --- land ----------------------------------------------------------------
409
+ const text = decisionMessageText(confirmed, name)
410
+ const landed = await ctx.api.client.session.prompt({
411
+ sessionID: parentID,
412
+ directory: ctx.directory,
413
+ noReply: true,
414
+ parts: [{ type: "text", text, metadata: { ctree: { kind: "decision", forkSessionID: input.sessionID, branchName: name } } }],
415
+ })
416
+ const messageID = String((landed.data as any)?.info?.id ?? (landed.data as any)?.id ?? "")
417
+ if (!messageID) throw new Error("could not write the decision record into the trunk")
418
+ record(ctx, treeId, "decision.recorded", { sessionID: parentID, messageID, forkSessionID: input.sessionID, branchName: name, siblings: siblings.map((s) => ({ name: s.name })), text })
419
+ record(ctx, treeId, "branch.closed", { sessionID: input.sessionID, status: "squashed", decisionMessageID: messageID })
420
+ for (const id of siblingIDs) record(ctx, treeId, "branch.closed", { sessionID: id, status: "rejected", note: `lost tournament to ${name}` })
421
+ await mirrorMetadata(ctx, input.sessionID, { status: "squashed", decisionMessageID: messageID })
422
+ for (const id of siblingIDs) await mirrorMetadata(ctx, id, { status: "rejected" })
423
+ debug("merge.landed", { messageID, siblings: siblingIDs.length })
424
+ navigateToSession(ctx, parentID)
425
+ ctx.api.ui.toast({ variant: "success", message: `◆ merged ⎇ ${name}${siblingIDs.length ? ` (+${siblingIDs.length} sibling${siblingIDs.length === 1 ? "" : "s"} closed)` : ""}` })
426
+ return parentID
427
+ }
428
+
429
+ async function fetchOwnTranscript(ctx: ActionContext, sessionID: string) {
430
+ const res = await ctx.api.client.session.messages({ sessionID, directory: ctx.directory })
431
+ const messages = ((res.data as any[]) ?? []).map((m) => ({
432
+ id: m.info.id as string,
433
+ role: (m.info.role === "user" ? "user" : "assistant") as "user" | "assistant",
434
+ time: m.info.time,
435
+ tokens: m.info.tokens,
436
+ parts: (m.parts as any[]).map((p) => ({ id: p.id, type: p.type, text: p.text, tool: p.tool, callID: p.callID, state: p.state, time: p.time, metadata: p.metadata })),
437
+ }))
438
+ return { sessionID, title: sessionID, status: "available" as const, messages }
439
+ }
440
+
441
+ /** Shared copy for the branch-name dialog (palette and route). */
442
+ export const BRANCH_DIALOG = { title: "Branch here → new OpenCode session", placeholder: "name, e.g. try-redis", modelTitle: "Model for this branch (Enter keeps the current one)" }
443
+
444
+ /** Truncate to `max` columns with an ellipsis — the sidebar and dialogs are narrow. */
445
+ export function clip(text: string, max: number): string {
446
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text
447
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The merge editor gate (DESIGN.md §6.4): suspend the renderer, hand the draft to
3
+ * $VISUAL/$EDITOR on a temp file, resume. Mirrors OpenCode's own `openEditor()`.
4
+ * Returns `undefined` when no editor is configured or the user aborted (non-zero
5
+ * exit / empty file).
6
+ */
7
+ import { spawn } from "node:child_process"
8
+ import fs from "node:fs"
9
+ import os from "node:os"
10
+ import path from "node:path"
11
+
12
+ type RendererLike = { suspend(): void; resume(): void; requestRender?(): void; currentRenderBuffer?: { clear?(): void } }
13
+
14
+ export function hasEditor(): boolean {
15
+ return Boolean(process.env["VISUAL"] || process.env["EDITOR"])
16
+ }
17
+
18
+ export async function editInExternalEditor(renderer: RendererLike, value: string, cwd?: string, notice?: string): Promise<string | undefined> {
19
+ const editor = process.env["VISUAL"] || process.env["EDITOR"]
20
+ if (!editor) return undefined
21
+ // private dir + 0600: the draft can quote source, and /tmp is world-readable
22
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ctree-"))
23
+ const file = path.join(dir, "decision.md")
24
+ // the notice is a markdown comment, so it never lands in the trunk even if left in place
25
+ const header = notice ? `<!--\n${notice}\n-->\n\n` : ""
26
+ fs.writeFileSync(file, header + value, { mode: 0o600 })
27
+ renderer.suspend()
28
+ renderer.currentRenderBuffer?.clear?.()
29
+ try {
30
+ await new Promise<void>((resolve, reject) => {
31
+ const options = { cwd: cwd && fs.existsSync(cwd) ? cwd : process.cwd(), stdio: "inherit" as const }
32
+ // through the shell, like OpenCode's own openEditor(): $EDITOR may carry flags or quotes
33
+ const child = process.platform === "win32" ? spawn(editor, [file], { ...options, shell: true }) : spawn("sh", ["-c", `${editor} "$1"`, "sh", file], options)
34
+ child.on("error", reject)
35
+ child.on("exit", (code, signal) => (code === 0 ? resolve() : reject(new Error(`editor exited with ${signal ? `signal ${signal}` : `code ${code}`}`))))
36
+ })
37
+ const text = fs.readFileSync(file, "utf8").replace(/^<!--[\s\S]*?-->\s*/, "")
38
+ return text.trim() ? text : undefined
39
+ } finally {
40
+ fs.rmSync(dir, { recursive: true, force: true })
41
+ renderer.currentRenderBuffer?.clear?.()
42
+ renderer.resume()
43
+ renderer.requestRender?.()
44
+ }
45
+ }