diff --git a/architecture.md b/architecture.md index cbb1c05d9..e1f2eea95 100644 --- a/architecture.md +++ b/architecture.md @@ -5842,19 +5842,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 6b031a9a3..c9f01b454 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 1808ace01..42a4e0df5 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 51d764d39..2a02feb12 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..dcd649860 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,81 @@ 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. + * + * 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. 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; + if (style === undefined) { + return [text(content)]; + } + const { runs } = box; + 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)]; } - const { colour, attrs } = textStyleOf(box.style, focused); + return bounded(runs, room).map((run) => { + const { colour, attrs } = runStyleOf(run.token, style, focused); + return drawn(run.text, colour, attrs); + }); +} + +/** + * 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 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 roomOf(props: ReplBoxProps): number | "whole" | "unknown" { + const width = props.layout?.width; + if (width === undefined) { + return "unknown"; + } + if (width.type === "fit") { + return "whole"; + } + 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 { return text(content, { color: terminalColour(colour), ...(attrs === 0 ? {} : { attrs }) }); } @@ -747,6 +862,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 +873,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/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-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 ce510dc86..292a671c3 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 }; } @@ -2686,8 +2702,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..7521ab234 100644 --- a/packages/cli/tests/repl-presentation-style.test.ts +++ b/packages/cli/tests/repl-presentation-style.test.ts @@ -21,25 +21,54 @@ 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 { + REFERENCE_ARCHIVE, + REFERENCE_BOLD, + RETAINED, + SEMANTIC, + SURFACE, + SYNTAX, +} from "./fixtures/repl/reference-style.ts"; import type { ReplTerminalSize } from "../src/repl/terminal.ts"; import { createGrid, @@ -973,11 +1002,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 +1042,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 +1274,726 @@ 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(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(SYNTAX.del); + expect(inkOfSpan(grid, elicit, "Elicit").foreground).toBe(SEMANTIC.active); + expect(inkOfSpan(grid, elicit, "as").foreground).toBe(SYNTAX.attr); + expect(inkOfSpan(grid, elicit, "=").foreground).toBe(SYNTAX.punct); + expect(inkOfSpan(grid, elicit, '"answers"').foreground).toBe(SYNTAX.str); + expect(inkOfSpan(grid, elicit, "schema", 20).foreground).toBe(SYNTAX.attr); + expect(inkOfSpan(grid, elicit, "{").foreground).toBe(SYNTAX.brace); + expect(inkOfSpan(grid, elicit, "}").foreground).toBe(SYNTAX.brace); + expect(inkOfSpan(grid, elicit, "Enter the project details.").foreground).toBe(SEMANTIC.src); + 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(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(SYNTAX.jstr); + }); + + 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(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(SEMANTIC.src); + }); +}); + +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(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(SEMANTIC.src); + expect(inkOfSpan(grid, draft, "Plan").foreground).toBe(SEMANTIC.active); + expect(inkOfSpan(grid, draft, '"draft"').foreground).toBe(SYNTAX.str); + expect(inkOfSpan(grid, draft, '"draft"').attrs).toContain(REFERENCE_BOLD); + expect(inkOfSpan(grid, draft, "answers").foreground).toBe(SYNTAX.ref); + 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: SURFACE.side }, + { region: "transcript", surface: SURFACE.center }, + { region: "inspection", surface: SURFACE.bind }, + ]; + 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(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(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. + 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(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(SEMANTIC.intro); + }); + + 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, SEMANTIC.hold, SURFACE.drawer]); + } + + // 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(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(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(SEMANTIC.dim); + const edited = placed(observed, "drawer:value:project"); + 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(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(SEMANTIC.label); + expect( + inkOfSpan(grid, second, "A lightweight workspace for coordinating coding agents.").foreground, + ).toBe(SEMANTIC.out); + expect( + inkOfSpan(grid, placed(observed, "drawer:field:description:about"), "Describe its purpose.") + .foreground, + ).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(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(SURFACE.input); + + // Every cell of the rectangle is the drawer's, the unused ones included. + const own: readonly number[] = [ + 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) { + 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(SURFACE.field); + } + 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, + SEMANTIC.hold, + ]); + 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"); + }); +}); + +describe("REPL presentation: a row the frame cannot hold whole", () => { + beforeAll(() => useTempFileCompiler()); + + /** 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(); + const question = yield* askingWith(DETAILS_SCHEMA, OVERFLOWING); + 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 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. + expect(written.length).toBeGreaterThan(40); + expect(OVERFLOWING.startsWith(written)).toBe(true); + expect(written).not.toContain(" "); + + // 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: