Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,90 @@
# Changelog

## Unreleased

- **The context gauge was illegible on a light theme.** `ctx ▓░░░░ 84.7k/1M · low · 99% cached`
was painted as one band-coloured run — `ctx`, the bar, and the numbers all in `success`
green — in both the tree header and the prompt line. That reads on a dark theme by luck: a
theme guarantees `text` and `textMuted` are readable on its own background, and guarantees
nothing of the kind about `success` / `warning` / `error`, which it picks to be
*distinguishable from each other*.

The rule now is **the bar carries the band, readable text never does**, and it lives in
`core/gauge.ts#gaugeRoles` as a table rather than inline in the JSX, so `test/gauge.test.ts`
can hold it. `ctx` is muted, the numbers and band word take the theme's own text colour, and
the bar splits muted-cached / band-fresh / subtle-empty.

Three neighbours had the same defect and are fixed the same way — the glyph carries the
colour, the label beside it is read: the prompt line's `⎇ <branch> · ` (band-coloured, which
was also a category error — which branch you are on has nothing to do with the context
band), its `▲ +24% (bash)` trend, and the sidebar card's `⎇ <branch>` and `✂ 2 crops · ~14k
hidden`. A second test greps both components for a readable string inside a band-coloured
element, which is the shape that shipped.

- **The `?` pane's colour was exactly inverted, and now is not.** Every indented line was
drawn in `textMuted` and every heading in the accent colour — so the headings, which name
no keys at all, were the brightest thing on the pane, and the keys, the only reason anyone
opens `?`, were the dimmest. A user looking for one key had no token-shaped thing to scan
for and had to read the pane at reading speed. **Keys are now the bright, bold thing**,
headings are accent, prose is muted, and the `│ ` gutter is always dim instead of taking
its line's colour.

This is why the pane is now built as `helpSegments()` — typed runs (`key`, `name`, `label`,
`heading`, `glyph`, `strong`, `text`) the route colours individually — rather than as flat
strings a route can only colour a line at a time. `helpLines()` remains as the joined-string
API the drift tests assert against.

- **`Act` and `Views` became the table they already were.** Every row in them was already
`<key> <name> — <purpose>` in a consistent order; the `" — "` is now two columns aligned at
render time, so you can enter from either side — scan the keys down column 3 if you think
`gm`, or the names if you think "merge". Column widths are measured per *section*, so one
`f12` rebound in `Act` cannot shift `Legend` sideways, and a key wider than 8 columns takes
its own row ragged rather than pushing every purpose in its section right.

`Move` deliberately keeps its packed clauses — tabulating twenty motion clauses would cost
twenty rows to make vim's own keys, the ones this audience already has, the most prominent.
It gets a dim label column (`rows` / `jumps` / `folds` / `search`) instead, as does `Legend`
(`rows` / `tree` / `cost` / `status` / `⎇` / `lanes`); those labels are static strings, so
no rebind can break their alignment.

- **Nine of the pane's lines were clipping at 100 columns with the width test green.** A help
row is `│ ` inside a `padding={1}` box, so its budget is `cols - 4` — which is what every
other line on the screen uses — but the test asserted `≤ 112` against the raw string. One
line clipped even at 112. The pane is now laid out *for the terminal it is on*: clauses drop
from the right and prose clips with `…`, never wrapping (§7.6), and the same 34 rows survive
down to a 44-column terminal. The test asserts against the row budget at four widths.

- The `?` footer names the section you are in — `12–29 of 34 · Act · PgUp/PgDn scroll · q/esc
back`. The pane is a three-page scroll on a short terminal and its headings scroll off, so
page 2 was rows of verbs under no heading at all. Zero extra rows.

- The legend draws colours instead of naming them: `█ open · █ squashed · …` in the branch
colours themselves, and the lane swatches in the lane colours. Spelling "green" in muted
grey asked the reader to take a monochrome word's word for it, and cost six columns where
showing it costs one — which is what let the pane's two longest lines fit.

- The pane's glyphs are drawn in the tree's own colours (`⚠` warning, `✗` error, `◆ ≣ ✂`
accent), `nothing here rewrites your transcript` is bold as the safety claim it is, and the
`*model*` markdown asterisks became a bold run. Net: 34 rows before, 34 after.

- `route.tsx` writes out `TextAttributes.BOLD`/`.DIM` as constants rather than importing them:
adding `@opentui/core` to the TUI bundle's runtime imports made the host fail to load the
route at all. `test/help.test.ts` pins both constants against the real enum and asserts the
built bundle imports only `@opentui/solid`.

- **The fold marker moved to the row's left edge, and stopped repeating the token column.**
A folded turn now reads `● ▸6 T5 add a retry to the flaky test 1✗ 2⚠`, where `▸6` sits
between the `●` and the text: the row's own disclosure control, where an outline puts one.

It used to trail the preview as `▸ 6 steps · ~12k · 1 ✗ · 2 ⚠`, which failed twice over on
a real screen. The caret was nowhere near the `●` it belonged to, so on a tree of folded
turns nothing marked a collapsed row until you had read to the end of its usually-clipped
preview. And `~12k` was the row's own token column *again* — `applyFolds` rolls the hidden
steps' tokens into the turn, so the two figures are the same number by construction; two
numbers of the same magnitude a few columns apart, always equal, read as one number that
had gone wrong. The tokens are now printed once, in the column that already exists, and the
flags (`1✗ 2⚠ 1✂`) sit at the end of the text where a step row already draws its own.

## 0.3.0-beta.2 — 2026-09-09

- **The row under the cursor names the key that acts on it.** The keymap is vim's, which is
Expand Down
74 changes: 71 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,25 @@ auto-compaction is the *lossy* event the user wants to pre-empt with `/crop` or
active crops, decisions on path, `[/tree]`.


**Which run takes which colour (0.3.0-beta.3, `core/gauge.ts#gaugeRoles`).** Through
0.3.0-beta.2 the whole gauge was one band-coloured run — `ctx`, the bar, and
`84.7k/1M · low` all in `success` green — which is legible on a dark theme by luck and was
reported illegible on a light one. A theme guarantees `text` and `textMuted` are readable on
its own background; it guarantees nothing of the kind about `success` / `warning` / `error`,
which it picks to be *distinguishable from each other*. So the rule is: **the bar carries the
band and readable text never does.** `ctx` is `textMuted`, the numbers and the band word are
`text`, the bar's cached cells are `textMuted`, its fresh cells the band colour, its empty
cells `borderSubtle`, and `· 95% cached` is `textMuted`. A filled bar survives a lower
contrast ratio than glyph strokes do, and its colour is the signal rather than a decoration
on text that says the same thing anyway.

