diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cce7e2..a46a18e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `⎇ · ` (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 `⎇ ` 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 + ` ` 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 diff --git a/DESIGN.md b/DESIGN.md index 6479343..33c68c4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 `⎇ · ` +(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 `⎇ ` 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 @@ -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 @@ -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 diff --git a/README.md b/README.md index 5eb6cc2..800df32 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/USAGE.md b/docs/USAGE.md index e33450f..00ebfa6 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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 | |---|---| @@ -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 diff --git a/src/core/fold.ts b/src/core/fold.ts index 59f4cdc..ae6abea 100644 --- a/src/core/fold.ts +++ b/src/core/fold.ts @@ -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 diff --git a/src/core/gauge.ts b/src/core/gauge.ts new file mode 100644 index 0000000..f944468 --- /dev/null +++ b/src/core/gauge.ts @@ -0,0 +1,62 @@ +/** + * Which theme colour each run of the context gauge takes. + * + * A decision table rather than colours inline in the JSX, because the rule it encodes is one + * a renderer keeps getting wrong and a test can hold: **readable text never takes a band + * colour.** `success` / `warning` / `error` are chosen by a theme to be *distinguishable from + * each other*, not to be readable as body text on that theme's background — so a gauge that + * paints `84.7k/1M · low` in `success` is legible on a dark theme by luck and illegible on a + * light one, which is exactly what shipped through 0.3.0-beta.2 (the whole gauge, `ctx`, bar, + * numbers and all, was one band-coloured run). + * + * The bar keeps the band colour, because a bar is what colour is *for* here: it is a filled + * shape rather than glyph strokes, so it survives a lower contrast ratio, and its colour is + * the signal rather than a decoration on text that carries the same information anyway. + * + * Pure, no OpenCode/opentui/solid-js imports — see test/core-purity.test.ts. + */ +import type { ContextBand } from "./tokens.js" + +/** A `TuiThemeCurrent` key. Named here so the table cannot name a colour that does not exist. */ +export type ThemeToken = "text" | "textMuted" | "borderSubtle" | "success" | "warning" | "error" + +/** The band colours, as the tree and the prompt slot both read them. */ +export const BAND_TOKEN: Record = { + low: "success", + healthy: "success", + filling: "warning", + red: "error", +} + +/** The tokens that a theme guarantees are readable on its own background. Everything the user + * is meant to *read* comes from this set; `test/gauge.test.ts` holds the table to it. */ +export const READABLE: readonly ThemeToken[] = ["text", "textMuted"] + +export type GaugeRoles = { + /** `ctx ` — a label for the thing, not the thing. */ + label: ThemeToken + /** Bar cells already served from the provider's cache. */ + cached: ThemeToken + /** Bar cells of fresh context: the one run that carries the band. */ + fresh: ThemeToken + /** Bar cells not yet used. Deliberately faint — it is the empty part of a bar. */ + empty: ThemeToken + /** `84.7k/1M · low` — the payload, and the part that was illegible. */ + numbers: ThemeToken + /** ` · 99% cached` — secondary by design. */ + cache: ThemeToken +} + +export function gaugeRoles(band: ContextBand): GaugeRoles { + return { + label: "textMuted", + cached: "textMuted", + fresh: BAND_TOKEN[band], + empty: "borderSubtle", + numbers: "text", + cache: "textMuted", + } +} + +/** The runs a reader is meant to read, as opposed to the bar they are meant to glance at. */ +export const READ_ROLES: readonly (keyof GaugeRoles)[] = ["label", "numbers", "cache"] diff --git a/src/core/help.ts b/src/core/help.ts index 3312fb6..2c2f860 100644 --- a/src/core/help.ts +++ b/src/core/help.ts @@ -85,97 +85,324 @@ export const DEFAULT_KEYS: Record = { } /** - * The `?` pane, given the running version. It sits under the rows and takes its height from - * them, so it stays as short as it can while still saying what each verb is *for*. + * A run of one help line, drawn in its own colour. The pane's whole job is to let a user find + * one key without reading, and a key is only findable if it is drawn differently from the + * prose around it — so the pane is built as segments and the route colours them, rather than + * as flat strings the route can only colour a line at a time. (It did the latter until + * 0.3.0-beta.3, with the salience exactly inverted: the headings, which name no keys at all, + * were the one thing drawn in the accent colour, and every line that named a key was muted.) */ -export function helpLines(version: string, overrides?: Record): string[] { +export type HelpKind = "heading" | "key" | "name" | "label" | "text" | "strong" | "glyph" +/** A `glyph`'s or a colour word's colour, named for what it means rather than for a palette + * entry, so the route maps it onto the same theme colours the tree itself draws with. */ +export type HelpTone = "success" | "info" | "error" | "warning" | "accent" | "muted" +export type HelpSeg = { text: string; kind: HelpKind; tone?: HelpTone } + +/** Columns a help row spends on chrome the pane does not control: the route's `padding={1}` + * either side and the `│ ` gutter it draws in front of every line. The route passes + * `cols() - HELP_CHROME`, the way every other line on the screen is budgeted. */ +export const HELP_CHROME = 4 +/** What the pane is written for when a caller does not say: a 100-column terminal. */ +export const HELP_WIDTH = 96 +/** A key column wider than this is one absurd rebind (`ctrl+shift+f12`) pushing every purpose + * in its section sideways. Past the cap that row alone goes ragged, as `:help` does. */ +const MAX_KEY_COL = 8 + +const seg = (text: string, kind: HelpKind, tone?: HelpTone): HelpSeg => (tone ? { text, kind, tone } : { text, kind }) +const txt = (text: string): HelpSeg => ({ text, kind: "text" }) +const wide = (segs: readonly HelpSeg[]): number => segs.reduce((n, s) => n + [...s.text].length, 0) + +/** The lines the pane is made of, before any of them knows how wide its columns are. */ +type Body = + | { row: "heading"; text: string } + | { row: "prose"; segs: HelpSeg[] } + /** `folds za this turn · zo zc open/close · …` — a dim static label and packed clauses. + * For the sections where one key per row would cost twenty rows to tabulate. */ + | { row: "packed"; label: string; clauses: HelpSeg[][] } + /** `gm merge end a branch: …` — a key column, a name column, and a purpose. For the + * sections whose verbs nobody can guess, which are the ones worth the columns. */ + | { row: "verb"; key: string; name: string; purpose: HelpSeg[] } + /** Indented under the row above it, in that row's purpose column. */ + | { row: "cont"; segs: HelpSeg[] } + +/** + * The `?` pane. It sits under the tree rows and takes its height from them, so vertical space + * is genuinely scarce — every row here is paid for out of the tree it explains. + * + * Two layouts, and the split is the point: **tabulate what the user cannot guess.** `Act` and + * `Views` name operations with no analogue anywhere else (crop, merge, a summarizing jump), so + * they get a key column and a name column and can be entered from either side — you scan the + * keys if you think `gm`, the names if you think "merge". `Move` and `Legend` get a dim label + * column and packed clauses instead: twenty motion clauses tabulated would be twenty rows, and + * they are vim's own keys, which this audience mostly has already. + */ +export function helpSegments(version: string, overrides?: Record, width = HELP_WIDTH): HelpSeg[][] { /** The key a verb answers to now. Every key named below comes from here, never a literal: * `keybinds` can move any of them, and a pane that lies is worse than no pane. */ const k = (command: string) => keyLabel(command, overrides) const ks = (command: string) => keyLabels(command, overrides) - /** Both strokes of a pair, as `↑ k` / `{ }` — dropping the half that is unbound. */ - const pair = (a: string, b: string) => [k(a), k(b)].filter(Boolean).join(" ") - /** The *second* stroke of each of a pair, when both have one: `(or h l)`. */ - const alsoPair = (a: string, b: string) => { + /** A `key text` clause, or nothing at all when the command is unbound. */ + const c = (command: string, text: string): HelpSeg[] => { + const key = k(command) + return key ? [seg(key, "key"), txt(` ${text}`)] : [] + } + /** Every stroke of a command as one key run: `↑ k`, `[[ [`. */ + const all = (command: string, text: string): HelpSeg[] => { + const keys = ks(command) + return keys.length ? [seg(keys.join(" "), "key"), txt(` ${text}`)] : [] + } + /** Two commands' first strokes as one run: `zo zc open/close`. */ + const both = (a: string, b: string, text: string): HelpSeg[] => { + const keys = [k(a), k(b)].filter(Boolean) + return keys.length ? [seg(keys.join(" "), "key"), txt(` ${text}`)] : [] + } + /** ` (or h l)` — the second stroke of each of a pair, when both have one. Written out + * rather than folded into the key run: `← h → l` reads as four unrelated keys. */ + const alias = (a: string, b: string): HelpSeg[] => { const [, x] = ks(a) const [, y] = ks(b) - return x && y ? ` (or ${x} ${y})` : "" + return x && y ? [txt(" (or "), seg(`${x} ${y}`, "key"), txt(")")] : [] + } + const g = (glyph: string, text: string, tone?: HelpTone): HelpSeg[] => [seg(glyph, "glyph", tone), txt(` ${text}`)] + + const body: Body[] = [ + // no "What this is" heading: the top of a document locates itself, and the row it costs + // is one the pane cannot spare + { row: "prose", segs: [txt("the tree is every turn of this session and its branches; the right column is what each costs")] }, + { + row: "prose", + segs: [seg("nothing here rewrites your transcript", "strong"), txt(" — crop and merge change what the "), seg("model", "strong"), txt(" is sent next")], + }, + // an orientation claim, not a motion: it tells a first-time reader they may stop reading + { row: "prose", segs: [txt("the row you are on names the key for what it can do — you do not have to know these")] }, + { row: "heading", text: "Move" }, + { + row: "packed", + label: "rows", + clauses: [ + [seg([...ks("up"), ...ks("down")].join(" "), "key"), txt(" move")], + both("screen_top", "screen_bottom", "screen top/bottom").length ? [seg([k("screen_top"), k("screen_middle"), k("screen_bottom")].filter(Boolean).join(" "), "key"), txt(" screen top/middle/bottom")] : [], + both("first", "last", "first/last"), + c("toggle", "toggle"), + ], + }, + { + row: "packed", + label: "jumps", + clauses: [both("page_down", "page_up", "page"), both("half_down", "half_up", "half page"), both("prev_turn", "next_turn", "turns"), both("prev_branch", "next_branch", "branches")], + }, + { + row: "packed", + label: "folds", + clauses: [c("fold_toggle", "this turn"), both("fold_open", "fold_close", "open/close"), c("fold_open_all", "all open"), c("fold_close_all", "all folded"), both("next_fold", "prev_fold", "between folds")], + }, + { + row: "cont", + segs: [ + seg([k("fold"), k("unfold")].filter(Boolean).join(" "), "key"), + ...alias("fold", "unfold"), + txt(" fold/unfold a branch — "), + seg(k("unfold"), "key"), + txt(" opens a folded turn, "), + seg(k("fold"), "key"), + txt(" never closes one"), + ], + }, + { row: "packed", label: "search", clauses: [c("search", "live search"), both("search_next", "search_prev", "next/prev match")] }, + { row: "heading", text: "Act" }, + { + row: "verb", + key: k("go"), + name: "go here", + purpose: [txt("fork at this point, or switch to a "), seg("⎇", "glyph"), txt(" branch — the footer says which for this row")], + }, + { row: "cont", segs: [txt("a summarizing jump carries the turns you leave as one "), seg("≣", "glyph", "accent"), txt(" message (esc stays put)")] }, + { row: "verb", key: k("branch"), name: "branch", purpose: [txt("a real OpenCode session to try something risky in; this one keeps its context")] }, + { + row: "verb", + key: k("merge"), + name: "merge", + purpose: [txt("end a branch: one "), seg("◆", "glyph", "accent"), txt(" record of what you concluded, not the noise")], + }, + { + row: "verb", + key: k("crop"), + name: "crop", + purpose: [txt("stop sending a fat tool result you no longer need — "), ...c("mark", "mark"), txt(" · "), ...c("auto", "auto")], + }, + { row: "cont", segs: [txt("· "), ...c("crop_toggle_mode", "result⇄turn"), txt(" · "), ...c("go", "apply"), txt(" · esc leave — the transcript keeps it, "), seg(k("undo"), "key"), txt(" restores")] }, + { row: "verb", key: k("undo"), name: "undo", purpose: [txt("reverse the last crop, branch or merge on this path")] }, + { + row: "verb", + key: k("label"), + name: "mark", + purpose: [txt("bookmark a message to find it again · "), ...c("copy", "copy the row"), txt(" · "), ...c("export", "export the "), seg("◆", "glyph", "accent"), txt(" records")], + }, + { row: "heading", text: "Views" }, + { row: "verb", key: k("consumers"), name: "consumers", purpose: [txt("what is actually filling the context, biggest first: where to point crop")] }, + { row: "verb", key: k("decisions"), name: "decisions", purpose: [txt("every "), seg("◆", "glyph", "accent"), txt(" record confirmed on this tree")] }, + { row: "verb", key: k("filter_pick"), name: "filter", purpose: [txt('which rows to show (tools-only is "what did I run") — the lanes follow it')] }, + { + row: "verb", + key: k("inspector"), + name: "inspector", + purpose: [txt("the selected row in full · "), ...c("inspector_full", "full screen"), txt(" · "), seg([k("inspector_up"), k("inspector_down")].filter(Boolean).join(" "), "key"), txt(" scroll it")], + }, + { + row: "verb", + key: k("mode_duration"), + name: "lanes", + purpose: [txt("duration x-axis · "), ...c("mode_turns", "turns x-axis"), txt(" · "), ...c("lanes_off", "off")], + }, + { row: "heading", text: "Legend" }, + { + row: "packed", + label: "rows", + clauses: [g("●", "user"), g("○", "assistant", "muted"), g("⚙", "tool step", "muted"), g("◆", "decision", "accent"), g("≣", "summary", "accent"), g("⎇", "branch")], + }, + { + row: "packed", + label: "tree", + clauses: [g("│ ├ ╰", "topology", "muted"), g("▾", "open", "muted"), g("▸6", "folded, standing for 6 rows", "muted"), g("←", "the session you are in", "muted")], + }, + { + row: "cont", + segs: [seg("──", "glyph", "muted"), txt(" not in this branch's context "), seg("──", "glyph", "muted"), txt(" is where your path forked · a dim row is not sent")], + }, + { + row: "packed", + label: "cost", + clauses: [[txt("right column is tokens")], g("~", "estimated", "muted"), g("⚠", "≥10k", "warning"), g("✂", "cropped", "accent"), g("✗", "tool error", "error")], + }, + { + row: "packed", + label: "status", + clauses: [[txt("right end is the prompt really sent here")], [txt("history, not re-costed after a crop")]], + }, + // the colours are drawn, not named: showing one costs a column, spelling "green" in grey + // costs six and asks the reader to take a monochrome word's word for it + { + row: "packed", + label: "⎇", + clauses: [g("█", "open", "success"), g("█", "squashed", "info"), g("█", "rejected/discarded", "error"), g("█", "abandoned", "muted")], + }, + { + row: "packed", + label: "lanes", + clauses: [ + [txt("Input "), seg("█", "glyph", "success"), txt(" you "), seg("█", "glyph", "muted"), txt(" context")], + [txt("Model "), seg("█", "glyph", "accent"), txt(" answer "), seg("█", "glyph", "muted"), txt(" thinking")], + [txt("Tools "), seg("█", "glyph", "warning"), txt(" call "), seg("█", "glyph", "error"), txt(" failed")], + ], + }, + { + row: "cont", + segs: [seg("│", "glyph", "muted"), txt(" turn boundary · …N / N… events hidden either side · all = the whole session")], + }, + ] + + return layout(body, version, k("help"), ks("back").join(" / "), width) +} + +/** ` ` indent, and the same again for a continuation that has no columns to sit under. */ +const INDENT = " " + +function layout(body: readonly Body[], version: string, helpKey: string, backKeys: string, width: number): HelpSeg[][] { + // Column widths are per *section*, not global: one `ctrl+shift+x` rebound in Act must not + // push every label in Legend sideways. A section is a run between headings. + const sections: Body[][] = [] + for (const row of body) { + if (row.row === "heading" || sections.length === 0) sections.push([]) + sections.at(-1)!.push(row) + } + + const out: HelpSeg[][] = [ + // the title's own key is a key, not part of the heading: `?` is the one stroke a reader + // may already be holding down, and it has to look like the others + clipSegs([seg(helpKey, "key"), seg(" help", "heading"), txt(" · "), seg(backKeys, "key"), txt(` closes · opencode-context-tree ${version}`)], width), + ] + for (const section of sections) { + const keyCol = Math.min(MAX_KEY_COL, Math.max(0, ...section.map((r) => (r.row === "verb" ? [...r.key].length : 0)))) + const nameCol = Math.max(0, ...section.map((r) => (r.row === "verb" ? [...r.name].length : 0))) + const labelCol = Math.max(0, ...section.map((r) => (r.row === "packed" ? [...r.label].length : 0))) + // where a continuation line's text starts: under the purpose of the rows above it + const contCol = keyCol > 0 ? INDENT.length + keyCol + 2 + nameCol + 2 : INDENT.length + labelCol + 2 + // a verb whose key the user unbound takes its continuation lines with it: an orphaned + // `a summarizing jump carries…` under no verb at all is worse than the missing row + let orphaned = false + for (const row of section) { + if (row.row !== "cont") orphaned = false + if (row.row === "heading") { + out.push([seg(row.text, "heading")]) + continue + } + if (row.row === "prose") { + out.push([txt(INDENT), ...clipSegs(row.segs, width - INDENT.length)]) + continue + } + if (row.row === "cont") { + if (!orphaned) out.push([txt(" ".repeat(contCol)), ...clipSegs(row.segs, width - contCol)]) + continue + } + if (row.row === "packed") { + const head = `${INDENT}${row.label.padEnd(labelCol)} ` + const kept = row.clauses.filter((cl) => cl.length > 0) + // clauses are dropped from the right until the line fits, never wrapped (§7.6) + let n = kept.length + const join = (take: number) => kept.slice(0, take).flatMap((cl, i) => (i === 0 ? cl : [txt(" · "), ...cl])) + while (n > 1 && [...head].length + wide(join(n)) > width) n-- + if (n === 0) continue + out.push([seg(head.slice(0, INDENT.length), "text"), seg(row.label.padEnd(labelCol), "label"), txt(" "), ...clipSegs(join(n), width - [...head].length)]) + continue + } + // a verb with no key is a command the user unbound: drop the row rather than draw a + // nameless one (its continuation, if any, goes with it — see `cont` below) + if (!row.key) { + orphaned = true + continue + } + const pad = (n: number) => txt(" ".repeat(Math.max(1, n))) + const used = INDENT.length + Math.max(keyCol, [...row.key].length) + 2 + nameCol + 2 + out.push([ + txt(INDENT), + seg(row.key, "key"), + pad(keyCol - [...row.key].length + 2), + seg(row.name, "name"), + pad(nameCol - [...row.name].length + 2), + ...clipSegs(row.purpose, width - used), + ]) + } } - /** One `key text` clause — "" when the command is unbound, so the line can drop it. */ - const c = (command: string, text: string) => (k(command) ? `${k(command)} ${text}` : "") - /** A line of clauses joined by ` · `, or "" when every clause came out empty. `keybinds` - * can unbind anything (`{ "copy": "none" }`), and a pane that answers with a headless - * ` — try something risky on a copy` is worse than one that says nothing. */ - const line = (...parts: string[]) => { - const kept = parts.filter(Boolean) - return kept.length ? ` ${kept.join(" · ")}` : "" + return out +} + +/** Clip a run of segments to `room` columns, ending in `…`. Prose degrades from the right; + * a key or a name is never the thing that clips, because they are never last. */ +function clipSegs(segs: readonly HelpSeg[], room: number): HelpSeg[] { + if (room <= 0) return [] + if (wide(segs) <= room) return segs as HelpSeg[] + const out: HelpSeg[] = [] + let left = room - 1 + for (const s of segs) { + const chars = [...s.text] + if (chars.length <= left) { + out.push(s) + left -= chars.length + continue + } + if (left > 0) out.push({ ...s, text: chars.slice(0, left).join("") }) + out.push(txt("…")) + return out } - /** A verb and its continuation line: both go when the verb has no key. */ - const withTail = (head: string, tail: string) => (head ? [` ${head}`, tail] : []) - return [ - `${k("help")} help · ${ks("back").join(" / ")} closes · opencode-context-tree ${version}`, - "What this is", - " the tree is every turn of this session and its branches; the right column is what each costs", - " nothing here rewrites your transcript — crop and merge change what the *model* is sent next", - "Move", - line( - `${[...ks("up"), ...ks("down")].join(" ")} move`, - `${pair("page_down", "page_up")} page`, - `${pair("half_down", "half_up")} half page`, - `${[k("screen_top"), k("screen_middle"), k("screen_bottom")].filter(Boolean).join(" ")} screen top/middle/bottom`, - pair("first", "last"), - ), - line(`${pair("prev_turn", "next_turn")} turn rows (the lanes scrub with them)`, `${pair("prev_branch", "next_branch")}${alsoPair("prev_branch", "next_branch")} branch rows`), - line( - `${pair("fold", "unfold")}${alsoPair("fold", "unfold")} fold/unfold a branch`, - c("toggle", "toggle"), - c("search", "live search"), - `${pair("search_next", "search_prev")} next/prev match`, - ), - line( - c("fold_toggle", "fold this turn"), - `${pair("fold_open", "fold_close")} open/close`, - c("unfold", "opens one"), - c("fold_open_all", "all open"), - c("fold_close_all", "all folded"), - `${pair("next_fold", "prev_fold")} between folds`, - ), - " the row you are on names the key for what it can do — you do not have to know these", - "Act", - ...withTail( - c("go", "go here — fork at this point, or switch to a ⎇ branch; the footer says which for this row"), - " a summarizing jump carries the turns you leave over as one ≣ message (esc stays put)", - ), - line(c("branch", "branch — try something risky on a copy; this session keeps its context either way")), - line(c("merge", "merge — end a branch: one ◆ record of what you concluded lands on the trunk, not the noise")), - ...withTail( - c("crop", `crop — stop sending a fat tool result you no longer need (${c("mark", "mark")} · ${c("auto", "auto")} · ${c("crop_toggle_mode", "result⇄turn")}`), - ` · ${c("go", "apply")} · esc leave) — the transcript keeps the text, ${k("undo")} puts it back in context`, - ), - line(c("undo", "undo — reverse the last crop, branch or merge on this path")), - line(c("label", "mark this message so you can find it again"), c("copy", "copy the row"), c("export", "export decisions")), - "Views", - line(c("consumers", "consumers — what is actually filling the context, biggest first: where to point crop")), - line(c("decisions", "decisions — every ◆ record confirmed on this tree")), - line(c("filter_pick", 'filter — which rows to show (tools-only is "what did I run") · the lanes follow it')), - line( - c("inspector", "inspector"), - c("inspector_full", "full screen"), - `${pair("inspector_up", "inspector_down")} scroll it`, - `${pair("mode_duration", "mode_turns")} lanes (duration/turns x-axis)`, - c("lanes_off", "off"), - ), - "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 ${k("filter_pick")} filter shows (→ tools-only = just calls)`, - ].filter(Boolean) + return out +} + +/** + * The pane as plain strings — what the drift tests assert against, and what any caller that + * cannot colour anything gets. The segments are the source of truth; this is them joined. + */ +export function helpLines(version: string, overrides?: Record, width = HELP_WIDTH): string[] { + return helpSegments(version, overrides, width).map((line) => line.map((s) => s.text).join("").trimEnd()) } /** How a stroke is written on screen: as the user types it, not as the keymap stores it. */ diff --git a/src/tui/gauge.tsx b/src/tui/gauge.tsx index 7281737..d099c80 100644 --- a/src/tui/gauge.tsx +++ b/src/tui/gauge.tsx @@ -1,26 +1,29 @@ /** @jsxImportSource @opentui/solid */ /** * The context gauge (DESIGN.md §6.7): `ctx ▓▓░░░ ~2.3k/32.8k · low · 95% cached`. One - * component so the prompt slot and the `/tree` header can never drift apart — the bar's - * filled cells split into a muted cached run and a band-coloured fresh run, and the cached - * suffix is muted too. Renders the exact characters `formatContext` would, just recoloured. + * component so the prompt slot and the `/tree` header can never drift apart. Renders the exact + * characters `formatContext` would, just recoloured — and which colour each run takes is + * `core/gauge.ts#gaugeRoles`, not a decision made here, so a test can hold the one rule that + * matters: the bar carries the band colour and the text never does. See that file for why. */ import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui" import { Show, type JSX } from "solid-js" import { bandFor, cacheShare, contextBarCells, formatK, type ContextSize } from "../core/tokens.js" - -const BAND_COLOR = { low: "success", healthy: "success", filling: "warning", red: "error" } as const +import { gaugeRoles } from "../core/gauge.js" /** `@opentui/solid`'s `SpanProps` omits `fg`/`bg` even though the `SpanRenderable` it wraps * accepts them the same as ``'s `TextRenderable` does — one cast here rather * than at every run below. */ -function Span(props: { fg: unknown; children?: JSX.Element }) { +export function Span(props: { fg: unknown; children?: JSX.Element }) { return } export function ContextGauge(props: { theme: TuiThemeCurrent; size: ContextSize; limit?: number; showCachedSuffix?: boolean }) { const band = () => bandFor(props.size.tokens, props.limit) - const fg = () => props.theme[BAND_COLOR[band()]] + /** `core/gauge.ts` decides which theme colour each run takes; this only looks it up. The + * rule it enforces: the bar carries the band, the text never does. */ + const role = () => gaugeRoles(band()) + const fg = (which: keyof ReturnType) => props.theme[role()[which]] const share = () => cacheShare(props.size) const cells = () => contextBarCells(props.size, props.limit ?? 0) const numbers = () => `${props.size.estimated ? "~" : ""}${formatK(props.size.tokens)}${props.limit ? `/${formatK(props.limit)}` : ""} · ${band()}` @@ -28,15 +31,17 @@ export function ContextGauge(props: { theme: TuiThemeCurrent; size: ContextSize; // one of s, not a row box: a box's children lay out side by side and clip // instead of soft-wrapping, which broke the narrow sidebar card (DESIGN.md §7 wrapping note) - ctx + ctx - {"▓".repeat(cells().cached)} - {`${"▓".repeat(cells().fresh)}${"░".repeat(cells().empty)} `} + {"▓".repeat(cells().cached)} + {"▓".repeat(cells().fresh)} + {"░".repeat(cells().empty)} + - {numbers()} + {numbers()} {/* the narrow sidebar card passes false: its own absolute line right below already says it */} - {` · ${share()}% cached`} + {` · ${share()}% cached`} ) diff --git a/src/tui/index.tsx b/src/tui/index.tsx index 91645f8..915e1c3 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -6,6 +6,7 @@ import type { TuiPluginApi, TuiPlugin } from "@opencode-ai/plugin/tui" import { Show, createEffect, createMemo, createSignal, on } from "solid-js" import { bandFor, cacheShare, contextSizeOf, formatK, type MinimalMessage, type MinimalPart } from "../core/tokens.js" +import { BAND_TOKEN } from "../core/gauge.js" import { JournalStore, type StorageMode } from "../shared/store.js" import { debug } from "../shared/debug.js" import { BRANCH_DIALOG, MERGE_TRUST, branchLabel, bumpJournal, clip, createNamedBranch, journalRevision, mergeBranch, mergeDialogOptions, mergeDialogTitle, mergePickerFigures, setLabel, type MergeMode } from "./actions.js" @@ -16,9 +17,8 @@ import { parseForkTitle } from "../core/adopt.js" import { adoptNativeForks, retryAdopt } from "../shared/adopt.js" import { sdkTimeout } from "../shared/sdk.js" import { fetchTranscript, modelContextLimit } from "./transcripts.js" -import { ContextGauge } from "./gauge.js" +import { ContextGauge, Span } from "./gauge.js" -const BAND_COLOR = { low: "success", healthy: "success", filling: "warning", red: "error" } as const type Options = { storage: StorageMode; jumpSummary: "ask" | "never"; hardCrop: boolean; keybinds: Record; open: string[] } @@ -323,7 +323,11 @@ const tui: TuiPlugin = async (api, rawOptions) => { Context tree {`trunk${siblings() ? ` · ${siblings()} branch${siblings() === 1 ? "" : "es"}` : ""}`}}> - {`⎇ ${branchLabel(api, props.session_id, branch()!.name, CARD_COLUMNS - 2)}`} + {/* the glyph carries the colour, the name is read — see core/gauge.ts */} + + + {branchLabel(api, props.session_id, branch()!.name, CARD_COLUMNS - 2)} + {status()} @@ -331,7 +335,10 @@ const tui: TuiPlugin = async (api, rawOptions) => { {`${formatK(size().cached!)} cached of ${formatK(size().prompt!)} (${cacheShare(size())}%)`} - {`✂ ${crops().length} crop${crops().length === 1 ? "" : "s"} · ~${formatK(hidden())} hidden`} + + + {`${crops().length} crop${crops().length === 1 ? "" : "s"} · ~${formatK(hidden())} hidden`} + /tree · ctrl+q @@ -389,7 +396,7 @@ const tui: TuiPlugin = async (api, rawOptions) => { if (delta > 0 && (!biggest || delta > biggest.delta)) biggest = { key, delta } } const rise = prevTokens > 0 ? (now - prevTokens) / prevTokens : 0 - if (now !== prevTokens) setTrend(rise >= 0.1 && biggest ? ` ▲ +${Math.round(rise * 100)}% (${biggest.key})` : "") + if (now !== prevTokens) setTrend(rise >= 0.1 && biggest ? ` +${Math.round(rise * 100)}% (${biggest.key})` : "") prevTokens = now prevParts = new Map([...parts].map(([id, v]) => [id, v.len])) }) @@ -409,10 +416,18 @@ const tui: TuiPlugin = async (api, rawOptions) => { // the same gauge the tree header shows, so both surfaces read identically - {`⎇ ${branchLabel(api, props.session_id, branch()!.name, 24)} · `} + {/* the branch's *name* is text to read, so it takes the theme's text colour — + a band colour here was both illegible on a light theme and a category error, + since which branch you are on has nothing to do with the context band */} + + {`${branchLabel(api, props.session_id, branch()!.name, 24)} · `} - {trend()} + {/* the arrow carries the band, the figure beside it is read */} + + + {trend()} + ) }, diff --git a/src/tui/route.tsx b/src/tui/route.tsx index fbfc9b2..c6a557f 100644 --- a/src/tui/route.tsx +++ b/src/tui/route.tsx @@ -17,8 +17,8 @@ import type { JournalStore } from "../shared/store.js" import { applyCrop, branchLabel, BRANCH_DIALOG, clip as clipTo, COPY_HINT, copyText, createNamedBranch, executeJump, executeUndo, jumpDialogOptions, jumpDialogTitle, mergeBranch, mergeDialogOptions, mergeDialogTitle, mergePickerFigures, MERGE_TRUST, setLabel, type ActionContext, type MergeMode, type SummaryChoice } from "./actions.js" import { decisionSummary, exportDecisions, renderDecision } from "../core/decision.js" import { formatProgress, SPINNER_MS, type ProgressState } from "../core/progress.js" -import { applyFolds, foldDigest, isFolded, nextFoldIndex, ownerTurnIndex, policyFor, setManualFold, type FoldPolicy } from "../core/fold.js" -import { DEFAULT_KEYS, footerLine, helpLines, keyLabel, keyLabels, rowHint, type RowAffordance } from "../core/help.js" +import { applyFolds, foldFlags, foldMark, isFolded, nextFoldIndex, ownerTurnIndex, policyFor, setManualFold, type FoldPolicy } from "../core/fold.js" +import { DEFAULT_KEYS, footerLine, HELP_CHROME, helpSegments, keyLabel, keyLabels, rowHint, type HelpSeg, type RowAffordance } from "../core/help.js" import { laneLabel, laneSuffix, layoutEventStrip, overviewTrack, stripIndexFor, windowFor, LANE_CHROME, type LaneMode, type StripCell } from "../core/lanes.js" import { bar, consumers, type Consumer, type ConsumerEntry } from "../core/consumers.js" import { hasEditor } from "./editor.js" @@ -41,6 +41,61 @@ export type TreeRouteProps = { initialView?: "tree" | "decisions" } +/** + * A help segment's colour and weight. The salience order is the point: a **key** is the + * brightest, boldest thing on the pane, because finding one key without reading is the only + * reason anyone opens `?`. Headings are accent, prose is muted, and a glyph is drawn in the + * colour the tree itself draws it — a legend that spells "green" in grey is a bug in a + * renderer that can just show you the green. + */ +function helpColor(t: TuiPluginApi["theme"]["current"], s: HelpSeg): unknown { + if (s.tone) { + switch (s.tone) { + case "success": + return t.success + case "info": + return t.info + case "error": + return t.error + case "warning": + return t.warning + case "accent": + return t.accent + case "muted": + return t.textMuted + } + } + switch (s.kind) { + case "heading": + return t.accent + case "key": + case "strong": + case "name": + case "glyph": + return t.text + case "label": + return t.accent + default: + return t.textMuted + } +} + +/** + * `@opentui/core`'s `TextAttributes` bits, written out rather than imported: the plugin has + * only ever imported `@opentui/solid` at runtime, and adding `@opentui/core` to the bundle's + * imports made the host fail to load the route at all (caught by the `?` pane e2e). They are + * a stable public enum and `test/help.test.ts` pins these two against it. + */ +const BOLD = 1 +const DIM = 2 + +/** Bold what you scan for, dim the label column that only anchors it. */ +function helpAttrs(s: HelpSeg): number { + if (s.kind === "key" || s.kind === "heading" || s.kind === "strong") return BOLD + if (s.kind === "label") return DIM + return 0 +} + function statusColor(t: TuiPluginApi["theme"]["current"], status: Row & { kind: "branch" }): unknown { switch (status.status) { case "open": @@ -114,9 +169,12 @@ function rowLine(row: Row, width: number, here: boolean, hint = ""): string { const flags = row.kind === "step" ? `${row.label ? ` [${row.label}]` : ""}${row.isCropped ? " ✂" : ""}${row.warn ? " ⚠" : ""}${row.isError ? " ✗" : ""}` - : `${row.label ? ` [${row.label}]` : ""}${isFolded(row) ? ` ${foldDigest(row.fold, formatK)}` : ""}` + : `${row.label ? ` [${row.label}]` : ""}${isFolded(row) ? foldFlags(row.fold) : ""}` const dur = row.kind === "step" && row.durationMs !== undefined ? ` ${(row.durationMs / 1000).toFixed(row.durationMs < 10_000 ? 1 : 0)}s` : "" - body = `${row.gutter}${glyphOf(row)} ${textOf(row)}${flags}${dur}${thoughtOf(row)}${marker}` + // the fold marker sits between the glyph and the text: it is this row's disclosure + // control, so it belongs at the row's left edge and not at the end of a clipped preview + const fold = isFolded(row) ? `${foldMark(row.fold)} ` : "" + body = `${row.gutter}${glyphOf(row)} ${fold}${textOf(row)}${flags}${dur}${thoughtOf(row)}${marker}` } return fitRow(body, tokens, width, hint) } @@ -225,8 +283,6 @@ export function TreeRoute(props: TreeRouteProps) { /** First `?` line drawn. The pane is longer than a short terminal, and what it teaches is * worth more than what fits, so it scrolls on the inspector's own keys. */ const [helpTop, setHelpTop] = createSignal(0) - /** The pane names live bindings, so a `keybinds` override changes what it says. */ - const HELP = createMemo(() => helpLines(PLUGIN_VERSION, props.options.keybinds)) const [inspector, setInspector] = createSignal(api.kv.get("ctree.inspector", false)) const [consumerIndex, setConsumerIndex] = createSignal(0) const [consumerOpen, setConsumerOpen] = createSignal>(new Set()) @@ -432,7 +488,12 @@ export function TreeRoute(props: TreeRouteProps) { api.renderer.on("resize", onResize) onCleanup(() => void api.renderer.off("resize", onResize)) const cols = () => size().cols - // the `?` pane sits under the rows so the tree stays visible: it takes its space from them + // the `?` pane sits under the rows so the tree stays visible: it takes its space from them. + // It must be declared *after* `size`: a createMemo body runs the moment it is created, so + // reading `size()` from above its own declaration threw on every render of the route. + /** The pane names live bindings, so a `keybinds` override changes what it says — and it is + * laid out for this terminal, so its columns and clauses fit rather than clip (§7.6). */ + const HELP = createMemo(() => helpSegments(PLUGIN_VERSION, props.options.keybinds, Math.max(40, size().cols - HELP_CHROME))) const helpHeight = () => (panel() === "help" ? Math.min(HELP().length, Math.max(0, size().rows - 12)) : 0) const helpPane = () => paneWindow(HELP().length, helpHeight(), helpTop()) const helpVisible = () => HELP().slice(helpPane().start, helpPane().start + helpHeight()) @@ -1722,7 +1783,18 @@ export function TreeRoute(props: TreeRouteProps) { if (panel() === "help") { const { from, to } = helpPane() const back = `${backVerb()} back` - return to < HELP().length || from > 1 ? `${from}–${to} of ${HELP().length} · ${scrollVerb()} · ${back}` : back + if (to >= HELP().length && from <= 1) return back + // which section you are in: 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 + let here = "" + for (let i = helpPane().start; i > 0; i--) { + const head = HELP()[i]?.[0] + if (head?.kind === "heading") { + here = `${head.text} · ` + break + } + } + return `${from}–${to} of ${HELP().length} · ${here}${scrollVerb()} · ${back}` } return fitFooter( goVerb(), @@ -1894,7 +1966,17 @@ export function TreeRoute(props: TreeRouteProps) { {(l) => │ {l}} {/* the help pane sits under the rows, so the tree it explains stays on screen */} - {(l) => │ {l}} + + {(line) => ( + + {/* the gutter is the tree's rule continuing down, so it is dim on every row — + it used to take the line's own colour, which made headings the brightest + thing on a pane whose whole job is to make *keys* findable */} + + {(s) => {s.text}} + + )} + diff --git a/test/e2e/tui.test.ts b/test/e2e/tui.test.ts index 71b76c8..3ff26f9 100644 --- a/test/e2e/tui.test.ts +++ b/test/e2e/tui.test.ts @@ -332,12 +332,16 @@ describe.skipIf(!e2e)("tui e2e: built plugin", () => { return hit.screen } const STEP = "⚙ [bash" - const FOLDED_FIRST = "● user: run the tool ▸ 2 steps" + const FOLDED_FIRST = "● ▸2 user: run the tool" // The tree opens on the default posture: the turn you are in is open, everything above // it is scrollback, folded. Only the first turn ran tools here, so it is the only row // with a fold at all — the second turn owns nothing and never grows a ▸. expect(before(4)).toContain(FOLDED_FIRST) expect(before(4)).not.toContain(STEP) + // the marker is the row's left edge, between the ● and the preview — not a digest + // trailing a clipped preview, and never a second copy of the token column + expect(before(4)).toMatch(/● ▸2 user: run the tool/) + expect(before(4)).not.toMatch(/▸\s*\d+ steps/) expect(before(4)).toContain("● user: second ") // gg puts the cursor on that folded turn, and the row says what it affords in the key // that actually does it — on that row only, so a search over row text never sees it @@ -355,9 +359,10 @@ describe.skipIf(!e2e)("tui e2e: built plugin", () => { // key did nothing at all on a turn row before, which is why it was free to mean this. expect(before(8)).toContain(STEP) expect(before(8)).not.toContain(FOLDED_FIRST) - // zr opens every fold there is, so nothing is left standing in for hidden rows + // zr opens every fold there is, so nothing is left standing in for hidden rows. + // `▸\d` rather than a bare caret: a collapsed *branch* row draws ▸ too. expect(before(9)).toContain(STEP) - expect(before(9)).not.toContain("▸ ") + expect(before(9)).not.toMatch(/▸\d/) } finally { await toolMock.stop() await proj.cleanup() @@ -451,10 +456,13 @@ describe.skipIf(!e2e)("tui e2e: built plugin", () => { const seen = (needle: string) => screens.some((x) => x.screen.includes(needle)) // the pane opens on what the tool is and what each verb is for if (!seen("nothing here rewrites your transcript")) throw new Error(`the ? pane never opened. screens: ${screens.map((x) => x.label).join(" | ")}`) - expect(seen("gb branch — try something risky")).toBe(true) + // the Act table: a key field, a name field, a purpose — aligned at render time + expect(seen("gb branch a real OpenCode session")).toBe(true) // a 30-row terminal cannot hold it, so it says so and PgDn reaches the rest expect(seen("PgUp/PgDn scroll")).toBe(true) - if (!seen("gs consumers — what is actually filling")) throw new Error("PgDn never reached the Views section of the ? pane") + if (!seen("gs consumers what is actually filling")) throw new Error("PgDn never reached the Views section of the ? pane") + // the footer names the section you scrolled into, since the headings scroll off + expect(screens.some((x) => /\d+–\d+ of \d+ · (Act|Views|Legend|Move) · /.test(x.screen))).toBe(true) } finally { await m.stop() await proj.cleanup() diff --git a/test/fold.test.ts b/test/fold.test.ts index 28e0c83..90be226 100644 --- a/test/fold.test.ts +++ b/test/fold.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { DEFAULT_OPEN_TURNS, applyFolds, foldDigest, isFolded, nextFoldIndex, ownerTurnIndex, policyFor, setManualFold, type FoldPolicy } from "../src/core/fold.js" +import { DEFAULT_OPEN_TURNS, applyFolds, foldFlags, foldMark, isFolded, nextFoldIndex, ownerTurnIndex, policyFor, setManualFold, type FoldPolicy, type FoldSummary } from "../src/core/fold.js" import { buildTreeView, type Row } from "../src/core/tree.js" import { formatK } from "../src/core/tokens.js" import { buildFixture, OPEN, TRUNK } from "./fixtures/tree.js" @@ -142,11 +142,43 @@ describe("nextFoldIndex", () => { }) }) -describe("foldDigest", () => { - test("steps and tokens always; flags only when there are any", () => { - expect(foldDigest({ steps: 6, tokens: 12_000, estimated: false, errors: 0, warns: 0, cropped: 0, messageIDs: [] }, formatK)).toBe("▸ 6 steps · 12k") - expect(foldDigest({ steps: 1, tokens: 900, estimated: false, errors: 0, warns: 0, cropped: 0, messageIDs: [] }, formatK)).toBe("▸ 1 step · 900") - expect(foldDigest({ steps: 6, tokens: 12_000, estimated: true, errors: 1, warns: 2, cropped: 1, messageIDs: [] }, formatK)).toBe("▸ 6 steps · ~12k · 1 ✗ · 2 ⚠ · 1 ✂") +const summary = (over: Partial = {}): FoldSummary => ({ steps: 6, tokens: 12_000, estimated: false, errors: 0, warns: 0, cropped: 0, messageIDs: [], ...over }) + +describe("foldMark", () => { + test("the caret and the count, and nothing else", () => { + expect(foldMark(summary())).toBe("▸6") + expect(foldMark(summary({ steps: 1 }))).toBe("▸1") + expect(foldMark(summary({ steps: 137 }))).toBe("▸137") + }) + + test("it never repeats the token column", () => { + // applyFolds rolls the hidden steps' tokens into the turn row, so the fold's tokens and + // the row's own figure are the same number: printing both read as one number gone wrong + const mark = foldMark(summary({ tokens: 23_500, estimated: true })) + expect(mark).not.toContain("k") + expect(mark).not.toContain("~") + expect(mark).not.toContain(formatK(23_500)) + }) + + test("it is short enough to sit inside the row's left edge", () => { + for (const steps of [1, 9, 99, 999]) expect([...foldMark(summary({ steps }))].length).toBeLessThanOrEqual(5) + }) +}) + +describe("foldFlags", () => { + test("nothing to flag draws nothing at all — most rows", () => { + expect(foldFlags(summary())).toBe("") + }) + + test("only the flags that fired, in a step row's own order", () => { + expect(foldFlags(summary({ errors: 1 }))).toBe(" 1✗") + expect(foldFlags(summary({ warns: 2 }))).toBe(" 2⚠") + expect(foldFlags(summary({ cropped: 1 }))).toBe(" 1✂") + expect(foldFlags(summary({ errors: 1, warns: 2, cropped: 3 }))).toBe(" 3✂ 2⚠ 1✗") + }) + + test("it leads with a space, so it joins the text without one of its own", () => { + expect(foldFlags(summary({ errors: 1 })).startsWith(" ")).toBe(true) }) }) diff --git a/test/gauge.test.ts b/test/gauge.test.ts new file mode 100644 index 0000000..fbd153b --- /dev/null +++ b/test/gauge.test.ts @@ -0,0 +1,82 @@ +/** + * The gauge shipped through 0.3.0-beta.2 painting every readable character in a band colour — + * `ctx`, the bar, and `84.7k/1M · low` were one `success`-green run. On a dark theme that + * reads; on a light one it does not, and the report was simply "this section is not legible". + * + * The rule that fixes it is a rule about *tokens*, not about any one theme's hex values, so it + * is testable without a terminal: 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. + */ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import path from "node:path" +import { BAND_TOKEN, gaugeRoles, READABLE, READ_ROLES, type GaugeRoles } from "../src/core/gauge.js" +import { bandFor, type ContextBand } from "../src/core/tokens.js" + +const BANDS: ContextBand[] = ["low", "healthy", "filling", "red"] + +describe("no run a user reads takes a band colour", () => { + for (const band of BANDS) { + test(`in the ${band} band`, () => { + const roles = gaugeRoles(band) + for (const role of READ_ROLES) { + expect(READABLE, `the gauge's "${role}" run is ${roles[role]} in the ${band} band — a band colour is chosen to be distinguishable, not readable`).toContain(roles[role]) + } + }) + } + + test("the numbers are the theme's own text colour, not merely readable", () => { + // `84.7k/1M · low` is the payload; textMuted would be legible but wrong for it + for (const band of BANDS) expect(gaugeRoles(band).numbers).toBe("text") + }) +}) + +describe("the bar still carries the band", () => { + test("the fresh run is the band's colour, and nothing else is", () => { + for (const band of BANDS) { + const roles = gaugeRoles(band) + expect(roles.fresh).toBe(BAND_TOKEN[band]) + const others = (Object.keys(roles) as (keyof GaugeRoles)[]).filter((r) => r !== "fresh") + for (const role of others) { + expect(Object.values(BAND_TOKEN), `"${role}" also carries the band — the bar should be the only thing that does`).not.toContain(roles[role]) + } + } + }) + + test("every band maps to a colour, and red is the alarming one", () => { + for (const band of BANDS) expect(BAND_TOKEN[band]).toBeTruthy() + expect(BAND_TOKEN[bandFor(100, 100)]).toBe("error") + expect(BAND_TOKEN[bandFor(1, 100)]).toBe("success") + }) + + test("the cached and empty runs stay distinguishable from the fresh one", () => { + for (const band of BANDS) { + const roles = gaugeRoles(band) + expect(new Set([roles.cached, roles.fresh, roles.empty]).size, `two of the bar's three runs share a colour in the ${band} band`).toBe(3) + } + }) +}) + +describe("the components take their colours from the table", () => { + const read = (file: string) => readFileSync(path.join(import.meta.dir, "..", "src", "tui", file), "utf8") + + test("gauge.tsx names no theme colour of its own", () => { + // every run must go through fg("…"), or the table stops being the single source of truth + const body = read("gauge.tsx").split("export function ContextGauge")[1] ?? "" + expect(body.length).toBeGreaterThan(100) + expect(body, "gauge.tsx reaches past gaugeRoles to a theme colour directly").not.toMatch(/props\.theme\.[a-zA-Z]+/) + }) + + test("no readable string is interpolated into a band-coloured element", () => { + // `{`⎇ ${name} · `}` is the shape that shipped: + // a whole label, not a glyph, wearing the band's colour + for (const file of ["index.tsx", "gauge.tsx"]) { + for (const line of read(file).split("\n")) { + if (!line.includes("BAND_TOKEN[")) continue + const content = line.match(/>\{?([^<]*)\}?<\/text>/)?.[1] ?? "" + expect([...content.replace(/[{}`\s]/g, "")].length, `a band-coloured run in ${file} carries text, not just a glyph: ${line.trim()}`).toBeLessThanOrEqual(2) + } + } + }) +}) diff --git a/test/help.test.ts b/test/help.test.ts index 168f7f3..a4aea5c 100644 --- a/test/help.test.ts +++ b/test/help.test.ts @@ -8,7 +8,9 @@ * first pass and lies to everyone who has rebound anything. */ import { describe, expect, test } from "bun:test" -import { DEFAULT_KEYS, footerLine, HELP_VERBS, helpLines, keyLabel, rowHint, type RowAffordance } from "../src/core/help.js" +import { existsSync, readFileSync } from "node:fs" +import path from "node:path" +import { DEFAULT_KEYS, footerLine, HELP_VERBS, HELP_WIDTH, helpLines, helpSegments, keyLabel, keyLabels, rowHint, type RowAffordance } from "../src/core/help.js" /** * Every verb the pane must name, moved somewhere it could not possibly have been hardcoded — @@ -32,8 +34,8 @@ for (const { name, overrides } of CASES) { const keys = (command: string) => overrides?.[command] ?? DEFAULT_KEYS[command] ?? [] const lines = helpLines("1.2.3", overrides) const body = lines.join("\n") - /** A verb's line: indented, and naming the key. Headings are unindented by construction. */ - const verbLines = lines.filter((l) => l.startsWith(" ") && l.includes(" — ")) + /** A verb row: the `Act`/`Views` table shape — a key field, a name field, a purpose. */ + const verbRows = helpSegments("1.2.3", overrides).filter((l) => l.some((s) => s.kind === "key") && l.some((s) => s.kind === "name")) describe(`the help pane spells keys the way the keymap binds them (${name})`, () => { for (const command of HELP_VERBS) { @@ -42,9 +44,16 @@ for (const { name, overrides } of CASES) { expect(DEFAULT_KEYS[command], `${command} is missing from DEFAULT_KEYS`).toBeDefined() // an override may unbind a command outright; then the pane must simply not claim a key if (bound.length === 0) return - // the pane writes keys as the user types them: `gb`, `⏎`, `/`, `?` - const spellings = bound.map((k) => k.replace("return", "⏎").replace("escape", "esc").replace("shift+", "")) - expect(spellings.some((k) => body.includes(k)), `no line in the ? pane names ${command} (${bound.join(", ")})`).toBe(true) + // The pane writes keys as the user types them (`gb`, `⏎`, `/`, `?`) — and it must + // write them *as keys*: `route.tsx#helpColor` can only bolden a run it has been told + // is a key, so a spelling that leaked into a `text` run is invisible again, which is + // the exact defect this whole segment model exists to prevent. + const spellings = new Set(keyLabels(command, overrides)) + const asKeys = helpSegments("1.2.3", overrides) + .flat() + .filter((x) => x.kind === "key") + .flatMap((x) => x.text.split(" ")) + expect([...spellings].some((k) => asKeys.includes(k)), `the ? pane never names ${command} (${bound.join(", ")}) in a key segment`).toBe(true) }) } @@ -59,13 +68,17 @@ for (const { name, overrides } of CASES) { }) test("an unbound verb takes its clause with it rather than leaving a headless one", () => { + // `keybinds: { branch: "none" }` used to leave " branch — try something risky…": a + // line whose key is simply gone, which reads as a typo rather than as an option. + for (const line of helpSegments("1.2.3", overrides)) { + for (const s of line) { + expect(s.text.length, `an empty ${s.kind} segment — a key-shaped hole: ${JSON.stringify(line)}`).toBeGreaterThan(0) + } + // a name column with no key in front of it is the same hole, one layer up + const kinds = line.map((s) => s.kind) + if (kinds.includes("name")) expect(kinds.includes("key"), `a name with no key: ${JSON.stringify(line)}`).toBe(true) + } for (const l of lines) { - // `keybinds: { branch: "none" }` used to leave " branch — try something risky…": - // a line whose key is simply gone, which reads as a typo rather than as an option. - // The pane has exactly three indents — a heading, a verb, a verb's continuation — - // so anything else is a key-shaped hole where the interpolation came out empty. - expect(l, `unexpected indent, probably an empty key: ${JSON.stringify(l)}`).toMatch(/^(\S| {2}\S| {5}\S)/) - expect(l, `a clause lost its key: ${l}`).not.toMatch(/^ +[a-z]+ — /) expect(l, `an empty clause survived: ${l}`).not.toMatch(/ · {2}|· ·/) } }) @@ -83,12 +96,34 @@ for (const { name, overrides } of CASES) { }) describe(`the pane teaches, not just lists (${name})`, () => { - test("every verb line says what the key is for", () => { - // "gb branch — try something risky on a copy": a key, a name, and a reason - expect(verbLines.length).toBeGreaterThanOrEqual(8) - for (const line of verbLines) { - const [, purpose] = line.split(" — ") - expect(purpose?.trim().length ?? 0, `no purpose after the em dash: ${line}`).toBeGreaterThan(12) + test("every verb row is a key, a name and a reason", () => { + // `gm merge end a branch: …` — three fields, and the third is what a key list + // would leave out. This is the assertion the em-dash split used to stand in for. + expect(verbRows.length, "the Act and Views tables lost their rows").toBeGreaterThanOrEqual(8) + for (const line of verbRows) { + const key = line.find((s) => s.kind === "key")! + const name = line.find((s) => s.kind === "name")! + const purpose = line + .slice(line.indexOf(name) + 1) + .map((s) => s.text) + .join("") + expect(key.text.trim().length, `a verb row with no key: ${JSON.stringify(line)}`).toBeGreaterThan(0) + expect(name.text.trim().length, `a verb row with no name: ${JSON.stringify(line)}`).toBeGreaterThan(0) + expect(purpose.trim().length, `no purpose after the name: ${purpose}`).toBeGreaterThan(12) + } + }) + + test("a key is always drawn as a key, so the pane can make it the bright thing", () => { + // the whole reason the pane is segments: `route.tsx#helpColor` can only bolden a key + // it has been told about, and a key that leaked into a `text` run is invisible again + // compare *spellings*, since that is what a segment carries: `up` is drawn `↑` + const spelled = new Set(Object.keys(DEFAULT_KEYS).flatMap((command) => keyLabels(command, overrides))) + for (const line of helpSegments("1.2.3", overrides)) { + for (const s of line.filter((x) => x.kind === "key")) { + for (const stroke of s.text.split(" ")) { + expect(spelled.has(stroke), `"${stroke}" is drawn as a key but nothing binds it`).toBe(true) + } + } } }) @@ -96,11 +131,21 @@ for (const { name, overrides } of CASES) { expect(body).toContain("nothing here rewrites your transcript") }) - test("lines fit a terminal row unwrapped", () => { - for (const line of lines) { - expect([...line].length, `too long to fit one row: ${line}`).toBeLessThanOrEqual(112) + test("lines fit the width they were laid out for, at every terminal worth having", () => { + // The budget is the *row* budget, not the terminal's: a help row is `│ ` inside a + // padding-1 box, so it gets `cols - HELP_CHROME`. Asserting against the raw terminal + // width is how nine lines came to clip at 100 columns with this test green. + for (const width of [HELP_WIDTH, 76, 56, 40]) { + for (const line of helpLines("1.2.3", overrides, width)) { + expect([...line].length, `${width + 4} columns: too long to fit one row: ${line}`).toBeLessThanOrEqual(width) + } } }) + + test("narrowing drops clauses and clips prose — it never drops a row", () => { + const rows = (width: number) => helpLines("1.2.3", overrides, width).length + expect(rows(40)).toBe(rows(HELP_WIDTH)) + }) }) } @@ -227,3 +272,29 @@ describe("footerLine", () => { expect(line(32)).not.toContain("gb branch") }) }) + +/** + * `route.tsx` writes out `TextAttributes.BOLD` / `.DIM` rather than importing them: adding + * `@opentui/core` to the TUI bundle's runtime imports made the host fail to load the route + * entirely (the plugin has only ever imported `@opentui/solid`). This holds the two written + * constants against the real enum, which the test process *can* import. + */ +describe("the text attribute bits route.tsx writes out", () => { + test("still match @opentui/core", async () => { + const { TextAttributes } = await import("@opentui/core") + const source = readFileSync(path.join(import.meta.dir, "..", "src", "tui", "route.tsx"), "utf8") + const bold = Number(source.match(/^const BOLD = (\d+)$/m)?.[1]) + const dim = Number(source.match(/^const DIM = (\d+)$/m)?.[1]) + expect(bold, "route.tsx no longer declares `const BOLD = `").not.toBeNaN() + expect(bold).toBe(TextAttributes.BOLD) + expect(dim).toBe(TextAttributes.DIM) + }) + + test("the TUI bundle imports only @opentui/solid at runtime", () => { + // dist is a build artefact; skip when it has not been built in this checkout + const dist = path.join(import.meta.dir, "..", "dist", "tui.js") + if (!existsSync(dist)) return + const imports = new Set(readFileSync(dist, "utf8").match(/@opentui\/[a-z]+/g) ?? []) + expect([...imports].sort()).toEqual(["@opentui/solid"]) + }) +})