opencode-context-tree 0.1.1 → 0.2.1

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.
package/src/core/tree.ts CHANGED
@@ -40,6 +40,9 @@ export type TurnRow = {
40
40
  isTip: boolean
41
41
  isDecision: boolean
42
42
  isSummary: boolean
43
+ /** false for rows the current session never sends: an ancestor's rows past the point where
44
+ * this path forked away, and every other branch's rows (DESIGN.md §7.1). */
45
+ inContext: boolean
43
46
  }
44
47
 
45
48
  export type StepRow = {
@@ -55,11 +58,16 @@ export type StepRow = {
55
58
  tokens: number
56
59
  estimated: boolean
57
60
  durationMs?: number
61
+ /** Time the model spent reasoning for the owning message, folded onto its first real step:
62
+ * outside the `all` filter thinking parts get no row of their own. */
63
+ thinkingMs?: number
58
64
  isError: boolean
59
65
  isCropped: boolean
60
66
  warn: boolean
61
67
  /** Label of the owning message, shown on its first step row only. */
62
68
  label?: string
69
+ /** See TurnRow.inContext. */
70
+ inContext: boolean
63
71
  }
64
72
 
65
73
  export type BranchRow = {
@@ -82,7 +90,19 @@ export type BranchRow = {
82
90
  last: boolean
83
91
  }
84
92
 
85
- export type Row = TurnRow | StepRow | BranchRow
93
+ /** Decoration drawn in an ancestor right after the point where the current path forked away:
94
+ * everything below it in that session is history the model is not shown. Never selectable. */
95
+ export type SeparatorRow = {
96
+ kind: "separator"
97
+ id: string
98
+ depth: number
99
+ gutter: string
100
+ text: string
101
+ }
102
+
103
+ export type Row = TurnRow | StepRow | BranchRow | SeparatorRow
104
+
105
+ const OFF_PATH_TEXT = "── not in this branch's context ──"
86
106
 
87
107
  export type TreeView = {
88
108
  rows: Row[]
@@ -293,7 +313,7 @@ function isCropped(ctx: Ctx, messageID: string, partID: string): boolean {
293
313
  return ctx.crops.some((c) => c.messageID === messageID && (c.partID === undefined || c.partID === partID))
294
314
  }
295
315
 
296
- function emitAssistantRows(ctx: Ctx, sessionID: string, message: TranscriptMessage, depth: number, gutter: string, out: Row[]): void {
316
+ function emitAssistantRows(ctx: Ctx, sessionID: string, message: TranscriptMessage, depth: number, gutter: string, inContext: boolean, out: Row[]): void {
297
317
  if (message.summary) {
298
318
  // OpenCode-native compaction summary: one row for the whole message (DESIGN.md §7).
299
319
  if (!stepAllowed(ctx.filter, "text")) return
@@ -319,18 +339,29 @@ function emitAssistantRows(ctx: Ctx, sessionID: string, message: TranscriptMessa
319
339
  isError: false,
320
340
  isCropped: false,
321
341
  warn: tokens >= WARN_TOKENS,
342
+ inContext,
322
343
  })
323
344
  return
324
345
  }
325
346
 
347
+ // Reasoning parts were 40–50% of the outline and say nothing: outside `all` they get no row
348
+ // and their duration rides the message's first real step instead (`· 9.8s thought`).
349
+ const collapseThinking = ctx.filter !== "all"
350
+ const rows: StepRow[] = []
351
+ let thinkingMs: number | undefined
326
352
  let first = true
327
353
  for (const part of message.parts) {
328
354
  const kind = stepKind(part)
355
+ if (collapseThinking && kind === "reasoning") {
356
+ const ms = durationOfPart(part)
357
+ if (ms !== undefined) thinkingMs = (thinkingMs ?? 0) + ms
358
+ continue
359
+ }
329
360
  const label = first ? ctx.labels[message.id] : undefined
330
361
  if (!stepAllowed(ctx.filter, kind, Boolean(label))) continue
331
362
  const { tokens, estimated } = stepTokensFor(part, message)
332
363
  first = false
333
- out.push({
364
+ rows.push({
334
365
  kind: "step",
335
366
  id: `${sessionID}:${message.id}:${part.id}`,
336
367
  sessionID,
@@ -347,8 +378,40 @@ function emitAssistantRows(ctx: Ctx, sessionID: string, message: TranscriptMessa
347
378
  isCropped: isCropped(ctx, message.id, part.id),
348
379
  warn: tokens >= WARN_TOKENS,
349
380
  label,
381
+ inContext,
350
382
  })
351
383
  }
384
+
385
+ if (rows.length > 0) {
386
+ if (thinkingMs !== undefined) rows[0]!.thinkingMs = thinkingMs
387
+ } else if (collapseThinking) {
388
+ // nothing but thinking: keep one row, standing for the whole message, or it goes invisible
389
+ const thinking = message.parts.filter((p) => stepKind(p) === "reasoning")
390
+ const label = ctx.labels[message.id]
391
+ if (thinking.length > 0 && stepAllowed(ctx.filter, "reasoning", Boolean(label))) {
392
+ const tokens = thinking.reduce((sum, p) => sum + stepTokensFor(p, message).tokens, 0)
393
+ rows.push({
394
+ kind: "step",
395
+ id: `${sessionID}:${message.id}:${thinking[0]!.id}`,
396
+ sessionID,
397
+ messageID: message.id,
398
+ partID: thinking[0]!.id,
399
+ depth,
400
+ gutter,
401
+ glyph: "○",
402
+ preview: partPreview(thinking[0]!),
403
+ tokens,
404
+ estimated: thinking.some((p) => stepTokensFor(p, message).estimated),
405
+ durationMs: thinkingMs,
406
+ isError: false,
407
+ isCropped: isCropped(ctx, message.id, thinking[0]!.id),
408
+ warn: tokens >= WARN_TOKENS,
409
+ label,
410
+ inContext,
411
+ })
412
+ }
413
+ }
414
+ out.push(...rows)
352
415
  }
353
416
 
354
417
  /** A branch is open in the outline when the user has toggled it: on-path branches start open
@@ -441,17 +504,34 @@ function emitChildBranches(ctx: Ctx, sessionID: string, anchorMessageID: string,
441
504
  })
442
505
  }
443
506
 
507
+ /** The message after which the current path leaves `sessionID` — its on-path child's anchor.
508
+ * undefined for the current session and for branches off the path (nothing forks away). */
509
+ function forkAnchorOf(ctx: Ctx, sessionID: string): string | undefined {
510
+ const childID = ctx.onPathChild.get(sessionID)
511
+ return childID ? ctx.state.sessions[childID]?.anchorMessageID : undefined
512
+ }
513
+
444
514
  /**
445
515
  * Depth-first over one session: emit its own `messages` (the tail after any copied prefix)
446
516
  * as rows at `depth`/`gutter`, and after each message recurse into the branches anchored on
447
517
  * it. `turnStart` seeds the turn counter (a branch continues its parent's numbering).
518
+ *
519
+ * Rows are marked `inContext` while they are part of what the current session sends: an
520
+ * ancestor's rows up to and including its fork point, and the current session's own rows.
521
+ * Where that stops, one separator row is drawn before the next row this session contributes.
448
522
  */
449
523
  function walkSession(ctx: Ctx, sessionID: string, messages: TranscriptMessage[], depth: number, gutter: string, turnStart: number, out: Row[]): void {
450
524
  const lastUserIndex = findLastUserIndex(messages, ctx.filter)
451
525
  const counter = { turn: turnStart }
452
526
  // set by a hidden plugin command turn, so the acknowledgement that follows it goes too
453
527
  let inPluginCommand = false
528
+ const forkAnchor = forkAnchorOf(ctx, sessionID)
529
+ let inContext = ctx.onPath.has(sessionID)
530
+ // anchor "" — the path forked before this session's first message, so none of it is sent
531
+ let separatorDue = inContext && forkAnchor === ""
532
+ if (separatorDue) inContext = false
454
533
  messages.forEach((message, i) => {
534
+ const before = out.length
455
535
  if (message.role === "user") {
456
536
  inPluginCommand = hiddenPluginTurn(ctx.filter, message)
457
537
  if (!inPluginCommand) {
@@ -478,15 +558,26 @@ function walkSession(ctx: Ctx, sessionID: string, messages: TranscriptMessage[],
478
558
  isTip: i === lastUserIndex,
479
559
  isDecision,
480
560
  isSummary,
561
+ inContext,
481
562
  })
482
563
  }
483
564
  }
484
565
  } else if (!inPluginCommand) {
485
- emitAssistantRows(ctx, sessionID, message, depth, gutter, out)
566
+ emitAssistantRows(ctx, sessionID, message, depth, gutter, inContext, out)
486
567
  }
487
568
  // Branches attach right after their anchor — the last message they share with
488
569
  // this session — whichever role that message has.
489
570
  emitChildBranches(ctx, sessionID, message.id, depth, gutter, out)
571
+
572
+ // drawn lazily, so a fork point with nothing after it never leaves a dangling separator
573
+ if (separatorDue && out.length > before) {
574
+ out.splice(before, 0, { kind: "separator", id: `separator:${sessionID}`, depth, gutter, text: OFF_PATH_TEXT })
575
+ separatorDue = false
576
+ }
577
+ if (inContext && message.id === forkAnchor) {
578
+ inContext = false
579
+ separatorDue = true
580
+ }
490
581
  })
491
582
  }
492
583
 
@@ -502,6 +593,8 @@ function rowSearchFields(row: Row): string[] {
502
593
  return row.label ? [row.preview, row.label] : [row.preview]
503
594
  case "branch":
504
595
  return row.model ? [row.name, row.model, row.status] : [row.name, row.status]
596
+ case "separator":
597
+ return [] // decoration: a flat search hit list has no fork point to divide
505
598
  }
506
599
  }
507
600
 
@@ -644,7 +737,7 @@ export function buildTreeView(o: BuildOptions): TreeView {
644
737
  let currentRowId: string | undefined
645
738
  for (let i = rows.length - 1; i >= 0; i--) {
646
739
  const r = rows[i]!
647
- if (r.kind !== "branch" && r.sessionID === o.currentSessionID) {
740
+ if ((r.kind === "turn" || r.kind === "step") && r.sessionID === o.currentSessionID) {
648
741
  currentRowId = r.id
649
742
  break
650
743
  }
@@ -16,6 +16,7 @@ import { CTREE_HELP, parseCtreeArgs } from "../core/ctree-args.js"
16
16
  import { autoMark, planResultCrop, resultCandidates, topCandidate, type CropRules, DEFAULT_RULES } from "../core/cropplan.js"
17
17
  import { planUndo } from "../core/undo.js"
18
18
  import { exportDecisions } from "../core/decision.js"
19
+ import { PLUGIN_VERSION } from "../shared/version.js"
19
20
  import type { Transcript, TranscriptMessage } from "../core/transcript.js"
20
21
  import { parseForkTitle } from "../core/adopt.js"
21
22
  import { adoptNativeForks } from "../shared/adopt.js"
@@ -97,7 +98,7 @@ export const server: Plugin = async ({ worktree, client, directory }, options) =
97
98
  const cmd = parseCtreeArgs(input.arguments)
98
99
  switch (cmd.kind) {
99
100
  case "help":
100
- return say(output, `${cmd.error ? `error: ${cmd.error}\n\n` : ""}${CTREE_HELP}`)
101
+ return say(output, `${cmd.error ? `error: ${cmd.error}\n\n` : ""}${CTREE_HELP}\n(opencode-context-tree ${PLUGIN_VERSION})`)
101
102
  case "status": {
102
103
  await adopt() // headless clients have no TUI half to do it for them
103
104
  const state = store.stateForSession(sessionID)
@@ -108,7 +109,7 @@ export const server: Plugin = async ({ worktree, client, directory }, options) =
108
109
  const branches = Object.values(state.sessions).filter((b) => b.parentSessionID === sessionID)
109
110
  // an adopted native fork carries no journal name: fall back to the session's own title
110
111
  const title = me && !me.name ? ((await client.session.get({ path: { id: sessionID }, query: { directory } }).catch(() => undefined))?.data as { title?: string } | undefined)?.title : undefined
111
- return say(output, [`tree ${state.treeId}`, me ? `this session is ⎇ ${me.name ?? title ?? "branch"} (${me.status}) of ${me.parentSessionID}${me.note ? ` — ${me.note}` : ""}` : "this session is the trunk", `${branches.length} branch(es) from here: ${branches.map((b) => `${b.name ?? b.sessionID} [${b.status}]`).join(", ") || "none"}`, `${crops.length} active crop(s), ~${hidden} tokens hidden`, `${Object.values(state.decisions).filter((d) => d.sessionID === sessionID && !d.hidden).length} decision record(s) here`].join("\n"))
112
+ return say(output, [`opencode-context-tree ${PLUGIN_VERSION} · tree ${state.treeId}`, me ? `this session is ⎇ ${me.name ?? title ?? "branch"} (${me.status}) of ${me.parentSessionID}${me.note ? ` — ${me.note}` : ""}` : "this session is the trunk", `${branches.length} branch(es) from here: ${branches.map((b) => `${b.name ?? b.sessionID} [${b.status}]`).join(", ") || "none"}`, `${crops.length} active crop(s), ~${hidden} tokens hidden`, `${Object.values(state.decisions).filter((d) => d.sessionID === sessionID && !d.hidden).length} decision record(s) here`].join("\n"))
112
113
  }
113
114
  case "branch": {
114
115
  const tr = await transcriptOf(sessionID)
@@ -0,0 +1,3 @@
1
+ /** Stamped by scripts/build.ts from package.json (the release tag); "dev" when run from source. */
2
+ declare const __CTREE_VERSION__: string | undefined
3
+ export const PLUGIN_VERSION: string = typeof __CTREE_VERSION__ === "string" ? __CTREE_VERSION__ : "dev"
@@ -5,9 +5,13 @@
5
5
  */
6
6
  import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
7
7
  import { createSignal } from "solid-js"
8
+ import fs from "node:fs"
9
+ import path from "node:path"
8
10
  import type { JournalStore } from "../shared/store.js"
9
11
  import type { CropAppliedData, JournalEntry } from "../core/journal.js"
10
12
  import type { UndoPlan } from "../core/undo.js"
13
+ import type { TranscriptMessage } from "../core/transcript.js"
14
+ import { contextSizeOf, formatK, type MinimalMessage } from "../core/tokens.js"
11
15
  import { DECISION_SYSTEM, branchTranscriptText, buildDecisionDraftPrompt, decisionMessageText, decisionTemplate, openSiblings } from "../core/decision.js"
12
16
  import { editInExternalEditor, hasEditor } from "./editor.js"
13
17
  import { debug } from "../shared/debug.js"
@@ -325,22 +329,55 @@ export type MergeMode = "squash" | "squash-no-llm" | "discard" | "tournament"
325
329
  /** The promise every merge confirmation repeats — the reason a merge is safe to try. */
326
330
  export const MERGE_TRUST = "Your transcript is never rewritten; the record is appended to the trunk as a normal message."
327
331
 
332
+ /** The tree route's undo key (`x` stays as an alias); every hint we print names this one. */
333
+ export const UNDO_KEY = "u"
334
+
328
335
  /** What the $EDITOR gate opens with: the draft is a proposal, saving is the confirmation. */
329
336
  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
337
 
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"}`
338
+ /** The discard gate's message: the same promise, plus the way back. */
339
+ export const DISCARD_NOTICE = `${MERGE_TRUST}\nThe branch is only marked rejected — ${UNDO_KEY} (alias x) undoes it.`
340
+
341
+ /** Where the merge lands, as the picker should name it. `label` is `TRUNK_LABEL` for the tree
342
+ * root, else the parent branch's name; the figures come from the parent's own transcript. */
343
+ export type MergeTarget = { label: string; turns: number; tokens: number }
344
+
345
+ export const TRUNK_LABEL = "trunk"
346
+
347
+ /** The picker's destination figures, computed the same way on every surface (the gauge's own
348
+ * context size and the user-turn count) so the numbers on one screen agree. */
349
+ export function mergeTargetOf(label: string, messages: readonly TranscriptMessage[]): MergeTarget {
350
+ const minimal = messages.map((m): MinimalMessage => ({ info: m.role === "assistant" ? { role: "assistant", tokens: m.tokens } : { role: "user" }, parts: m.parts }))
351
+ return { label, turns: messages.filter((m) => m.role === "user").length, tokens: contextSizeOf(minimal).tokens }
352
+ }
353
+
354
+ /** The branch's *own* user turns — the ones a squash folds into the record. Sliced like
355
+ * `branchTranscriptText`: everything past the anchor's index in the parent (an unknown anchor
356
+ * counts the whole session rather than throwing; this figure is only a label). */
357
+ export function ownTurnCount(messages: readonly { role: string }[], anchor: { messageID?: string; parentMessageIDs: readonly string[] }): number {
358
+ const anchorIndex = anchor.messageID ? anchor.parentMessageIDs.indexOf(anchor.messageID) : -1
359
+ return messages.slice(anchorIndex + 1).filter((m) => m.role === "user").length
360
+ }
361
+
362
+ /** Shared copy for the merge picker (palette and route). Naming the destination "trunk" rather
363
+ * than quoting the parent's title keeps the title from reading as a question ("→ What does git
364
+ * rebase do?"). */
365
+ export function mergeDialogTitle(branchName: string, target?: MergeTarget | string): string {
366
+ // a bare parent title is the old call shape, and quoting it is the bug: name the trunk instead
367
+ if (!target || typeof target === "string") return `Merge ⎇ ${branchName} → the trunk`
368
+ const where = target.label === TRUNK_LABEL ? TRUNK_LABEL : `⎇ ${clip(target.label, 24)}`
369
+ return `Merge ⎇ ${branchName} → ${where} (${plural(target.turns, "turn")}, ~${formatK(target.tokens)})`
334
370
  }
335
371
 
336
372
  /** Tournament only exists when there is something to compare against. Descriptions render on
337
373
  * the option's own line, which truncates past ~50 columns — keep them short; the full promise
338
374
  * is repeated at the confirmation step (`MERGE_TRUST`). */
339
- export function mergeDialogOptions(input: { siblings: number }): { title: string; value: MergeMode; description: string }[] {
375
+ export function mergeDialogOptions(input: { siblings: number; turns?: number }): { title: string; value: MergeMode; description: string }[] {
376
+ const folds = input.turns === undefined ? "the branch" : plural(input.turns, "turn")
340
377
  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" },
378
+ { title: "Squash", value: "squash" as const, description: `1 model call · folds ${folds} into one ◆ record` },
379
+ { title: "Squash without LLM", value: "squash-no-llm" as const, description: "you write it · no model call" },
380
+ { title: "Discard", value: "discard" as const, description: "rejected · nothing lands in the trunk" },
344
381
  ...(input.siblings > 0 ? [{ title: "Tournament", value: "tournament" as const, description: "compare sibling branches and keep one" }] : []),
345
382
  ]
346
383
  }
@@ -353,6 +390,50 @@ export type MergeInput = {
353
390
  confirm?: (draft: string) => Promise<string | undefined>
354
391
  }
355
392
 
393
+ /** Dialogs opened from an action (not from a route/palette handler), so every caller of
394
+ * `mergeBranch` gets the same gate. Both resolve to "cancelled" when the stack closes. */
395
+ function confirmDialog(ctx: ActionContext, title: string, message: string): Promise<boolean> {
396
+ return new Promise((resolve) => {
397
+ ctx.api.ui.dialog.replace(
398
+ () =>
399
+ ctx.api.ui.DialogConfirm({
400
+ title,
401
+ message,
402
+ onConfirm: () => {
403
+ resolve(true)
404
+ ctx.api.ui.dialog.clear()
405
+ },
406
+ onCancel: () => {
407
+ resolve(false)
408
+ ctx.api.ui.dialog.clear()
409
+ },
410
+ }),
411
+ () => resolve(false),
412
+ )
413
+ })
414
+ }
415
+
416
+ function promptDialog(ctx: ActionContext, title: string, placeholder?: string): Promise<string | undefined> {
417
+ return new Promise((resolve) => {
418
+ ctx.api.ui.dialog.replace(
419
+ () =>
420
+ ctx.api.ui.DialogPrompt({
421
+ title,
422
+ placeholder,
423
+ onConfirm: (value) => {
424
+ resolve(value)
425
+ ctx.api.ui.dialog.clear()
426
+ },
427
+ onCancel: () => {
428
+ resolve(undefined)
429
+ ctx.api.ui.dialog.clear()
430
+ },
431
+ }),
432
+ () => resolve(undefined),
433
+ )
434
+ })
435
+ }
436
+
356
437
  /** Close the branch the session lives on (DESIGN.md §6.4). Returns the parent session id. */
357
438
  export async function mergeBranch(ctx: ActionContext, input: MergeInput): Promise<string | undefined> {
358
439
  const treeId = ctx.store.ensureTree(input.sessionID, "tui")
@@ -364,8 +445,31 @@ export async function mergeBranch(ctx: ActionContext, input: MergeInput): Promis
364
445
  debug("merge.start", { mode: input.mode, sessionID: input.sessionID, parentID })
365
446
  await abortIfBusy(ctx, input.sessionID)
366
447
 
448
+ // both paths measure the branch by its own turns: what a squash folds into the record is
449
+ // also what a discard throws away — the prefix shared with the parent is neither
450
+ const parentMsgs = await ctx.api.client.session.messages({ sessionID: parentID, directory: ctx.directory }).catch(() => undefined)
451
+ const parentMessageIDs = ((parentMsgs?.data as any[]) ?? []).map((m) => String(m.info.id))
452
+ const own = await fetchOwnTranscript(ctx, input.sessionID)
453
+ const turns = ownTurnCount(own.messages, { messageID: branch.anchorMessageID, parentMessageIDs })
454
+
367
455
  if (input.mode === "discard") {
368
- record(ctx, treeId, "branch.closed", { sessionID: input.sessionID, status: "rejected", note: input.note })
456
+ // discard is the one mode that lands nothing, so it gets its own gate — and a cancelled
457
+ // note prompt has to abort too, not fall through as "no note"
458
+ const ok = await confirmDialog(ctx, `Discard ⎇ ${name} (${plural(turns, "turn")})?`, DISCARD_NOTICE)
459
+ if (!ok) {
460
+ ctx.api.ui.toast({ variant: "warning", message: `⎇ ${name} kept — nothing discarded` })
461
+ return undefined
462
+ }
463
+ let note = input.note
464
+ if (note === undefined) {
465
+ const answer = await promptDialog(ctx, "Why? (optional note on the close marker)", "dead end")
466
+ if (answer === undefined) {
467
+ ctx.api.ui.toast({ variant: "warning", message: `⎇ ${name} kept — nothing discarded` })
468
+ return undefined
469
+ }
470
+ note = answer.trim() || undefined
471
+ }
472
+ record(ctx, treeId, "branch.closed", { sessionID: input.sessionID, status: "rejected", note })
369
473
  await mirrorMetadata(ctx, input.sessionID, { status: "rejected" })
370
474
  navigateToSession(ctx, parentID)
371
475
  ctx.api.ui.toast({ variant: "success", message: `⎇ ${name} discarded — back on the trunk` })
@@ -373,9 +477,6 @@ export async function mergeBranch(ctx: ActionContext, input: MergeInput): Promis
373
477
  }
374
478
 
375
479
  // --- 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
480
  const transcript = branchTranscriptText(own, { messageID: branch.anchorMessageID, parentMessageIDs })
380
481
  const model = branch.branchModel ?? branch.trunkModel
381
482
  const modelRef = model ? { providerID: model.split("/")[0]!, modelID: model.split("/").slice(1).join("/") } : undefined
@@ -441,7 +542,26 @@ async function fetchOwnTranscript(ctx: ActionContext, sessionID: string) {
441
542
  /** Shared copy for the branch-name dialog (palette and route). */
442
543
  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
544
 
545
+ /** Where `y` lands when the terminal has no OSC 52 clipboard (relative to the project). */
546
+ export const COPY_HINT = ".opencode/context-tree/last-copy.txt"
547
+
548
+ /** `y` copy: the terminal's own clipboard through @opentui's OSC 52 (works over ssh/tmux when
549
+ * the terminal allows it), falling back to `COPY_HINT`. Throws if that file cannot be written. */
550
+ export function copyText(api: TuiPluginApi, text: string, directory: string): { target: "clipboard" | "file"; hint: string } {
551
+ const renderer = api.renderer as unknown as { copyToClipboardOSC52?: (text: string) => boolean } | undefined
552
+ // an empty selection must never wipe the user's clipboard; it still lands in the file
553
+ if (text && renderer?.copyToClipboardOSC52?.(text)) return { target: "clipboard", hint: "clipboard" }
554
+ const file = path.join(directory, COPY_HINT)
555
+ fs.mkdirSync(path.dirname(file), { recursive: true })
556
+ fs.writeFileSync(file, text)
557
+ return { target: "file", hint: COPY_HINT }
558
+ }
559
+
444
560
  /** Truncate to `max` columns with an ellipsis — the sidebar and dialogs are narrow. */
445
561
  export function clip(text: string, max: number): string {
446
562
  return text.length > max ? `${text.slice(0, max - 1)}…` : text
447
563
  }
564
+
565
+ function plural(n: number, noun: string): string {
566
+ return `${n} ${noun}${n === 1 ? "" : "s"}`
567
+ }
package/src/tui/index.tsx CHANGED
@@ -8,7 +8,7 @@ import { Show, createEffect, createMemo, createSignal, on } from "solid-js"
8
8
  import { bandFor, contextSizeOf, formatContext, formatK, type MinimalMessage, type MinimalPart } from "../core/tokens.js"
9
9
  import { JournalStore, type StorageMode } from "../shared/store.js"
10
10
  import { debug } from "../shared/debug.js"
11
- import { BRANCH_DIALOG, MERGE_TRUST, bumpJournal, clip, createNamedBranch, journalRevision, mergeBranch, mergeDialogOptions, mergeDialogTitle, setLabel, type MergeMode } from "./actions.js"
11
+ import { BRANCH_DIALOG, MERGE_TRUST, TRUNK_LABEL, bumpJournal, clip, createNamedBranch, journalRevision, mergeBranch, mergeDialogOptions, mergeDialogTitle, mergeTargetOf, ownTurnCount, setLabel, type MergeMode } from "./actions.js"
12
12
  import { openSiblings } from "../core/decision.js"
13
13
  import { hasEditor } from "./editor.js"
14
14
  import { TreeRoute } from "./route.js"
@@ -228,12 +228,16 @@ const tui: TuiPlugin = async (api, rawOptions) => {
228
228
  api.ui.toast({ message: "not on an open branch — /branch first" })
229
229
  return
230
230
  }
231
+ // the parent is usually not the loaded session, so its turn/token figures come over the SDK
232
+ const parent = await fetchTranscript(api, branch.parentSessionID, directory).catch(() => undefined)
233
+ const parentLabel = branch.parentSessionID === state.root ? TRUNK_LABEL : (state.sessions[branch.parentSessionID]?.name ?? TRUNK_LABEL)
234
+ const turns = ownTurnCount(api.state.session.messages(sessionID), { messageID: branch.anchorMessageID, parentMessageIDs: parent?.messages.map((m) => m.id) ?? [] })
231
235
  const mode = await new Promise<MergeMode | undefined>((resolve) => {
232
236
  api.ui.dialog.replace(
233
237
  () =>
234
238
  api.ui.DialogSelect<MergeMode>({
235
- title: mergeDialogTitle(branch.name ?? "branch", api.state.session.get(branch.parentSessionID)?.title),
236
- options: mergeDialogOptions({ siblings: openSiblings(state, sessionID).length }),
239
+ title: mergeDialogTitle(branch.name ?? "branch", parent ? mergeTargetOf(parentLabel, parent.messages) : undefined),
240
+ options: mergeDialogOptions({ siblings: openSiblings(state, sessionID).length, turns }),
237
241
  onSelect: (o) => {
238
242
  resolve(o.value)
239
243
  api.ui.dialog.clear()
@@ -314,6 +318,9 @@ const tui: TuiPlugin = async (api, rawOptions) => {
314
318
  return store.stateForSession(props.session_id)
315
319
  })
316
320
  const branch = () => st()?.sessions[props.session_id]
321
+ // the gauge's own string, so the card and the prompt line never show two numbers
322
+ const size = createMemo(() => contextSizeOf(toMinimalMessages(api.state.session.messages(props.session_id), api.state.part)))
323
+ const limit = createMemo(() => modelContextLimit(api, props.session_id))
317
324
  const crops = () => st()?.activeCrops(props.session_id) ?? []
318
325
  const hidden = () => crops().reduce((s, c) => s + c.targets.reduce((x, y) => x + y.estTokens, 0), 0)
319
326
  const siblings = () => Object.values(st()?.sessions ?? {}).filter((b) => b.parentSessionID === props.session_id && b.status === "open").length
@@ -334,6 +341,7 @@ const tui: TuiPlugin = async (api, rawOptions) => {
334
341
  <text fg={t.success}>{`⎇ ${branchLabel(api, props.session_id, branch()!.name, CARD_COLUMNS - 2)}`}</text>
335
342
  <text fg={t.textMuted}>{status()}</text>
336
343
  </Show>
344
+ <text fg={t[BAND_COLOR[bandFor(size().tokens, limit())]]}>{formatContext(size(), limit())}</text>
337
345
  <Show when={crops().length}>
338
346
  <text fg={t.warning}>{`✂ ${crops().length} crop${crops().length === 1 ? "" : "s"} · ~${formatK(hidden())} hidden`}</text>
339
347
  </Show>
@@ -344,13 +352,13 @@ const tui: TuiPlugin = async (api, rawOptions) => {
344
352
  session_prompt_right: (_ctx, props: { session_id: string }) => {
345
353
  const t = api.theme.current
346
354
  const size = createMemo(() => contextSizeOf(toMinimalMessages(api.state.session.messages(props.session_id), api.state.part)))
347
- const band = () => bandFor(size().tokens)
348
355
  const branch = () => {
349
356
  journalRevision() // the journal is plain files: without the revision `⎇ name` never refreshes
350
357
  return store.stateForSession(props.session_id)?.sessions[props.session_id]
351
358
  }
352
- // model context limit + compaction reserve, for the guard (DESIGN.md §6.7)
359
+ // model context limit + compaction reserve, for the bands and the guard (DESIGN.md §6.7)
353
360
  const limit = createMemo(() => modelContextLimit(api, props.session_id))
361
+ const band = () => bandFor(size().tokens, limit())
354
362
  const reserve = () => (api.state.config as { compaction?: { reserved?: number } }).compaction?.reserved ?? 16_384
355
363
  // trend + attribution: an effect compares each new size with the previous one
356
364
  // (side effects and closure state stay out of the memo graph)
@@ -401,7 +409,7 @@ const tui: TuiPlugin = async (api, rawOptions) => {
401
409
  const b = band()
402
410
  if (b === "red" && !redNudged) {
403
411
  redNudged = true
404
- api.ui.toast({ variant: "warning", message: "context is in the red band (≥64k) — consider /tree → c crop, or /merge a branch", duration: 6000 })
412
+ api.ui.toast({ variant: "warning", message: `context is in the red band (${limit() ? "85% of the window" : "≥64k"}) — consider /tree → c crop, or /merge a branch`, duration: 6000 })
405
413
  } else if (b === "low" || b === "healthy") redNudged = false
406
414
  const lim = limit()
407
415
  if (lim && size().tokens >= lim - reserve() && !guardNudged) {