The same error was next to the gauge in three other places, all fixed the same way — the
glyph carries the colour, the label beside it is read: the prompt slot's `⎇ <branch> · `
(band-coloured, which was also a category error — which branch you are on has nothing to do
with the context band), its `▲ +24% (bash)` trend, and the sidebar card's `⎇ <branch>` and
`✂ 2 crops · ~14k hidden`. `test/gauge.test.ts` holds the table to the rule and greps both
components for a readable string inside a band-coloured element.

**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
Expand Down Expand Up @@ -731,8 +750,17 @@ filter change (as in Pi) and is otherwise remembered per session in `api.kv`.

**Turn folds (`core/fold.ts`).** A turn whose model ran six tools costs seven rows and one of
them is the skeleton you were skimming, so a turn collapses into its `●` row carrying what it
swallowed: `● T7 add a retry to the flaky test ▸ 6 steps · ~12k · 1 ✗ · 2 ⚠`. Nothing escapes
a fold — the digest is the whole story of what is inside it.
swallowed: `● ▸6 T7 add a retry to the flaky test 1✗ 2⚠`. Nothing escapes a fold.

*Where the marker goes (0.3.0-beta.2).* `▸6` sits **between the glyph and the text**, because
that is where an outline puts a disclosure control — and the first shipped version put a
digest after the preview instead, which failed twice over. The caret was nowhere near the `●`
it belonged to, so on a screen of folded turns nothing marked a collapsed row until you had
read to the end of its (usually clipped) preview; and the digest carried a token figure that
was the row's own token column *again*, since `applyFolds` rolls the hidden steps' tokens into
the turn. Two numbers of the same magnitude a few columns apart, always equal, read as one
number that had gone wrong. So: the caret and the count on the left (`foldMark`), the flags at
the end of the text where a step row already draws its own (`foldFlags`), and the tokens once.

*Posture.* `auto` (the default) keeps **the current turn of the path you are on** open, so the
far scrollback compresses while the end you are working at stays in detail; `zm` folds every
Expand Down Expand Up @@ -811,7 +839,47 @@ One row and one action, deliberately:
unbound, crop mode and the `?` pane (both of which already own a key legend of their own)
all render no hint at all.

### 7.8 What we deliberately do not copy from DSH
### 7.8 The `?` pane (0.3.0-beta.3)

