From 56afe3af7a960d00494cd495b00274b5986e812a Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 8 Oct 2026 09:47:38 -0400 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=92=84=20Say=20what=20a=20row's=20cha?= =?UTF-8?q?racters=20are,=20and=20where=20a=20drawer=20stops=20(#881)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XMD source and JSON keep their own colours wherever this screen shows them verbatim, panes and drawers have surfaces of their own, and a drawer says where the reading behind it stops. A row whose characters mean different things now carries immutable token runs: plain view data, issued by the composition module, concatenating to exactly the text the row draws. The ops builder emits one text operation per run inside the one element the row was measured as, so a row reading as several things is still one node, one bound and one pointer target. A private `presentation-text.ts` says what the characters of the two readings this screen shows verbatim are — executable Markdown source and a serialized JSON value — one line at a time, classifying nothing it was not handed. It validates nothing, evaluates nothing and rewrites nothing, and JSON is classified from a value this product already parsed rather than from a guess about arbitrary text. Focus no longer repaints a row. It adds weight, and the cue in front of the row is the one run the focus colour belongs to, so the row a keystroke reaches stays readable as the thing it is while selection keeps its own surface. The application paints its own uncovered area, a question or permission drawer is named in the waiting accent, a field's name and the value in it are told apart, and the contextual drawer is bounded by a rule along the top of its rectangle and inset one cell on each side. Both belong to the measured rectangle: the content window is measured at what they leave, which costs that window one row and two columns at every profile and is why UI16 and UI17 now reach `[submit]` and the validation row through one `[v later]`. --- architecture.md | 32 +- packages/cli/src/repl/application.ts | 173 ++++- packages/cli/src/repl/components/rows.ts | 39 +- packages/cli/src/repl/description.ts | 48 ++ packages/cli/src/repl/layout.ts | 90 ++- packages/cli/src/repl/presentation-style.ts | 134 +++- packages/cli/src/repl/presentation-text.ts | 258 +++++++ packages/cli/tests/repl-composition.test.ts | 6 +- packages/cli/tests/repl-forms.test.ts | 31 +- packages/cli/tests/repl-journey.test.ts | 27 +- .../cli/tests/repl-presentation-style.test.ts | 685 +++++++++++++++++- specs/repl-spec.md | 32 +- 12 files changed, 1478 insertions(+), 77 deletions(-) create mode 100644 packages/cli/src/repl/presentation-text.ts diff --git a/architecture.md b/architecture.md index a8483d667..b7408677c 100644 --- a/architecture.md +++ b/architecture.md @@ -5875,19 +5875,43 @@ surface. It is pure: no operation, no contextual Api, no Journal access, no mutable state and no export beyond this feature, so a role owns nothing that can outlive the frame that computed it. The palette lives there and nowhere else. +**A row whose characters mean different things carries runs.** A run is plain +immutable view data — a string and the name of a role — issued by the composition +module and concatenating to exactly the text the row draws. The ops builder emits +one text operation per run *inside the one element the row was measured as*, so a +row reading as several things is still one node, one bound and one pointer target: +a child per token would publish a hit box for every delimiter on the screen. Runs +apply only when they spell the text being drawn; a row whose mounted node +contributed something else is drawn as one stretch under the row's own role. + +A second private module says what the characters of the two readings this screen +shows verbatim are: executable Markdown source, and a serialized JSON value. It +classifies one line at a time, because a cell is a row and a classifier carrying +state across rows would be answering about a document. It is pure and +presentation-only — it validates nothing, evaluates nothing and rewrites nothing, +and every run it returns concatenates back to the line it was given. JSON is +classified from a value this product had already parsed or from a reading a caller +knows is JSON, never from a guess that arbitrary provider or document text looks +like JSON. + **Keyboard focus is not a fact about the reading**, so it arrives later. After reconciliation the commit boundary passes the actual focused mounted node's id to the ops builder for that frame alone, matched by exact mounted identity and -discarded with the ops it decorated. Focus changes a foreground and its attributes -and never geometry; selection keeps an independent surface. Neither repairs, -stores or grants focus. +discarded with the ops it decorated. Focus adds weight and never geometry, and it +takes no foreground away: a row's own roles say what its characters are, and the +focus colour belongs to the marker in front of the row, which is a run of its own. +Selection keeps an independent surface. Neither repairs, stores or grants focus. **One builder decorates both passes.** The measurement pass and the committed pass run through the same op builder, so pane edges, surfaces and headings participate in measurement and a measured region cannot disagree with the region drawn into it. A pane's own edges are not part of the room inside it: a bordered pane carries a border-free content box, and rows are built to the width the engine -reports for that box rather than to the pane's outer bound. The renderer stays +reports for that box rather than to the pane's outer bound. A drawer's own +decoration is held to the same rule: the rule along the top of its rectangle and +the one-cell inset on each side are elements of that rectangle, so the content +window is measured at what they leave and admission answers from that measurement +rather than from the undecorated rectangle. The renderer stays model-blind — it receives ops and geometry, classifies no row text, and no production string carries an escape sequence. diff --git a/packages/cli/src/repl/application.ts b/packages/cli/src/repl/application.ts index f020fa069..9ebaa31c2 100644 --- a/packages/cli/src/repl/application.ts +++ b/packages/cli/src/repl/application.ts @@ -28,8 +28,14 @@ import { Ok, type Result } from "effection"; import type { Json } from "@executablemd/durable-streams"; -import { describe as describeNode, fields, readDescription } from "./description.ts"; -import type { ReplComponent, ReplDescription, ReplViewData } from "./description.ts"; +import { + describe as describeNode, + fields, + readDescription, + runText, + tokenRuns, +} from "./description.ts"; +import type { ReplComponent, ReplDescription, ReplTokenRun, ReplViewData } from "./description.ts"; import { actionRowProps, bandProps, @@ -74,7 +80,8 @@ import { } from "./layout-admission.ts"; import type { ReplAdmission, ReplWindow } from "./layout-admission.ts"; import { ORDINARY, REPL_PALETTE, styleOf } from "./presentation-style.ts"; -import type { ReplRowStyle } from "./presentation-style.ts"; +import type { ReplPresentationRole, ReplRowStyle } from "./presentation-style.ts"; +import { jsonRuns, sourceRuns } from "./presentation-text.ts"; import type { ReplTerminalSize } from "./terminal.ts"; import { decodeLocation, @@ -112,6 +119,7 @@ import type { ReplTree } from "./reconcile.ts"; import { DRAWER, FIELD, + fieldParts, fieldText, focusPrefixed, LINE, @@ -1534,6 +1542,34 @@ interface Described { readonly key: string; readonly description: ReplDescription; readonly style: ReplRowStyle; + /** + * The stretches this row's text is made of, where its characters mean + * different things. + * + * Built here, with the text, from the same pieces — so the runs spell exactly + * what the row draws. A row whose characters all mean one thing carries none. + */ + readonly runs?: readonly ReplTokenRun[]; +} + +/** + * One row's label as runs, padded to the room it has, the padding reading as the + * row does. + * + * The same arithmetic `pad` does, in runs: a row is padded so that what it draws + * is exactly as wide as the box it was measured in, and the cells it pads with + * belong to the row rather than to whatever its last stretch happened to be. + */ +function padded( + parts: readonly ReplTokenRun[], + room: number, + token: ReplPresentationRole, +): readonly ReplTokenRun[] { + const text = runText(parts); + if (room < 1 || text.length >= room) { + return parts; + } + return tokenRuns([...parts, { text: "".padEnd(room - text.length, " "), token }]); } function row( @@ -1546,12 +1582,22 @@ function row( readonly here?: string | undefined; /** The key this frame restores focus to, for the row that turns out to be it. */ readonly claim?: string | undefined; + /** What this row's label is made of, where it is made of more than one thing. */ + readonly runs?: readonly ReplTokenRun[]; } = {}, ): Described { const claims = options.focus === true || options.claim === key; + // The marker is its own stretch, which is how a focused row keeps every + // foreground it had and still says where the next keystroke lands. + const here = options.here === key; + const runs = tokenRuns([ + { text: focusPrefixed("", here), token: here ? "focus-marker" : style.role }, + ...(options.runs ?? [{ text: label, token: style.role }]), + ]); return { key, style, + runs, description: describeNode({ key, component: SELECT_ROW, @@ -1701,10 +1747,16 @@ function selected(on: boolean): string { return on ? "* " : " "; } -function line(key: string, label: string, style: ReplRowStyle): Described { +function line( + key: string, + label: string, + style: ReplRowStyle, + runs?: readonly ReplTokenRun[], +): Described { return { key, style, + ...(runs === undefined ? {} : { runs }), description: describeNode({ key, component: LINE, input: { label } }), }; } @@ -2081,8 +2133,19 @@ function guidanceParts( * whatever was underneath it visible from where its text ends — and a modal you * can read the transcript through is not a modal. */ -function drawerLine(key: string, label: string, width: number, style: ReplRowStyle): Described { - return line(key, width < 1 ? label : label.padEnd(width, " "), style); +function drawerLine( + key: string, + label: string, + width: number, + style: ReplRowStyle, + /** What the label is made of, where it is made of more than one thing. */ + runs?: readonly ReplTokenRun[], +): Described { + const text = width < 1 ? label : label.padEnd(width, " "); + if (runs === undefined) { + return line(key, text, style); + } + return line(key, text, style, padded(runs, text.length, style.role)); } function field( @@ -2096,11 +2159,24 @@ function field( readonly here?: string | undefined; /** The form field this line edits, for a line that edits one. */ readonly field?: string; + /** What the value on this line is, for a line showing something classified. */ + readonly classify?: (value: string) => readonly ReplTokenRun[]; } = {}, ): Described { + const here = options.here === key; + const parts = fieldParts(prompt, text, here); + const runs = tokenRuns([ + { text: parts.marker, token: here ? "focus-marker" : style.role }, + { text: parts.prompt, token: style.role }, + { text: parts.earlier, token: style.role }, + ...(options.classify === undefined + ? [{ text: parts.value, token: style.role }] + : options.classify(parts.value)), + ]); return { key, style, + runs, description: describeNode({ key, component: FIELD, @@ -2577,7 +2653,9 @@ function described(view: ReplView, context: ReplPresentationContext): DescribedS state.draft, "draft", styleOf("draft"), - claiming ? { focus: true, here: view.focused } : { here: view.focused }, + claiming + ? { focus: true, here: view.focused, classify: sourceRuns } + : { here: view.focused, classify: sourceRuns }, ), ); @@ -3000,7 +3078,7 @@ function drawerFor( if (held === undefined) { return undefined; } - const { title, dismissing, entering, content } = held; + const { title, titleStyle, dismissing, entering, content } = held; const width = context.widths?.drawer ?? 0; const rows: Described[] = []; rows.push( @@ -3052,7 +3130,7 @@ function drawerFor( ); const drawer: Described = { key: "drawer:open", - style: styleOf("drawer-title"), + style: titleStyle, description: describeNode({ key: "drawer:open", component: DRAWER, @@ -3067,6 +3145,14 @@ function drawerFor( /** What one drawer reading holds, before any window decides what is shown. */ interface DrawerContent { readonly title: string; + /** + * What the title is, which is not the same for every drawer. + * + * A drawer holding something nobody has answered yet is named in the accent + * this screen already uses for waiting, so the one drawer a reader has to act + * on is the one that says so before they have read a word of it. + */ + readonly titleStyle: ReplRowStyle; readonly dismissing: string | undefined; readonly entering: boolean; readonly content: readonly DrawerRow[]; @@ -3089,6 +3175,8 @@ function drawerContent(view: ReplView, width: number): DrawerContent | undefined // text stopped, because a renderer writes what changed and nothing else. const content: DrawerRow[] = []; let title: string; + /** What the title reads as. Ordinary unless this drawer is a question. */ + let titleStyle: ReplRowStyle = styleOf("drawer-title"); /** * The request this drawer's close control denies, when it is one. * @@ -3104,7 +3192,13 @@ function drawerContent(view: ReplView, width: number): DrawerContent | undefined title = drawerTitle("Binding", open.name, width); for (const [offset, text] of detail(open.binding.value).entries()) { content.push({ - described: drawerLine(`drawer:value:${offset}`, text, width, styleOf("field-value")), + described: drawerLine( + `drawer:value:${offset}`, + text, + width, + styleOf("field-value"), + jsonRuns(text), + ), }); } } else if (open.kind === "recorded-elicit") { @@ -3117,6 +3211,10 @@ function drawerContent(view: ReplView, width: number): DrawerContent | undefined }); for (const [offset, text] of detail(open.elicitation.schema).entries()) { content.push({ + // The schema stays the subordinate reading it already is. What was + // asked for is the shape of the question, not the value this drawer + // exists to show, and colouring it as a value would put the two + // readings on the same footing. described: drawerLine(`drawer:schema:${offset}`, text, width, styleOf("field-hint")), }); } @@ -3125,7 +3223,13 @@ function drawerContent(view: ReplView, width: number): DrawerContent | undefined }); for (const [offset, text] of detail(open.elicitation.answer).entries()) { content.push({ - described: drawerLine(`drawer:answer:${offset}`, text, width, styleOf("field-value")), + described: drawerLine( + `drawer:answer:${offset}`, + text, + width, + styleOf("field-value"), + jsonRuns(text), + ), }); } } else if (open.kind === "live-permission") { @@ -3140,6 +3244,7 @@ function drawerContent(view: ReplView, width: number): DrawerContent | undefined return undefined; } title = drawerTitle("Permission", request.title, width); + titleStyle = styleOf("question-title"); // One window over the whole of it. `options` is the provider's, and nothing // bounds how many it offers: a drawer that described every choice would have // layout clip the last ones, which are exactly the ones a person scrolled @@ -3175,6 +3280,7 @@ function drawerContent(view: ReplView, width: number): DrawerContent | undefined } const form = question.form; title = form.title ?? "Answer"; + titleStyle = styleOf("question-title"); // One viewport over the whole ordered content. Everything a person has to // read or reach — the complete message, the form's description, every field // with its annotation, options, editable value, every validation message and @@ -3183,7 +3289,13 @@ function drawerContent(view: ReplView, width: number): DrawerContent | undefined // placed in. for (const [offset, text] of question.message.split("\n").entries()) { content.push({ - described: drawerLine(`drawer:message:${offset}`, text, width, styleOf("source")), + described: drawerLine( + `drawer:message:${offset}`, + text, + width, + styleOf("source"), + sourceRuns(text), + ), }); } if (form.description !== undefined) { @@ -3198,13 +3310,26 @@ function drawerContent(view: ReplView, width: number): DrawerContent | undefined const value = view.state.form.values[one.name] ?? ""; const marked = requiredNow(form, view.state.form.values, one) ? "*" : " "; const label = one.title ?? one.name; + // What the field is called and what is in it, on one row and told apart: + // a reader scanning a form reads down the labels, and a label that looked + // like its own value would make them read every row to find the one they + // have not filled in yet. + const named = padded( + tokenRuns([ + { text: `${marked}${label}:`, token: "field-label" }, + { text: " ", token: "field-label" }, + { text: value, token: "field-value" }, + ]), + width - FOCUS_MARKER, + "field-label", + ); content.push({ described: row( `drawer:field:${one.name}`, - padControl(`${marked}${label}: ${value}`, width), + runText(named), { select: "form-field", field: one.name }, styleOf("field-label"), - { here: view.focused }, + { here: view.focused, runs: named }, ), }); if (one.description !== undefined) { @@ -3283,7 +3408,7 @@ function drawerContent(view: ReplView, width: number): DrawerContent | undefined }); } - return { title, dismissing, entering, content }; + return { title, titleStyle, dismissing, entering, content }; } /** @@ -4018,6 +4143,7 @@ interface ReplCandidate { readonly text: string; readonly control: boolean; readonly style: ReplRowStyle; + readonly runs: readonly ReplTokenRun[] | undefined; } /** @@ -4038,6 +4164,14 @@ function candidatesOf( * ordinary prose. */ styles: ReadonlyMap, + /** + * What each described row's characters are, by the key it was described under. + * + * Carried for the same reason the style is: the finished descriptions no + * longer hold the pieces a row was built from, and a pass that recovered them + * from the string would be classifying text it had already lost the facts for. + */ + runs: ReadonlyMap, ): readonly ReplCandidate[] { const found: ReplCandidate[] = []; const walk = (description: ReplDescription): void => { @@ -4050,6 +4184,7 @@ function candidatesOf( text: measurementTextOf(read.component, read.input), control: isControl(read.component, read.input), style: styles.get(read.key) ?? ORDINARY, + runs: runs.get(read.key), }); } for (const child of read.children) { @@ -4124,6 +4259,7 @@ function rowBox(candidate: ReplCandidate, region: ReplRegion, width: number | un text: candidate.text, control: candidate.control, style: candidate.style, + runs: candidate.runs, }); } @@ -4142,8 +4278,12 @@ export function presentationFor( const screen = described(view, context); const descriptions = screen.items.map((item) => item.description); const styles = new Map(); + const runs = new Map(); for (const one of screen.rows) { styles.set(one.key, one.style); + if (one.runs !== undefined) { + runs.set(one.key, one.runs); + } } const size = view.size; const profile = profileFor(size); @@ -4188,7 +4328,7 @@ export function presentationFor( }; } - const candidates = candidatesOf(descriptions, styles); + const candidates = candidatesOf(descriptions, styles, runs); const viewports: ReplViewportSlot[] = []; const regions: { region: ReplRegion; id: string }[] = []; /** The border-free inside of each pane that has edges. */ @@ -4457,6 +4597,7 @@ export function presentationFor( text: candidate.text, control: candidate.control, style: candidate.style, + runs: candidate.runs, }), ), }), diff --git a/packages/cli/src/repl/components/rows.ts b/packages/cli/src/repl/components/rows.ts index 147dcd9b9..cf24f1030 100644 --- a/packages/cli/src/repl/components/rows.ts +++ b/packages/cli/src/repl/components/rows.ts @@ -59,18 +59,47 @@ export function focusPrefixed(label: string, focused: boolean): string { } /** - * One editable line's text, for the same reason. + * One editable line, as the four stretches it is drawn in. + * + * Separate because they are four different things — where the keystroke goes, + * what the line is called, how much of the value is not on this row, and the + * value itself — and the screen colours them separately. `fieldText` is their + * concatenation, so there is one answer to what the row says and the parts + * cannot drift from it. * * A value holding several lines shows its last one under a count of the rest: a * cell is a row, so a line that paid itself out over four of them would push the * controls below it off the screen. */ -export function fieldText(prompt: string, text: string, focused: boolean): string { +export function fieldParts( + prompt: string, + text: string, + focused: boolean, +): { + readonly marker: string; + readonly prompt: string; + readonly earlier: string; + readonly value: string; +} { const lines = text.split("\n"); - const last = lines[lines.length - 1]; const earlier = lines.length - 1; - const body = earlier === 0 ? last : `[${earlier} line${earlier === 1 ? "" : "s"}] ${last}`; - return `${focused ? ">" : " "}${prompt}${body}`; + return Object.freeze({ + marker: focused ? ">" : " ", + prompt, + earlier: earlier === 0 ? "" : `[${earlier} line${earlier === 1 ? "" : "s"}] `, + value: lines[lines.length - 1], + }); +} + +/** + * One editable line's text, for the pass that measures it. + * + * Pure, and exported, because the pass that measures a region has to ask how + * wide this row is before any node renders it. + */ +export function fieldText(prompt: string, text: string, focused: boolean): string { + const parts = fieldParts(prompt, text, focused); + return `${parts.marker}${parts.prompt}${parts.earlier}${parts.value}`; } /** Read an optional string field. */ diff --git a/packages/cli/src/repl/description.ts b/packages/cli/src/repl/description.ts index feafb5761..0c3d90dc9 100644 --- a/packages/cli/src/repl/description.ts +++ b/packages/cli/src/repl/description.ts @@ -28,6 +28,8 @@ import { Err, Ok } from "effection"; import type { Operation, Result } from "effection"; +import type { ReplPresentationRole } from "./presentation-style.ts"; + /** * What a component may be given, and therefore what a tree may hold. * @@ -49,6 +51,52 @@ export type ReplViewData = /** Placement a parent decides for one direct child. Opaque to everyone else. */ export type ReplPlacement = { readonly [key: string]: string | number | boolean }; +/** + * One stretch of a row's text, and what those characters are. + * + * View data, like everything else that crosses into the tree: a string and the + * name of a role, with no function to call and nothing to interpret. A row's + * runs concatenate to exactly the text the row draws, which is what lets a + * reader see a delimiter, a tag and a quoted value as three different things + * without the row becoming three nodes — one node, several text operations + * inside the one element it was measured as. + */ +export interface ReplTokenRun { + readonly text: string; + readonly token: ReplPresentationRole; +} + +/** + * Issue one row's runs, detached and frozen. + * + * Empty stretches are dropped rather than carried: a run with no characters + * draws nothing, and the concatenation a caller is held to is the same either + * way. Adjacent runs of one role are joined, so what reaches the engine is one + * operation per change of role rather than one per call that happened to split. + */ +export function tokenRuns( + parts: readonly { readonly text: string; readonly token: ReplPresentationRole }[], +): readonly ReplTokenRun[] { + const runs: ReplTokenRun[] = []; + for (const part of parts) { + if (part.text.length === 0) { + continue; + } + const last = runs[runs.length - 1]; + if (last !== undefined && last.token === part.token) { + runs[runs.length - 1] = Object.freeze({ text: last.text + part.text, token: last.token }); + continue; + } + runs.push(Object.freeze({ text: part.text, token: part.token })); + } + return Object.freeze(runs); +} + +/** What one row's runs say its text is. */ +export function runText(runs: readonly ReplTokenRun[]): string { + return runs.map((run) => run.text).join(""); +} + /** * The named keys this composition layer understands. * diff --git a/packages/cli/src/repl/layout.ts b/packages/cli/src/repl/layout.ts index 9db3b8545..fda06be45 100644 --- a/packages/cli/src/repl/layout.ts +++ b/packages/cli/src/repl/layout.ts @@ -37,7 +37,15 @@ import { close, fixed, fit, grow, open, text } from "@bomb.sh/tty"; import type { Op, OpenElement, SizingAxis } from "@bomb.sh/tty"; -import { REPL_PALETTE, surfaceOf, terminalColour, textStyleOf } from "./presentation-style.ts"; +import { runText } from "./description.ts"; +import type { ReplTokenRun } from "./description.ts"; +import { + REPL_PALETTE, + runStyleOf, + surfaceOf, + terminalColour, + textStyleOf, +} from "./presentation-style.ts"; import type { ReplRowStyle } from "./presentation-style.ts"; import type { ReplTerminalSize } from "./terminal.ts"; @@ -95,6 +103,16 @@ export interface ReplBox { * structure, and structure takes the surface of whatever it is inside. */ readonly style: ReplRowStyle | undefined; + /** + * The stretches this row's text is made of, where its characters mean + * different things, or none where the whole row reads as one thing. + * + * They apply only when they spell exactly the text being drawn. A row whose + * mounted node contributed something else is drawn as one stretch under the + * row's own role, because runs that said something about other characters + * would be colouring text they were never built for. + */ + readonly runs: readonly ReplTokenRun[] | undefined; readonly children: readonly ReplBox[]; } @@ -211,6 +229,16 @@ const DRAWER_SHARE = 8; */ const DRAWER_BACKGROUND = terminalColour(REPL_PALETTE.drawerSurface); +/** + * How far in from each side of its rectangle a drawer's rows start. + * + * One cell. The reference this screen follows separates a drawer's content from + * its own edge with pixels, and a cell is the terminal's smallest version of + * that — enough to tell a title from the text it is in front of, and not enough + * to cost the content a column it needed. + */ +const DRAWER_INSET = 1; + /** Which profile a size gets. */ export function profileFor(size: ReplTerminalSize): ReplProfile { if (size.columns < NARROW.columns || size.rows < NARROW.rows) { @@ -428,10 +456,18 @@ export const CONTROL_PROPS: ReplBoxProps = Object.freeze({ layout: Object.freeze({ width: fit(), height: fixed(1) }), }); -/** The whole terminal, stacked top to bottom. */ +/** + * The whole terminal, stacked top to bottom, on the application's own surface. + * + * Painted here rather than left to the terminal's default, because the parts of + * the screen no pane covers — the footer, the band, whatever a profile leaves + * over — are still this application's. A frame that painted only its panes would + * read as three lit columns on somebody's wallpaper. + */ export function rootProps(size: ReplTerminalSize): ReplBoxProps { return { layout: { width: fixed(size.columns), height: fixed(size.rows), direction: "ttb" }, + bg: terminalColour(REPL_PALETTE.applicationSurface), }; } @@ -590,10 +626,17 @@ export function bandProps(): ReplBoxProps { * `drawerRect()` states, and painted with its own background so the blank * interior of a short modal line obscures the text beneath it. * - * Every row of the rectangle is the drawer's to use. There is no inset inside - * it: the rectangle is already inset from the body, and a second margin within - * it would cost the content two of the rows the product gives it — a drawer that - * showed two fewer rows than before because it had gained a border. + * It is bounded by a rule along its top and inset one cell on each side. Both + * are part of the rectangle rather than additions to it, and both are measured: + * the rule is a row the engine takes out of the layer and the inset is two + * columns, so the content window below is measured at what is left and admits + * only what fits there. A drawer in front of a reading has to say where the + * reading stops, and a title flush against the text it covers reads as one more + * line of that text. + * + * The vertical rows beyond the rule are the drawer's to use. There is no second + * margin: the rectangle is already inset from the body, and another one inside + * it would cost the content more of the rows the product gives it. * * `capture` narrows the engine's own hit test over the rectangle, blank cells * included. It is not modal containment and nothing here treats it as such: a @@ -609,8 +652,10 @@ export function drawerLayerProps( width: fixed(rect.width), height: fixed(rect.height), direction: "ttb", + padding: { left: DRAWER_INSET, right: DRAWER_INSET }, }, bg: DRAWER_BACKGROUND, + border: { color: terminalColour(REPL_PALETTE.waiting), top: 1 }, // Vertically only: every row inside clips itself to the layer's width, so // nothing paints past the rectangle. Clipping horizontally here would make // each of those rows resolve its width against its own text instead. @@ -698,7 +743,7 @@ function opsOf( ops.push(open(id, decorated(box))); const content = textOf(id) ?? box.text; if (content !== undefined && content !== "") { - ops.push(written(content, box, id === focused)); + ops.push(...written(content, box, id === focused)); } ops.push(...opsOf(box.children, idOf, textOf, focused)); ops.push(close()); @@ -725,11 +770,32 @@ function decorated(box: ReplBox): ReplBoxProps { return { ...box.props, bg: terminalColour(surface) }; } -function written(content: string, box: ReplBox, focused: boolean): Op { - if (box.style === undefined) { - return text(content); +/** + * One row's text, as the operations that draw it. + * + * Several operations inside the one element the row was measured as, never one + * element per stretch: the engine lays adjacent text out along the element's own + * flow, so what a reader sees is still one row of exactly the measured width — + * while a child per token would be a box, a bound and a pointer target for every + * delimiter on the screen. + */ +function written(content: string, box: ReplBox, focused: boolean): Op[] { + const style = box.style; + if (style === undefined) { + return [text(content)]; } - const { colour, attrs } = textStyleOf(box.style, focused); + const { runs } = box; + if (runs === undefined || runs.length === 0 || runText(runs) !== content) { + const { colour, attrs } = textStyleOf(style, focused); + return [drawn(content, colour, attrs)]; + } + return runs.map((run) => { + const { colour, attrs } = runStyleOf(run.token, style, focused); + return drawn(run.text, colour, attrs); + }); +} + +function drawn(content: string, colour: number, attrs: number): Op { return text(content, { color: terminalColour(colour), ...(attrs === 0 ? {} : { attrs }) }); } @@ -747,6 +813,7 @@ export function box(input: { readonly text?: string; readonly control?: boolean; readonly style?: ReplRowStyle; + readonly runs?: readonly ReplTokenRun[]; readonly children?: readonly ReplBox[]; }): ReplBox { return Object.freeze({ @@ -757,6 +824,7 @@ export function box(input: { text: input.text, control: input.control === true, style: input.style, + runs: input.runs, children: Object.freeze(input.children === undefined ? [] : [...input.children]), }); } diff --git a/packages/cli/src/repl/presentation-style.ts b/packages/cli/src/repl/presentation-style.ts index 800722f0f..a0a62ad3b 100644 --- a/packages/cli/src/repl/presentation-style.ts +++ b/packages/cli/src/repl/presentation-style.ts @@ -18,10 +18,16 @@ * A row carries one immutable **role**, and separately whether it is * **selected** and whether it is being read as **history**. Those are facts * about the reading. Keyboard **focus** is not: it belongs to the mounted tree - * and is known only after reconciliation, so it arrives at draw time and may - * change nothing but the foreground and its attributes. That division is what - * keeps selection visible while focus is somewhere else — the two are drawn by - * different properties of the same cell, so neither can erase the other. + * and is known only after reconciliation, so it arrives at draw time and adds + * weight to whatever the row already was. That division is what keeps selection + * visible while focus is somewhere else — the two are drawn by different + * properties of the same cell, so neither can erase the other. + * + * Focus takes no colour of its own away from a row. A row's foregrounds say + * what its characters *are* — a delimiter, a tag, a quoted value, a failure — + * and a focus that repainted them all one colour would cost a reader the + * reading in exchange for a fact the marker in front of the row already + * carries. The marker is the one cyan thing, and it is a run of its own. * * No geometry is decided here. Width, height, padding and borders are the * layout's, and a focused row occupies exactly the cells an unfocused one does. @@ -50,6 +56,27 @@ export type ReplPresentationRole = | "pane-heading" /** Document text and ordinary prose, and whatever has no stronger reading. */ | "source" + /** A heading written in the source being shown. */ + | "source-heading" + /** The characters that open and close a tag: `<`, ``, `/>`. */ + | "xmd-delimiter" + /** What a tag is called. */ + | "xmd-tag" + /** What an attribute is called, and what a JSON key is. */ + | "xmd-attribute" + /** A quoted attribute value, its quotes included. */ + | "xmd-value" + /** The braces around a reference, and a JSON boolean. */ + | "reference-brace" + /** What a reference names. */ + | "reference-content" + | "json-number" + | "json-string" + | "json-null" + /** Characters that join a reading rather than say anything themselves. */ + | "punctuation" + /** The cue in front of the row a keystroke reaches. */ + | "focus-marker" /** What a run produced: the thing a reader came for. */ | "output" /** Facts about an event rather than its result. */ @@ -66,6 +93,8 @@ export type ReplPresentationRole = | "action" /** The name of the reading a drawer is showing. */ | "drawer-title" + /** The name of a drawer holding something nobody has answered yet. */ + | "question-title" /** What a field is called. */ | "field-label" /** What a field means, or where a record came from. */ @@ -118,10 +147,28 @@ export const REPL_PALETTE = Object.freeze({ source: 0xc8d2d9, /** A result, which outranks the metadata around it. */ output: 0xe6ecf1, - /** A heading, a field's name, a drawer's title. */ + /** A heading, a drawer's title, something you activate. */ heading: 0xcfe0ea, /** Subordinate facts: metadata, a hint, where a record came from. */ muted: 0x7b858d, + /** What a field is called, and the one JSON value that is an absence. */ + label: 0x8b959c, + /** A heading written in the source being shown. */ + sourceHeading: 0xc7c4e6, + /** What opens and closes a tag. */ + delimiter: 0x4e8b9c, + /** What an attribute is called, and what a JSON key is. */ + attribute: 0x8fa7b8, + /** A quoted attribute value. */ + quoted: 0xd6b477, + /** The braces around a reference, and a JSON boolean. */ + brace: 0x9b8ed0, + /** What a reference names. */ + reference: 0xb7aee0, + number: 0xd69a6a, + string: 0x9ec49a, + /** Characters that join a reading rather than say anything themselves. */ + punctuation: 0x7c868d, success: 0x5aa87c, failure: 0xc2766e, waiting: 0xc99a3f, @@ -134,6 +181,8 @@ export const REPL_PALETTE = Object.freeze({ historySurface: 0x08090b, draftSurface: 0x0a0d0f, fieldSurface: 0x090c0e, + /** Whatever no pane, drawer or footer row of its own covers. */ + applicationSurface: 0x0b0d0f, /** The rectangle a drawer covers the body with. */ drawerSurface: 0x0e1316, /** The transcript's own surface. */ @@ -164,15 +213,31 @@ export interface ReplTextStyle { /** * The foreground one row's text takes, focus included. * - * Focus wins the foreground and adds weight, because the row a keystroke reaches - * is the one fact a reader needs before any other. It leaves the surface alone, - * so a selected row that loses focus is still visibly the selected row. + * Focus adds weight and nothing else. It leaves the surface alone, so a selected + * row that loses focus is still visibly the selected row; and it leaves the + * foreground alone, so the row a keystroke reaches is still readable as the + * thing it is. Which row that is, is said by the marker in front of it. */ export function textStyleOf(style: ReplRowStyle, focused: boolean): ReplTextStyle { - if (focused) { - return Object.freeze({ colour: REPL_PALETTE.focus, attrs: BOLD }); - } - return Object.freeze({ colour: colourOf(style), attrs: attrsOf(style.role) }); + return runStyleOf(style.role, style, focused); +} + +/** + * The foreground one run of a row's text takes. + * + * The run's own role decides the colour and the weight; the row it belongs to + * decides whether a retained reading is being marked, which is a fact about the + * row rather than about any of its characters. + */ +export function runStyleOf( + role: ReplPresentationRole, + row: ReplRowStyle, + focused: boolean, +): ReplTextStyle { + return Object.freeze({ + colour: colourOf(role, row.inspected), + attrs: attrsOf(role) | (focused ? BOLD : 0), + }); } /** @@ -212,22 +277,47 @@ export function terminalColour(colour: number): number { return rgba((colour >> 16) & 0xff, (colour >> 8) & 0xff, colour & 0xff); } -function colourOf(style: ReplRowStyle): number { +function colourOf(role: ReplPresentationRole, inspected: boolean): number { // A retained reading is marked on the two rows that say which reading it is, // and nowhere else: accenting every source line of a historical prefix would // say the document had changed rather than that the position had. - if (style.inspected && (style.role === "status" || style.role === "history")) { + if (inspected && (role === "status" || role === "history")) { return REPL_PALETTE.historical; } - switch (style.role) { + switch (role) { case "output": case "field-value": return REPL_PALETTE.output; case "pane-heading": case "drawer-title": - case "field-label": case "action": return REPL_PALETTE.heading; + case "field-label": + case "json-null": + return REPL_PALETTE.label; + case "source-heading": + return REPL_PALETTE.sourceHeading; + case "xmd-delimiter": + return REPL_PALETTE.delimiter; + case "xmd-tag": + case "focus-marker": + return REPL_PALETTE.focus; + case "xmd-attribute": + return REPL_PALETTE.attribute; + case "xmd-value": + return REPL_PALETTE.quoted; + case "reference-brace": + return REPL_PALETTE.brace; + case "reference-content": + return REPL_PALETTE.reference; + case "json-number": + return REPL_PALETTE.number; + case "json-string": + return REPL_PALETTE.string; + case "punctuation": + return REPL_PALETTE.punctuation; + case "question-title": + return REPL_PALETTE.waiting; case "metadata": case "field-hint": return REPL_PALETTE.muted; @@ -247,8 +337,14 @@ function colourOf(style: ReplRowStyle): number { } function attrsOf(role: ReplPresentationRole): number { - if (role === "pane-heading" || role === "drawer-title" || role === "field-label") { - return BOLD; + switch (role) { + case "pane-heading": + case "drawer-title": + case "question-title": + case "field-label": + case "source-heading": + return BOLD; + default: + return 0; } - return 0; } diff --git a/packages/cli/src/repl/presentation-text.ts b/packages/cli/src/repl/presentation-text.ts new file mode 100644 index 000000000..1a8a33948 --- /dev/null +++ b/packages/cli/src/repl/presentation-text.ts @@ -0,0 +1,258 @@ +/** + * What the characters of one line *are*, for the two readings this screen shows + * verbatim: executable Markdown source, and a parsed JSON value. + * + * Presentation only. Nothing here validates, evaluates, rejects or rewrites + * anything: every run it returns concatenates back to exactly the line it was + * given, so a draft part-way through a tag keeps every character the person + * typed and an escape inside a string survives being coloured. A stretch this + * cannot classify reads as ordinary source, which is what unclassified text + * looks like everywhere else on this screen. + * + * It is pure and private to the REPL. No operation, no host Api, no state — a + * line in, runs out — so what it answers cannot depend on anything but the line. + * + * ## Why one line at a time + * + * A cell is a row. The screen has already decided which rows a reading is drawn + * in by the time these are built, so a classifier that carried state from one + * row to the next would be answering about a document while the thing being + * drawn is a row. An unterminated quote therefore ends with its line, which is + * also what a person typing one wants to see. + * + * ## JSON is classified from JSON + * + * `jsonRuns` is for text this product produced by serializing a value it had + * already parsed, or for a reading a caller knows is JSON. It is never a guess + * about whether arbitrary provider or document output happens to look like + * JSON: prose that begins with a brace is prose. + */ + +import { tokenRuns } from "./description.ts"; +import type { ReplTokenRun } from "./description.ts"; +import type { ReplPresentationRole } from "./presentation-style.ts"; + +/** One stretch of a line, before the runs are joined. */ +interface Part { + readonly text: string; + readonly token: ReplPresentationRole; +} + +/** What a tag and an attribute may be called. */ +const NAME = /^[A-Za-z_][A-Za-z0-9_.:-]*/; + +/** A heading, as the source writes one. */ +const HEADING = /^(\s*)(#{1,6}(?:\s.*)?)$/; + +/** + * One line of executable Markdown source, as the roles its characters carry. + * + * A heading is the whole line, because that is what a heading is. Everything + * else is read as text with tags and references in it: a `<` that no name + * follows is a less-than sign, which is why the opening delimiter is recognized + * together with the name after it rather than on its own. + */ +export function sourceRuns(line: string): readonly ReplTokenRun[] { + const heading = HEADING.exec(line); + if (heading !== null) { + return tokenRuns([ + { text: heading[1], token: "source" }, + { text: heading[2], token: "source-heading" }, + ]); + } + const parts: Part[] = []; + let at = 0; + let tag = false; + while (at < line.length) { + if (tag) { + const stepped = withinTag(line, at, parts); + at = stepped.at; + tag = stepped.tag; + continue; + } + const rest = line.slice(at); + const opened = /^<\/?[A-Za-z_]/.test(rest); + if (opened) { + const delimiter = rest.startsWith("")) { + parts.push({ text: "/>", token: "xmd-delimiter" }); + return { at: at + 2, tag: false }; + } + if (rest.startsWith(">")) { + parts.push({ text: ">", token: "xmd-delimiter" }); + return { at: at + 1, tag: false }; + } + if (rest.startsWith('"') || rest.startsWith("'")) { + const quote = rest[0]; + let end = at + 1; + while (end < line.length && line[end] !== quote) { + end += 1; + } + // An unterminated quote ends with the line. A draft is edited one keystroke + // at a time, and `as="ans` is a thing a person is in the middle of typing. + const closed = end < line.length ? end + 1 : line.length; + parts.push({ text: line.slice(at, closed), token: "xmd-value" }); + return { at: closed, tag: true }; + } + if (rest.startsWith("{")) { + return { at: reference(line, at, parts), tag: true }; + } + if (rest.startsWith("=")) { + parts.push({ text: "=", token: "punctuation" }); + return { at: at + 1, tag: true }; + } + const name = NAME.exec(rest); + if (name !== null) { + parts.push({ text: name[0], token: "xmd-attribute" }); + return { at: at + name[0].length, tag: true }; + } + parts.push({ text: line[at], token: "source" }); + return { at: at + 1, tag: true }; +} + +/** + * One reference, from its opening brace to the brace that closes it. + * + * Every brace is a brace and everything between them is what the reference + * names, at whatever depth: an expression's own braces are part of the + * expression, and a classifier that tried to tell a reference's outermost pair + * from the ones inside it would be parsing the expression language. + */ +function reference(line: string, at: number, parts: Part[]): number { + let depth = 0; + let end = at; + while (end < line.length) { + const character = line[end]; + if (character === "{") { + depth += 1; + parts.push({ text: "{", token: "reference-brace" }); + end += 1; + continue; + } + if (character === "}") { + depth -= 1; + parts.push({ text: "}", token: "reference-brace" }); + end += 1; + if (depth <= 0) { + return end; + } + continue; + } + const next = Math.max(nextOf(line, end, ["{", "}"]), end + 1); + parts.push({ text: line.slice(end, next), token: "reference-content" }); + end = next; + } + return end; +} + +/** Where the first of these characters is from `from`, or the end of the line. */ +function nextOf(line: string, from: number, characters: readonly string[]): number { + let found = line.length; + for (const character of characters) { + const at = line.indexOf(character, from); + if (at >= 0 && at < found) { + found = at; + } + } + return found; +} + +const NUMBER = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/; +const SPACING = /^[ \t]+/; + +/** + * One line of a JSON value, as the roles its characters carry. + * + * A key is a string with a colon after it, which is the only thing that tells + * the two apart in a serialized object. Escapes are stepped over rather than + * decoded: what this screen shows is the serialized text, and a backslash is + * one of its characters. + */ +export function jsonRuns(line: string): readonly ReplTokenRun[] { + const parts: Part[] = []; + let at = 0; + while (at < line.length) { + const rest = line.slice(at); + const spacing = SPACING.exec(rest); + if (spacing !== null) { + parts.push({ text: spacing[0], token: "source" }); + at += spacing[0].length; + continue; + } + if (rest.startsWith('"')) { + const end = endOfString(line, at); + const quoted = line.slice(at, end); + parts.push({ + text: quoted, + token: /^\s*:/.test(line.slice(end)) ? "xmd-attribute" : "json-string", + }); + at = end; + continue; + } + if ("{}[],:".includes(rest[0])) { + parts.push({ text: rest[0], token: "punctuation" }); + at += 1; + continue; + } + if (rest.startsWith("true") || rest.startsWith("false")) { + const literal = rest.startsWith("true") ? "true" : "false"; + parts.push({ text: literal, token: "reference-brace" }); + at += literal.length; + continue; + } + if (rest.startsWith("null")) { + parts.push({ text: "null", token: "json-null" }); + at += 4; + continue; + } + const number = NUMBER.exec(rest); + if (number !== null) { + parts.push({ text: number[0], token: "json-number" }); + at += number[0].length; + continue; + } + parts.push({ text: rest[0], token: "source" }); + at += 1; + } + return tokenRuns(parts); +} + +/** Where one serialized string ends, its closing quote included. */ +function endOfString(line: string, at: number): number { + let end = at + 1; + while (end < line.length) { + if (line[end] === "\\") { + end += 2; + continue; + } + if (line[end] === '"') { + return end + 1; + } + end += 1; + } + return line.length; +} diff --git a/packages/cli/tests/repl-composition.test.ts b/packages/cli/tests/repl-composition.test.ts index bba6ccec1..38597eb69 100644 --- a/packages/cli/tests/repl-composition.test.ts +++ b/packages/cli/tests/repl-composition.test.ts @@ -1108,7 +1108,9 @@ describe("REPL composition: the construction surface", () => { it("C1: the only public construction is describe() and component()", function* () { // The discriminator for the opacity claim. An alternate issuer, a reader // that unwraps an arbitrary value, or an exported definition reader would - // each make the classes forgeable again, and each would show up here. + // each make the classes forgeable again, and each would show up here. The + // two run helpers issue and read plain view data, which carries no identity + // and unwraps nothing. expect(Object.keys(composition).sort()).toEqual([ "ReplDescriptionError", "component", @@ -1117,6 +1119,8 @@ describe("REPL composition: the construction surface", () => { "fields", "readComponent", "readDescription", + "runText", + "tokenRuns", "validateDescriptions", ]); diff --git a/packages/cli/tests/repl-forms.test.ts b/packages/cli/tests/repl-forms.test.ts index a082c165d..69c01160d 100644 --- a/packages/cli/tests/repl-forms.test.ts +++ b/packages/cli/tests/repl-forms.test.ts @@ -1488,7 +1488,20 @@ describe("F3 — every placed modal control is in the ring", () => { // A real one-entry history under it, so the row names the entry the question // belongs to rather than a readiness with no entry in it. const recorded = yield* retained(); - const state = opened(live); + // One window action first. The drawer's top rule and side inset are part of + // its measured rectangle, so at 72x20 the content window is one row shorter + // than this form's rows and `[submit]` is in the next one. Advanced through + // the control a person presses, against the admission the frame measured, + // rather than by setting an offset: what the ring must hold is what a frame + // really placed. + const atTop = opened(live); + const state = reduceRepl( + atTop, + { kind: "scroll", delta: 1 }, + recorded.model, + live, + yield* admissionOf(atTop, live, recorded.model, NARROW), + ).state; const view = reading(state, live, recorded.model, NARROW, undefined); yield* applied(tree, view); @@ -1585,7 +1598,7 @@ describe("F2 — an invalid answer is not a lifecycle refusal", () => { it("UI17: the field keeps its own explanation, and the state row still says the state", function* () { // A short question, so the whole form — the message under its field included - // — is inside the drawer's window rather than scrolled out of it. + // — is reachable inside the drawer's window rather than past the end of it. const asked = yield* askingFor(CONFIRM_SCHEMA); const live = asking(asked.question); const recorded = yield* retained(); @@ -1598,7 +1611,19 @@ describe("F2 — an invalid answer is not a lifecycle refusal", () => { messages: Object.freeze([{ field: "decision", message: "decision is required" }]), }), }); - const rows = rowsOf(yield* seenView(reading(invalid, live, recorded.model, NARROW, undefined))); + // One window action, for the row the drawer's top rule and side inset take + // out of its measured content window at 72x20. The validation row is the + // last of this form's rows, so it is in the window after the first one. + const advanced = reduceRepl( + invalid, + { kind: "scroll", delta: 1 }, + recorded.model, + live, + yield* admissionOf(invalid, live, recorded.model, NARROW), + ).state; + const rows = rowsOf( + yield* seenView(reading(advanced, live, recorded.model, NARROW, undefined)), + ); // Under the form, about the field, in its own row. const under = rows.find((one) => one.key.startsWith("drawer:invalid:")); diff --git a/packages/cli/tests/repl-journey.test.ts b/packages/cli/tests/repl-journey.test.ts index 6ecc02d70..2f354adea 100644 --- a/packages/cli/tests/repl-journey.test.ts +++ b/packages/cli/tests/repl-journey.test.ts @@ -1004,7 +1004,16 @@ function drawerMarkers(terminal: Terminal): string[] { const box = drawerBox(terminal.size); let title = false; for (let row = box.top; row < box.bottom; row += 1) { - const text = (rows[row] ?? "").slice(box.left, box.right).trimEnd(); + // Trimmed both ends, because a drawer's rows start one column in from its + // rectangle: a focus marker read from the rectangle's own left edge has a + // space in front of it, and the strip below would leave it on the label. + const text = (rows[row] ?? "").slice(box.left, box.right).trim(); + // The drawer's own top rule, which is decoration rather than a reading: it + // is the first row of the rectangle, so a walk that took it for the title + // would read the title as a position and every position as the one after it. + if (text.length > 0 && /^\u2500+$/.test(text)) { + continue; + } const label = text.replace(/^>\s*/, "").trim(); if (label.length === 0) { // Before the drawer's first row there is nothing of it to read; after its @@ -1056,13 +1065,20 @@ function* drawerContent( const first = drawerMarkers(terminal); take(); yield* focusOn(terminal, "[v later]"); + // Stopped when the window itself stops moving, not when a press reveals no + // label this walk had not already collected. A serialized value repeats rows — + // `}` closes every object — so a press that only brought a duplicate into view + // would end the walk with the rows after it never read. + let previous = first.join("\n"); for (let press = 0; press < limit; press += 1) { - const before = seen.length; terminal.feed("\r"); yield* settled(20); - if (take() === before) { + const shown = drawerMarkers(terminal).join("\n"); + take(); + if (shown === previous) { return { first, reached: seen }; } + previous = shown; } return { first, reached: seen }; } @@ -2415,8 +2431,9 @@ describe("REPL journey: the same product at every size", () => { // And positionally: no label this drawer lists is anywhere near this // long, so every column past it belongs to the drawer and must have // been painted by it. A drawer that stopped short would leave whatever - // the transcript has out there exactly where it was. - expect((inside.slice(LABEL_ROOM) ?? "").trim()).toBe(""); + // the transcript has out there exactly where it was. The drawer's own + // top rule reaches that edge too, and is the drawer painting it. + expect((inside.slice(LABEL_ROOM) ?? "").replace(/\u2500/g, "").trim()).toBe(""); } terminal.end(); diff --git a/packages/cli/tests/repl-presentation-style.test.ts b/packages/cli/tests/repl-presentation-style.test.ts index 66ff53cfa..cd0ce2d32 100644 --- a/packages/cli/tests/repl-presentation-style.test.ts +++ b/packages/cli/tests/repl-presentation-style.test.ts @@ -21,23 +21,44 @@ import { beforeAll, describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { collect, inlineSource, retainedSource, useTempFileCompiler } from "@executablemd/core"; +import { + collect, + Elicitation, + inlineSource, + retainedSource, + useTempFileCompiler, +} from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; import { InMemoryStream } from "@executablemd/durable-streams"; import { fileURLToPath } from "node:url"; import type { DurableEvent, DurableStream, Json } from "@executablemd/durable-streams"; -import { scoped } from "effection"; +import { scoped, sleep, spawn } from "effection"; import type { Operation } from "effection"; import { entryInitialBindings, projectRepl } from "../src/repl/model.ts"; import type { ReplModel } from "../src/repl/model.ts"; import { EntrySegmentStream } from "../src/repl/entries.ts"; -import { initialState, NO_AGENT, viewFor } from "../src/repl/application.ts"; +import { + EMPTY_FORM, + initialState, + NO_AGENT, + reduceRepl, + viewFor, +} from "../src/repl/application.ts"; +import { useReplElicitation } from "../src/repl/elicitation.ts"; +import type { ReplQuestion } from "../src/repl/elicitation.ts"; import { encodeLocation } from "../src/repl/route.ts"; -import type { ReplAction, ReplLive, ReplState, ReplView } from "../src/repl/application.ts"; +import type { + ReplAction, + ReplFormMessage, + ReplLive, + ReplState, + ReplView, +} from "../src/repl/application.ts"; import type { ReplDispatched } from "../src/repl/reconcile.ts"; import type { ReplDrawerRef } from "../src/repl/route.ts"; import { FOOTER_ROWS, HISTORY_LABEL, HISTORY_ROWS, NARROW } from "../src/repl/layout.ts"; +import { ACTION_ROW, DRAWER_WINDOW } from "../src/repl/application.ts"; import type { ReplBounds } from "../src/repl/layout.ts"; import { BOLD, REPL_PALETTE } from "../src/repl/presentation-style.ts"; import type { ReplTerminalSize } from "../src/repl/terminal.ts"; @@ -973,11 +994,14 @@ describe("REPL presentation: what a drawer says it is showing", () => { // A title is a heading: bold, and read rather than activated. expect(inkOf(grid, title).attrs).toContain(BOLD); expect(observed.targets.some((one) => one.node === observed.nodeOf("drawer:open"))).toBe(false); - // The value it holds reads as a value, not as the words around it — and it - // sits on the surface an editable value sits on, across the whole row the - // drawer measured for it, so what holds a value is visible before it is read. + // The value it holds reads as the JSON it is, not as the words around it — + // and it sits on the surface an editable value sits on, across the whole row + // the drawer measured for it, so what holds a value is visible before it is + // read. const value = placed(observed, "drawer:value:0"); - expect(inkOf(grid, value).foreground).toBe(REPL_PALETTE.output); + expect(textOf(grid, value).trimEnd()).toBe(JSON.stringify(binding.value, undefined, 2)); + expect(inkOf(grid, value).foreground).toBe(REPL_PALETTE.string); + expect(inkOf(grid, value).foreground).not.toBe(REPL_PALETTE.heading); for (let x = value.x; x < value.x + value.width; x += 1) { expect(grid.styleAt(x, value.y).background).toBe(REPL_PALETTE.fieldSurface); } @@ -1010,8 +1034,37 @@ describe("REPL presentation: what a drawer says it is showing", () => { const answered = placed(observed, "drawer:answered"); expect(inkOf(grid, schema).attrs).toContain(BOLD); expect(inkOf(grid, answered).attrs).toContain(BOLD); - expect(inkOf(grid, placed(observed, "drawer:answer:0")).foreground).toBe(REPL_PALETTE.output); expect(inkOf(grid, placed(observed, "drawer:schema:0")).foreground).toBe(REPL_PALETTE.muted); + + // The answer itself is one window further on: the drawer's own top rule and + // side inset are part of its measured rectangle, and this schema is long. + // Reached through the window action a person presses, against the admission + // each frame measured, so what is asserted is what a reader would see. + let advanced = state; + for (let press = 0; press < 40; press += 1) { + if (presenter.last()?.boundsOf("drawer:answer:1") !== undefined) { + break; + } + const view = reading(advanced, model, NOTHING_LIVE, WIDE); + advanced = reduceRepl( + advanced, + { kind: "scroll", delta: 1 }, + model, + NOTHING_LIVE, + yield* presenter.prepare(view), + ).state; + yield* presenter.commit(reading(advanced, model, NOTHING_LIVE, WIDE)); + } + const scrolled = presenter.last(); + if (scrolled === undefined) { + throw new Error("this presenter committed no frame"); + } + // Read as the JSON it is: what the answer calls its field, and the value + // recorded under it, are two readings and neither is the muted schema. + const recordedAnswer = placed(scrolled, "drawer:answer:1"); + expect(inkOfSpan(grid, recordedAnswer, '"decision"').foreground).toBe(REPL_PALETTE.attribute); + expect(inkOfSpan(grid, recordedAnswer, '"approve"').foreground).toBe(REPL_PALETTE.string); + expect(inkOfSpan(grid, recordedAnswer, '"approve"').foreground).not.toBe(REPL_PALETTE.muted); }); it("D1: History names itself, and its rows keep the band's own accent", function* () { @@ -1213,3 +1266,617 @@ describe("REPL presentation: the one guidance row", () => { } }); }); + +/** + * The frozen source example, exactly as the accepted presentation states it. + * + * Three lines of existing grammar: a heading, a tag with an attribute, a quoted + * value and a reference, and a tag whose whole content is a reference. It is a + * presentation example rather than a program — nothing here is executed — which + * is why it is carried as a question's message and as a draft. + */ +const SOURCE_EXAMPLE: readonly string[] = Object.freeze([ + "# Create a project README", + 'Enter the project details.', + '{answers}', +]); + +/** + * One entry binding the frozen JSON example, and one value holding escapes. + * + * Bound by a real eval block in a real run, so what the inspector shows is a + * value this Journal actually recorded and `detail()` actually serialized. + */ +const BINDING_EXAMPLE = [ + "```js eval", + "const tokens = {", + ' name: "Northstar",', + " count: 3,", + " approved: true,", + " empty: null,", + ' quote: "say \\"hi\\" \\\\ once",', + "};", + "```", + "", +].join("\n"); + +/** Project details, with the exact labels and hints the presentation freezes. */ +const DETAILS_SCHEMA: { readonly [key: string]: Json } = { + type: "object", + title: "Project details", + properties: { + project: { + type: "string", + minLength: 1, + title: "Project name", + description: "Name your project.", + }, + description: { + type: "string", + minLength: 1, + title: "One-sentence description", + description: "Describe its purpose.", + }, + }, + required: ["project", "description"], + additionalProperties: false, +}; + +/** One real entry whose eval block binds the frozen JSON example. */ +function* boundExample(): Operation { + const physical = new InMemoryStream(); + yield* runEntry(physical, BINDING_EXAMPLE); + return projected(yield* physical.readAll()); +} + +/** + * Install the real provider, ask one real question, and hand back the pending + * one. + * + * The provider's own question, so the form a drawer draws is the one Core + * compiled from the request rather than a shape this file assembled. + */ +function* askingWith( + schema: { readonly [key: string]: Json }, + message: string, +): Operation { + const elicitation = yield* useReplElicitation(); + yield* spawn(function* () { + yield* Elicitation.operations.elicit({ message, schema: { ...schema } }); + }); + // The provider publishes before it suspends, so one turn is enough. + yield* sleep(0); + const question = elicitation.pending; + if (question === undefined) { + throw new Error("the provider published no question"); + } + return question; +} + +/** A live reading with one question waiting. */ +function asking(question: ReplQuestion): ReplLive { + return Object.freeze({ ...NOTHING_LIVE, question }); +} + +/** The same state with the live question's drawer over it and a form filled in. */ +function answering( + state: ReplState, + values: Readonly>, + messages: readonly ReplFormMessage[] = [], +): ReplState { + return Object.freeze({ + ...openingDrawer(state, { kind: "live-elicit" }), + form: Object.freeze({ + ...EMPTY_FORM, + values: Object.freeze({ ...values }), + messages: Object.freeze([...messages]), + }), + }); +} + +/** + * What one stretch of a row was drawn with, found by where that stretch is. + * + * The whole stretch, and it has to be one colour: a span drawn half in one + * foreground and half in another is not a classified token, and a helper that + * returned its first cell would call that a pass. The span is located in the + * characters the frame actually painted, so a row that drew something else has + * no such stretch and says so. + */ +function inkOfSpan(grid: TerminalGrid, bounds: ReplBounds, span: string, from = 0): CellStyle { + const row = textOf(grid, bounds); + const at = row.indexOf(span, from); + if (at < 0) { + throw new Error(`the row ${JSON.stringify(row)} holds no ${JSON.stringify(span)}`); + } + const first = grid.styleAt(bounds.x + at, bounds.y); + for (let step = 1; step < span.length; step += 1) { + const next = grid.styleAt(bounds.x + at + step, bounds.y); + if (next.foreground !== first.foreground) { + throw new Error( + `${JSON.stringify(span)} is drawn in two foregrounds: ` + + `${String(first.foreground)} then ${String(next.foreground)} at column ` + + `${bounds.x + at + step}`, + ); + } + } + return first; +} + +/** + * The source rows of an open question drawer, in order. + * + * Found by the keys the frame placed rather than by searching the screen for + * words: the message is arbitrary text, and a row of it may say anything. + */ +function messageRows(observed: Observed): readonly ReplBounds[] { + const found: ReplBounds[] = []; + for (let offset = 0; ; offset += 1) { + const bounds = observed.boundsOf(`drawer:message:${offset}`); + if (bounds === undefined) { + return found; + } + found.push(bounds); + } +} + +describe("REPL presentation: what the characters of a reading are", () => { + beforeAll(() => useTempFileCompiler()); + + it("P1-T1: a source row's delimiters, names, values and references are each their own", function* () { + const model = yield* settledAndFailed(); + const question = yield* askingWith(DETAILS_SCHEMA, SOURCE_EXAMPLE.join("\n")); + const presenter = yield* usePresenter(WIDE); + const state = answering(stateWith({}), {}); + const observed = yield* presenter.commit(reading(state, model, asking(question), WIDE)); + const { grid } = presenter; + + // The whole example, preserved exactly: three rows, each holding the line + // it was given and nothing the screen added to it. + const rows = messageRows(observed); + expect(rows.length).toBe(SOURCE_EXAMPLE.length); + expect(rows.map((bounds) => textOf(grid, bounds).trimEnd())).toEqual([...SOURCE_EXAMPLE]); + + // A heading is the whole line, and it is a heading: its own colour, bold. + const [heading, elicit, plan] = rows; + expect(inkOfSpan(grid, heading, "# Create a project README").foreground).toBe( + REPL_PALETTE.sourceHeading, + ); + expect(inkOfSpan(grid, heading, "# Create a project README").attrs).toContain(BOLD); + + // And every part of a tag is read as the thing it is, in the cells it was + // drawn in: one colour for the whole row would satisfy none of these. + expect(inkOfSpan(grid, elicit, "<").foreground).toBe(REPL_PALETTE.delimiter); + expect(inkOfSpan(grid, elicit, "Elicit").foreground).toBe(REPL_PALETTE.focus); + expect(inkOfSpan(grid, elicit, "as").foreground).toBe(REPL_PALETTE.attribute); + expect(inkOfSpan(grid, elicit, "=").foreground).toBe(REPL_PALETTE.punctuation); + expect(inkOfSpan(grid, elicit, '"answers"').foreground).toBe(REPL_PALETTE.quoted); + expect(inkOfSpan(grid, elicit, "schema", 20).foreground).toBe(REPL_PALETTE.attribute); + expect(inkOfSpan(grid, elicit, "{").foreground).toBe(REPL_PALETTE.brace); + expect(inkOfSpan(grid, elicit, "}").foreground).toBe(REPL_PALETTE.brace); + expect(inkOfSpan(grid, elicit, "Enter the project details.").foreground).toBe( + REPL_PALETTE.source, + ); + expect(inkOfSpan(grid, elicit, " one.name === "tokens"); + if (binding === undefined) { + throw new Error( + `this entry bound ${(model.entries[0]?.bindings ?? []) + .map((one) => one.name) + .join(", ")} rather than tokens`, + ); + } + const presenter = yield* usePresenter(WIDE); + const state = openingDrawer(selecting(stateWith({}), "entry-1"), { + kind: "binding", + name: "tokens", + }); + const observed = yield* presenter.commit(reading(state, model, NOTHING_LIVE, WIDE)); + const { grid } = presenter; + + // The complete parsed value, line for line, exactly as it serializes. + const serialized = (JSON.stringify(binding.value, undefined, 2) ?? "null").split("\n"); + const rows = serialized.map((_, offset) => placed(observed, `drawer:value:${offset}`)); + expect(rows.map((bounds) => textOf(grid, bounds).trimEnd())).toEqual(serialized); + + const of = (needle: string): ReplBounds => { + const at = serialized.findIndex((line) => line.includes(needle)); + if (at < 0) { + throw new Error(`this value serializes without ${needle}`); + } + return rows[at]; + }; + // A key is a key, a string is a string, and a brace is not either of them. + expect(inkOfSpan(grid, of('"name"'), '"name"').foreground).toBe(REPL_PALETTE.attribute); + expect(inkOfSpan(grid, of('"name"'), '"Northstar"').foreground).toBe(REPL_PALETTE.string); + expect(inkOfSpan(grid, of('"name"'), ":").foreground).toBe(REPL_PALETTE.punctuation); + expect(inkOfSpan(grid, of('"count"'), "3").foreground).toBe(REPL_PALETTE.number); + expect(inkOfSpan(grid, of('"approved"'), "true").foreground).toBe(REPL_PALETTE.brace); + expect(inkOfSpan(grid, of('"empty"'), "null").foreground).toBe(REPL_PALETTE.label); + expect(inkOfSpan(grid, rows[0], "{").foreground).toBe(REPL_PALETTE.punctuation); + // An escaped quote and an escaped backslash are characters of the string + // they are in, so the whole quoted run is one colour and nothing is lost. + const quoted = of('"quote"'); + expect(inkOfSpan(grid, quoted, '"say \\"hi\\" \\\\ once"').foreground).toBe( + REPL_PALETTE.string, + ); + }); + + it("P1-T1: an unfinished draft keeps every character it has", function* () { + const model = yield* settledAndFailed(); + const presenter = yield* usePresenter(WIDE); + const unfinished = ' Draft: ${unfinished}`); + expect(inkOfSpan(grid, draft, "<").foreground).toBe(REPL_PALETTE.delimiter); + expect(inkOfSpan(grid, draft, "Elicit").foreground).toBe(REPL_PALETTE.focus); + expect(inkOfSpan(grid, draft, "as").foreground).toBe(REPL_PALETTE.attribute); + expect(inkOfSpan(grid, draft, '"ans').foreground).toBe(REPL_PALETTE.quoted); + // And the row still says what it is, in its own colour rather than a token's. + expect(inkOfSpan(grid, draft, "Draft:").foreground).toBe(REPL_PALETTE.source); + }); +}); + +describe("REPL presentation: focus marks the row without repainting it", () => { + beforeAll(() => useTempFileCompiler()); + + it("P1-T2: a focused draft keeps its token colours while the marker is the cyan", function* () { + const model = yield* settledAndFailed(); + const presenter = yield* usePresenter(WIDE); + const typed = '{answers}'; + const state = selecting(stateWith({ draft: typed }), "entry-1"); + + yield* presenter.commit(reading(state, model, NOTHING_LIVE, WIDE)); + yield* focusOn(presenter, "footer:input"); + const observed = yield* presenter.commit( + reading(state, model, NOTHING_LIVE, WIDE, focusKeyOf(presenter)), + ); + const { grid } = presenter; + + const draft = placed(observed, "footer:input"); + // The marker is the one cyan thing on the row, and it is a column wide. + expect(textOf(grid, draft).trimEnd()).toBe(`>> Draft: ${typed}`); + expect(grid.styleAt(draft.x, draft.y).foreground).toBe(REPL_PALETTE.focus); + expect(grid.styleAt(draft.x, draft.y).attrs).toContain(BOLD); + // Everything after it keeps the colour it had, and gains the weight focus + // adds: a focus that repainted the row would make all of these cyan. + expect(grid.styleAt(draft.x + 1, draft.y).foreground).toBe(REPL_PALETTE.source); + expect(inkOfSpan(grid, draft, "Plan").foreground).toBe(REPL_PALETTE.focus); + expect(inkOfSpan(grid, draft, '"draft"').foreground).toBe(REPL_PALETTE.quoted); + expect(inkOfSpan(grid, draft, '"draft"').attrs).toContain(BOLD); + expect(inkOfSpan(grid, draft, "answers").foreground).toBe(REPL_PALETTE.reference); + expect(inkOfSpan(grid, draft, " { + beforeAll(() => useTempFileCompiler()); + + it("P1-T3: an empty screen's panes, edges and uncovered area are each their own", function* () { + const model = yield* settledAndFailed(); + const presenter = yield* usePresenter(WIDE); + const observed = yield* presenter.commit(reading(stateWith({}), model, NOTHING_LIVE, WIDE)); + const { grid } = presenter; + + // Each pane's own surface, across the pane the engine admitted — and the + // first column of a pane that begins with an edge is that edge. + const surfaces: readonly { readonly region: string; readonly surface: number }[] = [ + { region: "sidebar", surface: REPL_PALETTE.sideSurface }, + { region: "transcript", surface: REPL_PALETTE.centreSurface }, + { region: "inspection", surface: REPL_PALETTE.bindingsSurface }, + ]; + for (const { region, surface } of surfaces) { + const bounds = regionBounds(observed, region); + if (bounds === undefined) { + throw new Error(`this frame published no ${region} region`); + } + expect([region, grid.styleAt(bounds.x + bounds.width - 1, bounds.y).background]).toEqual([ + region, + surface, + ]); + expect([ + region, + grid.styleAt(bounds.x + bounds.width - 1, bounds.y + bounds.height - 1).background, + ]).toEqual([region, surface]); + } + + // The rule that parts two columns is the separator's own colour, drawn in + // the first column of the pane that begins at it. + const transcript = regionBounds(observed, "transcript"); + const footer = regionBounds(observed, "footer"); + if (transcript === undefined || footer === undefined) { + throw new Error("this frame published no transcript or footer region"); + } + expect(grid.styleAt(transcript.x, transcript.y).foreground).toBe(REPL_PALETTE.edge); + // And the rule that parts the body from the footer, on the row above it. + expect(grid.styleAt(transcript.x, footer.y - 1).foreground).toBe(REPL_PALETTE.edge); + + // What no row of its own covers is still this application's: the end of the + // action row, where no control was placed. + const actions = observed.regionOf(ACTION_ROW); + if (actions === undefined) { + throw new Error("this frame published no action row"); + } + expect(grid.at(actions.x + actions.width - 1, actions.y)).toBe(" "); + expect(grid.styleAt(actions.x + actions.width - 1, actions.y).background).toBe( + REPL_PALETTE.applicationSurface, + ); + + // Both shared columns still say what they are while they hold nothing. + expect(textOf(grid, placed(observed, "sessions:heading"))).toContain("Sessions"); + expect(inkOf(grid, placed(observed, "inspection:heading")).foreground).toBe( + REPL_PALETTE.heading, + ); + }); + + it("P1-T3: a question drawer is bounded, named as waiting, and read in groups", function* () { + const model = yield* settledAndFailed(); + const question = yield* askingWith(DETAILS_SCHEMA, SOURCE_EXAMPLE.join("\n")); + const presenter = yield* usePresenter(WIDE); + const filled = answering( + stateWith({}), + { + project: "Northstar", + description: "A lightweight workspace for coordinating coding agents.", + }, + [{ field: "project", message: "project is required" }], + ); + const observed = yield* presenter.commit(reading(filled, model, asking(question), WIDE)); + const { grid } = presenter; + + const rect = regionBounds(observed, "drawer"); + if (rect === undefined) { + throw new Error("this frame published no drawer region"); + } + // One cell of rule along the top of the rectangle, inside it, across it. + for (let x = rect.x; x < rect.x + rect.width; x += 1) { + const style = grid.styleAt(x, rect.y); + expect([x, style.foreground, style.background]).toEqual([ + x, + REPL_PALETTE.waiting, + REPL_PALETTE.drawerSurface, + ]); + } + + // The title is inside the rectangle, below the rule, inset from its side — + // so it cannot be read as one more line of what the drawer is covering. + const title = placed(observed, "drawer:open"); + expect(title.y).toBe(rect.y + 1); + expect(title.x).toBe(rect.x + 1); + expect(title.x + title.width).toBe(rect.x + rect.width - 1); + expect(textOf(grid, title).trimEnd()).toBe("Project details"); + // A question nobody has answered yet says so before a word of it is read. + expect(inkOfSpan(grid, title, "Project details").foreground).toBe(REPL_PALETTE.waiting); + expect(inkOfSpan(grid, title, "Project details").attrs).toContain(BOLD); + + // A field's name, what it means and what is in it are three readings. + const named = placed(observed, "drawer:field:project"); + expect(inkOfSpan(grid, named, "*Project name:").foreground).toBe(REPL_PALETTE.label); + expect(inkOfSpan(grid, named, "Northstar").foreground).toBe(REPL_PALETTE.output); + const hint = placed(observed, "drawer:field:project:about"); + expect(inkOfSpan(grid, hint, "Name your project.").foreground).toBe(REPL_PALETTE.muted); + const edited = placed(observed, "drawer:value:project"); + expect(inkOfSpan(grid, edited, "Northstar").foreground).toBe(REPL_PALETTE.output); + // The row text goes into sits on the surface that says so, whole width. + for (let x = edited.x; x < edited.x + edited.width; x += 1) { + expect(grid.styleAt(x, edited.y).background).toBe(REPL_PALETTE.fieldSurface); + } + // The second field's group reads the same way, with its own words. + const second = placed(observed, "drawer:field:description"); + expect(inkOfSpan(grid, second, "*One-sentence description:").foreground).toBe( + REPL_PALETTE.label, + ); + expect( + inkOfSpan(grid, second, "A lightweight workspace for coordinating coding agents.").foreground, + ).toBe(REPL_PALETTE.output); + expect( + inkOfSpan(grid, placed(observed, "drawer:field:description:about"), "Describe its purpose.") + .foreground, + ).toBe(REPL_PALETTE.muted); + + // What the last submission was told, in the accent a refusal has. + const invalid = placed(observed, "drawer:invalid:0"); + expect(textOf(grid, invalid)).toContain("project is required"); + expect(inkOfSpan(grid, invalid, "project is required").foreground).toBe(REPL_PALETTE.failure); + // And the way to answer is still there, on a control's own surface. + const submit = placed(observed, "drawer:form:submit"); + expect(textOf(grid, submit)).toContain("[submit]"); + expect(grid.styleAt(submit.x, submit.y).background).toBe(REPL_PALETTE.draftSurface); + + // Every cell of the rectangle is the drawer's, the unused ones included. + const own: readonly number[] = [ + REPL_PALETTE.drawerSurface, + REPL_PALETTE.selectedSurface, + REPL_PALETTE.fieldSurface, + REPL_PALETTE.draftSurface, + ]; + for (let y = rect.y; y < rect.y + rect.height; y += 1) { + for (let x = rect.x; x < rect.x + rect.width; x += 1) { + const background = grid.styleAt(x, y).background; + if (background === undefined || !own.includes(background)) { + throw new Error( + `the cell at ${x},${y} is inside the drawer and shows ${String(background)}`, + ); + } + } + } + }); + + it("P1-T3: a shorter reading and a closed drawer leave nothing behind", function* () { + const model = yield* settledAndFailed(); + const question = yield* askingWith(DETAILS_SCHEMA, SOURCE_EXAMPLE.join("\n")); + const live = asking(question); + const long = answering(stateWith({}), { + project: "Northstar", + description: "A lightweight workspace for coordinating coding agents.", + }); + const short = answering(stateWith({}), { project: "N", description: "x" }); + + const incremental = yield* scoped(function* () { + const presenter = yield* usePresenter(WIDE); + yield* presenter.commit(reading(long, model, live, WIDE)); + const after = yield* presenter.commit(reading(short, model, live, WIDE)); + // The cells the longer value reached and the shorter one does not: still + // the row's own surface, with nothing of the longer value left in them. + const edited = placed(after, "drawer:value:description"); + // Its unfocused marker, its name and the one character left in it. + expect(textOf(presenter.grid, edited).trimEnd()).toBe(" = x"); + for (let x = edited.x; x < edited.x + edited.width; x += 1) { + expect(presenter.grid.styleAt(x, edited.y).background).toBe(REPL_PALETTE.fieldSurface); + } + const closed = yield* presenter.commit(reading(stateWith({}), model, live, WIDE)); + return { + cells: presenter.grid.styledIn({ x: 0, y: 0, width: WIDE.columns, height: WIDE.rows }), + keys: closed.keys.slice().sort(), + }; + }); + + const fresh = yield* scoped(function* () { + const presenter = yield* usePresenter(WIDE); + const first = yield* presenter.commit(reading(stateWith({}), model, live, WIDE)); + return { + cells: presenter.grid.styledIn({ x: 0, y: 0, width: WIDE.columns, height: WIDE.rows }), + keys: first.keys.slice().sort(), + }; + }); + + // Every cell, its characters, its foreground, its background and its blanks: + // a drawer that left its rule, its inset or a vacated value behind would + // differ from a screen that never drew one. + expect(incremental.cells).toEqual(fresh.cells); + expect(incremental.keys).toEqual(fresh.keys); + expect(incremental.keys.some((key) => key.startsWith("drawer:"))).toBe(false); + }); +}); + +describe("REPL presentation: decoration the frame measured", () => { + beforeAll(() => useTempFileCompiler()); + + it("P1-T4: the drawer's rule and inset are measured, and every row lands inside them", function* () { + const model = yield* boundExample(); + for (const size of [WIDE, MEDIUM, NARROW]) { + yield* scoped(function* () { + const presenter = yield* usePresenter(size); + const state = openingDrawer(selecting(stateWith({}), "entry-1"), { + kind: "binding", + name: "tokens", + }); + const view = reading(state, model, NOTHING_LIVE, size); + const admission = yield* presenter.prepare(view); + const observed = yield* presenter.commit(view); + const { grid } = presenter; + + const rect = regionBounds(observed, "drawer"); + const viewport = viewportBounds(observed, DRAWER_WINDOW); + const footer = regionBounds(observed, "footer"); + if (rect === undefined || viewport === undefined || footer === undefined) { + throw new Error(`${size.columns}x${size.rows} published no drawer or footer`); + } + // The rule is the rectangle's own first row, and the content begins + // below it and one column in from each side. + expect([size.columns, grid.styleAt(rect.x, rect.y).foreground]).toEqual([ + size.columns, + REPL_PALETTE.waiting, + ]); + expect([size.columns, viewport.y > rect.y]).toEqual([size.columns, true]); + expect([size.columns, viewport.x]).toEqual([size.columns, rect.x + 1]); + expect([size.columns, viewport.x + viewport.width]).toEqual([ + size.columns, + rect.x + rect.width - 1, + ]); + + // Capacity is the viewport the engine measured with the decoration + // already in it, so what the window admits was never the undecorated + // rectangle: padding worked out afterwards would admit a row too many. + const admitted = admission.windows.get(DRAWER_WINDOW); + if (admitted === undefined) { + throw new Error(`${size.columns}x${size.rows} measured no drawer window`); + } + expect([size.columns, admitted.capacity]).toEqual([size.columns, viewport.height]); + expect([size.columns, admitted.count <= admitted.capacity]).toEqual([size.columns, true]); + + // Every row the drawer placed is whole, inside the inset content area, + // and below the rule — the window controls and the way out included. + const inside: ReplBounds = { + x: rect.x + 1, + y: rect.y + 1, + width: rect.width - 2, + height: rect.height - 1, + }; + const drawn = observed.keys.filter((key) => key.startsWith("drawer:")); + expect([size.columns, drawn.length > 0]).toEqual([size.columns, true]); + for (const key of drawn) { + const bounds = observed.boundsOf(key); + if (bounds === undefined) { + continue; + } + expect([key, size.columns, within(bounds, inside)]).toEqual([key, size.columns, true]); + } + // And the footer is still the seven rows it always is. + expect([size.columns, footer.height]).toEqual([size.columns, FOOTER_ROWS]); + }); + } + }); + + it("P1-T4: a reading longer than the decorated window is in no cell and no target", function* () { + const model = yield* boundExample(); + const presenter = yield* usePresenter(NARROW); + const state = openingDrawer(selecting(stateWith({}), "entry-1"), { + kind: "binding", + name: "tokens", + }); + const view = reading(state, model, NOTHING_LIVE, NARROW); + const admission = yield* presenter.prepare(view); + const observed = yield* presenter.commit(view); + + const admitted = admission.windows.get(DRAWER_WINDOW); + if (admitted === undefined) { + throw new Error("this frame measured no drawer window"); + } + // The narrowest supported frame cannot hold this value whole, which is what + // makes the window the thing under test rather than a formality. + expect(admitted.total).toBeGreaterThan(admitted.capacity); + expect(admitted.more).toBe(true); + + // Exactly the admitted rows mounted; the ones past the window are in no + // cell, so they are in no pointer target and no Tab stop either. + const shown = observed.keys.filter((key) => key.startsWith("drawer:value:")); + expect(shown.length).toBe(admitted.count); + expect(observed.boundsOf(`drawer:value:${admitted.total - 1}`)).toBe(undefined); + for (const key of shown) { + expect([key, observed.nodeOf(key) !== undefined]).toEqual([key, true]); + } + // And the way out of the drawer is still reachable at this size. + expect(observed.keys).toContain("drawer:close"); + }); +}); diff --git a/specs/repl-spec.md b/specs/repl-spec.md index 08a655064..a94c9888c 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -285,6 +285,16 @@ moves the window and changes nothing else. It selects no position, answers no question, writes nothing to the file and does not touch the location, so moving through a long record cannot change what you are reading. +A drawer is bounded. A rule along the top of its rectangle says where the reading +behind it stops, and its title and its rows start one cell in from each side, so a +title cannot be read as one more line of what the drawer is covering. Both belong +to the rectangle and both are measured: the window is measured at what is left +after them, so a reading one row longer than that window is reached with one more +`later` rather than drawn over the rule. A drawer holding a question nobody has +answered yet — the question itself, or a pending permission request — is named in +the accent this screen uses for waiting, so the drawer you have to act on says so +before you have read a word of it. + The title, both window controls and `[close]` stay outside the thing they move, and the footer keeps its own seven rows — `[history]` and `[exit]` stay reachable from inside an open drawer, where they belong to that drawer. @@ -524,10 +534,24 @@ the same surface wherever it is, in a drawer or in the footer, so you can tell what you can press from what you can only read. Two of these are independent and stay that way. The row you have **selected** -keeps its own surface for its whole measured width, and keyboard **focus** changes -only the colour and weight of the row your next keystroke reaches — so moving -focus never takes the selection off what you were reading, and both are visible at -once. +keeps its own surface for its whole measured width, and keyboard **focus** marks +the row your next keystroke reaches and adds weight to it — so moving focus never +takes the selection off what you were reading, and both are visible at once. +Focus takes no colour off a row: what a row's characters are stays readable while +you are standing on it, and the marker in front of the row is the one thing the +focus colour belongs to. + +Source and JSON are read character by character. In a draft, in a read-only +source row and in the source a question shows you, a heading, the characters that +open and close a tag, a tag's name, an attribute's name, a quoted value, the +braces around a reference and what the reference names are each drawn as the +thing they are; and a binding's or a recorded answer's complete value shows its +keys, its strings, its numbers, its booleans, its nulls and its punctuation +apart. Reading is all it is. Nothing is validated, evaluated, rejected or +rewritten, every character you typed is still there — a half-written `as="ans` +included — and anything the screen cannot classify reads as ordinary source. Nor +is anything guessed at: a value is read as JSON because this product serialized +one, not because some text happens to begin with a brace. A reading of this screen with the colour thrown away loses nothing it needs. An outcome is spelled `[ok]` or `[err]`, a selected row keeps its `*`, a focused From f0b8eb1f323c1ad5826eafba978ef9c2e9f5fffa Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 8 Oct 2026 10:20:10 -0400 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20Draw=20a=20row=20as=20its=20?= =?UTF-8?q?own=20stretches=20only=20where=20the=20frame=20holds=20it=20who?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine wraps each text operation on its own, and a row is one cell tall: several stretches whose total is wider than the element each show their own first line and leave the cells their remainder went to blank. A 400-column generated tag in a 118-column drawer came out with its quoted values missing — found by capturing the gallery, which the focused tests could not reach because every example in them fits. A row carries its stretches only when the element it was measured in holds them whole, read off the constraint the element was opened with. A row the engine must wrap is drawn as the one thing it is, which is the reading this screen has always shown. The regression commits a tag far wider than any supported drawer and holds every written cell to being the next character of the source. --- packages/cli/src/repl/layout.ts | 33 ++++++++++++++- .../cli/tests/repl-presentation-style.test.ts | 41 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/repl/layout.ts b/packages/cli/src/repl/layout.ts index fda06be45..24b89b7f6 100644 --- a/packages/cli/src/repl/layout.ts +++ b/packages/cli/src/repl/layout.ts @@ -778,6 +778,14 @@ function decorated(box: ReplBox): ReplBoxProps { * flow, so what a reader sees is still one row of exactly the measured width — * while a child per token would be a box, a bound and a pointer target for every * delimiter on the screen. + * + * Only for a row the element can hold. The engine wraps each text operation it + * is given **on its own**, and a row is one cell tall: several operations whose + * total is wider than the element each show their own first line and leave the + * cells their remainder went to blank — measured, a 400-column generated tag in + * a 118-column drawer came out with its quoted values missing. One operation + * wraps once, which is the reading this screen has always shown, so a row that + * does not fit is drawn as the one thing it is. */ function written(content: string, box: ReplBox, focused: boolean): Op[] { const style = box.style; @@ -785,7 +793,12 @@ function written(content: string, box: ReplBox, focused: boolean): Op[] { return [text(content)]; } const { runs } = box; - if (runs === undefined || runs.length === 0 || runText(runs) !== content) { + if ( + runs === undefined || + runs.length === 0 || + runText(runs) !== content || + !holds(box.props, content) + ) { const { colour, attrs } = textStyleOf(style, focused); return [drawn(content, colour, attrs)]; } @@ -795,6 +808,24 @@ function written(content: string, box: ReplBox, focused: boolean): Op[] { }); } +/** + * Whether this element holds this text without the engine wrapping it. + * + * Read off the constraint the element was opened with rather than worked out + * again: a box stated at a measured width says how much room its text has, and + * a box that sizes itself to its content always has enough. + */ +function holds(props: ReplBoxProps, content: string): boolean { + const width = props.layout?.width; + if (width === undefined) { + return false; + } + if (width.type === "fit") { + return true; + } + return width.type === "fixed" && content.length <= width.value; +} + function drawn(content: string, colour: number, attrs: number): Op { return text(content, { color: terminalColour(colour), ...(attrs === 0 ? {} : { attrs }) }); } diff --git a/packages/cli/tests/repl-presentation-style.test.ts b/packages/cli/tests/repl-presentation-style.test.ts index cd0ce2d32..91306f4c6 100644 --- a/packages/cli/tests/repl-presentation-style.test.ts +++ b/packages/cli/tests/repl-presentation-style.test.ts @@ -1880,3 +1880,44 @@ describe("REPL presentation: decoration the frame measured", () => { expect(observed.keys).toContain("drawer:close"); }); }); + +describe("REPL presentation: a row the frame cannot hold whole", () => { + beforeAll(() => useTempFileCompiler()); + + it("P1-T1: a source row wider than its drawer keeps every character it shows", function* () { + const model = yield* settledAndFailed(); + // One tag far wider than any drawer at any supported size, so the engine + // has to wrap it. Drawn as several stretches it would wrap each of them on + // its own and leave the cells their remainders went to blank. + const long = + '' + + "Enter the project details, at length, so that this one line is wider ".repeat(4) + + ""; + const question = yield* askingWith(DETAILS_SCHEMA, long); + const presenter = yield* usePresenter(WIDE); + const observed = yield* presenter.commit( + reading(answering(stateWith({}), {}), model, asking(question), WIDE), + ); + const { grid } = presenter; + + const row = placed(observed, "drawer:message:0"); + const shown = textOf(grid, row); + // Whatever the row shows is a prefix of the line it was given, with no gap + // where a stretch wrapped away: every written cell is the next character of + // the source, in order. + const written = shown.replace(/\s+$/, ""); + expect(written.length).toBeGreaterThan(40); + expect(long.startsWith(written)).toBe(true); + expect(written).not.toContain(" "); + + // And a row the drawer does hold is still read as the thing it is, so the + // bound is on the row that overflows rather than on the reading. + const short = yield* askingWith(DETAILS_SCHEMA, SOURCE_EXAMPLE.join("\n")); + const fitted = yield* presenter.commit( + reading(answering(stateWith({}), {}), model, asking(short), WIDE), + ); + expect(inkOfSpan(grid, placed(fitted, "drawer:message:1"), "Elicit").foreground).toBe( + REPL_PALETTE.focus, + ); + }); +}); From 080b6ec80ca74e614789e8d2252857ac281df7e5 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 8 Oct 2026 12:52:01 -0400 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20Keep=20the=20roles=20of=20th?= =?UTF-8?q?e=20characters=20a=20row=20can=20show=20(#881=20R1=E2=80=93R3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Planner feedback on f0b8eb1f: three frozen requirements were unmet. **R1.** An overflowing row went through a single-colour fallback, so the Plan preview read as ordinary source and, at 72×20, a populated field painted its visible value as though it were the field's name. The fallback existed because the engine wraps each text operation on its own. The stretches are now cut at the room the element has instead: nothing wraps, every character the row can show keeps the role it has and the order it was in, and the cue in front of a focused row is still the one cyan thing. A row whose width the flow decides — only the pass that is asking what the widths are draws one — is unchanged. The overflow regression now asserts the delimiter, tag, attribute, quoted value, reference and prose roles on the overflowing row itself, that no row is one colour throughout, and that the narrow populated form keeps its field's name and its value apart. The gallery audit no longer accepts a tag drawn as one stretch. **R2.** The new assertions read expected RGB from the production palette, which would have stayed green through a palette drift. They now read a fixture transcribed from the corrected Terminal Interface archive — its `S`, `C` and `Y` constants, with the archive hash and the sources they came from — so the two sides of every comparison are independent. Changing one palette entry by a single bit now fails three rows. **R3.** The frozen resize endpoints are taken with the populated project question open, so the refusal and the recovery prove the editable question and its values survive, rather than substituting the permission drawer. --- packages/cli/src/repl/layout.ts | 56 ++-- .../tests/fixtures/repl/reference-style.ts | 117 ++++++++ .../cli/tests/repl-presentation-style.test.ts | 274 +++++++++++------- 3 files changed, 329 insertions(+), 118 deletions(-) create mode 100644 packages/cli/tests/fixtures/repl/reference-style.ts diff --git a/packages/cli/src/repl/layout.ts b/packages/cli/src/repl/layout.ts index 24b89b7f6..dcd649860 100644 --- a/packages/cli/src/repl/layout.ts +++ b/packages/cli/src/repl/layout.ts @@ -779,13 +779,13 @@ function decorated(box: ReplBox): ReplBoxProps { * while a child per token would be a box, a bound and a pointer target for every * delimiter on the screen. * - * Only for a row the element can hold. The engine wraps each text operation it - * is given **on its own**, and a row is one cell tall: several operations whose - * total is wider than the element each show their own first line and leave the + * Bounded to the room the element has, because the engine wraps each operation + * it is given **on its own** and a row is one cell tall: several operations + * totalling more than the element each show their own first line and leave the * cells their remainder went to blank — measured, a 400-column generated tag in - * a 118-column drawer came out with its quoted values missing. One operation - * wraps once, which is the reading this screen has always shown, so a row that - * does not fit is drawn as the one thing it is. + * a 118-column drawer came out with its quoted values missing. Cutting the + * stretches at the room keeps every character the row can show, in order, in + * the role it has; what is past the edge was never on the screen either way. */ function written(content: string, box: ReplBox, focused: boolean): Op[] { const style = box.style; @@ -793,37 +793,55 @@ function written(content: string, box: ReplBox, focused: boolean): Op[] { return [text(content)]; } const { runs } = box; - if ( - runs === undefined || - runs.length === 0 || - runText(runs) !== content || - !holds(box.props, content) - ) { + const room = roomOf(box.props); + if (runs === undefined || runs.length === 0 || runText(runs) !== content || room === "unknown") { const { colour, attrs } = textStyleOf(style, focused); return [drawn(content, colour, attrs)]; } - return runs.map((run) => { + return bounded(runs, room).map((run) => { const { colour, attrs } = runStyleOf(run.token, style, focused); return drawn(run.text, colour, attrs); }); } /** - * Whether this element holds this text without the engine wrapping it. + * How many columns this element gives its text. * * Read off the constraint the element was opened with rather than worked out * again: a box stated at a measured width says how much room its text has, and - * a box that sizes itself to its content always has enough. + * a box that sizes itself to its own content never has too little. A box that + * grows takes its width from the flow around it, which is not an answer this + * builder has — and only the pass that is *asking* what the widths are draws + * one, so that row is drawn the way it always was and the measurement it + * contributes is unchanged. */ -function holds(props: ReplBoxProps, content: string): boolean { +function roomOf(props: ReplBoxProps): number | "whole" | "unknown" { const width = props.layout?.width; if (width === undefined) { - return false; + return "unknown"; } if (width.type === "fit") { - return true; + return "whole"; } - return width.type === "fixed" && content.length <= width.value; + return width.type === "fixed" ? width.value : "unknown"; +} + +/** The stretches of a row that fall inside the room it has, in order. */ +function bounded(runs: readonly ReplTokenRun[], room: number | "whole"): readonly ReplTokenRun[] { + if (room === "whole" || runText(runs).length <= room) { + return runs; + } + const kept: ReplTokenRun[] = []; + let used = 0; + for (const run of runs) { + if (used >= room) { + break; + } + const take = Math.min(run.text.length, room - used); + kept.push(Object.freeze({ text: run.text.slice(0, take), token: run.token })); + used += take; + } + return kept; } function drawn(content: string, colour: number, attrs: number): Op { diff --git a/packages/cli/tests/fixtures/repl/reference-style.ts b/packages/cli/tests/fixtures/repl/reference-style.ts new file mode 100644 index 000000000..e42f5fdd1 --- /dev/null +++ b/packages/cli/tests/fixtures/repl/reference-style.ts @@ -0,0 +1,117 @@ +/** + * The colours the accepted presentation is stated in, independent of the + * product that draws them. + * + * Every value here is a literal transcribed from the corrected Terminal + * Interface archive — SHA-256 + * `86be64318121546f3208342edaf17f0a3aed35f3fde2766b523423e6c708c77b`, the same + * bytes kept at `.reviewer/issue-881/evidence/reference.zip` — out of the `S`, + * `C` and `Y` constants its `xmd-repl-timetravel.jsx` and + * `xmd-repl-agent-sessions.jsx` declare. The extract those were read from is + * `.reviewer/issue-881/evidence/reference-style-extract.txt`, and the names + * below are the archive's own. + * + * Nothing here reads the product. A test that asked `REPL_PALETTE` what colour + * a delimiter is would agree with the product however the product drifted, and + * the one thing these regressions exist to catch is the product drifting away + * from this archive. So the two sides are stated separately and compared in the + * cells a frame actually wrote. + */ + +/** The archive these literals were transcribed from. */ +export const REFERENCE_ARCHIVE = Object.freeze({ + sha256: "86be64318121546f3208342edaf17f0a3aed35f3fde2766b523423e6c708c77b", + sources: Object.freeze(["xmd-repl-timetravel.jsx", "xmd-repl-agent-sessions.jsx"]), + extract: ".reviewer/issue-881/evidence/reference-style-extract.txt", +}); + +/** The archive's `S`: what each area of the screen is painted with. */ +export const SURFACE = Object.freeze({ + /** `app` — whatever no pane, drawer or footer row of its own covers. */ + app: 0x0b0d0f, + /** `side` — the Sessions and catalog column. */ + side: 0x090b0c, + /** `center` — the transcript, and the one routed outlet a narrow frame has. */ + center: 0x0c0e11, + /** `bind` — the bindings and recorded-answer column. */ + bind: 0x0a0c0e, + /** `drawer` — the whole rectangle a drawer covers the body with. */ + drawer: 0x0e1316, + /** `input` — the single-row draft, and anything else a keystroke reaches. */ + input: 0x0a0d0f, + /** `edge` — what parts one pane from the next. */ + edge: 0x161c21, + /** `field` — a value being edited. */ + field: 0x090c0e, +}); + +/** The archive's `C`: what a reading means, rather than what it is made of. */ +export const SEMANTIC = Object.freeze({ + /** `src` — document text and ordinary prose. */ + src: 0xc8d2d9, + /** `active` — where the next keystroke lands. */ + active: 0x7fd3e8, + /** `tick` — something that closed `ok`. */ + tick: 0x5aa87c, + /** `hold` — something nobody has answered yet. */ + hold: 0xc99a3f, + /** `intro` — a heading, a drawer's title, something you activate. */ + intro: 0xcfe0ea, + /** `out` — what a run produced, and a value that has been entered. */ + out: 0xe6ecf1, + /** `label` — what a field is called. */ + label: 0x8b959c, + /** `dim` — a hint, and the metadata around a result. */ + dim: 0x7b858d, + /** `exit` — a refusal, and something that closed `err`. */ + exit: 0xc2766e, +}); + +/** The archive's `Y`: what the characters of a source or a value *are*. */ +export const SYNTAX = Object.freeze({ + /** `del` — the characters that open and close a tag. */ + del: 0x4e8b9c, + /** `tag` — what a tag is called. */ + tag: 0x7fd3e8, + /** `attr` — what an attribute is called. */ + attr: 0x8fa7b8, + /** `str` — a quoted attribute value, its quotes included. */ + str: 0xd6b477, + /** `brace` — the braces around a reference. */ + brace: 0x9b8ed0, + /** `ref` — what a reference names. */ + ref: 0xb7aee0, + /** `num` — a JSON number. */ + num: 0xd69a6a, + /** `head` — a heading written in the source being shown. */ + head: 0xc7c4e6, + /** `prose` — source with no stronger reading. */ + prose: 0xc8d2d9, + /** `punct` — characters that join a reading rather than say anything. */ + punct: 0x7c868d, + /** `key` — a JSON key. */ + key: 0x8fa7b8, + /** `jstr` — a JSON string, its quotes included. */ + jstr: 0x9ec49a, + /** `bool` — a JSON boolean. */ + bool: 0x9b8ed0, + /** `nul` — the one JSON value that is an absence. */ + nul: 0x8b959c, +}); + +/** + * The two cues this terminal keeps that the archive states differently. + * + * The archive marks a selected row and a focused one with a badge, a variable + * font size and an animation, none of which a terminal has. The accepted + * presentation retains this product's own `*` cue and row surface instead, and + * names the value: these are the retained terminal adaptations, stated here so + * a regression asserting one is not reading it back off the product either. + */ +export const RETAINED = Object.freeze({ + /** Behind a selected row, for the whole width the frame measured it at. */ + selectedSurface: 0x122026, +}); + +/** Bold, as the installed engine reports an attribute parameter back. */ +export const REFERENCE_BOLD = 1; diff --git a/packages/cli/tests/repl-presentation-style.test.ts b/packages/cli/tests/repl-presentation-style.test.ts index 91306f4c6..7521ab234 100644 --- a/packages/cli/tests/repl-presentation-style.test.ts +++ b/packages/cli/tests/repl-presentation-style.test.ts @@ -61,6 +61,14 @@ import { FOOTER_ROWS, HISTORY_LABEL, HISTORY_ROWS, NARROW } from "../src/repl/la import { ACTION_ROW, DRAWER_WINDOW } from "../src/repl/application.ts"; import type { ReplBounds } from "../src/repl/layout.ts"; import { BOLD, REPL_PALETTE } from "../src/repl/presentation-style.ts"; +import { + REFERENCE_ARCHIVE, + REFERENCE_BOLD, + RETAINED, + SEMANTIC, + SURFACE, + SYNTAX, +} from "./fixtures/repl/reference-style.ts"; import type { ReplTerminalSize } from "../src/repl/terminal.ts"; import { createGrid, @@ -1439,34 +1447,30 @@ describe("REPL presentation: what the characters of a reading are", () => { // A heading is the whole line, and it is a heading: its own colour, bold. const [heading, elicit, plan] = rows; - expect(inkOfSpan(grid, heading, "# Create a project README").foreground).toBe( - REPL_PALETTE.sourceHeading, - ); - expect(inkOfSpan(grid, heading, "# Create a project README").attrs).toContain(BOLD); + expect(inkOfSpan(grid, heading, "# Create a project README").foreground).toBe(SYNTAX.head); + expect(inkOfSpan(grid, heading, "# Create a project README").attrs).toContain(REFERENCE_BOLD); // And every part of a tag is read as the thing it is, in the cells it was // drawn in: one colour for the whole row would satisfy none of these. - expect(inkOfSpan(grid, elicit, "<").foreground).toBe(REPL_PALETTE.delimiter); - expect(inkOfSpan(grid, elicit, "Elicit").foreground).toBe(REPL_PALETTE.focus); - expect(inkOfSpan(grid, elicit, "as").foreground).toBe(REPL_PALETTE.attribute); - expect(inkOfSpan(grid, elicit, "=").foreground).toBe(REPL_PALETTE.punctuation); - expect(inkOfSpan(grid, elicit, '"answers"').foreground).toBe(REPL_PALETTE.quoted); - expect(inkOfSpan(grid, elicit, "schema", 20).foreground).toBe(REPL_PALETTE.attribute); - expect(inkOfSpan(grid, elicit, "{").foreground).toBe(REPL_PALETTE.brace); - expect(inkOfSpan(grid, elicit, "}").foreground).toBe(REPL_PALETTE.brace); - expect(inkOfSpan(grid, elicit, "Enter the project details.").foreground).toBe( - REPL_PALETTE.source, - ); - expect(inkOfSpan(grid, elicit, " { return rows[at]; }; // A key is a key, a string is a string, and a brace is not either of them. - expect(inkOfSpan(grid, of('"name"'), '"name"').foreground).toBe(REPL_PALETTE.attribute); - expect(inkOfSpan(grid, of('"name"'), '"Northstar"').foreground).toBe(REPL_PALETTE.string); - expect(inkOfSpan(grid, of('"name"'), ":").foreground).toBe(REPL_PALETTE.punctuation); - expect(inkOfSpan(grid, of('"count"'), "3").foreground).toBe(REPL_PALETTE.number); - expect(inkOfSpan(grid, of('"approved"'), "true").foreground).toBe(REPL_PALETTE.brace); - expect(inkOfSpan(grid, of('"empty"'), "null").foreground).toBe(REPL_PALETTE.label); - expect(inkOfSpan(grid, rows[0], "{").foreground).toBe(REPL_PALETTE.punctuation); + expect(inkOfSpan(grid, of('"name"'), '"name"').foreground).toBe(SYNTAX.attr); + expect(inkOfSpan(grid, of('"name"'), '"Northstar"').foreground).toBe(SYNTAX.jstr); + expect(inkOfSpan(grid, of('"name"'), ":").foreground).toBe(SYNTAX.punct); + expect(inkOfSpan(grid, of('"count"'), "3").foreground).toBe(SYNTAX.num); + expect(inkOfSpan(grid, of('"approved"'), "true").foreground).toBe(SYNTAX.brace); + expect(inkOfSpan(grid, of('"empty"'), "null").foreground).toBe(SEMANTIC.label); + expect(inkOfSpan(grid, rows[0], "{").foreground).toBe(SYNTAX.punct); // An escaped quote and an escaped backslash are characters of the string // they are in, so the whole quoted run is one colour and nothing is lost. const quoted = of('"quote"'); - expect(inkOfSpan(grid, quoted, '"say \\"hi\\" \\\\ once"').foreground).toBe( - REPL_PALETTE.string, - ); + expect(inkOfSpan(grid, quoted, '"say \\"hi\\" \\\\ once"').foreground).toBe(SYNTAX.jstr); }); it("P1-T1: an unfinished draft keeps every character it has", function* () { @@ -1528,12 +1530,12 @@ describe("REPL presentation: what the characters of a reading are", () => { // Every character, in order, with the row's own name in front of it: a // classifier that dropped the half-written attribute would be editing input. expect(textOf(grid, draft).trimEnd()).toBe(` > Draft: ${unfinished}`); - expect(inkOfSpan(grid, draft, "<").foreground).toBe(REPL_PALETTE.delimiter); - expect(inkOfSpan(grid, draft, "Elicit").foreground).toBe(REPL_PALETTE.focus); - expect(inkOfSpan(grid, draft, "as").foreground).toBe(REPL_PALETTE.attribute); - expect(inkOfSpan(grid, draft, '"ans').foreground).toBe(REPL_PALETTE.quoted); + expect(inkOfSpan(grid, draft, "<").foreground).toBe(SYNTAX.del); + expect(inkOfSpan(grid, draft, "Elicit").foreground).toBe(SEMANTIC.active); + expect(inkOfSpan(grid, draft, "as").foreground).toBe(SYNTAX.attr); + expect(inkOfSpan(grid, draft, '"ans').foreground).toBe(SYNTAX.str); // And the row still says what it is, in its own colour rather than a token's. - expect(inkOfSpan(grid, draft, "Draft:").foreground).toBe(REPL_PALETTE.source); + expect(inkOfSpan(grid, draft, "Draft:").foreground).toBe(SEMANTIC.src); }); }); @@ -1556,25 +1558,25 @@ describe("REPL presentation: focus marks the row without repainting it", () => { const draft = placed(observed, "footer:input"); // The marker is the one cyan thing on the row, and it is a column wide. expect(textOf(grid, draft).trimEnd()).toBe(`>> Draft: ${typed}`); - expect(grid.styleAt(draft.x, draft.y).foreground).toBe(REPL_PALETTE.focus); - expect(grid.styleAt(draft.x, draft.y).attrs).toContain(BOLD); + expect(grid.styleAt(draft.x, draft.y).foreground).toBe(SEMANTIC.active); + expect(grid.styleAt(draft.x, draft.y).attrs).toContain(REFERENCE_BOLD); // Everything after it keeps the colour it had, and gains the weight focus // adds: a focus that repainted the row would make all of these cyan. - expect(grid.styleAt(draft.x + 1, draft.y).foreground).toBe(REPL_PALETTE.source); - expect(inkOfSpan(grid, draft, "Plan").foreground).toBe(REPL_PALETTE.focus); - expect(inkOfSpan(grid, draft, '"draft"').foreground).toBe(REPL_PALETTE.quoted); - expect(inkOfSpan(grid, draft, '"draft"').attrs).toContain(BOLD); - expect(inkOfSpan(grid, draft, "answers").foreground).toBe(REPL_PALETTE.reference); - expect(inkOfSpan(grid, draft, " { // Each pane's own surface, across the pane the engine admitted — and the // first column of a pane that begins with an edge is that edge. const surfaces: readonly { readonly region: string; readonly surface: number }[] = [ - { region: "sidebar", surface: REPL_PALETTE.sideSurface }, - { region: "transcript", surface: REPL_PALETTE.centreSurface }, - { region: "inspection", surface: REPL_PALETTE.bindingsSurface }, + { region: "sidebar", surface: SURFACE.side }, + { region: "transcript", surface: SURFACE.center }, + { region: "inspection", surface: SURFACE.bind }, ]; for (const { region, surface } of surfaces) { const bounds = regionBounds(observed, region); @@ -1617,9 +1619,9 @@ describe("REPL presentation: the surfaces a reading is drawn on", () => { if (transcript === undefined || footer === undefined) { throw new Error("this frame published no transcript or footer region"); } - expect(grid.styleAt(transcript.x, transcript.y).foreground).toBe(REPL_PALETTE.edge); + expect(grid.styleAt(transcript.x, transcript.y).foreground).toBe(SURFACE.edge); // And the rule that parts the body from the footer, on the row above it. - expect(grid.styleAt(transcript.x, footer.y - 1).foreground).toBe(REPL_PALETTE.edge); + expect(grid.styleAt(transcript.x, footer.y - 1).foreground).toBe(SURFACE.edge); // What no row of its own covers is still this application's: the end of the // action row, where no control was placed. @@ -1628,15 +1630,11 @@ describe("REPL presentation: the surfaces a reading is drawn on", () => { throw new Error("this frame published no action row"); } expect(grid.at(actions.x + actions.width - 1, actions.y)).toBe(" "); - expect(grid.styleAt(actions.x + actions.width - 1, actions.y).background).toBe( - REPL_PALETTE.applicationSurface, - ); + expect(grid.styleAt(actions.x + actions.width - 1, actions.y).background).toBe(SURFACE.app); // Both shared columns still say what they are while they hold nothing. expect(textOf(grid, placed(observed, "sessions:heading"))).toContain("Sessions"); - expect(inkOf(grid, placed(observed, "inspection:heading")).foreground).toBe( - REPL_PALETTE.heading, - ); + expect(inkOf(grid, placed(observed, "inspection:heading")).foreground).toBe(SEMANTIC.intro); }); it("P1-T3: a question drawer is bounded, named as waiting, and read in groups", function* () { @@ -1661,11 +1659,7 @@ describe("REPL presentation: the surfaces a reading is drawn on", () => { // One cell of rule along the top of the rectangle, inside it, across it. for (let x = rect.x; x < rect.x + rect.width; x += 1) { const style = grid.styleAt(x, rect.y); - expect([x, style.foreground, style.background]).toEqual([ - x, - REPL_PALETTE.waiting, - REPL_PALETTE.drawerSurface, - ]); + expect([x, style.foreground, style.background]).toEqual([x, SEMANTIC.hold, SURFACE.drawer]); } // The title is inside the rectangle, below the rule, inset from its side — @@ -1676,49 +1670,47 @@ describe("REPL presentation: the surfaces a reading is drawn on", () => { expect(title.x + title.width).toBe(rect.x + rect.width - 1); expect(textOf(grid, title).trimEnd()).toBe("Project details"); // A question nobody has answered yet says so before a word of it is read. - expect(inkOfSpan(grid, title, "Project details").foreground).toBe(REPL_PALETTE.waiting); - expect(inkOfSpan(grid, title, "Project details").attrs).toContain(BOLD); + expect(inkOfSpan(grid, title, "Project details").foreground).toBe(SEMANTIC.hold); + expect(inkOfSpan(grid, title, "Project details").attrs).toContain(REFERENCE_BOLD); // A field's name, what it means and what is in it are three readings. const named = placed(observed, "drawer:field:project"); - expect(inkOfSpan(grid, named, "*Project name:").foreground).toBe(REPL_PALETTE.label); - expect(inkOfSpan(grid, named, "Northstar").foreground).toBe(REPL_PALETTE.output); + expect(inkOfSpan(grid, named, "*Project name:").foreground).toBe(SEMANTIC.label); + expect(inkOfSpan(grid, named, "Northstar").foreground).toBe(SEMANTIC.out); const hint = placed(observed, "drawer:field:project:about"); - expect(inkOfSpan(grid, hint, "Name your project.").foreground).toBe(REPL_PALETTE.muted); + expect(inkOfSpan(grid, hint, "Name your project.").foreground).toBe(SEMANTIC.dim); const edited = placed(observed, "drawer:value:project"); - expect(inkOfSpan(grid, edited, "Northstar").foreground).toBe(REPL_PALETTE.output); + expect(inkOfSpan(grid, edited, "Northstar").foreground).toBe(SEMANTIC.out); // The row text goes into sits on the surface that says so, whole width. for (let x = edited.x; x < edited.x + edited.width; x += 1) { - expect(grid.styleAt(x, edited.y).background).toBe(REPL_PALETTE.fieldSurface); + expect(grid.styleAt(x, edited.y).background).toBe(SURFACE.field); } // The second field's group reads the same way, with its own words. const second = placed(observed, "drawer:field:description"); - expect(inkOfSpan(grid, second, "*One-sentence description:").foreground).toBe( - REPL_PALETTE.label, - ); + expect(inkOfSpan(grid, second, "*One-sentence description:").foreground).toBe(SEMANTIC.label); expect( inkOfSpan(grid, second, "A lightweight workspace for coordinating coding agents.").foreground, - ).toBe(REPL_PALETTE.output); + ).toBe(SEMANTIC.out); expect( inkOfSpan(grid, placed(observed, "drawer:field:description:about"), "Describe its purpose.") .foreground, - ).toBe(REPL_PALETTE.muted); + ).toBe(SEMANTIC.dim); // What the last submission was told, in the accent a refusal has. const invalid = placed(observed, "drawer:invalid:0"); expect(textOf(grid, invalid)).toContain("project is required"); - expect(inkOfSpan(grid, invalid, "project is required").foreground).toBe(REPL_PALETTE.failure); + expect(inkOfSpan(grid, invalid, "project is required").foreground).toBe(SEMANTIC.exit); // And the way to answer is still there, on a control's own surface. const submit = placed(observed, "drawer:form:submit"); expect(textOf(grid, submit)).toContain("[submit]"); - expect(grid.styleAt(submit.x, submit.y).background).toBe(REPL_PALETTE.draftSurface); + expect(grid.styleAt(submit.x, submit.y).background).toBe(SURFACE.input); // Every cell of the rectangle is the drawer's, the unused ones included. const own: readonly number[] = [ - REPL_PALETTE.drawerSurface, - REPL_PALETTE.selectedSurface, - REPL_PALETTE.fieldSurface, - REPL_PALETTE.draftSurface, + SURFACE.drawer, + RETAINED.selectedSurface, + SURFACE.field, + SURFACE.input, ]; for (let y = rect.y; y < rect.y + rect.height; y += 1) { for (let x = rect.x; x < rect.x + rect.width; x += 1) { @@ -1752,7 +1744,7 @@ describe("REPL presentation: the surfaces a reading is drawn on", () => { // Its unfocused marker, its name and the one character left in it. expect(textOf(presenter.grid, edited).trimEnd()).toBe(" = x"); for (let x = edited.x; x < edited.x + edited.width; x += 1) { - expect(presenter.grid.styleAt(x, edited.y).background).toBe(REPL_PALETTE.fieldSurface); + expect(presenter.grid.styleAt(x, edited.y).background).toBe(SURFACE.field); } const closed = yield* presenter.commit(reading(stateWith({}), model, live, WIDE)); return { @@ -1806,7 +1798,7 @@ describe("REPL presentation: decoration the frame measured", () => { // below it and one column in from each side. expect([size.columns, grid.styleAt(rect.x, rect.y).foreground]).toEqual([ size.columns, - REPL_PALETTE.waiting, + SEMANTIC.hold, ]); expect([size.columns, viewport.y > rect.y]).toEqual([size.columns, true]); expect([size.columns, viewport.x]).toEqual([size.columns, rect.x + 1]); @@ -1884,16 +1876,15 @@ describe("REPL presentation: decoration the frame measured", () => { describe("REPL presentation: a row the frame cannot hold whole", () => { beforeAll(() => useTempFileCompiler()); - it("P1-T1: a source row wider than its drawer keeps every character it shows", function* () { + /** One tag far wider than any drawer at any supported size. */ + const OVERFLOWING = + '' + + "Enter the project details, at length, so that this one line is wider ".repeat(4) + + ""; + + it("P1-T1: an overflowing source row keeps its roles and every character it shows", function* () { const model = yield* settledAndFailed(); - // One tag far wider than any drawer at any supported size, so the engine - // has to wrap it. Drawn as several stretches it would wrap each of them on - // its own and leave the cells their remainders went to blank. - const long = - '' + - "Enter the project details, at length, so that this one line is wider ".repeat(4) + - ""; - const question = yield* askingWith(DETAILS_SCHEMA, long); + const question = yield* askingWith(DETAILS_SCHEMA, OVERFLOWING); const presenter = yield* usePresenter(WIDE); const observed = yield* presenter.commit( reading(answering(stateWith({}), {}), model, asking(question), WIDE), @@ -1901,23 +1892,108 @@ describe("REPL presentation: a row the frame cannot hold whole", () => { const { grid } = presenter; const row = placed(observed, "drawer:message:0"); - const shown = textOf(grid, row); + const written = textOf(grid, row).replace(/\s+$/, ""); // Whatever the row shows is a prefix of the line it was given, with no gap // where a stretch wrapped away: every written cell is the next character of // the source, in order. - const written = shown.replace(/\s+$/, ""); expect(written.length).toBeGreaterThan(40); - expect(long.startsWith(written)).toBe(true); + expect(OVERFLOWING.startsWith(written)).toBe(true); expect(written).not.toContain(" "); - // And a row the drawer does hold is still read as the thing it is, so the - // bound is on the row that overflows rather than on the reading. + // And every visible character still reads as the thing it is. A row drawn + // as one stretch to dodge the engine's per-operation wrapping would make + // all of these the same colour, which is the defect this rejects. + expect(inkOfSpan(grid, row, "<").foreground).toBe(SYNTAX.del); + expect(inkOfSpan(grid, row, "Elicit").foreground).toBe(SYNTAX.tag); + expect(inkOfSpan(grid, row, "as").foreground).toBe(SYNTAX.attr); + expect(inkOfSpan(grid, row, "=").foreground).toBe(SYNTAX.punct); + expect(inkOfSpan(grid, row, '"answers"').foreground).toBe(SYNTAX.str); + expect(inkOfSpan(grid, row, "{").foreground).toBe(SYNTAX.brace); + expect(inkOfSpan(grid, row, "schema", 28).foreground).toBe(SYNTAX.ref); + expect(inkOfSpan(grid, row, "Enter the project").foreground).toBe(SYNTAX.prose); + // Nothing on the row is drawn in one colour throughout. + const inks = new Set(); + for (let x = row.x; x < row.x + row.width; x += 1) { + if (grid.at(x, row.y) !== " ") { + inks.add(grid.styleAt(x, row.y).foreground); + } + } + expect(inks.size).toBeGreaterThan(3); + }); + + it("P1-T2: an overflowing focused row keeps its cue and its roles", function* () { + const model = yield* settledAndFailed(); + const presenter = yield* usePresenter(NARROW); + // A draft wider than the narrowest supported frame, with focus on it. + const typed = `{answers} ${"and more source ".repeat(6)}`; + const state = stateWith({ draft: typed }); + + yield* presenter.commit(reading(state, model, NOTHING_LIVE, NARROW)); + yield* focusOn(presenter, "footer:input"); + const observed = yield* presenter.commit( + reading(state, model, NOTHING_LIVE, NARROW, focusKeyOf(presenter)), + ); + const { grid } = presenter; + + const draft = placed(observed, "footer:input"); + expect(textOf(grid, draft).startsWith(">> Draft: