diff --git a/CHANGELOG.md b/CHANGELOG.md index df6a08e..ee0a1e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ ## 0.2.3 (unreleased) +- **Turns and Calls drew the same chart.** The lane modes only ever differed by one blank cell + at each turn boundary — everything else (which events, which lanes, glyphs, colours) was + identical, so switching between them looked like nothing happened. Checking DeepSeek Harness + settled the fix: its Trajectory Overview has no mode toggle at all, marks turns with rules, + and gets "just the tool calls" from search and interval-focus rather than a mode. + - `1` / `2` now select only the **x-axis**: Duration (proportional to wall clock) or Turns + (one cell per event). `3` is gone. + - A turn boundary is drawn as a `│` **rule across all three lanes**, in both modes, instead of + a wider gap you had to measure. + - "What did I run" is now a row filter, not a mode: `f` cycles through a new **`tools-only`**, + and the lanes follow the active filter — so `tools-only` thins the rows and the lanes + together, and `no-tools` / `user-only` do too. `labeled` leaves the lanes whole (a label is + not an event), and thinking stays on the Model lane under every filter. + - A `ctree.lanes` of `"calls"` stored by an older version falls back to Turns. +- Deleted the original magnitude-column lane model (`buildLanes`, `sparkline`, `fitColumns`, + `durationWeighted`, `columnFor`) and `buildEventStrip`, superseded by the windowed layout in + 0.2.2. None of it was reachable from the route; it survived only because its tests kept + passing, which is how the Turns/Calls bug shipped in the first place. + +- The tree's status line shows what the provider was really sent at the row under the cursor, + right-aligned under the header gauge: `T2 reply · prompt 43.7k · 30.1k cached`. Unlike the + per-row token column — a marginal, chars/4 estimate — this is the provider's own + `tokens.input` (+ cache), so it includes the system prompt and the tool definitions, and the + two numbers stack in one column to be read against each other. A user turn shows the reply to + it; a turn with no reply yet reads `not sent yet`; branch headers have none. It is history: an + older row's figure is what went out then, and does not shrink when you crop something above it + later. + Pi's fork-from-an-earlier-message flow, ported whole: - `⏎` on a row above where you are now opens Pi's tree-selector question — **No summary** / diff --git a/DESIGN.md b/DESIGN.md index 08ac7dc..f86260d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -255,7 +255,7 @@ built-in `tui.json` keybinds table only covers OpenCode's own action names): - inside the route: `↑↓`/`j k` move · `g G` top/bottom · `shift+↑↓`/`J K` jump 20 · `←→`/`h l` fold/unfold branch · `⏎` go here · `b` branch · `m` merge · `c` crop mark · `t` result⇄turn · `a` auto-mark · `x` undo · `i` inspector · `u` consumers · - `D` decisions · `L` label · `/` search · `f` filter cycle · `1 2 3` lane mode · + `D` decisions · `L` label · `/` search · `f` filter cycle · `1 2` lane x-axis · `y` copy · `e` expand branch inline · `q`/`esc` back. --- @@ -447,6 +447,24 @@ auto-compaction is the *lossy* event the user wants to pre-empt with `/crop` or `/merge`. `sidebar_content` slot: `⎇ fix-flaky-test · open · parent "Fix flaky test"`, active crops, decisions on path, `[/tree]`. + +**The cursor's own prompt figure.** The tree's status line carries, right-aligned directly under +the header gauge, what the provider was really sent at the row you are on: +`T2 reply · prompt 43.7k · 30.1k cached`. It sums `input + cache.read + cache.write` exactly as +`contextSizeOf` does, so the two numbers stack in one column and are read against each other — +the gauge is now, this is the cursor, the gap is everything after that point. Because it is +`tokens.input`, it inherently includes the system prompt and the tool definitions, which the +per-row token column (a marginal, chars/4 estimate) never does. + +`core/tree.ts#promptAtRow` resolves it: an assistant step carries its own message's report on +every one of its rows; a user turn takes the first assistant message after it, the reply whose +prompt was the first to include that turn; a branch header has none, its column already being a +subtree total. Nothing sent yet — a trailing turn, a reply in flight — reads `not sent yet` +rather than a zero. It is deliberately **not** a second per-row column: it is history (an older +row's figure is what went out then, not what a later crop would send now), and one number the +user is deliberately inspecting can carry that caveat where forty scrolling ones cannot. Dropped +whole, not wrapped, when the terminal is too narrow (§7.6). + ### 6.8 Compaction interplay - The transform hook runs during compaction too, so cropped results are already @@ -471,9 +489,9 @@ records as user messages, and can use the headless `/ctree` commands. ### 7.1 Can they be combined? Yes — they are two axes of one thing -> **Revised after the 0.1.1 UX review.** The lanes are an *event strip*, as in DSH: one pill per -> event on one shared axis across Input / Model / Tools, one cell of gap between neighbours (two at -> a turn boundary in Turns mode), width proportional to duration in Duration mode, categorical +> **Revised after the 0.1.1 UX review.** The lanes are an *event strip*: one pill per +> event on one shared axis across Input / Model / Tools, one cell of gap between neighbours (three at +> a turn boundary, holding the `│` rule), width proportional to duration in Duration mode, categorical > colours (input green / context grey, model purple, tools orange, error red), the selected step > inverted. Nothing is scaled by token count — height-as-magnitude produced flat or solid lanes on > real sessions; tokens live in the row column. When the strip does not fit, the newest events are @@ -487,6 +505,15 @@ records as user messages, and can use the headless `/ctree` commands. > a one-line overview track under the lanes shows the window's position and red ticks at failed > tool calls, so global orientation survives without giving up pill fidelity. +**On the DSH comparison.** The three-lane split is ours. DSH's own `ui-trajectory` README +describes a *single* combined Overview ("A fixed Overview above the ledger projects real record +start/duration timing from left to right; Assistant spans divide recorded TTFT from decoding") +above a vertical ledger of User/Assistant/Tool/Subtool records; a hands-on review describes +"input, model and tool lanes across the top", so the secondhand sources conflict and the +original research here (Appendix A) was a screenshot and write-ups, not the source. What we do +take from DSH directly is the event-pill idea, the always-available duration axis, and the turn +rule (§7.3). + DSH's Trajectory tab and Pi's `/tree` both render the *same* append-only event stream. DSH orders it by **time** and annotates each step with **cost and duration** (three lanes on top, an inspector on the right, role badges, turn markers). Pi orders @@ -542,15 +569,37 @@ squashed and its ◆ record is T3 on the trunk; `fix-flaky-test` is where you ar is expanded under its anchor; the `bun test` result is 4.7k and flagged as a crop candidate; the inspector shows the selected `ls -la` call with DSH's five facets. -### 7.3 Modes (the `Duration | Turns | Calls` toggle) +### 7.3 The lane x-axis (`1` / `2`), and why there is no "Calls" mode -They change the *x-scale of the minimap* and the *grouping of rows*: +> **Revised (0.2.3).** There were three modes — `Duration | Turns | Calls` — and Turns and Calls +> drew the *same events in the same lanes*, differing only by one blank cell at each turn +> boundary. Checking DSH settled it: its Trajectory Overview has **no mode toggle at all**. It +> is always duration-proportional ("projects real record start/duration timing from left to +> right"), marks turns with **rules** ("Thick rules mark Turn boundaries, compact inline markers +> identify Steps"), and reaches "just the tool calls" through zoom, drag-to-focus and search +> rather than a mode. -| Mode | Minimap x-axis | Rows | -|---|---|---| -| **Turns** (default) | one column per user turn | one row per step, grouped under `T` markers (what the mockup shows) | -| **Calls** | one column per tool call | tool rows only (assistant text folded into the turn header) — the "what did I run" view, also the natural crop view | -| **Duration** | proportional to wall-clock (`time.start`/`end`) | rows carry `+12.3s` gaps; long gaps (user thinking, permission waits) are drawn as `┆ 4m idle` separators | +So the toggle now carries only what it can honestly carry — the **x-scale** — and the two other +jobs move to the mechanisms that already existed: + +| | What it is | +|---|---| +| **`1` Duration** | cells proportional to wall clock (`time.start`/`end`), so a 3-minute `bash` is visibly wider than a 0.2 s `read`. DSH's only axis. | +| **`2` Turns** (default) | one cell per event: an event *count* axis, where a busy turn is wide because it did a lot, not because it took long. | +| **turn boundaries** | drawn as a `│` rule across all three lanes, in **both** modes — DSH's thick rule. Never a mode of its own, and never just a wider gap you have to measure. | +| **which events** | the row `Filter` (`f`, §7.5), not a mode. `tools-only` is the "what did I run" view and thins the rows *and* the lanes together; `no-tools` is its mirror; `user-only` leaves the prompts. | + +`core/lanes.ts#eventAllowed` is that coupling, with two deliberate mismatches against the rows' +`stepAllowed`: `labeled` is an annotation on a row rather than a property of an event, so the +lanes read it as no filter; and reasoning stays on the Model lane under every filter, because +folding thinking into its assistant row is a *reading* convenience while the strip is a +timeline — a minute of thinking is a thing that happened. + +What this deletes: the `calls` `LaneMode`, the `3` keybind, and the whole original +magnitude-column model (`buildLanes`, `sparkline`, `fitColumns`, `durationWeighted`, +`columnFor`) plus `buildEventStrip`, which the windowed layout superseded in 0.2.2. None of it +was reachable from the route; it survived only because its tests kept passing, which is exactly +how the Turns/Calls bug shipped. ### 7.4 Secondary views (`u`, `D`, crop mode) — from `pi-context-tree` diff --git a/README.md b/README.md index a1740a8..9471e10 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ Keys inside `/tree` (vim-aligned): `j k` `ctrl+d ctrl+u` `gg G` move · `[ ]` ho branches · `h l` `Tab` fold/unfold · `⏎` go (the footer says what it will do for the selected row) · `b` branch · `m` merge · `c` crop mode (`space` mark, `a` auto, `t` result⇄turn, `⏎` apply) · `u` undo (`x` too) · `/` live search, `n N` next/prev · `f` filter picker · `i` inspector · -`1 2 3` lanes, `0` off · `s` what's filling the context · `D` decisions · `L` label · `y` copy · +`1 2` lanes, `0` off · `s` what's filling the context · `D` decisions · `L` label · `y` copy · `?` help · `q`. ## Commands diff --git a/docs/USAGE.md b/docs/USAGE.md index 569fa55..c675894 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -37,7 +37,7 @@ e.g. `{ "keybinds": { "open": "ctrl+t", "up": "k,up", "copy": "none" } }` — na `open up down jump_up jump_down half_up half_down first last prev_branch next_branch fold unfold toggle go branch label filter_pick filter_prev search search_next search_prev back crop crop_toggle_mode mark auto undo merge inspector consumers copy mode_duration mode_turns -mode_calls lanes_off decisions export help`. +lanes_off decisions export help`. ## Upgrading @@ -113,7 +113,7 @@ appended to the trunk as a normal message.* | `D` `E` | decisions panel, export `ctree-decisions.md` | | `s` | consumers: what is filling the context (`⏎` opens a bucket, `space` marks one entry for crop) | | `i` | inspector pane on/off (auto-hidden under 110 columns) | -| `1 2 3` `0` | timeline lanes by duration / turns / tool calls; `0` off | +| `1 2` `0` | timeline lanes, x-axis by duration / one cell per event; `0` off. `│` marks a turn boundary, and the lanes show whatever the `f` filter shows — so `f` → `tools-only` is the "what did I run" view in both the rows and the lanes | | `L` | label the selected message | | `f` `F` | filter picker (default → no-tools → user-only → labeled → all); `F` steps back | | `/` `n` `N` | live search: typing re-filters the rows, `⏎` keeps the filter, `esc` clears; `n` `N` next / previous match | @@ -179,6 +179,22 @@ Palette: **Context tree**, **Branch here**, **Merge branch**, **Decisions**, **L - Tokens: a leading `~` means estimated (chars/4); assistant steps use the model's own counts. Step durations and lane heights are read from the same data — estimated wherever the `~` is. - `⚠` ≥10k tokens, `✂` cropped, `✗` tool error, `◆` decision record, `◇` branch summary. + +The right end of the status line (second line, under the `ctx …` gauge) is the same figure for +the row your cursor is on: `T2 reply · prompt 43.7k · 30.1k cached` — the whole prompt the +provider was actually sent at that point, **system prompt and tool definitions included**, +because that is what `tokens.input` covers. Read it against the gauge above it: the gauge is +now, this is where the cursor is, and the gap between them is everything after that point. + +It comes from the provider, not an estimate, so it has no `~`. An assistant step reports its +own message's prompt; a user turn reports the reply *to* it (the first prompt that included +it); a turn with no reply yet says `not sent yet`. Branch headers have none — their token +column is already a subtree total. On a terminal too narrow to hold both, the figure is +dropped rather than wrapped. + +One caveat: it is **history**. An older row's figure is what went out at the time, so it does +not shrink when you later crop or merge something above it — the estimated per-row column does, +because it is recomputed from the transcript each time. - A branch you just made says `just branched, nothing here yet` — there is nothing to unfold. - The gauge on the prompt line: `⎇ fix-flaky · ctx ▓▓░░░ ~46k/200k · filling · 95% cached ▲+24% (bash)` — the context of the next prompt (the same figure as OpenCode's own sidebar), diff --git a/src/core/lanes.ts b/src/core/lanes.ts index 12e99a6..154a25b 100644 --- a/src/core/lanes.ts +++ b/src/core/lanes.ts @@ -1,30 +1,18 @@ /** - * DSH-style timeline lanes (DESIGN.md §7.1, §7.3): three series — Input, Model, - * Tools — over the chosen mode's x-axis. Two models live here: the original - * magnitude columns (`buildLanes` + `sparkline`) and the event strip - * (`buildEventStrip`), which is what the DSH bar actually draws. Pure. + * Timeline lanes (DESIGN.md §7.1, §7.3): three series — Input, Model, Tools — of one pill + * per event on one shared axis, with turn boundaries drawn as a rule across all three. + * + * Two things pick what you see, and they are deliberately different questions: + * `LaneMode` is only the **x-scale** (uniform per event, or proportional to wall clock), + * and the route's row `Filter` is **which events** — so "tool calls only" is the same + * `tools-only` filter that thins the rows, never a third mode that redraws the same + * events a cell wider. Pure. */ import { estimateTokens } from "./tokens.js" import { stepKind, type StepPart, type Transcript, type TranscriptMessage } from "./transcript.js" +import type { Filter } from "./tree.js" -export type LaneMode = "turns" | "calls" | "duration" - -export type LaneColumn = { - /** identifies what the column represents, for cursor mirroring */ - messageID: string - /** the column's user message, so the cursor also mirrors ● rows (turns/duration mode) */ - userMessageID?: string - partID?: string - turn: number - input: number - output: number - tool: number - toolError: boolean - /** wall-clock span of the column, ms (duration mode) */ - ms: number -} - -export type Lanes = { mode: LaneMode; columns: LaneColumn[] } +export type LaneMode = "turns" | "duration" type Turn = { user?: TranscriptMessage; assistants: TranscriptMessage[]; index: number } @@ -47,107 +35,6 @@ function spanOf(m: TranscriptMessage): number { return end > m.time.created ? end - m.time.created : 0 } -export function buildLanes(transcript: Transcript, mode: LaneMode): Lanes { - const columns: LaneColumn[] = [] - for (const turn of turnsOf(transcript.messages)) { - if (mode === "calls") { - let any = false - for (const m of turn.assistants) { - for (const p of m.parts) { - if (p.type !== "tool") continue - any = true - const out = p.state?.output ?? "" - const t = p.state?.time - columns.push({ messageID: m.id, userMessageID: turn.user?.id, partID: p.id, turn: turn.index, input: m.tokens?.input ?? 0, output: 0, tool: estimateTokens(out), toolError: p.state?.status === "error", ms: t?.start !== undefined && t?.end !== undefined ? t.end - t.start : 0 }) - } - } - if (!any) { - const last = turn.assistants.at(-1) - columns.push({ messageID: last?.id ?? turn.user?.id ?? "", userMessageID: turn.user?.id, turn: turn.index, input: last?.tokens?.input ?? 0, output: last?.tokens?.output ?? 0, tool: 0, toolError: false, ms: last ? spanOf(last) : 0 }) - } - continue - } - // turns / duration: one column per user turn - const last = turn.assistants.at(-1) - let tool = 0 - let toolError = false - let ms = 0 - let output = 0 - for (const m of turn.assistants) { - output += m.tokens?.output ?? 0 - ms += spanOf(m) - for (const p of m.parts) { - if (p.type !== "tool") continue - tool += estimateTokens(p.state?.output ?? "") - if (p.state?.status === "error") toolError = true - } - } - columns.push({ messageID: last?.id ?? turn.user?.id ?? "", userMessageID: turn.user?.id, turn: turn.index, input: last?.tokens?.input ?? 0, output, tool, toolError, ms }) - } - return { mode, columns } -} - -const BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] - -/** `scale` (the model's context limit for the Input lane) fixes the reference height, so a - * two-message session no longer draws a full bar next to `ctx 100 · low`. */ -export function sparkline(values: number[], width: number, scale?: number): string { - if (values.length === 0 || width <= 0) return "" - const cells = fitColumns(values, width) - const max = Math.max(1, scale ?? 0, ...cells) - return cells.map((v) => (v <= 0 ? " " : BLOCKS[Math.min(7, Math.floor((v / max) * 7.999))]!)).join("") -} - -/** Resample `values` to exactly `width` cells (max-pooling when shrinking, repeating when growing). */ -export function fitColumns(values: number[], width: number): number[] { - if (values.length === 0) return [] - if (values.length === width) return values.slice() - const out: number[] = [] - if (values.length > width) { - const per = values.length / width - for (let i = 0; i < width; i++) { - const a = Math.floor(i * per) - const b = Math.max(a + 1, Math.floor((i + 1) * per)) - out.push(Math.max(...values.slice(a, b))) - } - return out - } - const rep = width / values.length - for (let i = 0; i < width; i++) out.push(values[Math.min(values.length - 1, Math.floor(i / rep))]!) - return out -} - -/** Duration mode: repeat each column proportionally to its wall-clock share. */ -export function durationWeighted(lanes: Lanes, width: number): { input: number[]; output: number[]; tool: number[]; toolError: boolean[]; columnAt: (cell: number) => number } { - const total = lanes.columns.reduce((s, c) => s + Math.max(1, c.ms), 0) || 1 - const input: number[] = [] - const output: number[] = [] - const tool: number[] = [] - const toolError: boolean[] = [] - const owner: number[] = [] - lanes.columns.forEach((c, i) => { - const cells = Math.max(1, Math.round((Math.max(1, c.ms) / total) * width)) - for (let k = 0; k < cells; k++) { - input.push(c.input) - output.push(c.output) - tool.push(c.tool) - toolError.push(c.toolError) - owner.push(i) - } - }) - return { input, output, tool, toolError, columnAt: (cell) => owner[Math.min(owner.length - 1, Math.max(0, cell))] ?? 0 } -} - -/** Index of the column that contains a given message/part (for the cursor marker). */ -export function columnFor(lanes: Lanes, messageID: string, partID?: string): number { - if (partID) { - const i = lanes.columns.findIndex((c) => c.partID === partID) - if (i >= 0) return i - } - const i = lanes.columns.findIndex((c) => c.messageID === messageID || c.userMessageID === messageID) - return i >= 0 ? i : -1 -} - /* ── Event strip (DESIGN.md §7.1's DSH trajectory bar) ──────────────────────── * The strip is an *event* timeline, not a histogram: one small pill per event on * one shared axis across the three lanes, gap between neighbours, width = duration @@ -171,20 +58,6 @@ export type LaneEvent = { /** One terminal cell of one lane. */ export type StripCell = { lane: LaneEvent["lane"]; eventIndex: number; glyph: string; error?: boolean } -export type EventStrip = { - /** time order; only the events that fit — see `truncatedLeft` */ - events: LaneEvent[] - width: number - /** `width` cells each; null = gap/empty */ - lanes: Record - /** cell index range [start, end) of `events[i]`, for cursor/scroll mapping */ - spans: { start: number; end: number }[] - /** lane has no events in the strip (render "no tool calls" etc.) */ - empty: Record - /** older events dropped off the left because they did not fit */ - truncatedLeft: number -} - const EVENT_GLYPH = "▬" function ctreeKindOf(message: TranscriptMessage): string | undefined { @@ -227,7 +100,32 @@ function partEvent(message: TranscriptMessage, part: StepPart, turn: number): La return { ...base, lane: "model", kind: part.type === "reasoning" ? "reasoning" : "text", tokens: estimateTokens(part.text ?? "") } } -function eventsOf(transcript: Transcript): LaneEvent[] { +/** + * Which events a row filter leaves on the strip, so the lanes and the rows always answer the + * same question (DESIGN.md §7.3): `tools-only` is the "what did I run" view in both, and + * there is no lane mode that does it separately. + * + * Two deliberate mismatches with `stepAllowed`: `labeled` is an annotation on a row, not a + * property of an event, so the lanes read it as no filter at all; and reasoning stays on the + * Model lane in every filter, because folding it into its assistant row is a *reading* + * convenience and the strip is a timeline — a minute of thinking is a thing that happened. + */ +export function eventAllowed(filter: Filter, kind: LaneEvent["kind"]): boolean { + switch (filter) { + case "user-only": + return kind === "user" || kind === "context" + case "tools-only": + return kind === "tool" + case "no-tools": + return kind !== "tool" + case "default": + case "labeled": + case "all": + return true + } +} + +function eventsOf(transcript: Transcript, filter: Filter): LaneEvent[] { const out: LaneEvent[] = [] for (const turn of turnsOf(transcript.messages)) { if (turn.user) out.push(messageEvent(turn.user, turn.index)) @@ -242,12 +140,15 @@ function eventsOf(transcript: Transcript): LaneEvent[] { } } } - return out + return out.filter((e) => eventAllowed(filter, e.kind)) } -/** Turns mode groups by turn, so a turn boundary breaks wider than a step boundary. */ -function gapBefore(events: LaneEvent[], i: number, mode: LaneMode): number { - return mode === "turns" && events[i]!.turn !== events[i - 1]!.turn ? 2 : 1 +/** A turn boundary is drawn, not implied: DSH's Overview marks turns with a rule rather than + * offering a "turns" layout, so the gap widens to hold one and both modes get it. */ +const TURN_RULE_GAP = 3 + +function isTurnBoundary(events: LaneEvent[], i: number): boolean { + return i > 0 && events[i]!.turn !== events[i - 1]!.turn } function durationWidths(events: LaneEvent[], budget: number): number[] { @@ -270,7 +171,7 @@ function durationWidths(events: LaneEvent[], budget: number): number[] { const DURATION_CELLS = 4 export type EventLayout = { - /** every event of the transcript, in time order */ + /** every event the filter left, in time order */ events: LaneEvent[] /** cell range [start, end) of `events[i]` on the unbounded axis */ spans: { start: number; end: number }[] @@ -278,16 +179,25 @@ export type EventLayout = { /** `totalWidth` cells each; null = gap/empty */ lanes: Record empty: Record + /** cells carrying a turn rule, drawn across every lane */ + rules: number[] } -/** The whole timeline on one axis, however wide it comes out — `windowFor` picks the slice to draw. */ -export function layoutEventStrip(transcript: Transcript, mode: LaneMode): EventLayout { - const events = eventsOf(transcript) +/** + * The whole timeline on one axis, however wide it comes out — `windowFor` picks the slice to + * draw. `mode` sets only the x-scale: one cell per event, or cells proportional to wall clock. + * `filter` decides which events are on it at all. + */ +export function layoutEventStrip(transcript: Transcript, mode: LaneMode, filter: Filter = "all"): EventLayout { + const events = eventsOf(transcript, filter) const widths = mode === "duration" ? durationWidths(events, events.length * DURATION_CELLS) : events.map(() => 1) const spans: { start: number; end: number }[] = [] + const rules: number[] = [] let cursor = 0 events.forEach((_, i) => { - const start = cursor + (i === 0 ? 0 : gapBefore(events, i, mode)) + const boundary = isTurnBoundary(events, i) + if (boundary) rules.push(cursor + 1) // centred in the wider gap it opens + const start = cursor + (i === 0 ? 0 : boundary ? TURN_RULE_GAP : 1) cursor = start + (widths[i] ?? 1) spans.push({ start, end: cursor }) }) @@ -298,29 +208,7 @@ export function layoutEventStrip(transcript: Transcript, mode: LaneMode): EventL for (let c = spans[i]!.start; c < spans[i]!.end; c++) lanes[e.lane][c] = cell }) const has = (lane: LaneEvent["lane"]) => !events.some((e) => e.lane === lane) - return { events, spans, totalWidth: cursor, lanes, empty: { input: has("input"), model: has("model"), tools: has("tools") } } -} - -/** The layout windowed at its end: the newest events, as many as `width` holds. */ -export function buildEventStrip(transcript: Transcript, mode: LaneMode, width: number): EventStrip { - const w = Math.max(0, Math.floor(width)) - const layout = layoutEventStrip(transcript, mode) - const start = Math.max(0, layout.totalWidth - w) - const truncatedLeft = layout.spans.filter((s) => s.end <= start).length - const lane = (l: LaneEvent["lane"]): (StripCell | null)[] => - Array.from({ length: w }, (_, c) => { - const cell = layout.lanes[l][start + c] - return cell ? { ...cell, eventIndex: cell.eventIndex - truncatedLeft } : null - }) - const lanes: EventStrip["lanes"] = { input: lane("input"), model: lane("model"), tools: lane("tools") } - return { - events: layout.events.slice(truncatedLeft), - width: w, - lanes, - spans: layout.spans.slice(truncatedLeft).map((s) => ({ start: Math.max(0, s.start - start), end: Math.min(w, s.end - start) })), - empty: { input: lanes.input.every((c) => c === null), model: lanes.model.every((c) => c === null), tools: lanes.tools.every((c) => c === null) }, - truncatedLeft, - } + return { events, spans, totalWidth: cursor, lanes, empty: { input: has("input"), model: has("model"), tools: has("tools") }, rules } } /** diff --git a/src/core/navigation.ts b/src/core/navigation.ts index d96b709..2ade090 100644 --- a/src/core/navigation.ts +++ b/src/core/navigation.ts @@ -61,10 +61,10 @@ export function toggleExpanded(expanded: Set, sessionID: string): Set): PromptAtRow { + if (row.kind === "branch" || row.kind === "separator") return { kind: "none" } + const of = (fields: StepRow["tokenFields"]): PromptAtRow => + fields ? { kind: "prompt", prompt: fields.input + fields.cacheRead + fields.cacheWrite, cached: fields.cacheRead } : { kind: "pending" } + if (row.kind === "step") return of(row.tokenFields) + + const messages = transcripts[row.sessionID]?.messages + if (!messages) return { kind: "none" } + const idx = messages.findIndex((m) => m.id === row.messageID) + if (idx === -1) return { kind: "none" } + // the first reply after this turn; if it is still streaming it has no report yet, and + // "pending" is the honest answer rather than a later turn's larger prompt + for (let i = idx + 1; i < messages.length; i++) { + if (messages[i]!.role === "assistant") return of(tokenFieldsOf(messages[i]!)) + } + return { kind: "pending" } +} + +/** `T2 reply · prompt 43.7k · 30.1k cached`, or `T3 · not sent yet`. Empty when there is + * nothing to say, so the caller can drop the field rather than print a placeholder. */ +export function formatPromptAt(at: PromptAtRow, o: { turn?: number; what?: string } = {}): string { + if (at.kind === "none") return "" + const head = [o.turn === undefined ? "" : `T${o.turn}`, at.kind === "pending" ? "" : (o.what ?? "")].filter(Boolean).join(" ") + const lead = head ? `${head} · ` : "" + if (at.kind === "pending") return `${lead}not sent yet` + // a permanent "0 cached" on a provider that never caches is noise, as in formatContext + return `${lead}prompt ${formatK(at.prompt)}${at.cached > 0 ? ` · ${formatK(at.cached)} cached` : ""}` +} + export function aggregateTokens(messages: TranscriptMessage[]): number { let total = 0 for (const m of messages) { @@ -283,12 +333,15 @@ function stepAllowed(filter: Filter, kind: "text" | "tool" | "reasoning" | "othe return kind !== "other" case "no-tools": return kind !== "other" && kind !== "tool" + case "tools-only": + return kind === "tool" case "all": return true } } function turnAllowed(filter: Filter, label: string | undefined): boolean { + if (filter === "tools-only") return false return filter === "labeled" ? Boolean(label) : true } diff --git a/src/tui/route.tsx b/src/tui/route.tsx index 5e68abc..0ab3b12 100644 --- a/src/tui/route.tsx +++ b/src/tui/route.tsx @@ -10,7 +10,7 @@ import { abandonedTail, planJump, type AbandonedTail, type JumpPlan } from "../c import { foldJournal, type TreeState } from "../core/journal.js" import { firstIndex, lastIndex, moveSelection, nextBranchIndex, resolveSelection, toggleExpanded } from "../core/navigation.js" import { contextSizeOf, formatContext, formatK, type MinimalMessage } from "../core/tokens.js" -import { buildSpineMap, buildTreeView, currentChainOf, type Filter, type Row, type StepRow, type TurnRow } from "../core/tree.js" +import { buildSpineMap, buildTreeView, currentChainOf, formatPromptAt, promptAtRow, type Filter, type Row, type StepRow, type TurnRow } from "../core/tree.js" import { ContextGauge } from "./gauge.js" import type { Transcript } from "../core/transcript.js" import type { JournalStore } from "../shared/store.js" @@ -172,7 +172,6 @@ const DEFAULT_KEYS: Record = { copy: ["y"], mode_duration: ["1"], mode_turns: ["2"], - mode_calls: ["3"], lanes_off: ["0"], decisions: ["shift+d"], export: ["shift+e"], @@ -205,21 +204,24 @@ const HELP = [ " b branch · m merge · c crop mode (space mark · a auto · t result⇄turn · ⏎ apply · esc leave)", " u undo (alias x) · L label · y copy · E export decisions", "Views", - " i inspector · 1 2 3 lanes (duration/turns/calls) · 0 off · s consumers · D decisions · f F filter", + " i inspector · 1 2 lanes (duration/turns x-axis) · 0 off · s consumers · D decisions · f F filter", "Legend", " ● user · ○ assistant · ⚙ tool step · ◆ decision · ≣ summary · ⎇ branch (a real OpenCode session)", " │ ├ ╰ draw the topology · ▾ open ▸ folded · ← here is the session you are in", " dim rows are not sent to the model; ── not in this branch's context ── is where your path forked", " right column is tokens; ~ estimated · ⚠ ≥10k · ✂ cropped · ✗ tool error", + " status-line right: the prompt really sent at the cursor · history, not re-costed after a crop", " ⎇ colours: open green · squashed blue · rejected/discarded red · abandoned grey", " lanes: Input green you / grey context · Model purple answer / grey thinking · Tools orange call / red failed", " the lanes are a window that follows the cursor: …N / N… are events hidden either side, all = whole session", + " │ in the lanes is a turn boundary · the lanes show what the f filter shows (f → tools-only = just calls)", ] /** `f` opens this as a picker; `F` steps back through it (DESIGN.md §7.5). */ const FILTERS: { title: string; value: Filter; description: string }[] = [ { title: "default", value: "default", description: "user turns, assistant text, tool steps" }, { title: "no-tools", value: "no-tools", description: "hide ⚙ tool steps" }, + { title: "tools-only", value: "tools-only", description: "⚙ tool steps only — what did I run (the lanes follow)" }, { title: "user-only", value: "user-only", description: "● user turns only" }, { title: "labeled", value: "labeled", description: "labelled rows only" }, { title: "all", value: "all", description: "everything, thinking parts included" }, @@ -254,7 +256,8 @@ export function TreeRoute(props: TreeRouteProps) { const [summaryAbort, setSummaryAbort] = createSignal() const [cropMode, setCropMode] = createSignal<"result" | "turn" | undefined>() const [panel, setPanel] = createSignal<"tree" | "decisions" | "consumers" | "help">(props.initialView ?? "tree") - const [laneMode, setLaneMode] = createSignal(api.kv.get("ctree.lanes", "turns")) + // "calls" was a third mode until it became the `tools-only` row filter; old kv still holds it + const [laneMode, setLaneMode] = createSignal(api.kv.get("ctree.lanes", "turns") === "duration" ? "duration" : "turns") // DSH lanes and inspector are first-class but off by default, so the first screen reads as // Pi's clean outline (header + tree + footer); `1/2/3` and `i` bring them in, one keystroke. const [lanesOn, setLanesOn] = createSignal(api.kv.get("ctree.lanesOn", false)) @@ -396,7 +399,7 @@ export function TreeRoute(props: TreeRouteProps) { // ---- lane geometry (the lanes themselves are further down) ---------------- // 61 = the 12-cell label column + the `N…` cue + the mode legend that follows the Input lane const laneWidth = () => Math.max(10, Math.min(width() - 61, 80)) - const layout = createMemo(() => layoutEventStrip(live() ?? EMPTY_TRANSCRIPT, laneMode())) + const layout = createMemo(() => layoutEventStrip(live() ?? EMPTY_TRANSCRIPT, laneMode(), filter())) /** DESIGN.md §7.6: below 80 columns the strip is the Input lane alone. */ const showAllLanes = () => cols() >= 80 /** The overview track only exists — and only costs its row — when the timeline overflows. */ @@ -615,6 +618,7 @@ export function TreeRoute(props: TreeRouteProps) { const cur = cursorEvents() const start = laneOffset() const w = laneWidth() + const rules = new Set(layout().rules) const runs: { text: string; fg: unknown; bg: unknown }[] = [] for (let c = 0; c < w; c++) { const cell = layout().lanes[lane][start + c] ?? null @@ -622,9 +626,12 @@ export function TreeRoute(props: TreeRouteProps) { const color = cell === null ? t.textMuted : cellColor(cell) const fg = sel ? t.background : color const bg = sel ? color : undefined + // a turn boundary is a rule across all three lanes, the way DSH marks turns on its + // Overview — it never lands on a pill, the gap that holds it is opened for it + const glyph = cell?.glyph ?? (rules.has(start + c) ? "│" : " ") const last = runs[runs.length - 1] - if (last && last.fg === fg && last.bg === bg) last.text += cell?.glyph ?? " " - else runs.push({ text: cell?.glyph ?? " ", fg, bg }) + if (last && last.fg === fg && last.bg === bg) last.text += glyph + else runs.push({ text: glyph, fg, bg }) } return runs } @@ -1303,7 +1310,6 @@ export function TreeRoute(props: TreeRouteProps) { { name: "ctree.copy", hidden: true, enabled: treeIdle, run: () => copySelected() }, { name: "ctree.mode_duration", hidden: true, enabled: treePanel, run: () => setLane("duration") }, { name: "ctree.mode_turns", hidden: true, enabled: treePanel, run: () => setLane("turns") }, - { name: "ctree.mode_calls", hidden: true, enabled: treePanel, run: () => setLane("calls") }, { name: "ctree.lanes_off", hidden: true, enabled: treePanel, run: () => { setLanesOn(false); api.kv.set("ctree.lanesOn", false) } }, { name: "ctree.decisions", hidden: true, enabled: () => !inCrop(), run: () => setPanel(panel() === "decisions" ? "tree" : "decisions") }, { name: "ctree.export", hidden: true, enabled: () => panel() === "decisions", run: () => exportDecisionsFile() }, @@ -1358,6 +1364,35 @@ export function TreeRoute(props: TreeRouteProps) { return `${lead}${where}${clipTo(title(), Math.max(8, room))}${tail}${modeTag()} ` } + /** The turn a row sits in, for the `T` in the prompt figure — the same walk the + * inspector's `Hierarchy` line does. */ + const turnOf = (row: Row) => { + const i = view().indexById[row.id] + if (i === undefined) return undefined + const owner = view().rows.slice(0, i + 1).findLast((r) => r.kind === "turn") + return owner?.kind === "turn" ? owner.turn : undefined + } + + /** What the selected row is, for the prompt figure: a tool step is named by its tool. */ + const whatOf = (row: Row) => { + if (row.kind !== "step") return "reply" + if (row.glyph === "◇") return "compaction" + if (row.glyph !== "⚙") return "reply" + const tr = row.sessionID === sessionID ? live() : others()[row.sessionID] + const part = tr?.messages.find((m) => m.id === row.messageID)?.parts.find((p) => p.id === row.partID) + return part?.type === "tool" ? (part.tool ?? "tool") : "reply" + } + + /** `T2 reply · prompt 43.7k · 30.1k cached` for the row under the cursor: what the provider + * was really sent at that point, against the whole-session `ctx …` gauge directly above it. + * It is history — an older row's prompt is what went out *then*, before any crop or merge + * you have applied since (DESIGN.md §6.7). */ + const promptHere = () => { + const row = current() + if (!row) return "" + return formatPromptAt(promptAtRow(row, transcripts()), { turn: turnOf(row), what: whatOf(row) }) + } + const statusLine = () => { const n = view().rows.length const pos = `${n ? Math.min(selected() + 1, n) : 0}/${n}` @@ -1368,7 +1403,12 @@ export function TreeRoute(props: TreeRouteProps) { if (searchMode()) return `search: ${search()}▏ · ${pos} rows · ⏎ keeps it · esc clears` const said = notice() if (said) return `${clipTo(said, cols())} ${pos} rows` - return `filter: ${filter()}${search() ? ` search: "${search()}"` : ""}${busy() ? ` … ${busy()}` : ""} ${pos} rows` + const left = `filter: ${filter()}${search() ? ` search: "${search()}"` : ""}${busy() ? ` … ${busy()}` : ""} ${pos} rows` + const right = promptHere() + // right-aligned under the header's `ctx …`; dropped rather than wrapped when the + // terminal is too narrow to hold both (DESIGN.md §7.6) + const room = cols() - 4 - left.length - right.length + return right && room >= 3 ? `${left}${" ".repeat(room)}${right}` : left } /** `⏎` does four different things; the footer says which one for the row under the cursor. */ @@ -1412,7 +1452,7 @@ export function TreeRoute(props: TreeRouteProps) { {"no input".padEnd(laneWidth())}}> {(r) => {r.text}} - {`${(hiddenRight() > 0 ? `${laneCue(hiddenRight())}…` : "").padStart(4).padEnd(5)}${laneMode() === "duration" ? "[1] Duration" : " 1 duration"} · ${laneMode() === "turns" ? "[2] Turns" : " 2 turns"} · ${laneMode() === "calls" ? "[3] Calls" : " 3 calls"} · 0 off`} + {`${(hiddenRight() > 0 ? `${laneCue(hiddenRight())}…` : "").padStart(4).padEnd(5)}${laneMode() === "duration" ? "[1] Duration" : " 1 duration"} · ${laneMode() === "turns" ? "[2] Turns" : " 2 turns"} · 0 off`} diff --git a/test/lanes.test.ts b/test/lanes.test.ts index d6ac653..2ff53a2 100644 --- a/test/lanes.test.ts +++ b/test/lanes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { buildEventStrip, buildLanes, columnFor, durationWeighted, fitColumns, layoutEventStrip, overviewTrack, sparkline, stripIndexFor, windowFor, type EventStrip } from "../src/core/lanes.js" +import { eventAllowed, layoutEventStrip, overviewTrack, stripIndexFor, windowFor, type EventLayout } from "../src/core/lanes.js" import { bar, consumers } from "../src/core/consumers.js" import type { Transcript, TranscriptMessage } from "../src/core/transcript.js" import { assistant, buildFixture, OPEN, user } from "./fixtures/tree.js" @@ -7,65 +7,6 @@ import { assistant, buildFixture, OPEN, user } from "./fixtures/tree.js" const f = buildFixture() const open = f.transcripts[OPEN]! -describe("lanes", () => { - test("turns mode: one column per user turn with context size and tool tokens", () => { - const l = buildLanes(open, "turns") - expect(l.columns.length).toBe(4) - expect(l.columns.map((c) => c.input)).toEqual([1200, 2400, 5000, 6000]) - expect(l.columns[2]!.tool).toBeGreaterThan(4000) - expect(l.columns[0]!.tool).toBeGreaterThan(1000) - }) - test("calls mode: one column per tool call, text-only turns keep a column", () => { - const l = buildLanes(open, "calls") - expect(l.columns.map((c) => Boolean(c.partID))).toEqual([true, false, true, false]) - expect(columnFor(l, "oa1", "oa1-tool")).toBe(2) - }) - test("sparkline scales to width and max", () => { - expect(sparkline([1, 2, 4, 8], 4)).toBe("▁▂▄█") - expect(sparkline([1, 2, 4, 8], 2).length).toBe(2) - expect(fitColumns([1, 2], 4)).toEqual([1, 1, 2, 2]) - expect(fitColumns([1, 5, 2, 9], 2)).toEqual([5, 9]) - }) - test("an explicit scale (the context limit) keeps a small session small", () => { - expect(sparkline([100], 4)).toBe("████") - expect(sparkline([100], 4, 200_000)).toBe("▁▁▁▁") - expect(sparkline([400_000], 4, 200_000)).toBe("████") // never clips above the limit - }) - test("turns mode: the cursor mirrors ● user rows too", () => { - const l = buildLanes(open, "turns") - expect(columnFor(l, "om1")).toBe(2) // the user message that opened turn 3 - expect(columnFor(l, "oa1")).toBe(2) - }) - test("a leading compaction summary opens turn 0 instead of being dropped", () => { - const compacted = { ...open, messages: [{ ...open.messages[1]!, summary: true }, ...open.messages.slice(4)] } - const l = buildLanes(compacted, "turns") - expect(l.columns.map((c) => c.turn)).toEqual([0, 1, 2]) - expect(l.columns[0]!.tool).toBeGreaterThan(1000) - }) - test("duration weighting maps cells back to columns", () => { - const l = buildLanes(open, "turns") - const w = durationWeighted(l, 20) - expect(w.input.length).toBeGreaterThanOrEqual(4) - expect(w.columnAt(0)).toBe(0) - expect(w.columnAt(w.input.length - 1)).toBe(3) - }) - test("duration mode spreads cells by wall clock, so it does not draw like turns mode", () => { - const columns = [ - { messageID: "a", turn: 1, input: 1000, output: 0, tool: 1000, toolError: false, ms: 1000 }, - { messageID: "b", turn: 2, input: 2000, output: 0, tool: 8000, toolError: false, ms: 9000 }, - ] - const w = durationWeighted({ mode: "turns", columns }, 20) - expect(w.tool.filter((v) => v === 8000).length).toBeGreaterThan(w.tool.filter((v) => v === 1000).length * 3) - const even = sparkline(fitColumns(columns.map((c) => c.tool), 20), 20) - expect(sparkline(fitColumns(w.tool, 20), 20)).not.toBe(even) - }) - test("duration weighting keeps tool sizes and flags errors separately", () => { - const w = durationWeighted({ mode: "turns", columns: [{ messageID: "a", turn: 1, input: 0, output: 0, tool: 500, toolError: true, ms: 10 }] }, 4) - expect(w.tool.every((v) => v === 500)).toBe(true) - expect(w.toolError.every(Boolean)).toBe(true) - }) -}) - describe("consumers", () => { test("tokens by source, sorted, with shares that sum to 1", () => { const c = consumers(open) @@ -90,143 +31,159 @@ describe("consumers", () => { const T = (messages: TranscriptMessage[]): Transcript => ({ sessionID: "s", title: "strip", status: "available", messages }) /** cells that belong to any lane, per index — the axis is shared, so this must never exceed 1 */ -function occupancy(strip: EventStrip): number[] { - return Array.from({ length: strip.width }, (_, c) => (["input", "model", "tools"] as const).filter((l) => strip.lanes[l][c] !== null).length) +function occupancy(l: EventLayout): number[] { + return Array.from({ length: l.totalWidth }, (_, c) => (["input", "model", "tools"] as const).filter((lane) => l.lanes[lane][c] !== null).length) } describe("event strip", () => { - test("calls mode: one pill per event, single gaps, no overlap across lanes", () => { - const s = buildEventStrip(T([user("u1", "hi"), assistant("a1", { text: "ok", tool: { name: "bash", input: { command: "ls" }, output: "out" } })]), "calls", 40) - expect(s.events.map((e) => `${e.lane}/${e.kind}`)).toEqual(["input/user", "tools/tool", "model/text"]) - expect(s.spans).toEqual([{ start: 0, end: 1 }, { start: 2, end: 3 }, { start: 4, end: 5 }]) - expect(s.lanes.input[0]).toEqual({ lane: "input", eventIndex: 0, glyph: "▬" }) - expect(s.lanes.input[1]).toBe(null) - expect(occupancy(s).every((n) => n <= 1)).toBe(true) - expect(s.truncatedLeft).toBe(0) + test("one pill per event, single gaps inside a turn, no overlap across lanes", () => { + const l = layoutEventStrip(T([user("u1", "hi"), assistant("a1", { text: "ok", tool: { name: "bash", input: { command: "ls" }, output: "out" } })]), "turns") + expect(l.events.map((e) => `${e.lane}/${e.kind}`)).toEqual(["input/user", "tools/tool", "model/text"]) + expect(l.spans).toEqual([{ start: 0, end: 1 }, { start: 2, end: 3 }, { start: 4, end: 5 }]) + expect(l.lanes.input[0]).toEqual({ lane: "input", eventIndex: 0, glyph: "▬" }) + expect(l.lanes.input[1]).toBe(null) + expect(occupancy(l).every((n) => n <= 1)).toBe(true) + expect(l.rules).toEqual([]) // one turn, so no boundary to rule }) - test("turns mode: a turn boundary opens a 2-cell gap, steps inside a turn keep 1", () => { - const s = buildEventStrip(T([user("u1", "a"), assistant("a1", { text: "one" }), user("u2", "b"), assistant("a2", { text: "two" })]), "turns", 40) - expect(s.events.map((e) => e.turn)).toEqual([1, 1, 2, 2]) - expect(s.spans[1]!.start - s.spans[0]!.end).toBe(1) - expect(s.spans[2]!.start - s.spans[1]!.end).toBe(2) - expect(s.spans[3]!.start - s.spans[2]!.end).toBe(1) - expect(s.lanes.input[s.spans[2]!.start]!.eventIndex).toBe(2) + + test("a turn boundary opens a gap and puts a rule in it, in both modes", () => { + const two = T([user("u1", "a"), assistant("a1", { text: "one" }), user("u2", "b"), assistant("a2", { text: "two" })]) + for (const mode of ["turns", "duration"] as const) { + const l = layoutEventStrip(two, mode) + expect(l.events.map((e) => e.turn)).toEqual([1, 1, 2, 2]) + expect(l.spans[1]!.start - l.spans[0]!.end).toBe(1) // inside a turn + expect(l.spans[2]!.start - l.spans[1]!.end).toBe(3) // across the boundary + expect(l.rules).toEqual([l.spans[1]!.end + 1]) + // the rule never lands on a pill: its cell is empty in every lane + for (const lane of ["input", "model", "tools"] as const) expect(l.lanes[lane][l.rules[0]!]).toBe(null) + } }) - test("duration mode: widths follow durations, untimed events keep 1 cell", () => { + + test("duration mode: widths follow durations, untimed events keep one cell", () => { const slow = assistant("a1", { tool: { name: "bash", input: { command: "slow" }, output: "x", ms: 8000 } }) const fast = assistant("a2", { tool: { name: "bash", input: { command: "fast" }, output: "x", ms: 1000 } }) - const s = buildEventStrip(T([user("u1", "go"), slow, fast]), "duration", 40) - const width = (i: number) => s.spans[i]!.end - s.spans[i]!.start + const l = layoutEventStrip(T([user("u1", "go"), slow, fast]), "duration") + const width = (i: number) => l.spans[i]!.end - l.spans[i]!.start expect(width(0)).toBe(1) // a user prompt has no wall clock of its own expect(width(1)).toBeGreaterThan(width(2) * 3) - expect(s.spans[2]!.start - s.spans[1]!.end).toBe(1) - expect(s.spans.at(-1)!.end).toBeLessThanOrEqual(40) - }) - test("duration mode falls back to the calls layout when no event is timed", () => { - const s = buildEventStrip(T([user("u1", "a"), user("u2", "b"), user("u3", "c")]), "duration", 20) - expect(s.spans).toEqual([{ start: 0, end: 1 }, { start: 2, end: 3 }, { start: 4, end: 5 }]) }) - test("truncation keeps the newest events and reports how many were dropped", () => { - const messages = Array.from({ length: 12 }, (_, i) => [user(`u${i}`, "q"), assistant(`a${i}`, { text: "r" })]).flat() - const all = buildEventStrip(T(messages), "calls", 200) - const s = buildEventStrip(T(messages), "calls", 9) - expect(s.truncatedLeft + s.events.length).toBe(all.events.length) - expect(s.truncatedLeft).toBeGreaterThan(0) - expect(s.events.at(-1)!.messageID).toBe("a11") - expect(s.spans.at(-1)!.end).toBeLessThanOrEqual(9) - expect(occupancy(s).every((n) => n <= 1)).toBe(true) + + test("duration mode falls back to one cell per event when nothing is timed", () => { + const l = layoutEventStrip(T([user("u1", "a"), user("u2", "b"), user("u3", "c")]), "duration") + expect(l.spans.map((s) => s.end - s.start)).toEqual([1, 1, 1]) }) + test("stripIndexFor maps a tool part to its event, and its span covers it", () => { - const s = buildEventStrip(open, "calls", 80) - const i = stripIndexFor(s, "oa1", "oa1-tool") + const l = layoutEventStrip(open, "turns") + const i = stripIndexFor(l, "oa1", "oa1-tool") expect(i).toBeGreaterThanOrEqual(0) - expect(s.events[i]!.lane).toBe("tools") - expect(s.lanes.tools[s.spans[i]!.start]!.eventIndex).toBe(i) - expect(stripIndexFor(s, "om1")).toBe(s.events.findIndex((e) => e.messageID === "om1")) - expect(stripIndexFor(s, "nope")).toBe(-1) + expect(l.events[i]!.lane).toBe("tools") + expect(l.lanes.tools[l.spans[i]!.start]!.eventIndex).toBe(i) + expect(stripIndexFor(l, "om1")).toBe(l.events.findIndex((e) => e.messageID === "om1")) + expect(stripIndexFor(l, "nope")).toBe(-1) }) + test("a lane with no events is reported as empty", () => { - const s = buildEventStrip(T([user("u1", "hi"), assistant("a1", { text: "no tools here" })]), "calls", 20) - expect(s.empty).toEqual({ input: false, model: false, tools: true }) - expect(s.lanes.tools.every((c) => c === null)).toBe(true) + const l = layoutEventStrip(T([user("u1", "hi"), assistant("a1", { text: "no tools here" })]), "turns") + expect(l.empty).toEqual({ input: false, model: false, tools: true }) + expect(l.lanes.tools.every((c) => c === null)).toBe(true) }) + test("a failed tool call keeps the glyph and flags the error", () => { const failed = assistant("a1", { tool: { name: "bash", input: { command: "boom" }, output: "nope" } }) failed.parts = failed.parts.map((p) => (p.type === "tool" ? { ...p, state: { ...p.state, status: "error" } } : p)) - const s = buildEventStrip(T([user("u1", "go"), failed]), "calls", 20) - const i = stripIndexFor(s, "a1", "a1-tool") - expect(s.events[i]!.error).toBe(true) - expect(s.lanes.tools[s.spans[i]!.start]).toEqual({ lane: "tools", eventIndex: i, glyph: "▬", error: true }) + const l = layoutEventStrip(T([user("u1", "go"), failed]), "turns") + const i = stripIndexFor(l, "a1", "a1-tool") + expect(l.events[i]!.error).toBe(true) + expect(l.lanes.tools[l.spans[i]!.start]).toEqual({ lane: "tools", eventIndex: i, glyph: "▬", error: true }) }) + test("a compaction summary is context on the Input lane, not model output", () => { const compacted = { ...open, messages: [{ ...open.messages[1]!, summary: true }, ...open.messages.slice(4)] } - const s = buildEventStrip(compacted, "turns", 60) - expect(s.events[0]).toMatchObject({ lane: "input", kind: "context", turn: 0, messageID: "o-a1" }) - expect(s.events.some((e) => e.messageID === "o-a1" && e.lane === "model")).toBe(false) - expect(s.events.some((e) => e.messageID === "o-a1" && e.lane === "tools")).toBe(true) // its tool calls stay tool calls + const l = layoutEventStrip(compacted, "turns") + expect(l.events[0]).toMatchObject({ lane: "input", kind: "context", turn: 0, messageID: "o-a1" }) + expect(l.events.some((e) => e.messageID === "o-a1" && e.lane === "model")).toBe(false) + expect(l.events.some((e) => e.messageID === "o-a1" && e.lane === "tools")).toBe(true) // its tool calls stay tool calls + }) +}) + +describe("the lanes follow the row filter, so there is no 'calls' mode", () => { + const one = T([user("u1", "hi"), assistant("a1", { text: "ok", think: { text: "hmm" }, tool: { name: "bash", input: { command: "ls" }, output: "out" } })]) + + test("tools-only leaves the tool calls and nothing else — the 'what did I run' view", () => { + const l = layoutEventStrip(one, "turns", "tools-only") + expect(l.events.map((e) => e.kind)).toEqual(["tool"]) + expect(l.empty).toEqual({ input: true, model: true, tools: false }) + }) + + test("no-tools is its mirror; user-only keeps the prompts", () => { + expect(layoutEventStrip(one, "turns", "no-tools").events.some((e) => e.kind === "tool")).toBe(false) + expect(layoutEventStrip(one, "turns", "user-only").events.map((e) => e.kind)).toEqual(["user"]) + }) + + test("default, labeled and all leave the strip whole — a label is not an event", () => { + const whole = layoutEventStrip(one, "turns", "all").events.length + expect(layoutEventStrip(one, "turns", "default").events.length).toBe(whole) + expect(layoutEventStrip(one, "turns", "labeled").events.length).toBe(whole) + }) + + test("thinking stays on the Model lane in every filter: the strip is a timeline", () => { + for (const filter of ["default", "no-tools", "labeled", "all"] as const) { + expect(layoutEventStrip(one, "turns", filter).events.some((e) => e.kind === "reasoning")).toBe(true) + } + expect(eventAllowed("tools-only", "reasoning")).toBe(false) + }) + + test("filtering the strip narrows it — the complaint that started this", () => { + expect(layoutEventStrip(open, "turns", "tools-only").totalWidth).toBeLessThan(layoutEventStrip(open, "turns", "default").totalWidth) }) }) -/** 20 turns: 40 events, so `calls` mode lays out 79 cells — several screens of a 20-cell lane. */ +/** 20 turns: 40 events; one cell each, 1-cell gaps inside a turn and 3 across a boundary. */ const LONG = T(Array.from({ length: 20 }, (_, i) => [user(`u${i}`, "q"), assistant(`a${i}`, { text: "r" })]).flat()) describe("event layout", () => { - test("lays out every event on an unbounded axis", () => { - const l = layoutEventStrip(LONG, "calls") + test("lays out every event on an unbounded axis, with a rule per turn boundary", () => { + const l = layoutEventStrip(LONG, "turns") expect(l.events.length).toBe(40) - expect(l.totalWidth).toBe(79) + expect(l.totalWidth).toBe(117) // 40 pills + 19 boundaries × 3 + 20 gaps × 1 + expect(l.rules.length).toBe(19) expect(l.spans[0]).toEqual({ start: 0, end: 1 }) - expect(l.spans.at(-1)).toEqual({ start: 78, end: 79 }) - expect(l.lanes.input.length).toBe(79) + expect(l.spans.at(-1)).toEqual({ start: 116, end: 117 }) + expect(l.lanes.input.length).toBe(117) expect(l.lanes.input[0]!.eventIndex).toBe(0) expect(l.empty).toEqual({ input: false, model: false, tools: true }) }) - test("the strip is the layout windowed at its end", () => { - const l = layoutEventStrip(LONG, "calls") - const s = buildEventStrip(LONG, "calls", 20) - expect(s.truncatedLeft).toBe(30) - expect(s.events).toEqual(l.events.slice(30)) - expect(s.spans[0]).toEqual({ start: 1, end: 2 }) // flush right: the slack lands on the left - expect(s.lanes.model[19]!.eventIndex).toBe(s.events.length - 1) - }) test("duration mode keeps its proportions off-screen instead of squeezing into a width", () => { const slow = assistant("a1", { tool: { name: "bash", input: { command: "slow" }, output: "x", ms: 8000 } }) const fast = assistant("a2", { tool: { name: "bash", input: { command: "fast" }, output: "x", ms: 1000 } }) - const l = layoutEventStrip(T([user("u1", "go"), slow, fast]), "duration") + const messages = T([user("u1", "go"), slow, fast]) + const l = layoutEventStrip(messages, "duration") const width = (i: number) => l.spans[i]!.end - l.spans[i]!.start - expect(l.totalWidth).toBeGreaterThan(layoutEventStrip(T([user("u1", "go"), slow, fast]), "calls").totalWidth) + expect(l.totalWidth).toBeGreaterThan(layoutEventStrip(messages, "turns").totalWidth) expect(width(1)).toBeGreaterThan(width(2) * 3) }) }) describe("window following the cursor", () => { - const l = layoutEventStrip(LONG, "calls") // 79 cells; at width 20: margin 2, chunk 6, last start 59 + const l = layoutEventStrip(LONG, "turns") // 117 cells; at width 20: margin 2, chunk 6, last start 97 const spanOfEvent = (i: number) => l.spans[i]! test("a layout that fits starts at 0", () => { expect(windowFor(l, 39, 200, undefined)).toBe(0) expect(windowFor(l, 0, 200, 40)).toBe(0) }) test("no previous window opens at the newest events", () => { - expect(windowFor(l, 39, 20, undefined)).toBe(59) - expect(windowFor(l, -1, 20, undefined)).toBe(59) + expect(windowFor(l, 39, 20, undefined)).toBe(97) + expect(windowFor(l, -1, 20, undefined)).toBe(97) }) test("a cursor between the margins does not move the window", () => { - expect(windowFor(l, 22, 20, 40)).toBe(40) // span 44..45, inside [42, 58) - expect(windowFor(l, -1, 20, 40)).toBe(40) // a row with no event holds the window still - }) - test("a cursor just past the left margin shifts one chunk", () => { - expect(spanOfEvent(20).start).toBe(40) // one cell outside [42, 58) - expect(windowFor(l, 20, 20, 40)).toBe(34) - }) - test("a cursor past the right margin shifts in chunks until it is inside", () => { - const start = windowFor(l, 12, 20, 0) - expect(start).toBe(12) - expect(spanOfEvent(12).end).toBeLessThanOrEqual(start + 20 - 2) + expect(windowFor(l, 22, 20, 60)).toBe(60) // span 66..67, inside [62, 78) + expect(windowFor(l, -1, 20, 60)).toBe(60) // a row with no event holds the window still }) test("a far jump clamps: gg lands at 0, G at the last window", () => { - expect(windowFor(l, 0, 20, 59)).toBe(0) - expect(windowFor(l, 39, 20, 0)).toBe(59) - expect(windowFor(l, -1, 20, 999)).toBe(59) + expect(windowFor(l, 0, 20, 97)).toBe(0) + expect(windowFor(l, 39, 20, 0)).toBe(97) + expect(windowFor(l, -1, 20, 999)).toBe(97) }) test("walking the cursor backwards keeps its event on the strip", () => { let start = windowFor(l, 39, 20, undefined) @@ -242,13 +199,13 @@ describe("window following the cursor", () => { }) describe("overview track", () => { - const l = layoutEventStrip(LONG, "calls") + const l = layoutEventStrip(LONG, "turns") test("the bright segment sits where the window is", () => { const head = overviewTrack(l, 0, 20) expect(head.length).toBe(20) expect(head[0]).toBe("window") expect(head.at(-1)).toBe("track") - const tail = overviewTrack(l, 59, 20) + const tail = overviewTrack(l, l.totalWidth - 20, 20) expect(tail.at(-1)).toBe("window") expect(tail[0]).toBe("track") expect(tail.filter((k) => k === "window").length).toBeGreaterThan(0) @@ -256,7 +213,7 @@ describe("overview track", () => { test("a failed call outside the window still shows as a tick", () => { const failed = assistant("a0", { tool: { name: "bash", input: { command: "boom" }, output: "nope" } }) failed.parts = failed.parts.map((p) => (p.type === "tool" ? { ...p, state: { ...p.state, status: "error" } } : p)) - const withError = layoutEventStrip(T([user("u0", "go"), failed, ...LONG.messages]), "calls") + const withError = layoutEventStrip(T([user("u0", "go"), failed, ...LONG.messages]), "turns") const track = overviewTrack(withError, withError.totalWidth - 20, 20) expect(track[0]).toBe("error") expect(track.at(-1)).toBe("window") diff --git a/test/navigation.test.ts b/test/navigation.test.ts index 2647243..d3bfa9b 100644 --- a/test/navigation.test.ts +++ b/test/navigation.test.ts @@ -28,8 +28,8 @@ describe("navigation", () => { expect(toggleExpanded(b, OPEN).has(OPEN)).toBe(false) }) test("cycleFilter order", () => { - expect(["default", "no-tools", "user-only", "labeled", "all", "default"].slice(1)).toEqual( - ["default", "no-tools", "user-only", "labeled", "all"].map((x) => cycleFilter(x as any)), + expect(["default", "no-tools", "tools-only", "user-only", "labeled", "all", "default"].slice(1)).toEqual( + ["default", "no-tools", "tools-only", "user-only", "labeled", "all"].map((x) => cycleFilter(x as any)), ) }) test("resolveSelection keeps the id, falls back to the owning turn, then the current row", () => { diff --git a/test/prompt-at.test.ts b/test/prompt-at.test.ts new file mode 100644 index 0000000..3876e46 --- /dev/null +++ b/test/prompt-at.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test" +import { buildTreeView, formatPromptAt, promptAtRow, type Row } from "../src/core/tree.js" +import { buildFixture, OPEN, TRUNK } from "./fixtures/tree.js" + +const f = buildFixture() +const view = buildTreeView({ state: f.state, transcripts: f.transcripts, currentSessionID: TRUNK, expanded: new Set([TRUNK, OPEN]), filter: "all" }) +const row = (pick: (r: Row) => boolean) => view.rows.find(pick)! +const at = (r: Row) => promptAtRow(r, f.transcripts) + +describe("promptAtRow — what the provider was really sent here", () => { + test("an assistant step reports its own message's prompt", () => { + // fixture a2: input 2400, no cache + expect(at(row((r) => r.kind === "step" && r.messageID === "a2"))).toEqual({ kind: "prompt", prompt: 2400, cached: 0 }) + }) + + test("every step of one message carries the same figure", () => { + const steps = view.rows.filter((r) => r.kind === "step" && r.messageID === "a1") + expect(steps.length).toBeGreaterThan(1) + for (const s of steps) expect(at(s)).toEqual({ kind: "prompt", prompt: 1200, cached: 0 }) + }) + + test("a user turn takes the reply to it — the first prompt that included it", () => { + expect(at(row((r) => r.kind === "turn" && r.messageID === "m2"))).toEqual({ kind: "prompt", prompt: 2400, cached: 0 }) + }) + + test("a branch header has none; its own column is already a subtree total", () => { + expect(at(row((r) => r.kind === "branch"))).toEqual({ kind: "none" }) + }) + + test("the figure sums input + cache read + write, as the header gauge does", () => { + const cached = { ...f.transcripts[TRUNK]!, messages: f.transcripts[TRUNK]!.messages.map((m) => (m.id === "a2" ? { ...m, tokens: { input: 400, output: 60, reasoning: 0, cache: { read: 1800, write: 200 } } } : m)) } + // a step row carries its own message's report, so the view has to be rebuilt to see it + const transcripts = { ...f.transcripts, [TRUNK]: cached } + const v = buildTreeView({ state: f.state, transcripts, currentSessionID: TRUNK, expanded: new Set([TRUNK]), filter: "all" }) + const step = v.rows.find((r) => r.kind === "step" && r.messageID === "a2")! + expect(promptAtRow(step, transcripts)).toEqual({ kind: "prompt", prompt: 2400, cached: 1800 }) + // and the user turn it answers reads the same figure through the transcript + expect(promptAtRow(v.rows.find((r) => r.kind === "turn" && r.messageID === "m2")!, transcripts)).toEqual({ kind: "prompt", prompt: 2400, cached: 1800 }) + }) +}) + +describe("promptAtRow — nothing sent yet", () => { + const trailing = { ...f.transcripts[TRUNK]!, messages: f.transcripts[TRUNK]!.messages.slice(0, 4).concat({ id: "m9", role: "user" as const, time: { created: 1 }, parts: [{ id: "m9-p0", type: "text" as const, text: "unanswered" }] }) } + const transcripts = { ...f.transcripts, [TRUNK]: trailing } + const v = buildTreeView({ state: f.state, transcripts, currentSessionID: TRUNK, expanded: new Set([TRUNK]), filter: "all" }) + + test("a turn with no reply after it is pending, not zero", () => { + expect(promptAtRow(v.rows.find((r) => r.kind === "turn" && r.messageID === "m9")!, transcripts)).toEqual({ kind: "pending" }) + }) +}) + +describe("formatPromptAt", () => { + test("reads against the gauge above it", () => { + expect(formatPromptAt({ kind: "prompt", prompt: 43_700, cached: 30_100 }, { turn: 2, what: "reply" })).toBe("T2 reply · prompt 43.7k · 30.1k cached") + }) + test("a tool step is named by its tool", () => { + expect(formatPromptAt({ kind: "prompt", prompt: 43_700, cached: 0 }, { turn: 2, what: "bash" })).toBe("T2 bash · prompt 43.7k") + }) + test("no cache figure on a provider that never caches", () => { + expect(formatPromptAt({ kind: "prompt", prompt: 900, cached: 0 }, { turn: 1, what: "reply" })).toBe("T1 reply · prompt 900") + }) + test("pending drops the descriptor — there is no reply to describe", () => { + expect(formatPromptAt({ kind: "pending" }, { turn: 3, what: "reply" })).toBe("T3 · not sent yet") + }) + test("nothing to say prints nothing, so the caller drops the field", () => { + expect(formatPromptAt({ kind: "none" }, { turn: 3 })).toBe("") + }) +}) diff --git a/test/tree.test.ts b/test/tree.test.ts index 996060c..50c64b5 100644 --- a/test/tree.test.ts +++ b/test/tree.test.ts @@ -351,6 +351,15 @@ describe("filters, search, labels, crops", () => { expect(shape(buildTreeView({ ...base, filter: "user-only" }).rows)).toEqual(["T1", "T2", "T3"]) expect(buildTreeView({ ...base, filter: "all" }).rows.filter((r) => r.kind === "step").length).toBeGreaterThan(4) }) + test("tools-only keeps ⚙ rows and the branch structure: the 'what did I run' view", () => { + const rows = buildTreeView({ ...base, filter: "tools-only" }).rows + expect(shape(rows)).toEqual(["⚙", "├⎇try-redis", "╰⎇fix-flaky-test"]) + expect(rows.every((r) => r.kind !== "turn")).toBe(true) + // it is the mirror of no-tools: between them they cover every step + const all = buildTreeView({ ...base, filter: "default" }).rows.filter((r) => r.kind === "step").length + const split = buildTreeView({ ...base, filter: "no-tools" }).rows.filter((r) => r.kind === "step").length + rows.filter((r) => r.kind === "step").length + expect(split).toBe(all) + }) test("labeled shows only labelled turns", () => { const view = buildTreeView({ ...base, filter: "labeled", labels: { m2: "⎇ checkpoint" } }) expect(shape(view.rows)).toEqual(["T2"])