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/CHANGELOG.md +28 -0
- package/README.md +22 -24
- package/dist/tui.js +1294 -489
- package/docs/USAGE.md +40 -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/tui/actions.ts +131 -11
- package/src/tui/index.tsx +14 -6
- package/src/tui/route.tsx +544 -180
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
|
|
38
|
-
|
|
39
|
-
consumers copy mode_duration mode_turns
|
|
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
|
-
|
|
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) · `
|
|
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
|
|
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
|
-
| `
|
|
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` |
|
|
84
|
+
| `1 2 3` `0` | timeline lanes by duration / turns / tool calls; `0` off |
|
|
84
85
|
| `L` | label the selected message |
|
|
85
|
-
| `f`
|
|
86
|
-
| `
|
|
87
|
-
|
|
|
88
|
-
|
|
|
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
|
|
91
|
-
q back`; the rest live
|
|
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
|
|
100
|
-
│ filter: default
|
|
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
|
-
└ ⏎
|
|
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.
|
|
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",
|
package/src/core/actions.ts
CHANGED
|
@@ -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" }
|
package/src/core/consumers.ts
CHANGED
|
@@ -3,34 +3,76 @@
|
|
|
3
3
|
* current session's context. Pure.
|
|
4
4
|
*/
|
|
5
5
|
import { estimateTokens } from "./tokens.js"
|
|
6
|
-
import type
|
|
6
|
+
import { partPreview, type StepPart, type Transcript, type TranscriptMessage } from "./transcript.js"
|
|
7
7
|
|
|
8
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 {
|
package/src/core/decision.ts
CHANGED
|
@@ -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):
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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
|
+
}
|
package/src/core/navigation.ts
CHANGED
|
@@ -6,14 +6,37 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { Filter, Row, TreeView } from "./tree.js"
|
|
8
8
|
|
|
9
|
-
/**
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
}
|
package/src/core/tokens.ts
CHANGED
|
@@ -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
|
-
/**
|
|
88
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|