The pane opens **under** the tree rather than over it, so the rows it explains stay on screen
— which makes the `Legend` a live reference rather than a memory test, and is why vertical
space here is genuinely scarce: every row is paid for out of the tree.

*Salience.* The pane's job is to let someone find **one key** without reading. That only works
if a key is drawn differently from the prose around it, so the pane is a list of typed
segments (`key`, `name`, `label`, `heading`, `glyph`, `strong`, `text`) that the route colours
individually — `core/help.ts#helpSegments`, `route.tsx#helpColor`. Keys are the brightest and
the only bold thing; headings are accent; prose is muted; the `│ ` gutter is always dim.
Through 0.3.0-beta.2 this was exactly inverted — the route coloured whole lines, keyed on
whether the line was indented, so headings (which name no keys) were the one accent-coloured
thing and every key on the pane was muted.

*Two layouts, and the split is the principle: **tabulate what the user cannot guess.***
`Act` and `Views` name operations with no analogue anywhere else, so they get a key column and
a name column and can be entered from either side — the keys if you think `gm`, the names if
you think "merge". `Move` and `Legend` keep packed clauses behind a dim static label: twenty
motion clauses tabulated would be twenty rows spent making vim's own keys the most prominent
thing on the pane, which is the wrong trade for this audience.

Column widths are measured **per section**, so one absurd rebind in `Act` cannot shift
`Legend`; past an 8-column key the row alone goes ragged rather than pushing its whole
section right, as `:help` does. Rejected: a two-column pane (at 100 columns each column gets
~46, and the teaching sentences are 66–81 — it would convert the pane into the key list it
deliberately is not, and is impossible at 80); a strict `:help` key column throughout (see
`Move` above); a separate `Legend` pane (modality, and it needs a key).

*Width.* The pane is laid out for the terminal it is on: `cols - HELP_CHROME`, where the 4
columns are the box's padding and the `│ ` gutter. Clauses drop from the right and prose
clips with `…` — never wrapping, per §7.6 — so the same 34 rows survive from 100 columns down
to 44. Before this it was written for a fixed width and asserted against the raw terminal
number, so nine lines clipped at 100 columns with the test green.

*Where am I.* The footer names the section (`12–29 of 34 · Act · PgUp/PgDn scroll · q/esc
back`), because on a 24-row terminal you see 12 of 34 rows and the headings scroll off.
Rejected: snapping PgUp/PgDn to section boundaries — `Legend` is 8 rows and would not fit a
short terminal's window, so snapping would sometimes strand you.

### 7.9 What we deliberately do not copy from DSH

DSH is a web GUI with unlimited space and mouse; its Payload/Schema tabs show full
JSON. In the TUI, Payload is pretty-printed and truncated with `y` to copy the full
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,17 @@ Every turn but the one you are in folds to a single row, so the outline reads as
rather than a wall of tool calls:

```
● T5 add a retry to the flaky test ▸ 6 steps · ~12k · 1 ✗ · 2 ⚠
● ▸6 T5 add a retry to the flaky test 1✗ 2⚠ ~12k
● T6 now make it pass on CI ~310
```

`▸6` between the `●` and the text is the fold: the row's own disclosure control, at the row's
own left edge, saying it stands for six hidden rows. The token column already counts them.

`za` folds or opens the turn you are on, `l` opens the folded one under the cursor (vim opens
a fold on a horizontal move), `zm` folds them all, `zr` opens them all — vim's own fold keys.
And you do not have to know them: the row the cursor is on names the key for the one thing it
affords, and only that row. Nothing is lost either — the digest counts what is inside, the
affords, and only that row. Nothing is lost either — the marker counts what is inside, the
timeline still shows every event (a folded turn lights the whole span it stands for), and crop
mode opens everything while you pick targets.

Expand Down
14 changes: 9 additions & 5 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,14 +197,18 @@ A turn where the model ran six tools is seven rows, and the one you skim for is
turn. So every turn but **the one you are working in** opens folded, carrying what it holds:

```
│ ● T5 add a retry to the flaky test ▸ 6 steps · ~12k · 1 ✗ · 2 ⚠
│ ● T6 now make it pass on CI
│ ● ▸6 T5 add a retry to the flaky test 1✗ 2⚠ ~12k
│ ● T6 now make it pass on CI ~310
│ ⚙ [bash $ bun test src/foo.test.ts] → 3 failed … ~5.1k
│ ○ assistant: the failures share a timing assumption ~90
```

