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/CHANGELOG.md +38 -0
- package/README.md +35 -25
- package/dist/server.js +6 -2
- package/dist/tui.js +1310 -490
- package/docs/USAGE.md +52 -21
- package/package.json +1 -1
- package/src/core/actions.ts +1 -0
- package/src/core/consumers.ts +55 -13
- package/src/core/decision.ts +62 -0
- package/src/core/lanes.ts +166 -5
- package/src/core/navigation.ts +39 -10
- package/src/core/tokens.ts +22 -4
- package/src/core/tree.ts +98 -5
- package/src/server/index.ts +3 -2
- package/src/shared/version.ts +3 -0
- package/src/tui/actions.ts +131 -11
- package/src/tui/index.tsx +14 -6
- package/src/tui/route.tsx +556 -181
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
|
-
|
|
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
|
-
|
|
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
|
|
740
|
+
if ((r.kind === "turn" || r.kind === "step") && r.sessionID === o.currentSessionID) {
|
|
648
741
|
currentRowId = r.id
|
|
649
742
|
break
|
|
650
743
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -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)
|
package/src/tui/actions.ts
CHANGED
|
@@ -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
|
-
/**
|
|
332
|
-
export
|
|
333
|
-
|
|
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:
|
|
342
|
-
{ title: "Squash without LLM", value: "squash-no-llm" as const, description: "you write
|
|
343
|
-
{ title: "Discard", value: "discard" as const, description: "rejected
|
|
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
|
-
|
|
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",
|
|
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:
|
|
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) {
|