opencode-context-tree 0.1.1 → 0.2.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.
package/docs/USAGE.md CHANGED
@@ -34,9 +34,10 @@ or `"global"` (OpenCode's state dir); `jumpSummary` `"ask"` (default, Pi behavio
34
34
  transcript shows `[Old tool result content cleared]` (reversible by undo, but it touches
35
35
  OpenCode storage — off by default); `keybinds` overrides any route key by command name,
36
36
  e.g. `{ "keybinds": { "open": "ctrl+t", "up": "k,up", "copy": "none" } }` — names are
37
- `open up down jump_up jump_down first last prev_branch next_branch fold unfold toggle go
38
- branch label filter search back crop crop_toggle_mode mark auto undo merge inspector
39
- consumers copy mode_duration mode_turns mode_calls decisions export help`.
37
+ `open up down jump_up jump_down half_up half_down first last prev_branch next_branch fold
38
+ unfold toggle go branch label filter_pick filter_prev search search_next search_prev back
39
+ crop crop_toggle_mode mark auto undo merge inspector consumers copy mode_duration mode_turns
40
+ mode_calls lanes_off decisions export help`.
40
41
 
41
42
  ## The loop
42
43
 
@@ -50,7 +51,7 @@ consumers copy mode_duration mode_turns mode_calls decisions export help`.
50
51
  /tree see where you are, what it costs, and jump anywhere
51
52
  c … space … ⏎ crop a fat tool result (double space for protected ones) → the model
52
53
  sees "[cropped: bash …]" from the next turn; the transcript keeps the text
53
- x undo the last crop / branch / merge on this path
54
+ u undo the last crop / branch / merge on this path (alias x)
54
55
  ```
55
56
 
56
57
  `/merge` asks how to close the branch:
@@ -69,26 +70,28 @@ appended to the trunk as a normal message.*
69
70
 
70
71
  | key | action |
71
72
  |---|---|
72
- | `↑↓` `j k` · `J K` (20) · `g G` | move |
73
+ | `↑↓` `j k` · `J K` (20) · `ctrl+d` `ctrl+u` · `gg` `G` | move · half page · top / bottom |
73
74
  | `[` `]` | previous / next branch row |
74
- | `← →` `h l` `e` | fold / unfold a branch inline |
75
- | `⏎` | go here: switch to a branch tip, or fork (a user turn = redo with the text pre-filled; a step = continue from there). Asks "Summarize the branch you are leaving?" (Pi) |
75
+ | `← →` `h l` · `Tab` (or `e`) | fold / unfold a branch inline |
76
+ | `⏎` | go here — the footer names what it will do for the row you are on: switch to a `⎇` branch, fork & prefill a user turn, fork after a step. Confirms first, then asks "Summarize the branch you are leaving?" (Pi); `u` undoes it |
76
77
  | `b` | branch here: name it, then "Model for this branch" (Enter keeps the current one) |
77
78
  | `m` | merge: Squash / Squash without LLM / Discard / Tournament (siblings only) |
78
- | `c` `space` `a` `t` `⏎` | crop mode: mark, auto-mark (≥10k tokens, older than 2 turns), result⇄turn, apply |
79
- | `x` | undo |
79
+ | `c` `space` `a` `t` `⏎` | crop mode: mark (`space` alone enters it on a croppable row), auto-mark (≥10k tokens, older than 2 turns), result⇄turn, apply |
80
+ | `u` (`x`) | undo |
80
81
  | `D` `E` | decisions panel, export `ctree-decisions.md` |
81
- | `u` | consumers: what is filling the context |
82
+ | `s` | consumers: what is filling the context (`⏎` opens a bucket, `space` marks one entry for crop) |
82
83
  | `i` | inspector pane on/off (auto-hidden under 110 columns) |
83
- | `1 2 3` | minimap lanes by duration / turns / tool calls |
84
+ | `1 2 3` `0` | timeline lanes by duration / turns / tool calls; `0` off |
84
85
  | `L` | label the selected message |
85
- | `f` `/` | filter cycle (default → no-tools → user-only → labeled → all), search |
86
- | `y` | save the selected text to `.opencode/context-tree/last-copy.txt` |
87
- | `?` | help overlay: how to read the screen + every key (`?` or `esc` closes) |
88
- | `q` `esc` | back (esc leaves crop mode / panels first) |
86
+ | `f` `F` | filter picker (default → no-tools → user-only → labeled → all); `F` steps back |
87
+ | `/` `n` `N` | live search: typing re-filters the rows, `⏎` keeps the filter, `esc` clears; `n` `N` next / previous match |
88
+ | `y` | copy the selected text the terminal's clipboard when it allows it, else `.opencode/context-tree/last-copy.txt` |
89
+ | `?` | help pane under the tree: how to read the screen + every key (`?` or `esc` closes) |
90
+ | `q` `esc` | back (esc leaves crop mode / a panel / a search first) |
89
91
 
90
- The footer only lists the six you need`⏎ go b branch m merge c crop x undo ? help
91
- q back`; the rest live behind `?`.
92
+ The footer follows the panel and the row under the cursor on the tree `⏎ fork & prefill
93
+ this turn b branch m merge c crop u undo s consumers ? help q back`; the rest live
94
+ behind `?`.
92
95
 
93
96
  Palette: **Context tree**, **Branch here**, **Merge branch**, **Decisions**, **Label this point**.
94
97
  `ctrl+q` opens the tree.
@@ -96,8 +99,8 @@ Palette: **Context tree**, **Branch here**, **Merge branch**, **Decisions**, **L
96
99
  ## Reading the screen
97
100
 
98
101
  ```
99
- ┌ Context tree · Fix flaky test · trunk ctx ~46k/200k · filling
100
- │ filter: default 24 rows
102
+ ┌ Context tree · Fix flaky test · trunk ctx ▓▓░░░ ~46k/200k · filling
103
+ │ filter: default 4/24 rows
101
104
  │ ● user: build yourself a tool that reads the context window… ~1.2k
102
105
  │ ○ assistant: I'll start by inspecting my environment… 0.3k
103
106
  │ ⚙ [bash $ ls -la ~/Documents/] → total 744 … ~2.1k
@@ -107,7 +110,7 @@ Palette: **Context tree**, **Branch here**, **Merge branch**, **Decisions**, **L
107
110
  │ │ ● user: the bun test is flaky, find the race ~0.4k
108
111
  │ │ ⚙ [bash $ bun test src/foo.test.ts] ⚠ ~4.7k
109
112
  │ ◆ Decision: try-redis · Outcome: switched to a write-through cache… ~0.9k
110
- └ ⏎ go b branch m merge c crop i inspector 1·2·3 lanes x undo ? help q back
113
+ └ ⏎ fork & prefill this turn b branch m merge c crop u undo s consumers ? help q back
111
114
  ```
112
115
 
113
116
  - `/tree` is an outline of the *whole* tree: one content-forward row per message (`● user:` /
@@ -115,13 +118,17 @@ Palette: **Context tree**, **Branch here**, **Merge branch**, **Decisions**, **L
115
118
  whole tree — your branch open with `← here`, the rest folded to their `⎇` header (`→` opens one).
116
119
  - The Input/Model/Tools lanes and the right-hand inspector (DeepSeek-Harness trajectory) are OFF by
117
120
  default so the first screen is the clean outline; `1/2/3` bring in the lanes, `i` the inspector.
121
+ The lanes are an event strip — one `▬` pill per prompt / model step / tool call on a shared time
122
+ axis, coloured by lane (nothing is scaled by tokens); the row you are on draws inverted.
118
123
  - The header's context string is the same one the prompt gauge shows, character for
119
124
  character. The lanes only appear once there are three turns to plot; a session with no
120
125
  messages says so instead of drawing an empty frame.
121
126
  - `⎇` rows hang off the message they were forked from. Colours: open green, squashed blue,
122
127
  rejected red, abandoned/deleted grey.
123
128
  - From inside a branch, its own `⎇` row is drawn at the fork point with `← here`; the rows
124
- below it are the branch's own turns.
129
+ below it are the branch's own turns. The header reads `⎇ <branch> ← <trunk title>`.
130
+ - Rows the model is not shown — an ancestor's turns past the point where your path forked — are
131
+ dimmed, under a `── not in this branch's context ──` line.
125
132
  - `┆⎇` rows at the bottom are branches you cannot reach on the active path (siblings, or the
126
133
  trunk continuing past your fork point); `⏎` switches to them, `→` expands them.
127
134
  - Sessions made with OpenCode's own `/fork` are adopted into the tree automatically (matched
@@ -162,3 +169,15 @@ a model (`provider/model`): `/ctree branch fix flaky test` names the branch "fix
162
169
  storage is never rewritten. `/undo` appends, never deletes.
163
170
  - Decision records are ordinary user messages (`noReply`) tagged in part metadata; they are
164
171
  re-injected verbatim when OpenCode compacts.
172
+
173
+ ## Releasing (maintainers)
174
+
175
+ GitHub → Actions → **Release** → *Run workflow*: pick `patch` / `minor` / `major` (relative to
176
+ the latest `v*` tag) or type an exact version. The workflow runs typecheck and tests, tags the
177
+ current `main` as `vX.Y.Z`, creates the GitHub Release with generated notes, and dispatches the
178
+ publish workflow on that tag, which publishes to npm through trusted publishing (OIDC, no token).
179
+ Tick *dry run* to see the version it would cut without doing anything.
180
+
181
+ The tag is the version: `package.json` on `main` says `0.0.0-dev` and is stamped from the tag at
182
+ publish time, so no commit ever has to land on the protected branch. Keep `CHANGELOG.md` by hand
183
+ (the workflow warns when the section for the new version is missing).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-context-tree",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Pi-style context tree for OpenCode: branch, merge (human-confirmed decision records), crop, undo, plus a DeepSeek-Harness-style trajectory view",
@@ -32,6 +32,7 @@ export type JumpPlan =
32
32
  * - the **last message of the current session** → noop, "already here".
33
33
  */
34
34
  export function planJump(row: Row, ctx: { transcripts: Record<string, Transcript>; currentSessionID: string }): JumpPlan {
35
+ if (row.kind === "separator") return { kind: "noop", reason: "nothing to go to" }
35
36
  if (row.kind === "branch") return row.sessionID === ctx.currentSessionID ? { kind: "noop", reason: "you are here" } : { kind: "switch", sessionID: row.sessionID }
36
37
  const tr = ctx.transcripts[row.sessionID]
37
38
  if (!tr) return { kind: "noop", reason: "that session is not loaded" }
@@ -3,34 +3,76 @@
3
3
  * current session's context. Pure.
4
4
  */
5
5
  import { estimateTokens } from "./tokens.js"
6
- import type { Transcript } from "./transcript.js"
6
+ import { partPreview, type StepPart, type Transcript, type TranscriptMessage } from "./transcript.js"
7
7
 
8
- export type Consumer = { source: string; kind: "tool" | "assistant" | "user" | "decision" | "summary" | "reasoning"; tokens: number; count: number; share: number }
8
+ /** One part inside a bucket, so the route can drill in and crop it in place. */
9
+ export type ConsumerEntry = {
10
+ messageID: string
11
+ partID?: string
12
+ tokens: number
13
+ preview: string
14
+ /** only a completed tool result can be stubbed by crop's result mode (core/crop.ts) */
15
+ croppable: boolean
16
+ }
17
+
18
+ export type Consumer = {
19
+ source: string
20
+ kind: "tool" | "assistant" | "user" | "decision" | "summary" | "reasoning"
21
+ tokens: number
22
+ count: number
23
+ /** share of this transcript's own total */
24
+ share: number
25
+ /** share of the model's context window, when a limit is known */
26
+ shareOfWindow?: number
27
+ /** biggest first */
28
+ entries: ConsumerEntry[]
29
+ /** why this bucket cannot be acted on, when it cannot */
30
+ note?: string
31
+ }
32
+
33
+ const THINKING = "(thinking)"
34
+ const THINKING_NOTE = "provider reasoning · not croppable"
9
35
 
10
- export function consumers(transcript: Transcript, opts: { cropped?: Set<string> } = {}): Consumer[] {
36
+ export function consumers(transcript: Transcript, opts: { cropped?: Set<string>; limit?: number } = {}): Consumer[] {
11
37
  const acc = new Map<string, Consumer>()
12
- const add = (source: string, kind: Consumer["kind"], tokens: number) => {
13
- const c = acc.get(source) ?? { source, kind, tokens: 0, count: 0, share: 0 }
38
+ const add = (source: string, kind: Consumer["kind"], tokens: number, message: TranscriptMessage, part: StepPart) => {
39
+ const c = acc.get(source) ?? { source, kind, tokens: 0, count: 0, share: 0, entries: [] }
14
40
  c.tokens += tokens
15
41
  c.count += 1
42
+ c.entries.push({
43
+ messageID: message.id,
44
+ partID: part.id,
45
+ tokens,
46
+ preview: partPreview(part),
47
+ croppable: part.type === "tool" && part.state?.status === "completed",
48
+ })
16
49
  acc.set(source, c)
17
50
  }
18
51
  for (const m of transcript.messages) {
19
52
  for (const p of m.parts) {
20
53
  if (opts.cropped?.has(p.id)) continue
21
- if (p.type === "tool") add(p.tool ?? "tool", "tool", estimateTokens(p.state?.output ?? "") + estimateTokens(JSON.stringify(p.state?.input ?? "")))
54
+ if (p.type === "tool") add(p.tool ?? "tool", "tool", estimateTokens(p.state?.output ?? "") + estimateTokens(JSON.stringify(p.state?.input ?? "")), m, p)
22
55
  else if (p.type === "text") {
23
56
  const kind = (p.metadata?.["ctree"] as { kind?: string } | undefined)?.kind
24
- if (kind === "decision") add("◆ decisions", "decision", estimateTokens(p.text ?? ""))
25
- else if (kind === "summary") add("◇ branch summaries", "summary", estimateTokens(p.text ?? ""))
26
- else if (m.role === "user") add("● user prompts", "user", estimateTokens(p.text ?? ""))
27
- else if (m.summary) add("◇ compaction summaries", "summary", estimateTokens(p.text ?? ""))
28
- else add("○ assistant text", "assistant", estimateTokens(p.text ?? ""))
29
- } else if (p.type === "reasoning") add("(thinking)", "reasoning", estimateTokens(p.text ?? ""))
57
+ if (kind === "decision") add("◆ decisions", "decision", estimateTokens(p.text ?? ""), m, p)
58
+ else if (kind === "summary") add("◇ branch summaries", "summary", estimateTokens(p.text ?? ""), m, p)
59
+ else if (m.role === "user") add("● user prompts", "user", estimateTokens(p.text ?? ""), m, p)
60
+ else if (m.summary) add("◇ compaction summaries", "summary", estimateTokens(p.text ?? ""), m, p)
61
+ else add("○ assistant text", "assistant", estimateTokens(p.text ?? ""), m, p)
62
+ } else if (p.type === "reasoning") add(THINKING, "reasoning", estimateTokens(p.text ?? ""), m, p)
30
63
  }
31
64
  }
32
65
  const total = [...acc.values()].reduce((s, c) => s + c.tokens, 0) || 1
33
- return [...acc.values()].map((c) => ({ ...c, share: c.tokens / total })).sort((a, b) => b.tokens - a.tokens)
66
+ const limit = opts.limit !== undefined && opts.limit > 0 ? opts.limit : undefined
67
+ return [...acc.values()]
68
+ .map((c) => ({
69
+ ...c,
70
+ share: c.tokens / total,
71
+ ...(limit === undefined ? {} : { shareOfWindow: c.tokens / limit }),
72
+ ...(c.kind === "reasoning" ? { note: THINKING_NOTE } : {}),
73
+ entries: c.entries.sort((a, b) => b.tokens - a.tokens),
74
+ }))
75
+ .sort((a, b) => b.tokens - a.tokens)
34
76
  }
35
77
 
36
78
  export function bar(share: number, width: number): string {
@@ -75,6 +75,68 @@ export function decisionMessageText(record: string, branchName: string): string
75
75
  return body.startsWith("## Decision:") ? `◆ ${body}` : `◆ ## Decision: ${branchName}\n${body}`
76
76
  }
77
77
 
78
+ // ---------------------------------------------------------------------------
79
+ // Rendering a record in the TUI (DESIGN.md §7.4's ◆ cards).
80
+ // ---------------------------------------------------------------------------
81
+
82
+ const BULLET = /^(\s*)[-*•]\s+/
83
+ /** the `◆` a landed record carries survives; the `#` markers do not */
84
+ const HEADING = /^(\s*(?:◆\s*)?)#{1,6}\s*/
85
+
86
+ /** Markdown emphasis is noise in a terminal; bullets and blank lines are structure. */
87
+ function stripMarkdown(text: string): string {
88
+ return text.replace(/[*`]/g, "")
89
+ }
90
+
91
+ function wrapLine(body: string, width: number, marker: string): string[] {
92
+ const room = Math.max(1, width - marker.length)
93
+ const indent = " ".repeat(marker.length)
94
+ const lines: string[] = []
95
+ let current = ""
96
+ for (const raw of body.split(/\s+/).filter(Boolean)) {
97
+ // the only mid-word cut: one token that cannot fit on a line of its own
98
+ const word = raw.length > room ? `${raw.slice(0, Math.max(1, room - 1))}…` : raw
99
+ if (current === "") current = word
100
+ else if (current.length + 1 + word.length <= room) current += ` ${word}`
101
+ else {
102
+ lines.push(current)
103
+ current = word
104
+ }
105
+ }
106
+ lines.push(current)
107
+ return lines.map((line, i) => `${i === 0 ? marker : indent}${line}`.trimEnd())
108
+ }
109
+
110
+ /** A decision record as terminal lines: emphasis and heading markers gone, list bullets and
111
+ * paragraph breaks kept, every line word-wrapped to `width` with a hanging indent. */
112
+ export function renderDecision(text: string, width: number): string[] {
113
+ const out: string[] = []
114
+ for (const raw of text.replace(/\r/g, "").trim().split("\n")) {
115
+ const line = raw.trimEnd()
116
+ if (line.trim() === "") {
117
+ if (out.length > 0 && out[out.length - 1] !== "") out.push("")
118
+ continue
119
+ }
120
+ const bullet = BULLET.exec(line)
121
+ const marker = bullet ? `${bullet[1]}- ` : ""
122
+ const body = stripMarkdown(bullet ? line.slice(bullet[0].length) : line.replace(HEADING, "$1"))
123
+ out.push(...wrapLine(body, width, marker))
124
+ }
125
+ return out
126
+ }
127
+
128
+ /** The one-line form of a record: its `## Decision: <name>` title and `Outcome:` line. */
129
+ export function decisionSummary(text: string): { title: string; outcome?: string } {
130
+ const lines = text
131
+ .replace(/\r/g, "")
132
+ .split("\n")
133
+ .map((l) => stripMarkdown(l.replace(/^\s*◆\s*/, "").replace(HEADING, "$1").replace(BULLET, "")).trim())
134
+ const heading = lines.find((l) => /^Decision:\s*\S/i.test(l))
135
+ const title = heading ? heading.replace(/^Decision:\s*/i, "").trim() : (lines.find((l) => l !== "") ?? "")
136
+ const outcome = lines.find((l) => /^Outcome:\s*\S/i.test(l))?.replace(/^Outcome:\s*/i, "").trim()
137
+ return outcome ? { title, outcome } : { title }
138
+ }
139
+
78
140
  /** Open sibling branches forked from the same point (tournament, DESIGN.md §6.4). */
79
141
  export function openSiblings(state: TreeState, sessionID: string): string[] {
80
142
  const me = state.sessions[sessionID]
package/src/core/lanes.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
- * DSH-style timeline lanes (DESIGN.md §7.1, §7.3): one column per unit of the
3
- * chosen mode, three series Input (context size at each assistant turn), Model
4
- * (output tokens per assistant step), Tools (result size per tool call, errors
5
- * flagged). Pure.
2
+ * DSH-style timeline lanes (DESIGN.md §7.1, §7.3): three series Input, Model,
3
+ * Tools over the chosen mode's x-axis. Two models live here: the original
4
+ * magnitude columns (`buildLanes` + `sparkline`) and the event strip
5
+ * (`buildEventStrip`), which is what the DSH bar actually draws. Pure.
6
6
  */
7
7
  import { estimateTokens } from "./tokens.js"
8
- import type { Transcript, TranscriptMessage } from "./transcript.js"
8
+ import { stepKind, type StepPart, type Transcript, type TranscriptMessage } from "./transcript.js"
9
9
 
10
10
  export type LaneMode = "turns" | "calls" | "duration"
11
11
 
@@ -147,3 +147,164 @@ export function columnFor(lanes: Lanes, messageID: string, partID?: string): num
147
147
  const i = lanes.columns.findIndex((c) => c.messageID === messageID || c.userMessageID === messageID)
148
148
  return i >= 0 ? i : -1
149
149
  }
150
+
151
+ /* ── Event strip (DESIGN.md §7.1's DSH trajectory bar) ────────────────────────
152
+ * The strip is an *event* timeline, not a histogram: one small pill per event on
153
+ * one shared axis across the three lanes, gap between neighbours, width = duration
154
+ * in Duration mode. Colour is categorical (per lane), so nothing here scales by
155
+ * tokens — `tokens` rides along only for the inspector.
156
+ */
157
+
158
+ export type LaneEvent = {
159
+ lane: "input" | "model" | "tools"
160
+ /** `context` = compaction/branch summary: machine-written context, not the human prompting */
161
+ kind: "user" | "context" | "text" | "reasoning" | "tool"
162
+ messageID: string
163
+ partID?: string
164
+ turn: number
165
+ startMs?: number
166
+ durationMs?: number
167
+ error?: boolean
168
+ tokens: number
169
+ }
170
+
171
+ /** One terminal cell of one lane. */
172
+ export type StripCell = { lane: LaneEvent["lane"]; eventIndex: number; glyph: string; error?: boolean }
173
+
174
+ export type EventStrip = {
175
+ /** time order; only the events that fit — see `truncatedLeft` */
176
+ events: LaneEvent[]
177
+ width: number
178
+ /** `width` cells each; null = gap/empty */
179
+ lanes: Record<LaneEvent["lane"], (StripCell | null)[]>
180
+ /** cell index range [start, end) of `events[i]`, for cursor/scroll mapping */
181
+ spans: { start: number; end: number }[]
182
+ /** lane has no events in the strip (render "no tool calls" etc.) */
183
+ empty: Record<LaneEvent["lane"], boolean>
184
+ /** older events dropped off the left because they did not fit */
185
+ truncatedLeft: number
186
+ }
187
+
188
+ const EVENT_GLYPH = "▬"
189
+
190
+ function ctreeKindOf(message: TranscriptMessage): string | undefined {
191
+ for (const p of message.parts) {
192
+ const ctree = p.metadata?.["ctree"] as { kind?: string } | undefined
193
+ if (ctree?.kind) return ctree.kind
194
+ }
195
+ return undefined
196
+ }
197
+
198
+ function isContextMessage(message: TranscriptMessage): boolean {
199
+ return message.summary === true || ctreeKindOf(message) === "summary"
200
+ }
201
+
202
+ /** A whole message as one pill: user prompts and summaries read as single events. */
203
+ function messageEvent(message: TranscriptMessage, turn: number): LaneEvent {
204
+ const ms = spanOf(message)
205
+ return {
206
+ lane: "input",
207
+ kind: isContextMessage(message) ? "context" : "user",
208
+ messageID: message.id,
209
+ turn,
210
+ startMs: message.time.created,
211
+ ...(ms > 0 ? { durationMs: ms } : {}),
212
+ tokens: message.parts.reduce((s, p) => s + estimateTokens(p.text ?? ""), 0),
213
+ }
214
+ }
215
+
216
+ function partEvent(message: TranscriptMessage, part: StepPart, turn: number): LaneEvent {
217
+ const t = part.state?.time ?? part.time
218
+ const base = {
219
+ messageID: message.id,
220
+ partID: part.id,
221
+ turn,
222
+ startMs: t?.start ?? message.time.created,
223
+ ...(t?.start !== undefined && t?.end !== undefined ? { durationMs: Math.max(0, t.end - t.start) } : {}),
224
+ }
225
+ if (part.type === "tool")
226
+ return { ...base, lane: "tools", kind: "tool", error: part.state?.status === "error", tokens: estimateTokens(part.state?.output ?? "") + estimateTokens(JSON.stringify(part.state?.input ?? "")) }
227
+ return { ...base, lane: "model", kind: part.type === "reasoning" ? "reasoning" : "text", tokens: estimateTokens(part.text ?? "") }
228
+ }
229
+
230
+ function eventsOf(transcript: Transcript): LaneEvent[] {
231
+ const out: LaneEvent[] = []
232
+ for (const turn of turnsOf(transcript.messages)) {
233
+ if (turn.user) out.push(messageEvent(turn.user, turn.index))
234
+ for (const m of turn.assistants) {
235
+ const context = isContextMessage(m)
236
+ if (context) out.push(messageEvent(m, turn.index))
237
+ for (const p of m.parts) {
238
+ const kind = stepKind(p)
239
+ // a summary's prose is already the context pill above; a tool call it made is still a tool call
240
+ if (kind === "other" || (context && kind !== "tool")) continue
241
+ out.push(partEvent(m, p, turn.index))
242
+ }
243
+ }
244
+ }
245
+ return out
246
+ }
247
+
248
+ /** Turns mode groups by turn, so a turn boundary breaks wider than a step boundary. */
249
+ function gapBefore(events: LaneEvent[], i: number, mode: LaneMode): number {
250
+ return mode === "turns" && events[i]!.turn !== events[i - 1]!.turn ? 2 : 1
251
+ }
252
+
253
+ /** Index of the oldest event that still fits at one cell each — everything before it is dropped. */
254
+ function firstFitting(events: LaneEvent[], mode: LaneMode, width: number): number {
255
+ let cells = 0
256
+ for (let i = events.length - 1; i >= 0; i--) {
257
+ const next = cells === 0 ? 1 : cells + 1 + gapBefore(events, i + 1, mode)
258
+ if (next > width) return i + 1
259
+ cells = next
260
+ }
261
+ return 0
262
+ }
263
+
264
+ function durationWidths(events: LaneEvent[], budget: number): number[] {
265
+ const total = events.reduce((s, e) => s + Math.max(0, e.durationMs ?? 0), 0)
266
+ if (total <= 0) return events.map(() => 1) // no timing data: read as the calls layout
267
+ const widths = events.map((e) => Math.max(1, Math.round((Math.max(0, e.durationMs ?? 0) / total) * budget)))
268
+ let over = widths.reduce((s, v) => s + v, 0) - budget
269
+ while (over > 0) {
270
+ let widest = -1
271
+ for (let i = 0; i < widths.length; i++) if ((widths[i] ?? 0) > 1 && (widest < 0 || (widths[i] ?? 0) > (widths[widest] ?? 0))) widest = i
272
+ if (widest < 0) break // every event is already down to its one cell
273
+ widths[widest] = (widths[widest] ?? 1) - 1
274
+ over--
275
+ }
276
+ return widths
277
+ }
278
+
279
+ export function buildEventStrip(transcript: Transcript, mode: LaneMode, width: number): EventStrip {
280
+ const w = Math.max(0, Math.floor(width))
281
+ const all = eventsOf(transcript)
282
+ const truncatedLeft = firstFitting(all, mode, w)
283
+ const events = all.slice(truncatedLeft)
284
+ const gaps = events.map((_, i) => (i === 0 ? 0 : gapBefore(events, i, mode)))
285
+ const widths = mode === "duration" ? durationWidths(events, w - gaps.reduce((s, g) => s + g, 0)) : events.map(() => 1)
286
+
287
+ const blank = (): (StripCell | null)[] => Array.from({ length: w }, () => null)
288
+ const lanes: EventStrip["lanes"] = { input: blank(), model: blank(), tools: blank() }
289
+ const empty: EventStrip["empty"] = { input: true, model: true, tools: true }
290
+ const spans: { start: number; end: number }[] = []
291
+ let cursor = 0
292
+ events.forEach((e, i) => {
293
+ const start = cursor + (gaps[i] ?? 0)
294
+ const end = Math.min(w, start + (widths[i] ?? 1))
295
+ for (let c = start; c < end; c++) lanes[e.lane][c] = { lane: e.lane, eventIndex: i, glyph: EVENT_GLYPH, ...(e.error ? { error: true } : {}) }
296
+ spans.push({ start, end })
297
+ empty[e.lane] = false
298
+ cursor = start + (widths[i] ?? 1)
299
+ })
300
+ return { events, width: w, lanes, spans, empty, truncatedLeft }
301
+ }
302
+
303
+ /** Index into `strip.events` of the event a message/part belongs to (-1 if it is not on the strip). */
304
+ export function stripIndexFor(strip: EventStrip, messageID: string, partID?: string): number {
305
+ if (partID) {
306
+ const byPart = strip.events.findIndex((e) => e.partID === partID)
307
+ if (byPart >= 0) return byPart
308
+ }
309
+ return strip.events.findIndex((e) => e.messageID === messageID)
310
+ }
@@ -6,14 +6,37 @@
6
6
  */
7
7
  import type { Filter, Row, TreeView } from "./tree.js"
8
8
 
9
- /** Move the selection by `delta` rows, clamped to the row list's bounds.
9
+ /** First row at or after `index` scanning in `dir` that the cursor may sit on — separator rows
10
+ * are decoration (`── not in this branch's context ──`). -1 when there is none. */
11
+ function scan(rows: Row[], index: number, dir: 1 | -1): number {
12
+ for (let i = index; i >= 0 && i < rows.length; i += dir) if (rows[i]!.kind !== "separator") return i
13
+ return -1
14
+ }
15
+
16
+ /** Move the selection by `delta` selectable rows, clamped to the row list's bounds.
10
17
  * Returns -1 for an empty row list. */
11
18
  export function moveSelection(rows: Row[], index: number, delta: number): number {
12
19
  if (rows.length === 0) return -1
13
- const next = index + delta
14
- if (next < 0) return 0
15
- if (next > rows.length - 1) return rows.length - 1
16
- return next
20
+ const dir: 1 | -1 = delta < 0 ? -1 : 1
21
+ let i = Math.min(Math.max(index, 0), rows.length - 1)
22
+ for (let step = Math.abs(delta); step > 0; step--) {
23
+ const next = scan(rows, i + dir, dir)
24
+ if (next === -1) break
25
+ i = next
26
+ }
27
+ if (rows[i]!.kind !== "separator") return i
28
+ // the cursor started on a separator (a rebuilt view moved it there): step off it either way
29
+ const ahead = scan(rows, i, dir)
30
+ return ahead === -1 ? scan(rows, i, dir === 1 ? -1 : 1) : ahead
31
+ }
32
+
33
+ /** First / last selectable row (`g` / `G`). -1 when there is none. */
34
+ export function firstIndex(rows: Row[]): number {
35
+ return scan(rows, 0, 1)
36
+ }
37
+
38
+ export function lastIndex(rows: Row[]): number {
39
+ return scan(rows, rows.length - 1, -1)
17
40
  }
18
41
 
19
42
  /** The next/previous `branch` row from `index` in direction `dir` (DESIGN.md §7.2's
@@ -65,31 +88,37 @@ export function resolveSelection(
65
88
  previousIndex?: number,
66
89
  ): number {
67
90
  if (view.rows.length === 0) return -1
91
+ // the cursor never rests on a separator: take the next real row, else the previous one
92
+ const land = (i: number) => {
93
+ const ahead = scan(view.rows, i, 1)
94
+ return ahead === -1 ? scan(view.rows, i, -1) : ahead
95
+ }
68
96
 
69
97
  if (preferredId !== undefined) {
70
98
  const exact = view.indexById[preferredId]
71
- if (exact !== undefined) return exact
99
+ if (exact !== undefined) return land(exact)
72
100
 
73
101
  const owner = preferredId.split(":").slice(0, 2).join(":")
74
102
  for (let i = 0; i < view.rows.length; i++) {
75
103
  const row = view.rows[i]!
104
+ if (row.kind === "separator") continue
76
105
  const rowOwner = row.kind === "branch" ? row.id : `${row.sessionID}:${row.messageID}`
77
106
  if (rowOwner === owner) return i
78
107
  }
79
108
  }
80
109
 
81
110
  // 2b. the row that now sits where the old selection was (filter/fold changed the list)
82
- if (previousIndex !== undefined && previousIndex >= 0) return Math.min(previousIndex, view.rows.length - 1)
111
+ if (previousIndex !== undefined && previousIndex >= 0) return land(Math.min(previousIndex, view.rows.length - 1))
83
112
 
84
113
  if (currentRowId !== undefined) {
85
114
  const idx = view.indexById[currentRowId]
86
- if (idx !== undefined) return idx
115
+ if (idx !== undefined) return land(idx)
87
116
  }
88
117
 
89
118
  if (view.currentRowId !== undefined) {
90
119
  const idx = view.indexById[view.currentRowId]
91
- if (idx !== undefined) return idx
120
+ if (idx !== undefined) return land(idx)
92
121
  }
93
122
 
94
- return 0
123
+ return land(0)
95
124
  }
@@ -84,14 +84,30 @@ export function contextSizeOf(messages: MinimalMessage[]): { tokens: number; est
84
84
 
85
85
  export type ContextBand = "low" | "healthy" | "filling" | "red"
86
86
 
87
- /** Absolute bands from DESIGN.md §6.7: <8k low · 8-32k healthy · 32-64k filling · >=64k red. */
88
- export function bandFor(tokens: number): ContextBand {
87
+ /** Bands relative to the model's window when it is known (<25% low · <60% healthy · <85%
88
+ * filling · else red); DESIGN.md §6.7's absolute 8k/32k/64k bands only as the fallback,
89
+ * since 30k is "healthy" on a 200k model and one prompt from compaction on a 32k one. */
90
+ export function bandFor(tokens: number, limit?: number): ContextBand {
91
+ if (limit && limit > 0) {
92
+ const r = tokens / limit
93
+ if (r < 0.25) return "low"
94
+ if (r < 0.6) return "healthy"
95
+ if (r < 0.85) return "filling"
96
+ return "red"
97
+ }
89
98
  if (tokens < 8_000) return "low"
90
99
  if (tokens < 32_000) return "healthy"
91
100
  if (tokens < 64_000) return "filling"
92
101
  return "red"
93
102
  }
94
103
 
104
+ /** `▓▓▓░░`: `cells` wide, filled by tokens/limit, always at least one cell once anything is used. */
105
+ export function contextBar(tokens: number, limit: number, cells = 5): string {
106
+ if (!(limit > 0)) return ""
107
+ const filled = tokens <= 0 ? 0 : Math.min(cells, Math.max(1, Math.round((tokens / limit) * cells)))
108
+ return "▓".repeat(filled) + "░".repeat(cells - filled)
109
+ }
110
+
95
111
  /** `12345` → `12.3k`, `800` → `800`. */
96
112
  export function formatK(tokens: number): string {
97
113
  if (tokens < 1000) return String(tokens)
@@ -99,7 +115,9 @@ export function formatK(tokens: number): string {
99
115
  return `${k.endsWith(".0") ? k.slice(0, -2) : k}k`
100
116
  }
101
117
 
102
- /** The one context string every surface shows: `ctx ~2.3k/32.8k · low` (`~` when estimated). */
118
+ /** The one context string every surface shows: `ctx ▓▓░░░ ~2.3k/32.8k · low` (`~` when
119
+ * estimated; the bar and the `/limit` only when the model's window is known). */
103
120
  export function formatContext(size: { tokens: number; estimated: boolean }, limit?: number): string {
104
- return `ctx ${size.estimated ? "~" : ""}${formatK(size.tokens)}${limit ? `/${formatK(limit)}` : ""} · ${bandFor(size.tokens)}`
121
+ const bar = limit ? `${contextBar(size.tokens, limit)} ` : ""
122
+ return `ctx ${bar}${size.estimated ? "~" : ""}${formatK(size.tokens)}${limit ? `/${formatK(limit)}` : ""} · ${bandFor(size.tokens, limit)}`
105
123
  }