Nothing escapes a fold — the digest is the whole story: how many steps, their tokens, and how
many were errors (`✗`), fat (`⚠` ≥10k) or already cropped (`✂`).
**`▸6` is the fold**, sitting between the `●` and the text: this row's disclosure control, at
the row's own left edge, standing for six hidden rows. The right-hand column already includes
their tokens — a folded turn's figure is the whole turn — so the marker does not repeat it.
What it does carry is what you would want to know before opening it: `1✗ 2⚠ 1✂` after the
text, in the same place and the same glyphs a step row flags its own errors (`✗`), fat results
(`⚠` ≥10k) and crops (`✂`). Nothing escapes a fold.

| key | what it folds |
|---|---|
Expand Down Expand Up @@ -256,7 +260,7 @@ Three things are deliberate:
| `gf` | filter picker (default · no-tools · tools-only · user-only · labeled · all) |
| `/` `n` `N` | live search: typing re-filters the rows, `⏎` keeps the filter, `esc` clears; `n` `N` next / previous match |
| `y` | copy the selected text — the terminal's clipboard when it allows it, else `.opencode/context-tree/last-copy.txt` |
| `?` | help pane under the tree: how to read the screen + every key (`?` or `esc` closes) |
| `?` | help pane under the tree, so the rows it explains stay on screen: how to read them, and what every key is *for*. Keys are the bright, bold thing on it — you can scan for one rather than read. `Act` and `Views` are a key column and a name column, so you can look up either `gm` or "merge"; `Move` and `Legend` are packed behind a dim label. It is laid out for your terminal's width, and it scrolls with `PgUp`/`PgDn` with the footer naming the section (`12–29 of 34 · Act · …`). `?` or `esc` closes |
| `q` `esc` | back (esc leaves crop mode / a panel / a search first) |

The footer follows the panel and the row under the cursor — on the tree `⏎ fork & prefill
Expand Down
39 changes: 29 additions & 10 deletions src/core/fold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,16 +173,35 @@ export function applyFolds(rows: readonly Row[], policy: FoldPolicy): Row[] {
return out
}

/** `▸ 6 steps · ~12k · 1 ✗ · 2 ⚠` — what the fold is standing in for, in the row's own
* vocabulary (`⚠` ≥10k, `✗` tool error, `✂` cropped, as the legend already reads them).
* "steps", not `⚙`: a turn's hidden rows are tool calls *and* assistant text, and claiming
* six tool calls when two of them were replies would be a small lie on every folded row. */
export function foldDigest(fold: FoldSummary, formatTokens: (n: number) => string): string {
const parts = [`${fold.steps} step${fold.steps === 1 ? "" : "s"}`, `${fold.estimated ? "~" : ""}${formatTokens(fold.tokens)}`]
if (fold.errors > 0) parts.push(`${fold.errors} ✗`)
if (fold.warns > 0) parts.push(`${fold.warns} ⚠`)
if (fold.cropped > 0) parts.push(`${fold.cropped} ✂`)
return `▸ ${parts.join(" · ")}`
/**
* `▸38` — the collapsed marker and how many rows it stands for, drawn between the turn's `●`
* glyph and its text so it reads as part of the row's left edge, where an outline's
* disclosure control belongs.
*
* It used to be a digest trailing the text (`▸ 38 steps · ~23.5k · 1 ✗`), which had two
* problems the tree showed plainly: the caret was nowhere near the `●` it belonged to, so
* nothing marked a collapsed row until you read to the end of its preview; and the token
* figure was the row's own token column *again*, since `applyFolds` rolls the hidden steps'
* tokens into the turn. Two numbers of the same size a few columns apart, always equal, read
* as one number that had gone wrong.
*
* No unit word: a bare count next to a caret is unambiguous, and the six columns "steps"
* costs are the ones the preview wanted.
*/
export function foldMark(fold: FoldSummary): string {
return `▸${fold.steps}`
}

/** ` 1✂ 2⚠ 1✗` — what a fold is hiding that you would want to know before opening it, in the
* legend's own glyphs (`✂` cropped, `⚠` ≥10k, `✗` tool error) and in a step row's own order
* and position: at the end of the text, exactly where a step draws its own flags. Empty when
* there is nothing to flag, which is most rows. */
export function foldFlags(fold: FoldSummary): string {
const parts: string[] = []
if (fold.cropped > 0) parts.push(`${fold.cropped}✂`)
if (fold.warns > 0) parts.push(`${fold.warns}⚠`)
if (fold.errors > 0) parts.push(`${fold.errors}✗`)
return parts.length ? ` ${parts.join(" ")}` : ""
}

/** Toggle one turn in the manual map, returning a new Map. `folded` states it outright
Expand Down
Loading
Loading