diff --git a/architecture.md b/architecture.md index a490c995a..a8c94800b 100644 --- a/architecture.md +++ b/architecture.md @@ -5745,6 +5745,8 @@ DurableEvents -> frozen ReplModel -> resolved immutable view | `description.ts` | opaque immutable descriptions and the closed action boundary | | `reconcile.ts` | reconciling descriptions into one mounted Freedom tree | | `handoff.ts` | the acknowledged commit boundary | +| `navigation.ts` | every retained position and the head, as the one reading taken at the whole file | +| `history-rail.ts` | the five-row History band, placed from engine measurements | | `source-reading.ts` | one entry as output and then source, with what each element is doing | | `presentation-text.ts` · `presentation-style.ts` | what the characters of a reading are, and the colours it is drawn in | | `fitting.ts` | engine-measured widths, and the rows a reading is cut into | @@ -5756,6 +5758,46 @@ DurableEvents -> frozen ReplModel -> resolved immutable view | `components/` · `application.ts` | the screens, and the one state transition boundary | | `storage.ts` · `program.ts` | where histories live, and the command as one scope | +### Navigation is the whole file; content is one prefix + +Every pane reads the selected prefix. The History band and its drawer are the +explicit exception, and the only one: they read a thin summary of the *whole* +validated file — a marker and a kind per retained position, plus the head — +because the positions after the selected one are where a reader can go next. +No label, source, name, value, outcome detail or observation is in it, so +nothing a later position holds reaches a reading of an earlier one. + +Both halves come from one acknowledged event array per wake: the full file for +the summary, the requested marker for everything else, through the projector's +existing whole-file validation. The full model is discarded from child +reachability rather than kept, so nothing downstream can walk to a later entry +through it, and navigation is never assembled by appending to a selected +model. A later malformed segment refuses the whole reading even for an earlier +marker — the alternative is stale navigation standing beside a prefix that +still looks plausible. + +The session hands both halves over together, as one reading, and the root +carries that one value through resolution, paint and resize rather than asking +again for either half. It has to: the root reads the terminal's size between +resolving a frame and composing it, that read suspends, and a record can be +retained while it does. Content taken before it and navigation taken after it +would describe two different files on one screen. A resize recomposes the +reading that was resolved at a new size, never at a new snapshot. + +When that reading cannot be built at all — a record this process wrote and can +no longer read — the root draws the refused view, the same screen a cold open +of the same file shows. That is a different outcome from a route that does not +resolve at its position: there, a reading still stands and the reason is said +beside it. A refusal has nothing to stand beside, so nothing is left on the +screen pretending to be current. + +The head is derived on read from retained facts and from work this session +owns, never from a body's return, a recorded root Close, a component badge or +the selected content. It changes while the file stands still, so it publishes +through the wake the session and the controller already send; History adds no +stream, timer or clock. The inspected action row keeps its historical +restrictions: navigation is the exception for the band, not for capabilities. + ### Sequential entries over one physical journal One execution is one append-only stream, and entries are ranges of it. A segment diff --git a/packages/cli/src/repl/application.ts b/packages/cli/src/repl/application.ts index 99ae90ba6..6eede758c 100644 --- a/packages/cli/src/repl/application.ts +++ b/packages/cli/src/repl/application.ts @@ -47,8 +47,6 @@ import { drawerLayerProps, drawerRect, footerProps, - historyBand, - HISTORY_ROWS, inspectionWidth, paneColumnProps, paneContentProps, @@ -82,6 +80,10 @@ import type { ReplAdmission, ReplWindow } from "./layout-admission.ts"; import { ORDINARY, REPL_PALETTE, styleOf } from "./presentation-style.ts"; import type { ReplPresentationRole, ReplRowStyle } from "./presentation-style.ts"; import { jsonRuns, sourceRuns } from "./presentation-text.ts"; +import { NO_NAVIGATION, numbered, pointLabel } from "./navigation.ts"; +import type { ReplHistoryNavigation } from "./navigation.ts"; +import { blankBand, EMPTY_BAND, HISTORY_ROWS } from "./history-rail.ts"; +import type { ReplPreparedRail } from "./history-rail.ts"; import { entryReading, NO_READING } from "./source-reading.ts"; import type { ReplReading } from "./source-reading.ts"; import { readingRuns } from "./fitting.ts"; @@ -532,6 +534,16 @@ export interface ReplTransition { export interface ReplView { readonly state: ReplState; readonly model: ReplModel; + /** + * Every position the history retained, and what the head is doing. + * + * The explicit exception to this view being a reading of one prefix: the + * band is a map of the whole recorded order, because the positions after + * the selected one are where a reader can go next. It is a marker and a + * kind each — nothing a later position holds crosses — and it reaches the + * band and the History drawer only. Every other pane gets the prefix. + */ + readonly navigation: ReplHistoryNavigation; readonly selection: ReplSelection; readonly live: ReplLive; /** The canonical location this view is at. */ @@ -758,6 +770,7 @@ export function viewFor( live: ReplLive, size: ReplTerminalSize, focused?: string, + navigation: ReplHistoryNavigation = NO_NAVIGATION, ): Result { // This process is the only thing that can say a question is waiting, so it // says so here rather than leaving resolution to infer it from a history that @@ -790,6 +803,7 @@ export function viewFor( Object.freeze({ state, model, + navigation, selection: resolved.value, live: shown, location: encodeLocation(state.route), @@ -812,6 +826,9 @@ export function refusedView( return Object.freeze({ state, model: EMPTY_MODEL, + // A refusal navigates nowhere: there is no validated reading to draw a + // rail from, and a stale one beside a refusal would be the worst of both. + navigation: NO_NAVIGATION, selection: Object.freeze({ route: state.route, surface: state.route.surface, @@ -2790,6 +2807,20 @@ export function readingOf(view: ReplView): ReplReading { }); } +/** + * The band's five rows, or five empty ones where none was prepared. + * + * The measuring pass has no rail — it is the pass that answers how wide the + * band is — and an unmeasured frame describes no band content, exactly as it + * describes no windowed content. + */ +function bandRowsOf( + rail: ReplPreparedRail | undefined, + columns: number, +): readonly (readonly ReplTokenRun[])[] { + return rail === undefined ? blankBand(columns) : rail.rows; +} + /** * The transcript rows the selected locus holds. * @@ -3383,14 +3414,20 @@ function drawerContent( // not described, so it is not a cell, not a target and not a Tab stop — // which is what makes reaching the last one a thing the controls do rather // than something clipping hides. - for (const checkpoint of view.model.checkpoints) { + // The whole recorded order, not the selected prefix: the positions after + // the one being read are where a reader can go next, and a drawer that + // stopped at the selection would be a list of where they had already + // been. Every position gets its own row — a group is how the rail *draws* + // two of them, never how it counts them — and each says what kind it is + // without reading anything the position holds. + for (const one of numbered(view.navigation)) { content.push({ described: row( - `drawer:marker:${checkpoint.marker}`, - padControl(checkpoint.label, width), - { select: "marker", marker: checkpoint.marker }, + `drawer:marker:${one.point.marker}`, + padControl(pointLabel(one.point, one.ordinal, one.entry), width), + { select: "marker", marker: one.point.marker }, styleOf("history", { - selected: view.state.route.at === checkpoint.marker, + selected: view.state.route.at === one.point.marker, inspected: true, }), { here: view.focused }, @@ -4126,6 +4163,14 @@ export interface ReplPresentationContext { * prepared rows; it cannot recover the rest of an unprepared one. */ readonly preview: readonly ReplFittedLine[] | undefined; + /** + * The History band this frame measured, or none before it has measured one. + * + * Prepared beside the reading: where a mark lands is a proportion of the + * rail's measured columns, so the pass that asks how wide the band is + * cannot already have placed anything on it. + */ + readonly rail: ReplPreparedRail | undefined; } /** One reading's descriptions, paired with where the engine places them. */ @@ -4451,13 +4496,10 @@ export function presentationFor( } const size = view.size; const profile = profileFor(size); - const band = historyBand( - view.model.checkpoints.map((checkpoint) => ({ - marker: checkpoint.marker, - label: checkpoint.label, - })), - size.columns, - ); + // The band the frame measured. The pass that asks how wide it is has none, + // and describes none: a rail placed against a width still being asked for + // would be a rail placed on nothing. + const rail = context.rail; if (profile === "too-small") { // No cell beyond the sentence, so nothing this frame hides can be pointed // at: a control that is not in the frame is not in its target map either. @@ -4487,7 +4529,7 @@ export function presentationFor( actions: undefined, regions: Object.freeze([Object.freeze({ region: "refusal", id: "box:refusal" })]), contents: Object.freeze([]), - history: band, + history: rail ?? EMPTY_BAND, }), }; } @@ -4790,14 +4832,16 @@ export function presentationFor( id: "box:footer:band", region: "footer", props: bandProps(), - children: band.rows.map((text, at) => + children: bandRowsOf(rail, size.columns).map((row, at) => box({ id: `box:band:${at}`, region: "footer", props: rowProps(size.columns), - text, + text: runText(row), + runs: row, // The one part of this screen that is not a mounted row, so // its style comes from the band rather than from a candidate. + // Each cell carries its own role; this is the row's base. style: styleOf("history", { inspected: view.state.route.at !== undefined, }), @@ -4822,7 +4866,7 @@ export function presentationFor( }), regions: Object.freeze(regions.map((region) => Object.freeze(region))), contents: Object.freeze(contents.map((content) => Object.freeze(content))), - history: band, + history: rail ?? EMPTY_BAND, }), }; } diff --git a/packages/cli/src/repl/history-rail.ts b/packages/cli/src/repl/history-rail.ts new file mode 100644 index 000000000..8d42caedf --- /dev/null +++ b/packages/cli/src/repl/history-rail.ts @@ -0,0 +1,545 @@ +/** + * The History band: every retained position, on five rows of measured rail. + * + * A map of the whole recorded order, not of the part a reader has reached. + * The selection sits on it in amber; the head sits below its end. Content + * stays at the selected prefix — this is the one reading taken at the whole + * file, and it carries a marker and a kind and nothing else. + * + * ## Why it is measured rather than counted + * + * Where a mark goes is a proportion of the rail's *columns*, and a column is + * not a character: the title, the head labels and the entry captions are + * engine answers, and the rail is whatever they leave. A band laid out by + * `String.length` would put its last mark off the end of a pane the moment a + * caption held a character wider than one cell. + * + * ## What each row is + * + * 1. the band's own name, and the head label against the right + * 2. entry captions, and the selection caption against the right + * 3. the tall uprisers of entry marks, and the selection's diamond + * 4. the rule itself, with a junction under every mark + * 5. the short stems of minor marks, and the head's own marker + * + * The right-hand captions share one reserved width across every head state, + * so a head changing from LIVE to SETTLING moves no mark and re-cuts no + * caption. Only the count, the selection or the size can do that. + * + * ## Grouping keeps every position selectable + * + * Two positions that land on one column are one *visual* group and stay two + * positions: the group remembers both, and the drawer offers each its own + * row. A rail that merged them would be a rail that cost a reader the ability + * to reach one of them, which is the whole point of retaining it. + */ + +import { close, fit, open, text } from "@bomb.sh/tty"; +import type { Op } from "@bomb.sh/tty"; +import { Err, Ok } from "effection"; +import type { Operation, Result } from "effection"; +import { tokenRuns } from "./description.ts"; +import type { ReplTokenRun } from "./description.ts"; +import { HEAD_LABELS, numbered } from "./navigation.ts"; +import type { ReplHeadState, ReplHistoryNavigation, ReplNavigationPoint } from "./navigation.ts"; +import type { ReplPresentationRole } from "./presentation-style.ts"; +import type { ReplRenderer } from "./renderer.ts"; +import type { ReplTerminalSize } from "./terminal.ts"; + +/** What the band calls itself, on the first of its own rows. */ +export const HISTORY_TITLE = "History · recorded order"; + +/** What it says where a history holds nothing. */ +export const HISTORY_EMPTY = "No recorded checkpoints"; + +/** The caption for a live reading, which is not standing at a position. */ +export const SELECTED_HEAD = "SELECTED · HEAD"; + +/** Exactly five, at every size. */ +export const HISTORY_ROWS = 5; + +/** + * A band with nothing on it. + * + * What a frame carries before it has measured one, and what a refusal + * carries: there is no validated reading to draw a rail from, and a stale one + * beside a refusal would be the worst of both. + */ +export const EMPTY_BAND: ReplPreparedRail = Object.freeze({ + reservation: Object.freeze({ right: 0, separator: 0, rail: 0 }), + rows: Object.freeze(Array.from({ length: HISTORY_ROWS }, () => Object.freeze([]))), + groups: Object.freeze([]), +}); + +/** + * Five rows of the band's own surface, at a width. + * + * What a frame draws where it has not prepared a rail. Full width rather than + * empty, for the reason every other row of this screen is: the renderer + * writes what changed, so a band row that got shorter would keep the tail of + * whatever used to be there. + */ +export function blankBand(columns: number): readonly (readonly ReplTokenRun[])[] { + const blank = tokenRuns([{ text: " ".repeat(Math.max(0, columns)), token: "history" }]); + return Object.freeze(Array.from({ length: HISTORY_ROWS }, () => blank)); +} + +/** A tall mark: an entry, which is what a reader navigates by. */ +const ENTRY_UPRISER = "┃"; +/** Its junction on the rule, and the heavier one a crowded group takes. */ +const ENTRY_JUNCTION = "━"; +const ENTRY_CROWDED = "┳"; +/** A minor position: its junction on the rule, and its stem below. */ +const MINOR_JUNCTION = "┬"; +const MINOR_STEM = "│"; +/** The rule itself. */ +const RULE = "─"; +/** The selection, above the rule; the head, below its end. */ +const SELECTED_DIAMOND = "◆"; +const HEAD_MARKER = "▼"; + +/** One visual column, and every position that landed on it. */ +export interface ReplRailGroup { + readonly column: number; + /** Every member, in recorded order. Never merged away. */ + readonly members: readonly ReplNavigationPoint[]; + /** Their one-based ordinals in the full list. */ + readonly ordinals: readonly number[]; + /** Whether any member is an entry, and whether any is at or before the selection. */ + readonly entry: boolean; + readonly minor: boolean; + readonly entryEarlier: boolean; + readonly minorEarlier: boolean; + /** Whether the selected position is one of these. */ + readonly selected: boolean; +} + +/** What the band reserved before it placed anything. */ +export interface ReplRailReservation { + /** The right-hand caption column, shared by every head state. */ + readonly right: number; + /** The measured space parting it from the rail. */ + readonly separator: number; + /** What is left, which is the rail. */ + readonly rail: number; +} + +/** The band, prepared. Inert: text and roles, no capability and no target. */ +export interface ReplPreparedRail { + readonly reservation: ReplRailReservation; + /** Exactly `HISTORY_ROWS` rows of runs, each already the band's full width. */ + readonly rows: readonly (readonly ReplTokenRun[])[]; + readonly groups: readonly ReplRailGroup[]; +} + +/** Ask the engine how wide each of these texts is, in one render. */ +function* widths( + renderer: ReplRenderer, + size: ReplTerminalSize, + texts: readonly string[], +): Operation> { + if (texts.length === 0) { + return Ok([]); + } + const ops: Op[] = [ + open("band:root", { layout: { width: fit(), height: fit(), direction: "ttb" } }), + ]; + for (const [index, one] of texts.entries()) { + ops.push(open(`band:${index}`, { layout: { width: fit(), height: fit() } })); + if (one.length > 0) { + ops.push(text(one)); + } + ops.push(close()); + } + ops.push(close()); + const measured = yield* renderer.measure(ops, size); + if (!measured.ok) { + return measured; + } + const answers: number[] = []; + for (const [index, one] of texts.entries()) { + if (one.length === 0) { + answers.push(0); + continue; + } + const bounds = measured.value.boundsOf(`band:${index}`); + if (bounds === undefined) { + return Err(new Error(`the engine measured no width for ${JSON.stringify(one)}`)); + } + answers.push(Math.ceil(bounds.width)); + } + return Ok(answers); +} + +/** + * Where each position lands, as a proportion of the rail's measured columns. + * + * Deterministic and frozen: the first is at 0, the last at W−1, and the rest + * spread evenly between them. A single position goes at the end, where the + * head is — one recorded thing and the present are the same place. This is + * proportional spacing inside measured bounds and says nothing about when + * anything happened. + */ +function columnOf(index: number, count: number, room: number): number { + if (count <= 1) { + return Math.max(0, room - 1); + } + return Math.round((index * (room - 1)) / (count - 1)); +} + +/** + * The positions, gathered into the columns they landed on. + * + * Consecutive ordinals sharing a column are one group. Every member stays in + * it: the group is how the band *draws* them, never how it counts them. + */ +export function groupsOf( + navigation: ReplHistoryNavigation, + selected: string | undefined, + room: number, +): readonly ReplRailGroup[] { + const points = numbered(navigation); + if (points.length === 0 || room < 1) { + return Object.freeze([]); + } + const at = + selected === undefined ? undefined : points.findIndex((one) => one.point.marker === selected); + const selectedOrdinal = at === undefined || at < 0 ? undefined : at + 1; + const byColumn = new Map(); + for (const [index, one] of points.entries()) { + const column = columnOf(index, points.length, room); + byColumn.set(column, [...(byColumn.get(column) ?? []), one]); + } + return Object.freeze( + [...byColumn.entries()] + .sort(([a], [b]) => a - b) + .map(([column, members]) => { + // Each category decides its own colour independently: a group holding + // an entry a reader has passed and a minor position they have not is + // both, and saying so with one colour would lose half of it. + const entries = members.filter((one) => one.point.kind === "entry"); + const minors = members.filter((one) => one.point.kind !== "entry"); + const earlier = (of: typeof members) => + selectedOrdinal === undefined || of.some((one) => one.ordinal <= selectedOrdinal); + return Object.freeze({ + column, + members: Object.freeze(members.map((one) => one.point)), + ordinals: Object.freeze(members.map((one) => one.ordinal)), + entry: entries.length > 0, + minor: minors.length > 0, + entryEarlier: entries.length > 0 && earlier(entries), + minorEarlier: minors.length > 0 && earlier(minors), + selected: + selectedOrdinal !== undefined && members.some((one) => one.ordinal === selectedOrdinal), + }); + }), + ); +} + +/** One row under construction: a fixed-width line of cells with roles. */ +class Row { + readonly #cells: { text: string; role: ReplPresentationRole }[]; + + constructor(width: number, role: ReplPresentationRole) { + this.#cells = Array.from({ length: Math.max(0, width) }, () => ({ text: " ", role })); + } + + /** Put one character at a column, if the column is on this row. */ + put(column: number, glyph: string, role: ReplPresentationRole): void { + const cell = this.#cells[column]; + if (cell !== undefined) { + cell.text = glyph; + cell.role = role; + } + } + + /** Put a string starting at a column, clamped to what the row has. */ + write(column: number, value: string, role: ReplPresentationRole): void { + for (const [offset, glyph] of [...value].entries()) { + this.put(column + offset, glyph, role); + } + } + + /** The row as runs, adjacent cells of one role joined. */ + runs(): readonly ReplTokenRun[] { + return tokenRuns(this.#cells.map((cell) => ({ text: cell.text, token: cell.role }))); + } +} + +/** One entry caption, before and after merging. */ +interface Caption { + /** The first and last entry numbers it covers. */ + readonly from: number; + readonly to: number; + /** The rail columns of those entries. */ + readonly first: number; + readonly last: number; + readonly text: string; + readonly earlier: boolean; + width: number; + column: number; +} + +function captionText(from: number, to: number): string { + return from === to ? `Entry ${from}` : `Entry ${from}–${to}`; +} + +/** + * The band, measured and laid out. + * + * Every width here is an engine answer. The right-hand column is reserved + * first and identically for every head state, so a head change moves nothing; + * the rail is what remains, and the captions are fitted into it by merging + * overlapping runs of entries until they fit. + */ +export function* prepareRail( + renderer: ReplRenderer, + size: ReplTerminalSize, + navigation: ReplHistoryNavigation, + selected: string | undefined, + width: number, +): Operation> { + const points = numbered(navigation); + const at = selected === undefined ? -1 : points.findIndex((one) => one.point.marker === selected); + const selectedOrdinal = at < 0 ? undefined : at + 1; + const selectionCaption = + selectedOrdinal === undefined + ? SELECTED_HEAD + : `${SELECTED_DIAMOND} SELECTED · ${selectedOrdinal}`; + + // Every head label, so the reserved column is the same whichever one is + // showing: a reservation that tracked the current state would re-cut the + // rail each time the head moved. + const heads = Object.values(HEAD_LABELS); + const measured = yield* widths(renderer, size, [ + " ", + HISTORY_TITLE, + HISTORY_EMPTY, + SELECTED_HEAD, + selectionCaption, + ...heads, + ]); + if (!measured.ok) { + return measured; + } + const [separator, titleWidth, emptyWidth, headCaption, selectionWidth, ...headWidths] = + measured.value; + const right = Math.max(headCaption, selectionWidth, ...headWidths); + const rail = width - right - separator; + if (rail < 1 || titleWidth > rail) { + return Err( + new Error( + `the History band needs ${right + separator + Math.max(titleWidth, 1)} columns and ` + + `has ${width}`, + ), + ); + } + + const groups = groupsOf(navigation, selected, rail); + const rows = Array.from({ length: HISTORY_ROWS }, () => new Row(width, "history-rail")); + + // Row 1: what this is, and what the execution is doing. + rows[0].write(0, HISTORY_TITLE, "history-title"); + const headLabel = HEAD_LABELS[navigation.head]; + const headWidth = headWidths[heads.indexOf(headLabel)] ?? headLabel.length; + rows[0].write( + width - headWidth, + headLabel, + navigation.head === "live" ? "history-head-live" : "history-head", + ); + + // Row 2: the entry captions, and which position is being read. + rows[1].write( + width - (selectedOrdinal === undefined ? headCaption : selectionWidth), + selectionCaption, + selectedOrdinal === undefined ? "history-head" : "history-selected", + ); + if (points.length === 0) { + rows[1].write( + 0, + HISTORY_EMPTY.slice(0, Math.max(0, Math.min(emptyWidth, rail))), + "history-head", + ); + return Ok( + Object.freeze({ + reservation: Object.freeze({ right, separator, rail }), + rows: Object.freeze(rows.map((row) => row.runs())), + groups, + }), + ); + } + + const captions = yield* fitCaptions(renderer, size, groups, rail, selectedOrdinal, points); + if (!captions.ok) { + return captions; + } + for (const caption of captions.value) { + rows[1].write( + caption.column, + caption.text, + caption.earlier ? "history-entry-earlier" : "history-entry-later", + ); + } + + // Rows 3–5: the marks themselves, on and around the rule. + for (let column = 0; column < rail; column += 1) { + rows[3].put(column, RULE, "history-rail"); + } + for (const group of groups) { + const crowded = group.members.length > 1; + if (group.entry) { + const role: ReplPresentationRole = group.entryEarlier + ? "history-tick-earlier" + : "history-entry-later"; + rows[2].put(group.column, ENTRY_UPRISER, role); + rows[3].put(group.column, crowded ? ENTRY_CROWDED : ENTRY_JUNCTION, role); + } + if (group.minor) { + const role: ReplPresentationRole = group.minorEarlier + ? "history-minor-earlier" + : "history-minor-later"; + // A group holding both keeps the entry's tall boundary above and its + // heavier junction on the rule; the minor position keeps its own stem + // below, so neither category is dropped for the other. + if (!group.entry) { + rows[3].put(group.column, crowded ? ENTRY_CROWDED : MINOR_JUNCTION, role); + } + rows[4].put(group.column, MINOR_STEM, role); + } + if (group.selected) { + // The selection wins its cells above and on the rule. What the group + // also holds stays readable in its junction, its caption and its own + // drawer rows. + rows[2].put(group.column, SELECTED_DIAMOND, "history-selected"); + rows[3].put(group.column, crowded ? ENTRY_CROWDED : ENTRY_JUNCTION, "history-selected"); + } + } + // The head is below the rail's end, in its own colour — so a selection on + // that same column keeps its diamond above and the head keeps its marker + // below, and neither has to borrow the other's cell. + rows[4].put( + Math.max(0, rail - 1), + HEAD_MARKER, + navigation.head === "live" ? "history-head-live" : "history-head", + ); + + return Ok( + Object.freeze({ + reservation: Object.freeze({ right, separator, rail }), + rows: Object.freeze(rows.map((row) => row.runs())), + groups, + }), + ); +} + +/** + * The entry captions, merged left to right until they fit. + * + * One `Entry E` per entry, centred on its mark. Where two overlap, the + * leftmost pair becomes the span they actually cover — `Entry A–B`, never a + * number that is not there — recentred between its own first and last marks, + * remeasured, and the pass repeats. A caption is never clipped and never + * reaches into the reserved right column: a band that cannot fit its captions + * is a frame refusal, which already recovers on resize. + */ +function* fitCaptions( + renderer: ReplRenderer, + size: ReplTerminalSize, + groups: readonly ReplRailGroup[], + rail: number, + selectedOrdinal: number | undefined, + points: ReturnType, +): Operation> { + // Where each entry's mark is, by its admitted number. + const marks = new Map(); + for (const group of groups) { + for (const [index, member] of group.members.entries()) { + if (member.kind === "entry") { + const ordinal = group.ordinals[index]; + const found = points.find((one) => one.ordinal === ordinal); + if (found !== undefined) { + marks.set(found.entry, group.column); + } + } + } + } + if (marks.size === 0) { + return Ok([]); + } + const earliest = (from: number, to: number): boolean => { + if (selectedOrdinal === undefined) { + return true; + } + // A merged caption spanning the selection reads as the earlier one: part + // of what it covers is behind the reader. + return points.some( + (one) => + one.point.kind === "entry" && + one.entry >= from && + one.entry <= to && + one.ordinal <= selectedOrdinal, + ); + }; + + let captions: Caption[] = [...marks.entries()] + .sort(([a], [b]) => a - b) + .map(([entry, column]) => ({ + from: entry, + to: entry, + first: column, + last: column, + text: captionText(entry, entry), + earlier: earliest(entry, entry), + width: 0, + column: 0, + })); + + // One pass per possible merge, counted from the captions there were — the + // list shrinks as they merge, so a bound read from it each time would stop + // the pass before the last pair had been joined. + const passes = captions.length; + for (let pass = 0; pass <= passes; pass += 1) { + const sized = yield* widths( + renderer, + size, + captions.map((one) => one.text), + ); + if (!sized.ok) { + return sized; + } + for (const [index, caption] of captions.entries()) { + caption.width = sized.value[index]; + const centre = Math.round((caption.first + caption.last) / 2); + caption.column = Math.max( + 0, + Math.min(centre - Math.floor(caption.width / 2), rail - caption.width), + ); + } + // The first pair that touches, left to right. One separating cell is + // required, so "touching" is an end that reaches the next one's start. + const collision = captions.findIndex( + (one, index) => + index + 1 < captions.length && one.column + one.width >= captions[index + 1].column, + ); + if (collision < 0) { + return Ok(Object.freeze(captions.map((one) => Object.freeze({ ...one })))); + } + const left = captions[collision]; + const right = captions[collision + 1]; + captions = [ + ...captions.slice(0, collision), + { + from: left.from, + to: right.to, + first: left.first, + last: right.last, + text: captionText(left.from, right.to), + earlier: earliest(left.from, right.to), + width: 0, + column: 0, + }, + ...captions.slice(collision + 2), + ]; + } + return Err(new Error("the History band could not fit its entry captions")); +} diff --git a/packages/cli/src/repl/layout.ts b/packages/cli/src/repl/layout.ts index dcd649860..4caa26cdb 100644 --- a/packages/cli/src/repl/layout.ts +++ b/packages/cli/src/repl/layout.ts @@ -38,6 +38,7 @@ import { close, fixed, fit, grow, open, text } from "@bomb.sh/tty"; import type { Op, OpenElement, SizingAxis } from "@bomb.sh/tty"; import { runText } from "./description.ts"; +import type { ReplPreparedRail } from "./history-rail.ts"; import type { ReplTokenRun } from "./description.ts"; import { REPL_PALETTE, @@ -197,7 +198,10 @@ const WIDE: ReplTerminalSize = { columns: 160, rows: 36 }; */ export const FOOTER_ROWS = 7; /** The History band's own five rows. */ -export const HISTORY_ROWS = 5; +export { HISTORY_ROWS } from "./history-rail.ts"; + +/** Five, at every size — imported for this module's own placement. */ +import { HISTORY_ROWS } from "./history-rail.ts"; /** How wide a column is at each profile that has one. */ interface ColumnWidths { @@ -297,115 +301,16 @@ export function drawerRect(size: ReplTerminalSize): ReplBounds | undefined { }); } -/** One selectable history position, as the band would label it. */ -export interface ReplSurfaceMarker { - readonly marker: string; - readonly label: string; -} - -/** One marker as the band shows it, grouped or not. */ -export interface ReplPlacedMarker { - readonly marker: string; - /** The label shown. Several markers may share one when space is short. */ - readonly label: string; - /** The other markers sharing that label, this one excluded. */ - readonly grouped: readonly string[]; -} - -/** The History band: its five rows, and what each marker is labelled. */ -export interface ReplHistoryBand { - /** Exactly five rows, whatever the size. */ - readonly rows: readonly string[]; - readonly markers: readonly ReplPlacedMarker[]; -} - -/** - * The History band, labelled and laid into its five rows. - * - * The band is the one thing on this screen that is not a mounted row: its labels - * come from the model rather than from a node, so nothing else can tell the - * engine what to put there. Grouping is visual — every marker stays under its - * own name and the ones sharing a label say which others they share it with, so - * a compact band never costs a reader the ability to select an exact position. - */ -export function historyBand( - markers: readonly ReplSurfaceMarker[], - columns: number, -): ReplHistoryBand { - const placed = groupedMarkers(markers, columns); - return Object.freeze({ rows: Object.freeze(bandRows(placed, columns)), markers: placed }); -} - -function groupedMarkers( - markers: readonly ReplSurfaceMarker[], - columns: number, -): readonly ReplPlacedMarker[] { - // Five rows wide as the band is wide. Grouping is about the band's whole - // budget, not one row's: a label that will not fit row three may fit row four. - const budget = HISTORY_ROWS * Math.max(1, columns - 2); - const separate = markers.reduce((width, marker) => width + marker.label.length + 1, 0); - if (markers.length === 0 || separate <= budget) { - return Object.freeze( - markers.map((marker) => Object.freeze({ ...marker, grouped: Object.freeze([]) })), - ); - } - - // A grouped label is its first member's plus a count, so budget for that. - const widest = markers.reduce((width, marker) => Math.max(width, marker.label.length), 1) + 4; - const affordable = Math.max(1, Math.floor(budget / (widest + 1))); - // At least two: reaching here means one-per-group would group nothing and the - // band would overflow exactly as before. - const perGroup = Math.max(2, Math.ceil(markers.length / affordable)); - const placed: ReplPlacedMarker[] = []; - for (let start = 0; start < markers.length; start += perGroup) { - const group = markers.slice(start, start + perGroup); - const label = group.length === 1 ? group[0].label : `${group[0].label} +${group.length - 1}`; - for (const marker of group) { - placed.push( - Object.freeze({ - marker: marker.marker, - label, - grouped: Object.freeze( - group.filter((other) => other !== marker).map((other) => other.marker), - ), - }), - ); - } - } - return Object.freeze(placed); -} - /** - * What the band is called, on the first of its own rows. + * The History band as the frame holds it: five rows of inert runs, and the + * columns its positions landed on. * - * On the band rather than above it, because the footer is seven rows and all - * five of these are the band's: a label given a row of its own would be a row - * taken from the positions it names. It is read, not activated — nothing here - * becomes a control. + * Prepared in `history-rail.ts` from engine measurements and handed here as + * text and roles. Layout places it; it receives no Journal, no session, no + * capability, no mounted focus and no authoritative outcome, and it decides + * nothing about what the band says. */ -export const HISTORY_LABEL = "History ·"; - -function bandRows(markers: readonly ReplPlacedMarker[], columns: number): string[] { - const rows = new Array(HISTORY_ROWS).fill(""); - // The label occupies the first row before any position does, so a position - // that will not fit beside it moves on like any other. - rows[0] = HISTORY_LABEL.length <= columns ? HISTORY_LABEL : ""; - const labels: string[] = []; - for (const marker of markers) { - if (!labels.includes(marker.label)) { - labels.push(marker.label); - } - } - labels.forEach((label, index) => { - const row = index % HISTORY_ROWS; - const next = rows[row] === "" ? label : `${rows[row]} ${label}`; - rows[row] = next.length <= columns ? next : rows[row]; - }); - // Padded to the full width, for the same reason the location rows are: a - // renderer writes what changed, so a band row that got shorter would keep the - // tail of the position that used to be there. - return rows.map((row) => (columns < 1 ? row : row.padEnd(columns, " "))); -} +export type ReplHistoryBand = ReplPreparedRail; /** The one region a refusal has: the whole terminal, holding one sentence. */ export function refusalProps(): ReplBoxProps { diff --git a/packages/cli/src/repl/navigation.ts b/packages/cli/src/repl/navigation.ts new file mode 100644 index 000000000..305a5c84c --- /dev/null +++ b/packages/cli/src/repl/navigation.ts @@ -0,0 +1,204 @@ +/** + * Every position this history retained, and what the execution is doing now. + * + * The one thing on this screen that is *not* read at the selected prefix. A + * reader standing at an earlier position is still owed the whole rail — the + * positions after theirs are where they can go next, and a rail that stopped + * at the selection would be a map of the part of the file they had already + * reached. So navigation is derived from the full validated reading while + * every other pane is derived from the prefix. + * + * ## It carries almost nothing + * + * A marker and a kind. No label, no source, no component or binding name, no + * value, no output, no question, no permission, no Agent detail, no outcome + * and no observation. That is the whole of the exception: a reader at an + * earlier position learns *that* there are later positions and what kind they + * are, and nothing about what they hold. Anything richer would be the future + * leaking into a reading of the past, which is the thing a frozen position + * exists to prevent. + * + * Selection is not here either. Which position is being read belongs to the + * route, and copying it into this summary would make two answers to one + * question. + * + * ## One reading, two projections + * + * Both halves come from one acknowledged event array: the full file for this + * summary, the requested prefix for everything else. Two projections of one + * immutable input cannot disagree about what the file holds, and a navigation + * built by appending to a selected model would be a rail assembled from a + * reading that never saw the rest of the file. + */ + +import type { ReplCheckpoint } from "./model.ts"; + +/** One retained position, as the rail needs to know it. */ +export type ReplNavigationPoint = Pick; + +/** + * What the execution is doing, as the band says it. + * + * Seven states and no eighth. Each is a fact about retained records and about + * work this process actually owns — never about what a body returned, what a + * root Close recorded, what a badge says or what the selected content shows. + */ +export type ReplHeadState = + | "empty" + | "live" + | "pausing" + | "paused" + | "settling" + | "settled" + | "unfinished"; + +/** The whole of what navigation knows. */ +export interface ReplHistoryNavigation { + readonly checkpoints: readonly ReplNavigationPoint[]; + readonly head: ReplHeadState; +} + +/** No history and nothing running. What an execution starts as. */ +export const NO_NAVIGATION: ReplHistoryNavigation = Object.freeze({ + checkpoints: Object.freeze([]), + head: "empty", +}); + +/** + * The thin summary of one validated full reading. + * + * Copied rather than referenced, and frozen item by item: the caller keeps + * this across frames, and a summary sharing structure with the model it came + * from is a summary that changes when the model is reprojected. + */ +export function navigationOf( + checkpoints: readonly ReplCheckpoint[], + head: ReplHeadState, +): ReplHistoryNavigation { + return Object.freeze({ + checkpoints: Object.freeze( + checkpoints.map((one) => Object.freeze({ marker: one.marker, kind: one.kind })), + ), + head, + }); +} + +/** What the head is doing, by the one precedence the band states. */ +export interface ReplHeadFacts { + /** Whether this prefix retained any entry at all. */ + readonly entries: number; + /** Whether the latest entry has a retained root outcome. */ + readonly outcome: boolean; + /** + * Whether this process still owns the entry's work. + * + * True until every resource that entry acquired has been released — its + * outer Agent attachment and its consumers included. A body that returned + * and a root Close that was recorded are both earlier than this, which is + * why neither of them answers it. + */ + readonly working: boolean; + /** Whether the controller this process holds is actually paused. */ + readonly paused: boolean; + /** Whether it is actually pausing. */ + readonly pausing: boolean; +} + +/** + * Which head state those facts are. + * + * In precedence order, because more than one can be true at once and the + * first match is the honest one: + * + * 1. an outcome is retained and the work is still coming down — **settling**, + * which is the window a reader watching a run end is actually in; + * 2. no outcome and the controller is paused — **paused**; + * 3. no outcome and it is pausing — **pausing**, distinct because the holds + * have not all been taken yet; + * 4. work this process owns, and none of the above — **live**; + * 5. nothing retained and nothing owned — **empty**; + * 6. no owned work and the latest entry has an outcome — **settled**, and + * neutrally: a success, a failure and a cancellation all finished; + * 7. no owned work and no outcome — **unfinished**. Never an inferred pause: + * nothing is holding it, it simply stopped. + */ +export function headOf(facts: ReplHeadFacts): ReplHeadState { + if (facts.working) { + if (facts.outcome) { + return "settling"; + } + if (facts.paused) { + return "paused"; + } + if (facts.pausing) { + return "pausing"; + } + return "live"; + } + if (facts.entries === 0) { + return "empty"; + } + return facts.outcome ? "settled" : "unfinished"; +} + +/** What the band says for each state. */ +export const HEAD_LABELS: Readonly> = Object.freeze({ + empty: "HEAD · EMPTY", + live: "HEAD · LIVE", + pausing: "HEAD · PAUSING EXPANSION", + paused: "HEAD · EXPANSION PAUSED", + settling: "HEAD · SETTLING", + settled: "HEAD · SETTLED", + unfinished: "HEAD · UNFINISHED", +}); + +/** + * What one retained position is, said as a category and nothing more. + * + * The drawer's row for a marker. `N` is its one-based ordinal in the full + * list and `E` is the admitted order of the entry it belongs to — both + * counted from the thin list itself, so no row needs a payload to describe + * itself. The wording is the same live and historical: a recorded answer is + * not somebody waiting, an admission is not an invocation that finished, and + * an entry outcome is not cleanup that completed. + */ +export function pointLabel(point: ReplNavigationPoint, ordinal: number, entry: number): string { + switch (point.kind) { + case "entry": + return `${ordinal} · Entry ${entry} admitted`; + case "scope": + return `${ordinal} · Component admitted`; + case "binding": + return `${ordinal} · Bindings recorded`; + case "generated": + return `${ordinal} · Generated XMD admitted`; + case "elicit": + return `${ordinal} · Answer recorded`; + case "agent": + return `${ordinal} · Agent turn recorded`; + case "terminal": + return `${ordinal} · Entry outcome recorded`; + } +} + +/** + * Every retained position with the entry it belongs to. + * + * The entry number is the count of `entry` kinds up to and including this + * point, which is the admitted order — counted from the list rather than + * looked up, because looking it up would mean reading a payload this summary + * deliberately does not carry. + */ +export function numbered(navigation: ReplHistoryNavigation): readonly { + readonly point: ReplNavigationPoint; + readonly ordinal: number; + readonly entry: number; +}[] { + let entry = 0; + return navigation.checkpoints.map((point, index) => { + if (point.kind === "entry") { + entry += 1; + } + return Object.freeze({ point, ordinal: index + 1, entry }); + }); +} diff --git a/packages/cli/src/repl/presentation-style.ts b/packages/cli/src/repl/presentation-style.ts index ad3425d3b..af6d38392 100644 --- a/packages/cli/src/repl/presentation-style.ts +++ b/packages/cli/src/repl/presentation-style.ts @@ -117,7 +117,18 @@ export type ReplPresentationRole = | "lifecycle-waiting" | "lifecycle-exit" | "lifecycle-settled" - | "lifecycle-failed"; + | "lifecycle-failed" + /** The History band: its rule, its title, and what each mark on it is. */ + | "history-rail" + | "history-title" + | "history-entry-earlier" + | "history-tick-earlier" + | "history-minor-earlier" + | "history-entry-later" + | "history-minor-later" + | "history-selected" + | "history-head-live" + | "history-head"; /** * One row's presentation facts, as the reading settles them. @@ -231,6 +242,30 @@ export const REPL_PALETTE = Object.freeze({ railSettled: 0x2e3c35, /** Source nothing has been observed in. Not a phase: an absence of one. */ railPending: 0x252b30, + + /** The horizontal rule the History rail's marks stand on. */ + historyRail: 0x141a1f, + /** What the band calls itself. */ + historyTitle: 0x8b959c, + /** + * A position at or before what is being read, and one after it. + * + * Two categories, each with its own pair: an entry is the coarse mark a + * reader navigates by and a minor position is everything else. "Later" + * means later in the recorded order than the selection — dimmer because it + * is where a reader could go, not where they are. + */ + historyEntryEarlier: 0xb8c4cc, + historyTickEarlier: 0xe6ecf1, + historyMinorEarlier: 0x7fd3e8, + historyEntryLater: 0x5d666e, + historyMinorLater: 0x4e565d, + /** The retained position being read: its diamond, junction and caption. */ + historySelected: 0xc9a86a, + /** The head, when this process is running something. */ + historyHeadLive: 0x7fd3e8, + /** Every other head state, `SELECTED · HEAD`, and the empty message. */ + historyHead: 0x7b858d, }); /** @@ -389,6 +424,26 @@ function colourOf(role: ReplPresentationRole, inspected: boolean): number { return REPL_PALETTE.lifecycleSettled; case "lifecycle-failed": return REPL_PALETTE.lifecycleFailed; + case "history-rail": + return REPL_PALETTE.historyRail; + case "history-title": + return REPL_PALETTE.historyTitle; + case "history-entry-earlier": + return REPL_PALETTE.historyEntryEarlier; + case "history-tick-earlier": + return REPL_PALETTE.historyTickEarlier; + case "history-minor-earlier": + return REPL_PALETTE.historyMinorEarlier; + case "history-entry-later": + return REPL_PALETTE.historyEntryLater; + case "history-minor-later": + return REPL_PALETTE.historyMinorLater; + case "history-selected": + return REPL_PALETTE.historySelected; + case "history-head-live": + return REPL_PALETTE.historyHeadLive; + case "history-head": + return REPL_PALETTE.historyHead; case "source": case "status": case "draft": diff --git a/packages/cli/src/repl/program.ts b/packages/cli/src/repl/program.ts index efe4c0183..b1566b72d 100644 --- a/packages/cli/src/repl/program.ts +++ b/packages/cli/src/repl/program.ts @@ -79,11 +79,12 @@ import { decodeLocation, encodeLocation, resolveLocation } from "./route.ts"; import { replRepository } from "./journal.ts"; import type { ReplExecution } from "./journal.ts"; import { lifecycleRefusal, openReplSession } from "./session.ts"; -import type { ReplSession } from "./session.ts"; +import type { ReplReading, ReplSession } from "./session.ts"; import { useReplFrames } from "./frame.ts"; import type { ReplFrames } from "./frame.ts"; import { committedOps, flatten, profileFor, skeletonOps } from "./layout.ts"; import { fitPlain, prepareReading } from "./fitting.ts"; +import { prepareRail } from "./history-rail.ts"; import { readingLines } from "./source-reading.ts"; import { sourceRuns } from "./presentation-text.ts"; import type { ReplBox, ReplLayoutManifest, ReplRegion } from "./layout.ts"; @@ -415,7 +416,9 @@ function* drive( // One session for the whole command: it owns the file, the observer and each // entry task in turn, so submitting an entry does not replace it. const current: ReplSession = session; - let model: ReplModel = session.model; + // One prepared reading, never a model beside a navigation that arrived + // separately. Everything below assigns both halves together or neither. + let reading: ReplReading = { model: session.model, navigation: session.navigation }; let rendered: ReplRendered | undefined; const outcome = yield* scoped(function* (): Operation> { @@ -480,7 +483,7 @@ function* drive( // no frame and acknowledges nothing — it is a question about geometry. const ready = yield* prepareFrame( renderer, - yield* build(state, model, current, yield* screen.size(), focused), + yield* build(state, reading, current, yield* screen.size(), focused), ); if (!ready.ok) { throw ready.error; @@ -488,14 +491,14 @@ function* drive( const transition = reduceRepl( state, dispatched.value.action, - model, + reading.model, liveOf(current), ready.value.admission, ); state = transition.state; const performed = yield* perform(transition.intent, current, execution, options, wakes); if (performed.submitted === true) { - model = current.model; + reading = { model: current.model, navigation: current.navigation }; // The entry exists now, so the draft that became it is finished. state = admitted(state); } @@ -503,7 +506,7 @@ function* drive( // The question took it, so the drawer that was asking is over. The model // here is the history without this answer in it yet, which is what makes // the record it adds recognisable. - state = answered(state, model, performed.answered); + state = answered(state, reading.model, performed.answered); } if (performed.settled !== undefined) { // The authority answered it, so the request is gone and the drawer over it @@ -530,10 +533,18 @@ function* drive( } let focused: string | undefined; - // Composed per frame: every branch below leaves `state` and `model` such + // Composed per frame: every branch below leaves `state` and `reading` such // that this reproduces the view it resolved, at whatever size the terminal - // turns out to be when the frame is drawn. - const compose: ReplCompose = (at) => build(state, model, current, at, focused); + // turns out to be when the frame is drawn. It recomposes from the reading + // that was resolved, never from the session again: a resize between + // resolution and commit would otherwise pair this content with navigation + // taken from a later snapshot. + let broken: string | undefined; + const compose: ReplCompose = function* (at): Operation { + return broken === undefined + ? yield* build(state, reading, current, at, focused) + : refusedView(state, broken, at, focused); + }; rendered = (yield* paint(frames, tree, renderer, screen, compose)).rendered; focused = keyOfFocus(tree); // The first frame has the same obligation as every other one. @@ -562,7 +573,7 @@ function* drive( break; } if (wake.kind === "session") { - model = current.model; + reading = { model: current.model, navigation: current.navigation }; } else if (wake.event.kind === "eof") { ended = true; } else if (wake.event.kind === "resize") { @@ -652,29 +663,56 @@ function* drive( // filter over the head. Reprojected, then verified — and a route that does // not resolve there leaves the standing one exactly as it was. // - // Resolution settles `state` and `model`; the view itself is composed per - // frame, because a resize between here and the commit rebuilds it. - const attempted = yield* reproject(state, model, execution); + // One reading answers both halves, taken before the size read below it. + // The size read suspends, and a record can arrive while it does: content + // taken before it and navigation taken after it would describe two + // different files on one screen. + const attempted = reproject(state, reading, current); const drawnAt = yield* screen.size(); + // The whole file, including the part before the position asked for, is + // unreadable. There is no prefix for a notice to stand beside, so what + // this draws is the refused view itself — the same screen a cold open + // of the same file shows — and the standing reading is not adopted. + broken = attempted.broken?.message; let adopting = attempted.state; - let built = viewFor(adopting, attempted.model, liveOf(current), drawnAt, focused); - if (!built.ok) { + let candidate = attempted.reading; + let built = + broken !== undefined + ? undefined + : viewFor( + adopting, + candidate.model, + liveOf(current), + drawnAt, + focused, + candidate.navigation, + ); + if (built !== undefined && !built.ok) { // A position earlier than the selected entry's admission has no such // entry in it. The position is what was asked for, so the invalid entry // and scope suffix goes and nothing guesses a replacement — the draft, // the surface, the conversation filter and the marker all stand. - const cleared = withoutAbsentEntry(adopting, attempted.model); + const cleared = withoutAbsentEntry(adopting, candidate.model); if (cleared !== undefined) { - const without = viewFor(cleared, attempted.model, liveOf(current), drawnAt, focused); + const without = viewFor( + cleared, + candidate.model, + liveOf(current), + drawnAt, + focused, + candidate.navigation, + ); if (without.ok) { adopting = cleared; built = without; } } } - if (built.ok) { + if (broken !== undefined) { + state = Object.freeze({ ...standing, refusal: broken }); + } else if (built !== undefined && built.ok) { state = adopting; - model = attempted.model; + reading = candidate; // The readiness may have moved while that refusal was on the screen: the // entry it named has finished, or the teardown it named has completed. // A refusal that is no longer true is worse than no refusal, because it @@ -688,16 +726,15 @@ function* drive( state = Object.freeze({ ...state, refusal: undefined }); refusedByReadiness = false; } - } else { - // The reason the *reprojection* refused, when there was one: it is the - // first thing that went wrong, and the resolution failure below it is a - // consequence of still holding the older reading. - state = Object.freeze({ - ...standing, - refusal: attempted.state.refusal ?? built.error.message, - }); - const back = yield* reproject(state, model, execution); - model = back.model; + } else if (built !== undefined) { + // The route did not resolve at that position. The standing reading is + // kept and the reason is said beside it: unlike the refusal above, + // there *is* a reading to stand beside. + state = Object.freeze({ ...standing, refusal: built.error.message }); + const back = reproject(state, reading, current); + if (back.broken === undefined) { + reading = back.reading; + } } const painted = yield* paint(frames, tree, renderer, screen, compose); rendered = painted.rendered; @@ -756,18 +793,23 @@ function exitLocation(state: ReplState): string { * the reason, so a caller can decide between adopting and restoring rather than * being handed a half-applied navigation. */ -function* reproject( +function reproject( state: ReplState, - model: ReplModel, - execution: ReplExecution, -): Operation<{ readonly state: ReplState; readonly model: ReplModel }> { - if (state.route.at === model.selection) { - return { state, model }; - } - const projected = projectRepl(yield* execution.stream.readAll(), state.route.at); - return projected.ok - ? { state, model: projected.value } - : { state: Object.freeze({ ...state, refusal: projected.error.message }), model }; + reading: ReplReading, + session: ReplSession, +): { + readonly state: ReplState; + readonly reading: ReplReading; + readonly broken: Error | undefined; +} { + const read = session.reading(state.route.at); + // A file this process can no longer read is not a position to carry on at. + // It is returned rather than folded into a route notice, because the two + // are different screens: a notice stands beside a reading, and this has + // none to stand beside. + return read.ok + ? { state, reading: read.value, broken: undefined } + : { state, reading, broken: read.error }; } /** The key of whatever holds focus now, or none. */ @@ -798,12 +840,14 @@ function liveOf(session: ReplSession): ReplLive { /** The view for the state that stands, or a refusal naming why there is none. */ function* build( state: ReplState, - model: ReplModel, + reading: ReplReading, session: ReplSession, size: ReplTerminalSize, focused: string | undefined, ): Operation { - const view = viewFor(state, model, liveOf(session), size, focused); + // Both halves from the reading that was resolved, never from the session + // again. A resize recomposes at a new size, not at a new snapshot. + const view = viewFor(state, reading.model, liveOf(session), size, focused, reading.navigation); return view.ok ? view.value : refusedView(state, view.error.message, size); } @@ -1372,6 +1416,7 @@ function measuringAt( // pass is still asking for would be text fitted to nothing. reading: undefined, preview: undefined, + rail: undefined, }; } @@ -1463,6 +1508,27 @@ export function* prepareFrame( if (!preview.ok) { return preview; } + // The History band, measured against the terminal's own width. Prepared + // beside the reading: where a mark lands is a proportion of the rail's + // measured columns, so the pass that asks how wide the band is cannot + // already have placed anything on it. + // Below the minimum there is no band to measure: that frame is one + // sentence and publishes no footer at all, so preparing a rail for it + // would turn a refusal the product already handles into a measurement + // failure. + const rail = + profileFor(view.size) === "too-small" + ? Ok(undefined) + : yield* prepareRail( + renderer, + view.size, + view.navigation, + view.state.route.at, + view.size.columns, + ); + if (!rail.ok) { + return rail; + } const admission = admissionFor({ view, manifest: measured.manifest, @@ -1480,6 +1546,7 @@ export function* prepareFrame( capture: "capture", reading: reading.value, preview: preview.value, + rail: rail.value, }; return Ok({ context, admission, presentation: presentationFor(view, context) }); } diff --git a/packages/cli/src/repl/session.ts b/packages/cli/src/repl/session.ts index 7f577d388..799f55522 100644 --- a/packages/cli/src/repl/session.ts +++ b/packages/cli/src/repl/session.ts @@ -103,6 +103,8 @@ import { useExpansionController } from "./expansion.ts"; import type { ExpansionController, ExpansionState } from "./expansion.ts"; import type { ReplExecution } from "./journal.ts"; import { entryInitialBindings, projectRepl } from "./model.ts"; +import { headOf, navigationOf, NO_NAVIGATION } from "./navigation.ts"; +import type { ReplHeadState, ReplHistoryNavigation, ReplNavigationPoint } from "./navigation.ts"; import type { ReplEntry, ReplModel } from "./model.ts"; /** A submitted entry this environment cannot run, refused before it is admitted. */ @@ -225,12 +227,81 @@ export interface ExpansionView { readonly states: Stream; } +/** + * One coherent reading of this history at one position. + * + * Both halves from the same acknowledged array: the content the position + * asks for, and the whole file's positions beside it. Handed over together + * so a caller cannot pair one of them with a snapshot the other never saw — + * which is what happens when a frame reads the content, suspends to measure + * the terminal, and then reads the navigation again. + */ +export interface ReplReading { + /** The model of the requested prefix. */ + readonly model: ReplModel; + /** Every position the whole file retained, and what the head is doing. */ + readonly navigation: ReplHistoryNavigation; +} + +/** + * The two projections one position needs, from one array. + * + * Held together as a single `Result` rather than as optional fields beside an + * optional error: the pair either exists or the file could not be read, and a + * shape that can hold neither asks every reader to assert that it does. + */ +interface ReplProjections { + /** The whole validated file, which answers navigation and the head. */ + readonly whole: ReplModel; + /** The requested position, which answers everything else. */ + readonly prefix: ReplModel; +} + +/** + * Project both readings of one acknowledged array. + * + * The prefix is the whole file when no position is asked for, so the common + * case projects once. + */ +function project( + snapshot: readonly DurableEvent[], + at: string | undefined, +): Result { + const whole = projectRepl(snapshot); + if (!whole.ok) { + return whole; + } + if (at === undefined) { + return Ok({ whole: whole.value, prefix: whole.value }); + } + const prefix = projectRepl(snapshot, at); + return prefix.ok ? Ok({ whole: whole.value, prefix: prefix.value }) : prefix; +} + /** One execution as the application reads it. */ export interface ReplSession { /** The opaque identifier this execution's history is named by. */ readonly execution: string; /** The frozen model of the projected prefix, as it stands. */ readonly model: ReplModel; + /** + * Every position the whole history retained, and what the head is doing. + * + * The one reading not taken at the selected prefix. A reader standing at an + * earlier position is owed the whole rail — the positions after theirs are + * where they can go next — and nothing of what those positions hold. It + * carries a marker and a kind, and no payload of any sort. + */ + readonly navigation: ReplHistoryNavigation; + /** + * Both readings at one position, from one acknowledged array. + * + * What the root asks for every time it builds a frame. A later record this + * process can no longer read refuses the whole reading — including for a + * position earlier than the record — because stale navigation standing + * beside a prefix that still looks plausible is the worst of both. + */ + reading(at: string | undefined): Result; /** Everything about this process that the model deliberately does not hold. */ readonly overlay: ReplOverlay; /** What expansion is doing. Always readable, never actionable. */ @@ -471,25 +542,155 @@ function* start( let admitting = true; /** How the last entry to finish finished. */ let finished: Result | undefined; + /** + * Every retained position, taken at the whole file rather than at the + * selected prefix. Rebuilt with the model, so a wake that moves one moves + * both. + */ + let points: readonly ReplNavigationPoint[] = NO_NAVIGATION.checkpoints; + /** Whether the latest entry of the *whole* file has a retained outcome. */ + let outcome = false; + /** How many entries the whole file holds. */ + let admitted = 0; + /** + * The last pair of projections, and the array they were taken from. + * + * Keyed by the length of the acknowledged array and the position asked + * for, which together are the only things that can change what either + * projection says. A failure is memoized too: a file this process cannot + * read does not become readable by being asked again. + */ + let memo: + | { + readonly length: number; + readonly at: string | undefined; + readonly projected: Result; + } + | undefined; + + /** + * Rebuild both readings from one acknowledged array. + * + * The full file answers navigation and the head; the requested prefix + * answers every other pane. Two projections of one immutable input, so + * the rail and the content cannot disagree about what the file holds — + * and the full model is dropped here rather than kept, because nothing + * downstream may reach a later entry through it. + * + * A later malformed segment refuses the whole reading, including for an + * earlier marker: the alternative is stale navigation standing beside a + * prefix that still looks plausible. + */ + function readingAt(at: string | undefined): Result { + // One acknowledged array answers both halves. The stream is the one + // writer, so `retained` is what the file holds; projecting the whole of + // it and the requested prefix from the same slice is what makes the + // rail and the content incapable of describing different files. + // + // Memoized on the length of that array and the position asked for, + // because the root asks for this reading on every wake and a wake is + // usually an overlay moving rather than a record arriving. The head is + // *not* memoized: pausing, resuming and the last of an entry's + // resources being released all change it while the file stands still. + if (memo === undefined || memo.length !== retained.length || memo.at !== at) { + memo = { length: retained.length, at, projected: project(retained.slice(), at) }; + } + if (!memo.projected.ok) { + return memo.projected; + } + const { whole, prefix } = memo.projected.value; + const latest = whole.entries[whole.entries.length - 1]; + return Ok({ + model: prefix, + navigation: Object.freeze({ + checkpoints: navigationOf(whole.checkpoints, "empty").checkpoints, + head: headNow(whole.entries.length, latest?.terminal !== undefined), + }), + }); + } + /** + * Adopt the reading at the opening selection, and announce it. + * + * The session's own published model, for everything that reads + * `session.model` rather than asking for a position. The root does ask, + * which is why this is no longer the only way a reading is built. + */ function reproject(): void { - const next = projectRepl(retained, selection); - if (!next.ok) { + const read = readingAt(selection); + if (!read.ok) { // A history this process wrote and can no longer read is a refusal, // not a stale view: the alternative is a screen that keeps describing - // a prefix while the file has moved past it. - refuse(next.error); + // a prefix while the file has moved past it. Before admission that is + // the opening's answer; after it, the root reads the same failure for + // itself through `reading()` and shows the refused view. + refuse(read.error); + changes.send(model); return; } - model = next.value; + model = read.value.model; + points = read.value.navigation.checkpoints; + // The whole file's own facts, from the same projection the reading above + // came from. Reaching for them separately would be the second snapshot + // this whole change exists to remove. + if (memo !== undefined && memo.projected.ok) { + const { whole } = memo.projected.value; + admitted = whole.entries.length; + outcome = whole.entries[whole.entries.length - 1]?.terminal !== undefined; + } changes.send(model); } + /** + * The whole file's positions, and what the head is doing *now*. + * + * The positions come from the last reprojection, because only an append + * moves them. The head does not: pausing, resuming and the last of an + * entry's resources being released all change it while the file stands + * still. So it is derived on read, from the facts as they are, and the + * wake that publishes it is the one the session and the controller + * already send. + * + * Liveness is `live` — the entry-wide signal that stays true until every + * resource that entry acquired has been released. A body that returned + * and a root Close that was recorded are both earlier than that, which is + * why neither of them is consulted here. + */ + function navigation(): ReplHistoryNavigation { + return Object.freeze({ checkpoints: points, head: headNow(admitted, outcome) }); + } + + /** + * What the head is doing, from the facts as they stand. + * + * The one place the head is derived. Both readings ask here rather than + * each assembling the same five facts: two derivations of one head are + * two things to keep in step, and the owned-work fact — the entry-wide + * signal that stays true until every resource that entry acquired has + * been released — is exactly the one a second copy would be written + * without. + */ + function headNow(entries: number, settled: boolean): ReplHeadState { + return headOf({ + entries, + outcome: settled, + working: live, + paused: expansion.state === "paused", + pausing: expansion.state === "pausing", + }); + } + const session: ReplSession = { execution: execution.id, get model() { return model; }, + get navigation(): ReplHistoryNavigation { + return navigation(); + }, + reading(at: string | undefined): Result { + return readingAt(at); + }, get overlay(): ReplOverlay { return { // The recorded outcome wins the moment there is one: after the root @@ -787,6 +988,10 @@ function* start( // holds. Reprojection reads that acknowledged array rather than // accumulating a second copy of the history beside it. const retained = yield* stream.readAll(); + // The positions this history already holds, before anything is appended. + // `start` validated the same bytes; this derives the thin summary from + // them so a session that never appends still shows its rail. + reproject(); const observe = (event: DurableEvent): void => { retained.push(event); // One transition, and nothing suspends inside it: the record joins the diff --git a/packages/cli/tests/fixtures/repl/issue-881/HistoryRecord.md b/packages/cli/tests/fixtures/repl/issue-881/HistoryRecord.md new file mode 100644 index 000000000..8bd79b3af --- /dev/null +++ b/packages/cli/tests/fixtures/repl/issue-881/HistoryRecord.md @@ -0,0 +1,11 @@ +--- +title: One recorded position +required: [ordinal, secret] +props: + ordinal: + type: number + secret: + type: string +--- + +Record {props.ordinal}. diff --git a/packages/cli/tests/fixtures/repl/issue-881/controlled-host.ts b/packages/cli/tests/fixtures/repl/issue-881/controlled-host.ts new file mode 100644 index 000000000..2c18b5ab5 --- /dev/null +++ b/packages/cli/tests/fixtures/repl/issue-881/controlled-host.ts @@ -0,0 +1,106 @@ +/** + * A REPL host with one installation whose teardown is held open (#881 PR 3). + * + * The `settling-head` state is the window a reader watching a run end is + * actually in: the root has closed, its outcome is recorded, and the process + * still owns what the entry acquired. No public grammar holds that window + * open — an ordinary run passes through it in microseconds — so this host does + * what `holdingTeardown()` does in `repl-entries.test.ts` and waits inside the + * installation's own `ensure`. + * + * What it is not: a copy of the application. The terminal host, the frame + * pipeline, the renderer, the profile and `runReplProgram` are the production + * ones, imported. The only difference from `xmd repl` is one extra entry in + * `installations`, and the latch it waits on is a file this host owns rather + * than a hook in the product. + * + * It assembles no Agent stack, because this state needs none: the entry it + * holds is a `` publication. `xmd repl` itself captures every other + * state in the gallery, including the two paused ones. + * + * XMD881_ENTERED written when the teardown has been entered + * XMD881_RELEASE waited on; the teardown finishes when it appears + */ + +import { ensure, main, sleep } from "effection"; +import type { Operation } from "effection"; +import { exists, writeTextFile } from "@effectionx/fs"; +import process from "node:process"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { compileDataUri } from "@executablemd/core"; +import type { ExecutionInstallation } from "@executablemd/core/host"; + +import { useDenoRepl } from "../../../../src/deno-repl.ts"; +import { runReplProgram } from "../../../../src/repl/program.ts"; +import { ordinaryEvaluationProfile } from "../../../../src/evaluation-profile.ts"; + +/** + * The two latch paths, narrowed once at the edge. + * + * Read and checked here so everything below has two strings rather than two + * possibly-absent ones: asserting them later would be claiming what this + * check is for. + */ +function latches(): { readonly entered: string; readonly release: string } { + const entered = process.env.XMD881_ENTERED; + const release = process.env.XMD881_RELEASE; + if (entered === undefined || release === undefined) { + console.error("controlled-host: set XMD881_ENTERED and XMD881_RELEASE"); + process.exit(2); + } + return { entered, release }; +} + +const LATCH = latches(); + +/** + * One installation that will not finish coming down until it is let go. + * + * The `ensure` is registered by `install`, so it runs when this entry's + * installation scope is released — which is after the entry's outcome has been + * retained and before the entry's work is finished. Writing the latch is how + * the harness knows the window is open; waiting for the other one is what + * keeps it open. + */ +function holdingTeardown(): ExecutionInstallation { + return { + *install(): Operation { + yield* ensure(function* (): Operation { + yield* writeTextFile(LATCH.entered, ""); + while (!(yield* exists(LATCH.release))) { + yield* sleep(100); + } + }); + }, + }; +} + +await main(function* (args) { + yield* API.Env.around( + { + *command([xmdArgs = []]) { + return [process.execPath, "run", "--allow-all", ...xmdArgs]; + }, + *compile([source, options]) { + return yield* compileDataUri(source, options); + }, + }, + { at: "min" }, + ); + yield* useHostFiles(); + yield* useDenoRepl(); + + const ran = yield* runReplProgram({ + profile: { + includes: [process.cwd()], + installations: [{ evaluation: ordinaryEvaluationProfile() }, holdingTeardown()], + permissionMode: "deny-all", + }, + ...(args[0] === undefined ? {} : { location: args[0] }), + }); + if (!ran.ok) { + console.error(`controlled-host: ${ran.error.message}`); + process.exit(1); + } + console.log(`xmd repl '${ran.value.location}'`); +}); diff --git a/packages/cli/tests/fixtures/repl/issue-881/history-crowd.md b/packages/cli/tests/fixtures/repl/issue-881/history-crowd.md new file mode 100644 index 000000000..5ffdbeb0d --- /dev/null +++ b/packages/cli/tests/fixtures/repl/issue-881/history-crowd.md @@ -0,0 +1,221 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/cli/tests/fixtures/repl/presentation.ts b/packages/cli/tests/fixtures/repl/presentation.ts index b548f658a..ed6d96d81 100644 --- a/packages/cli/tests/fixtures/repl/presentation.ts +++ b/packages/cli/tests/fixtures/repl/presentation.ts @@ -709,6 +709,7 @@ export function measuringContext(size: ReplTerminalSize): ReplPresentationContex // it the pass that asks how wide the panes holding them are. reading: undefined, preview: undefined, + rail: undefined, }; } diff --git a/packages/cli/tests/fixtures/repl/surface.ts b/packages/cli/tests/fixtures/repl/surface.ts index ea6c05393..2f9a3ea3e 100644 --- a/packages/cli/tests/fixtures/repl/surface.ts +++ b/packages/cli/tests/fixtures/repl/surface.ts @@ -40,7 +40,6 @@ import { drawerLayerProps, drawerRect, footerProps, - historyBand, inspectionWidth, profileFor, refusalProps, @@ -52,11 +51,12 @@ import { stackProps, viewportProps, } from "../../../src/repl/layout.ts"; +import { blankBand, EMPTY_BAND } from "../../../src/repl/history-rail.ts"; +import { runText } from "../../../src/repl/description.ts"; import type { ReplBox, ReplLayoutManifest, ReplRegion, - ReplSurfaceMarker, ReplViewportSlot, } from "../../../src/repl/layout.ts"; import type { ReplTerminalSize } from "../../../src/repl/terminal.ts"; @@ -263,7 +263,7 @@ export interface ReplFixtureState { readonly scopes: readonly string[]; readonly transcript: readonly string[]; readonly bindings: readonly { readonly name: string; readonly value: string }[]; - readonly history: readonly ReplSurfaceMarker[]; + readonly history: readonly { readonly marker: string; readonly label: string }[]; /** The open drawer's title, or none. */ readonly drawer: string | undefined; /** The drawer's own ordered content, which its window shows part of. */ @@ -407,7 +407,13 @@ export function fixturePairs( }, build(pass) { const profile = profileFor(size); - const band = historyBand(state.history, size.columns); + // The band is prepared by the application from engine measurements, so + // this fixture screen carries an empty one: a double cannot measure, and + // a band it invented would be a band nothing placed. `repl-history` has + // the rail's own tests. + // Blank, at this size. The manifest and the boxes carry the same rows, + // so a test reading either is reading what the other drew. + const band = { ...EMPTY_BAND, rows: blankBand(size.columns) }; if (profile === "too-small") { return { descriptions: [leaf(LINE, "refusal", refusalText(size))], @@ -710,7 +716,8 @@ export function fixturePairs( id: `box:band:${at}`, region: "footer", props: ROW_PROPS, - text: line, + text: runText(line), + runs: line, }), ), }), diff --git a/packages/cli/tests/repl-agent-journey.test.ts b/packages/cli/tests/repl-agent-journey.test.ts index ef4e8c17d..9491e7f70 100644 --- a/packages/cli/tests/repl-agent-journey.test.ts +++ b/packages/cli/tests/repl-agent-journey.test.ts @@ -899,7 +899,11 @@ describe("J3 — durable truth, and a cold process over it", () => { // admission, the turn's publication, and the root settling. yield* click(terminal, "[history]"); yield* settled(40); - for (const marker of ["Entry 1 admitted", "Agent prompt completed", "Settled"]) { + // The categories a retained position reads as. Re-anchored for #881 + // PR 3: a drawer row was the checkpoint's own label and is now its place + // in the recorded order and what kind of position it is — identical + // wording live and historical, so neither route invents a richer one. + for (const marker of ["Entry 1 admitted", "Agent turn recorded", "Entry outcome recorded"]) { expect([marker, shows(terminal, marker)]).toEqual([marker, true]); } @@ -910,11 +914,20 @@ describe("J3 — durable truth, and a cold process over it", () => { expect(shows(terminal, REPLY.trim())).toBe(false); expect(shows(terminal, "ok? ·")).toBe(false); - // The drawer stays open on the position it moved to, and now offers only - // that prefix's markers: at `yield:root:0` the turn had not happened, so - // there is nothing about it to select. + // The drawer stays open on the position it moved to, and offers the + // whole recorded order from there. + // + // Re-anchored for #881 PR 3. This asserted the opposite — that an + // earlier prefix offers only its own share of the positions — and that + // is the premise this slice reverses: the positions *after* the one + // being read are where a reader can go next, so navigation is taken at + // the whole file while content stays at the prefix. The separation is + // still the claim, and it is now the pair below: the later position is + // offered, and nothing it holds is on the screen. expect(historicalOn(terminal)).toBe(true); - expect(shows(terminal, "Agent prompt completed")).toBe(false); + expect(shows(terminal, "Agent turn recorded")).toBe(true); + expect(shows(terminal, REPLY.trim())).toBe(false); + expect(shows(terminal, "completed, recorded")).toBe(false); // Back at the head: the drawer is modal, so it is closed first and then // the live control is reachable. @@ -927,7 +940,7 @@ describe("J3 — durable truth, and a cold process over it", () => { // record carries and nothing live. yield* click(terminal, "[history]"); yield* settled(40); - yield* click(terminal, "Agent prompt completed"); + yield* click(terminal, "Agent turn recorded"); yield* settled(40); yield* showing(terminal, REPLY.trim()); yield* showing(terminal, "completed, recorded"); diff --git a/packages/cli/tests/repl-forms.test.ts b/packages/cli/tests/repl-forms.test.ts index 7699f704e..b4e18fb1f 100644 --- a/packages/cli/tests/repl-forms.test.ts +++ b/packages/cli/tests/repl-forms.test.ts @@ -51,6 +51,7 @@ import type { ReplDescription } from "../src/repl/description.ts"; import { ENTRY_SCOPE, projectRepl } from "../src/repl/model.ts"; import type { ReplElicitation, ReplModel, ReplScope } from "../src/repl/model.ts"; import { useReplTree } from "../src/repl/reconcile.ts"; +import { navigationOf } from "../src/repl/navigation.ts"; import { presentationFor } from "../src/repl/application.ts"; import type { ReplPresentationContext } from "../src/repl/application.ts"; import { commitReplFrame, isStaleFrame } from "../src/repl/program.ts"; @@ -678,7 +679,17 @@ function reading( size = NARROW, focused?: string, ): ReplView { - const resolved = viewFor(state, model, live, size, focused); + // The navigation a session would publish for this reading. The History + // drawer is fed from it rather than from the model's prefix, so a view + // built here has to carry one or the drawer lists nothing. + const resolved = viewFor( + state, + model, + live, + size, + focused, + navigationOf(model.checkpoints, model.settled ? "settled" : "unfinished"), + ); if (!resolved.ok) { throw resolved.error; } diff --git a/packages/cli/tests/repl-history.test.ts b/packages/cli/tests/repl-history.test.ts new file mode 100644 index 000000000..f9ce34a1e --- /dev/null +++ b/packages/cli/tests/repl-history.test.ts @@ -0,0 +1,706 @@ +/** + * The recorded History rail (#881 PR 3, rows H1–H6). + * + * One real layout engine answers every width. Nothing here counts characters: + * where a mark lands is a proportion of the rail's measured *columns*, and a + * column is not a character — a band laid out by `String.length` puts its last + * mark off the end of the pane the first time a caption holds something wider + * than one cell. + * + * What is under test: + * + * - **H1/H2** navigation is the whole file while content is one prefix, and + * nothing of a later position's payload crosses; + * - **H3** the head is derived from retained facts and work this process + * actually owns, in the frozen precedence; + * - **H4** the five rows are allocated by measurement: first and last + * positions, deterministic collisions, the entry/minor hierarchy, caption + * merging, and a head change that moves nothing; + * - **H5** every position is reachable in the drawer, group or not. + */ + +import { beforeAll, describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, scoped, sleep } from "effection"; +import type { Operation, Result } from "effection"; +import { useTempFileCompiler } from "@executablemd/core"; +import { InMemoryStream } from "@executablemd/durable-streams"; + +import { openReplSession } from "../src/repl/session.ts"; +import type { ReplSession } from "../src/repl/session.ts"; +import type { ReplExecution } from "../src/repl/journal.ts"; + +import { + groupsOf, + HISTORY_EMPTY, + HISTORY_ROWS, + HISTORY_TITLE, + prepareRail, + SELECTED_HEAD, +} from "../src/repl/history-rail.ts"; +import { HEAD_LABELS, headOf, navigationOf, numbered, pointLabel } from "../src/repl/navigation.ts"; +import type { ReplHistoryNavigation, ReplHeadState } from "../src/repl/navigation.ts"; +import type { ReplCheckpoint, ReplModel } from "../src/repl/model.ts"; +import { useReplRenderer } from "../src/repl/renderer.ts"; +import type { ReplRenderer } from "../src/repl/renderer.ts"; +import { runText } from "../src/repl/description.ts"; +import type { ReplTerminalSize } from "../src/repl/terminal.ts"; +import { fileURLToPath } from "node:url"; + +const WIDE: ReplTerminalSize = { columns: 160, rows: 36 }; +const MEDIUM: ReplTerminalSize = { columns: 120, rows: 30 }; + +/** Where the crowded-order fixture's component is looked for. */ +const CROWD = fileURLToPath(new URL("./fixtures/repl/issue-881/", import.meta.url)); + +/** + * One entry that admits this many component scopes, in recorded order. + * + * Each invocation admits a scope and each admitted scope is one retained + * position, so this is how many positions the rail has to place. Built here + * rather than read from `history-crowd.md` so a case can say how many it + * needs; the fixture file is the same shape at two hundred. + */ +function crowded(count: number): string { + return ( + Array.from( + { length: count }, + (_, at) => ``, + ).join("\n") + "\n" + ); +} + +/** How many component scopes one reading's transcript actually admits. */ +function admits(model: ReplModel): number { + return model.transcript.filter((row: { readonly kind: string }) => row.kind === "scope").length; +} + +function execution(): ReplExecution { + return { id: "history", stream: new InMemoryStream([]) }; +} + +function opened(result: Result): ReplSession { + if (!result.ok) { + throw result.error; + } + return result.value; +} + +function accepted(result: Result): void { + if (!result.ok) { + throw result.error; + } +} +const NARROW: ReplTerminalSize = { columns: 72, rows: 20 }; + +function withRenderer(body: (renderer: ReplRenderer) => Operation): Operation { + return scoped(function* () { + return yield* body(yield* useReplRenderer(WIDE)); + }); +} + +/** A history of the given kinds, in recorded order. */ +function history( + kinds: readonly ReplCheckpoint["kind"][], + head: ReplHeadState = "settled", +): ReplHistoryNavigation { + return navigationOf( + kinds.map((kind, index) => ({ marker: `m${index + 1}`, kind, label: "unused" })), + head, + ); +} + +/** The text of one prepared row, rails and all. */ +function rowText(rail: { readonly rows: readonly (readonly { text: string }[])[] }, row: number) { + return runText(rail.rows[row] as never); +} + +describe("H4 — the five rows are allocated by measurement", () => { + it("puts the first position at the start and the last at the end", () => + withRenderer(function* (renderer) { + const prepared = yield* prepareRail( + renderer, + WIDE, + history(["entry", "binding", "terminal"]), + undefined, + 120, + ); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + return; + } + const { groups, reservation } = prepared.value; + expect(groups.length).toBe(3); + expect(groups[0].column).toBe(0); + expect(groups[groups.length - 1].column).toBe(reservation.rail - 1); + // Spread between them, not bunched at either end. + expect(groups[1].column).toBeGreaterThan(0); + expect(groups[1].column).toBeLessThan(reservation.rail - 1); + })); + + it("puts a lone position where the head is", () => + withRenderer(function* (renderer) { + const prepared = yield* prepareRail(renderer, WIDE, history(["entry"]), undefined, 120); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + return; + } + expect(prepared.value.groups[0].column).toBe(prepared.value.reservation.rail - 1); + })); + + it("groups collisions deterministically, and keeps every member", () => + withRenderer(function* (renderer) { + // Forty positions into a rail far narrower than forty columns. + const kinds: ReplCheckpoint["kind"][] = Array.from({ length: 40 }, (_, at) => + at % 5 === 0 ? "entry" : "binding", + ); + const crowded = history(kinds); + const first = yield* prepareRail(renderer, NARROW, crowded, undefined, 60); + const again = yield* prepareRail(renderer, NARROW, crowded, undefined, 60); + expect(first.ok && again.ok).toBe(true); + if (!first.ok || !again.ok) { + return; + } + // Same count and same measured size, same groups: grouping is a + // function of those two and of nothing else. + expect(again.value.groups.map((one) => one.column)).toEqual( + first.value.groups.map((one) => one.column), + ); + expect(first.value.groups.length).toBeLessThan(40); + // Not one position was dropped to make them fit. + const members = first.value.groups.flatMap((one) => one.ordinals); + expect(members.length).toBe(40); + expect(new Set(members).size).toBe(40); + // Sorted through a copy rather than with `toSorted`: the Node + // typecheck's `lib` is ES2022, and `toSorted` is ES2023. + expect([...members].sort((a, b) => a - b)).toEqual( + Array.from({ length: 40 }, (_, at) => at + 1), + ); + })); + + it("keeps the entry's tall mark and the minor's stem when they share a column", () => + withRenderer(function* (renderer) { + // Far more positions than the rail has columns, alternating, so an + // entry and a minor one are bound to land together. + const crowded = history( + Array.from({ length: 200 }, (_, at) => (at % 2 === 0 ? "entry" : "binding")), + ); + const prepared = yield* prepareRail(renderer, NARROW, crowded, undefined, NARROW.columns); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + return; + } + // Everywhere but the rail's end, where the head's own marker wins the + // cell below — a distinctive marker takes its cell and the category it + // covers stays readable in the junction, the caption and the drawer. + const end = prepared.value.reservation.rail - 1; + const both = prepared.value.groups.filter( + (one) => one.entry && one.minor && one.column !== end, + ); + expect(both.length).toBeGreaterThan(0); + for (const group of both) { + // The upriser above, the stem below, and a heavier junction between. + expect([group.column, rowText(prepared.value, 2)[group.column]]).toEqual([ + group.column, + "┃", + ]); + expect([group.column, rowText(prepared.value, 4)[group.column]]).toEqual([ + group.column, + "│", + ]); + expect([group.column, rowText(prepared.value, 3)[group.column]]).toEqual([ + group.column, + "┳", + ]); + } + })); + + it("merges entry captions into the span they actually cover", () => + withRenderer(function* (renderer) { + // Eight entries in a rail with nowhere near room for eight captions. + const prepared = yield* prepareRail( + renderer, + NARROW, + history(Array.from({ length: 8 }, () => "entry" as const)), + undefined, + NARROW.columns, + ); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + return; + } + const captions = rowText(prepared.value, 1); + // Merged, and only into ranges that exist: every number named is an + // entry this history has. + for (const [, from, to] of captions.matchAll(/Entry (\d+)(?:–(\d+))?/g)) { + expect(Number(from)).toBeGreaterThanOrEqual(1); + expect(Number(from)).toBeLessThanOrEqual(8); + if (to !== undefined) { + expect(Number(to)).toBeLessThanOrEqual(8); + expect(Number(to)).toBeGreaterThan(Number(from)); + } + } + // Nothing clipped: no caption ends with a half-written number. + expect(captions).not.toMatch(/Entry \d+–$/); + // And none of them reached into the reserved right column. + const rail = prepared.value.reservation.rail; + expect(captions.slice(rail).trim()).toBe(SELECTED_HEAD); + })); + + it("reserves the same right column for every head state", () => + withRenderer(function* (renderer) { + const kinds: ReplCheckpoint["kind"][] = ["entry", "binding", "terminal"]; + const reservations: number[] = []; + const rails: string[] = []; + for (const head of Object.keys(HEAD_LABELS) as ReplHeadState[]) { + const prepared = yield* prepareRail(renderer, WIDE, history(kinds, head), undefined, 120); + expect([head, prepared.ok]).toEqual([head, true]); + if (!prepared.ok) { + return; + } + reservations.push(prepared.value.reservation.right); + rails.push(rowText(prepared.value, 3).slice(0, prepared.value.reservation.rail)); + } + // One reservation, so a head change re-cuts nothing. + expect(new Set(reservations).size).toBe(1); + // And the rail itself is identical under every head. + expect(new Set(rails).size).toBe(1); + })); + + it("fits its title and its longest head at 72 columns, with rail left over", () => + withRenderer(function* (renderer) { + const prepared = yield* prepareRail( + renderer, + NARROW, + history(["entry", "terminal"], "pausing"), + undefined, + NARROW.columns, + ); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + return; + } + expect(rowText(prepared.value, 0)).toContain(HISTORY_TITLE); + expect(rowText(prepared.value, 0)).toContain(HEAD_LABELS.pausing); + expect(prepared.value.reservation.rail).toBeGreaterThan(0); + // Five rows, every one the band's full width. + expect(prepared.value.rows.length).toBe(HISTORY_ROWS); + for (const row of prepared.value.rows) { + expect(runText(row).length).toBe(NARROW.columns); + } + })); + + it("keeps the diamond above and the head marker below on one column", () => + withRenderer(function* (renderer) { + // One position: it lands at the rail's end, which is where the head is. + const prepared = yield* prepareRail(renderer, WIDE, history(["entry"]), "m1", 120); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + return; + } + const column = prepared.value.reservation.rail - 1; + expect(rowText(prepared.value, 2)[column]).toBe("◆"); + expect(rowText(prepared.value, 4)[column]).toBe("▼"); + // Two markers, two colours: neither borrowed the other's cell. + const above = prepared.value.rows[2].find((run) => run.text.includes("◆")); + const below = prepared.value.rows[4].find((run) => run.text.includes("▼")); + expect(above?.token).toBe("history-selected"); + expect(below?.token).not.toBe("history-selected"); + })); + + it("says so, and invents no mark, where nothing is recorded", () => + withRenderer(function* (renderer) { + const prepared = yield* prepareRail(renderer, WIDE, history([], "empty"), undefined, 120); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + return; + } + expect(prepared.value.groups).toEqual([]); + expect(rowText(prepared.value, 1)).toContain(HISTORY_EMPTY); + expect(rowText(prepared.value, 0)).toContain(HEAD_LABELS.empty); + // No rule, no tick, no head marker: an empty reading has a label and + // nothing to point at. + expect(rowText(prepared.value, 2).trim()).toBe(""); + expect(rowText(prepared.value, 3).trim()).toBe(""); + expect(rowText(prepared.value, 4).trim()).toBe(""); + })); + + it("refuses rather than overwriting when the band cannot hold its title", () => + withRenderer(function* (renderer) { + // Below the title plus the reserved head column, which is the band's + // own minimum rather than a number this test chose. + const prepared = yield* prepareRail(renderer, NARROW, history(["entry"]), undefined, 40); + expect(prepared.ok).toBe(false); + })); +}); + +/** `headOf` over one neutral baseline, so each case names only its own fact. */ +const facts = (over: Partial[0]>) => + headOf({ entries: 1, outcome: false, working: false, paused: false, pausing: false, ...over }); + +describe("H3 — the head is derived from retained facts and owned work", () => { + it("takes the frozen precedence, and never infers a pause", function* () { + // 1 settling beats everything: an outcome is retained and the work this + // process owns has not come down yet. + expect(facts({ outcome: true, working: true, paused: true, pausing: true })).toBe("settling"); + expect(facts({ working: true, paused: true, pausing: true })).toBe("paused"); + expect(facts({ working: true, pausing: true })).toBe("pausing"); + expect(facts({ working: true })).toBe("live"); + expect(facts({ entries: 0 })).toBe("empty"); + expect(facts({ outcome: true })).toBe("settled"); + // No outcome and nothing holding it: stopped, not paused. + expect(facts({})).toBe("unfinished"); + // An empty history with work already owned is live, not empty: the task + // exists before its first record does. + expect(facts({ entries: 0, working: true })).toBe("live"); + }); + + it("is neutral about how a settled entry settled", function* () { + // Success, failure and cancellation are all finished. The band says so + // once; which of them it was belongs to the reading, not to the head. + for (const _ of ["ok", "err", "cancelled"]) { + expect( + headOf({ entries: 1, outcome: true, working: false, paused: false, pausing: false }), + ).toBe("settled"); + } + }); +}); + +describe("H3 — the head a real session publishes", () => { + beforeAll(() => useTempFileCompiler()); + + it("is empty before an entry, live while one runs, settled once it has", function* () { + const session = opened(yield* openReplSession({ execution: execution() })); + // Nothing retained and nothing owned. + expect(session.navigation.head).toBe("empty"); + expect(session.navigation.checkpoints).toEqual([]); + + accepted(yield* session.submit('\n')); + // Owned work, from before its first record exists. + expect(session.navigation.head).toBe("live"); + + yield* session.join(); + yield* sleep(0); + // The outcome is retained and nothing of the entry is still standing. + expect(session.navigation.head).toBe("settled"); + expect(session.navigation.checkpoints.map((one) => one.kind)).toContain("entry"); + expect(session.navigation.checkpoints.map((one) => one.kind)).toContain("terminal"); + }); + + it("is SETTLING while the outcome is retained and the entry is still coming down", function* () { + // The window a reader watching a run end is actually in: the root closed, + // and this process still owns what the entry acquired. A head taken from + // the root Close alone would call this finished. + let duringRelease: string | undefined; + let held: ReplSession | undefined; + const session = opened( + yield* openReplSession({ + execution: execution(), + installations: [ + { + *install(): Operation { + yield* ensure(() => { + duringRelease = held?.navigation.head; + }); + }, + }, + ], + }), + ); + held = session; + accepted(yield* session.submit('\n')); + yield* session.join(); + yield* sleep(0); + // Read at the moment this entry's own installation was released, which + // is after its outcome was recorded and before its work was finished. + expect(duringRelease).toBe("settling"); + // And neutral once it is down, however it went. + expect(session.navigation.head).toBe("settled"); + }); + + it("carries no payload from the entry it is describing", function* () { + const session = opened(yield* openReplSession({ execution: execution() })); + accepted(yield* session.submit('\n')); + yield* session.join(); + yield* sleep(0); + expect(JSON.stringify(session.navigation)).not.toContain("shibboleth"); + for (const point of session.navigation.checkpoints) { + expect(Object.keys(point).sort()).toEqual(["kind", "marker"]); + } + }); +}); + +describe("H1/H2 — the whole order, and nothing of what it holds", () => { + beforeAll(() => useTempFileCompiler()); + + it("offers later positions from an earlier one, with the prefix's content", function* () { + const holder = execution(); + const first = opened(yield* openReplSession({ execution: holder })); + accepted(yield* first.submit('\n')); + yield* first.join(); + const early = first.navigation.checkpoints[0].marker; + accepted(yield* first.submit('\n')); + yield* first.join(); + yield* sleep(0); + const whole = first.navigation.checkpoints.length; + expect(whole).toBeGreaterThan(2); + + // Reopened at the earliest position. Its content is that prefix's — the + // second entry had not happened — and its navigation is the whole + // file's, because the positions after it are where a reader can go. + const inspecting = opened(yield* openReplSession({ execution: holder, selection: early })); + expect(inspecting.model.entries.length).toBe(1); + expect(inspecting.navigation.checkpoints.length).toBe(whole); + // The content channel is live and is the prefix's: it carries the first + // entry's payload and not the second's. Against that, navigation holds + // neither — it is an order, not a reading. + const content = JSON.stringify(inspecting.model); + expect(content).toContain("early"); + expect(content).not.toContain("late"); + const order = JSON.stringify(inspecting.navigation); + expect(order).not.toContain("early"); + expect(order).not.toContain("late"); + }); + + it("refuses the whole reading once an admitted session can no longer read its file", function* () { + const holder = execution(); + const session = opened(yield* openReplSession({ execution: holder })); + accepted(yield* session.submit('\n')); + yield* session.join(); + yield* sleep(0); + const early = session.navigation.checkpoints[0].marker; + const before = session.model; + expect(session.reading(early).ok).toBe(true); + expect(session.reading(undefined).ok).toBe(true); + + // A duplicate of this entry's own last record: real event data this + // process wrote, in a sequence nothing can read. This is the case a cold + // reopen cannot reach — the session is already admitted, and the question + // is what an admitted session does when its own file stops being readable. + const events = yield* holder.stream.readAll(); + yield* holder.stream.append(events[events.length - 1]); + yield* sleep(0); + + // Both readings refuse, including the one for a position recorded long + // before the record that broke it: a prefix that still looks plausible + // beside navigation nothing can build is the worst of both. + expect(session.reading(early).ok).toBe(false); + expect(session.reading(undefined).ok).toBe(false); + // And nothing was published in the meantime. The old model is still the + // old model — it was not quietly refreshed from a file this session can + // no longer read. + expect(session.model).toBe(before); + }); + + it("answers both halves of one position from one acknowledged array", function* () { + const holder = execution(); + const session = opened(yield* openReplSession({ execution: holder })); + accepted(yield* session.submit('\n')); + yield* session.join(); + yield* sleep(0); + const early = session.navigation.checkpoints[0].marker; + + const before = session.reading(early); + if (!before.ok) { + throw before.error; + } + accepted(yield* session.submit('\n')); + yield* session.join(); + yield* sleep(0); + const after = session.reading(early); + if (!after.ok) { + throw after.error; + } + + // The same position, read either side of an append. Content is the + // prefix's both times and does not move; navigation is the whole file's + // both times and does. What makes this one reading rather than two is + // that the second pair agrees with itself: the positions it offers are + // the file as it stands now, and the content is that same file read at + // the earlier marker. + expect(after.value.model.entries.length).toBe(before.value.model.entries.length); + expect(after.value.navigation.checkpoints.length).toBeGreaterThan( + before.value.navigation.checkpoints.length, + ); + expect(JSON.stringify(after.value.model)).not.toContain("second"); + expect(after.value.navigation.checkpoints.at(-1)?.marker).toBe( + session.navigation.checkpoints.at(-1)?.marker, + ); + }); + + it("refuses the whole reading when a later segment is malformed", function* () { + const holder = execution(); + const first = opened(yield* openReplSession({ execution: holder })); + accepted(yield* first.submit('\n')); + yield* first.join(); + yield* sleep(0); + const early = first.navigation.checkpoints[0].marker; + + // A record nothing can read, appended after the position being asked + // for. Reopening at that earlier marker must refuse: a plausible prefix + // beside navigation that cannot be built is the worst of both. + const events = yield* holder.stream.readAll(); + // The control: the same reconstruction, minus the bad record, opens at + // this very marker. So the refusal below is the record's, not the + // reconstruction's. + const rebuilt = { id: "history", stream: new InMemoryStream([...events]) }; + expect((yield* openReplSession({ execution: rebuilt, selection: early })).ok).toBe(true); + const corrupted = { + id: "history", + stream: new InMemoryStream([ + ...events, + { type: "nonsense-this-reader-cannot-validate" } as never, + ]), + }; + const reopened = yield* openReplSession({ execution: corrupted, selection: early }); + expect(reopened.ok).toBe(false); + }); +}); + +describe("H1 — two positions the rail draws as one mark select different content", () => { + beforeAll(() => useTempFileCompiler()); + + it("gives each member of a shared column its own exact prefix", () => + withRenderer(function* (renderer) { + // Enough component admissions that the rail has fewer columns than it + // has positions at every measured width, so sharing a column is forced + // rather than arranged. + const session = opened(yield* openReplSession({ execution: execution(), includes: [CROWD] })); + accepted(yield* session.submit(crowded(200))); + yield* session.join(); + yield* sleep(0); + const navigation = session.navigation; + expect(navigation.checkpoints.length).toBeGreaterThan(200); + + // A group holding two *consecutive* positions of the same category, at + // all three measured widths. Found rather than assumed: which pairs + // round onto one column is the layout's answer, not this test's. + const sizes: readonly (readonly [ReplTerminalSize, number])[] = [ + [WIDE, 160], + [MEDIUM, 120], + [NARROW, 72], + ]; + // The pairs that share a column at *every* width, intersected rather + // than carried over from the first: which adjacent positions round onto + // one column is a function of that width, and a pair that shares at one + // is not guaranteed to share at another. + let shared: readonly (readonly [number, number])[] | undefined; + for (const [size, width] of sizes) { + const rail = yield* prepareRail(renderer, size, navigation, undefined, width); + if (!rail.ok) { + throw rail.error; + } + const pairs = rail.value.groups + .flatMap((group) => { + const ordinals = [...group.ordinals].sort((a, b) => a - b); + return ordinals.flatMap((ordinal, at) => + at + 1 < ordinals.length && ordinals[at + 1] === ordinal + 1 + ? [[ordinal, ordinal + 1] as const] + : [], + ); + }) + .filter( + ([first, second]) => + navigation.checkpoints[first - 1]?.kind === "scope" && + navigation.checkpoints[second - 1]?.kind === "scope", + ); + const standing = shared; + shared = + standing === undefined + ? pairs + : pairs.filter(([first, second]) => + standing.some(([one, two]) => one === first && two === second), + ); + if (shared.length === 0) { + throw new Error( + `no consecutive component pair shares a column at every width up to ${width}`, + ); + } + } + // One mark on the screen, two positions behind it — and each is its own + // reading. The discriminator is the component admitted *between* them: + // the later prefix admits exactly one more scope than the earlier. + const [first, second] = (shared ?? [])[0] ?? [0, 0]; + expect(first).toBeGreaterThan(0); + const earlier = session.reading(navigation.checkpoints[first - 1].marker); + const later = session.reading(navigation.checkpoints[second - 1].marker); + if (!earlier.ok) { + throw earlier.error; + } + if (!later.ok) { + throw later.error; + } + expect(navigation.checkpoints[first - 1].marker).not.toBe( + navigation.checkpoints[second - 1].marker, + ); + expect(admits(later.value.model) - admits(earlier.value.model)).toBe(1); + // The content really is there to differ about: this is not two readings + // of nothing agreeing that nothing changed. + expect(admits(earlier.value.model)).toBeGreaterThan(0); + // And both keep the whole later order, which is what a group member is + // owed: the positions after it are where its reader can still go. + expect(earlier.value.navigation.checkpoints.length).toBe(navigation.checkpoints.length); + expect(later.value.navigation.checkpoints.length).toBe(navigation.checkpoints.length); + })); +}); + +describe("H1/H5 — every retained position keeps its own identity", () => { + it("says what each kind is, with its full ordinal and entry number", function* () { + const navigation = history([ + "entry", + "scope", + "binding", + "generated", + "elicit", + "agent", + "terminal", + "entry", + "terminal", + ]); + expect( + numbered(navigation).map((one) => pointLabel(one.point, one.ordinal, one.entry)), + ).toEqual([ + "1 · Entry 1 admitted", + "2 · Component admitted", + "3 · Bindings recorded", + "4 · Generated XMD admitted", + "5 · Answer recorded", + "6 · Agent turn recorded", + "7 · Entry outcome recorded", + "8 · Entry 2 admitted", + "9 · Entry outcome recorded", + ]); + }); + + it("keeps a grouped position selectable as itself", function* () { + // Two positions on one column: the group is how they are drawn and never + // how they are counted, so each keeps its own ordinal and marker. + const navigation = history(["binding", "binding"]); + const groups = groupsOf(navigation, undefined, 1); + expect(groups.length).toBe(1); + expect(groups[0].ordinals).toEqual([1, 2]); + expect(groups[0].members.map((one) => one.marker)).toEqual(["m1", "m2"]); + }); + + it("carries no payload at all", function* () { + const navigation = navigationOf( + [{ marker: "m1", kind: "binding", label: 'tokens = { name: "Northstar" }' }], + "settled", + ); + // The label the checkpoint had does not travel: a reader at an earlier + // position learns that a later one exists and what kind it is, and + // nothing about what it holds. + expect(Object.keys(navigation.checkpoints[0])).toEqual(["marker", "kind"]); + expect(JSON.stringify(navigation)).not.toContain("Northstar"); + }); + + it("colours each category by its own members, not by the group", function* () { + // A group holding an entry the reader has passed and a minor position + // they have not. Saying that with one colour would lose half of it. + const navigation = history(["entry", "binding"]); + const groups = groupsOf(navigation, "m1", 1); + expect(groups.length).toBe(1); + expect(groups[0].entryEarlier).toBe(true); + expect(groups[0].minorEarlier).toBe(false); + }); +}); diff --git a/packages/cli/tests/repl-journey.test.ts b/packages/cli/tests/repl-journey.test.ts index b942b5740..3368d6d8a 100644 --- a/packages/cli/tests/repl-journey.test.ts +++ b/packages/cli/tests/repl-journey.test.ts @@ -56,12 +56,13 @@ import type { ReplOutcome } from "../src/repl/program.ts"; import { parseDurableEvent, serializeDurableEvent } from "@executablemd/durable-streams"; import { drawerRect, + FOOTER_ROWS, HISTORY_ROWS, - HISTORY_LABEL, inspectionWidth, NARROW, sidebarWidth, } from "../src/repl/layout.ts"; +import { HISTORY_TITLE } from "../src/repl/history-rail.ts"; import { projectRepl } from "../src/repl/model.ts"; import type { ReplModel } from "../src/repl/model.ts"; import { decodeLocation, encodeLocation } from "../src/repl/route.ts"; @@ -599,7 +600,11 @@ function focusedOn(terminal: Terminal, label: string): boolean { // focused and the one being read, and the two markers are independent. const after = line.slice(at + 1).trimStart(); const beyond = after.startsWith("* ") ? after.slice(2) : after; - if (after.startsWith(label) || beyond.startsWith(label)) { + // And past a History row's own ordinal. #881 PR 3 gives every retained + // position its number in the full list — `12 · Entry 3 admitted` — which + // is another prefix the product puts there, like the two above. + const named = beyond.replace(/^\d+ \u00b7 /, ""); + if (after.startsWith(label) || beyond.startsWith(label) || named.startsWith(label)) { return true; } } @@ -2621,7 +2626,7 @@ describe("REPL journey: resizing while a frame is being prepared", () => { // A whole frame again, not a fragment: the footer's own rows are all back, // which a narrow frame draws beneath the one outlet it routes. expect(settledScreen.some((line) => line.includes("[history]"))).toBe(true); - expect(settledScreen.some((line) => line.includes(HISTORY_LABEL))).toBe(true); + expect(settledScreen.some((line) => line.includes(HISTORY_TITLE))).toBe(true); expect((execution ?? "").length).toBeGreaterThan(0); terminal.end(); @@ -4160,6 +4165,449 @@ const DRAFTED = "keep this draft"; */ const READING = "2. [unfinished] entry-2"; +/** Two more sources, so there are more positions than a narrow drawer holds. */ +const COLD_THREE = [ + "```js eval", + "const onward = `${carried}-again`;", + "```", + "", + "Three: {onward}", + "", +].join("\n"); + +const COLD_FOUR = [ + "```js eval", + "const onto = `${onward}-once-more`;", + "```", + "", + "Four: {onto}", + "", +].join("\n"); + +/** + * What each category of position is called, written out here on purpose. + * + * A second opinion about the wording. Reading it from `pointLabel()` would make + * the comparison below say only that one function agrees with itself, and the + * claim is that the drawer offers *these* words — a category and an order, with + * nothing of the payload in them. + */ +const CATEGORIES: Readonly> = Object.freeze({ + entry: "Entry %E admitted", + scope: "Component admitted", + binding: "Bindings recorded", + generated: "Generated XMD admitted", + elicit: "Answer recorded", + agent: "Agent turn recorded", + terminal: "Entry outcome recorded", +}); + +/** + * Every row this model's positions should put in the drawer, in order. + * + * Built from the projected checkpoints' kinds alone: the ordinal is the position + * in the recorded order and the entry number is how many entry admissions have + * been passed, both counted here rather than read out of the thing under test. + */ +function expectedRows(model: ReplModel): string[] { + let entry = 0; + return model.checkpoints.map((checkpoint, index) => { + if (checkpoint.kind === "entry") { + entry += 1; + } + const words = CATEGORIES[checkpoint.kind]; + if (words === undefined) { + throw new Error(`no category is written down for ${checkpoint.kind}`); + } + return `${index + 1} · ${words.replace("%E", String(entry))}`; + }); +} + +/** + * The band's own five rows, at the size the terminal is now. + * + * By placement, out of the footer's fixed seven rows: one action row, five band + * rows, one draft row. Not by the title the band writes — the guidance row says + * "History" at a frozen position (#870 UI15), and a walk that aimed at that word + * would read the sentence about the position instead of the rail. + */ +function bandOf(terminal: Terminal): string[] { + return bandIn(screenOf(terminal), terminal.size); +} + +/** + * The same five rows, out of rows a caller has already chosen. + * + * What a claim about one size needs: this renderer writes diffs, so the whole + * accumulated buffer still holds the rows the size before it drew, and the + * footer of a 36-row screen is not where a 20-row screen keeps its band. + */ +function bandIn(rows: readonly string[], size: ReplTerminalSize): string[] { + const actions = size.rows - FOOTER_ROWS; + return rows.slice(actions + 1, actions + 1 + HISTORY_ROWS).map((row) => row.trimEnd()); +} + +/** + * The band this terminal draws at one size, read from that size's own frames. + * + * Resizes, waits for a whole frame of the new size, and reads the band out of + * it. The wait is for the rows the band itself writes: a frame counted before + * it was finished would answer with a half-drawn rail. + */ +function* bandAt(terminal: Terminal, size: ReplTerminalSize): Operation { + if (terminal.size.columns === size.columns && terminal.size.rows === size.rows) { + // Already there. A window told it is the size it is draws nothing, so a + // wait for a frame of this size would wait for one nothing will send. + return bandOf(terminal); + } + const from = terminal.presented.length; + terminal.resized(size); + yield* until_(terminal, `the whole frame at ${size.columns}x${size.rows}`, (one) => { + if (one.presented.length <= from) { + return false; + } + const shown = replay(one.presented.slice(from)); + return ( + shown.length === size.rows && bandIn(shown, size).some((row) => row.includes(HISTORY_TITLE)) + ); + }); + return bandIn(replay(terminal.presented.slice(from)), size); +} + +describe("REPL journey: every recorded position, and every admitted size", () => { + it("H5: the drawer reaches them all, and four resizes keep the reading", function* () { + const { terminal, install } = recordingTerminal({ columns: 160, rows: 36 }); + let listed: string[] = []; + let ended: ReplOutcome | undefined; + + yield* scoped(function* (): Operation { + yield* install(); + yield* immediateClock(); + yield* useTempFileCompiler(); + const root = yield* useTemporaryHost(); + + const running = yield* spawn(function* (): Operation { + const ran = yield* runReplProgram({ profile: PROFILE }); + if (!ran.ok) { + throw ran.error; + } + ended = ran.value; + }); + yield* untilDrawn(terminal); + + // Three entries, so the recorded order is longer than one drawer window: + // reaching the end of it is then something the window controls have to do + // rather than something the first frame already did. + yield* submitted(terminal, COLD_ONE); + yield* settledEntry(terminal, 1, "ok"); + yield* submitted(terminal, COLD_TWO); + yield* settledEntry(terminal, 2, "ok"); + yield* submitted(terminal, COLD_THREE); + yield* settledEntry(terminal, 3, "ok"); + yield* submitted(terminal, COLD_FOUR); + yield* settledEntry(terminal, 4, "ok"); + + const model = yield* projectionOf(root, (yield* histories(root))[0]); + const expected = expectedRows(model); + expect(expected.length).toBeGreaterThan(8); + + // 1. The exact set, in the recorded order, reached through `[↓ later]`. + yield* activate(terminal, "[history]"); + expect(shows(terminal, "[close]")).toBe(true); + // Wide enough to hold them all, so this is the whole exact set in one + // window. That the window *controls* reach the rest of a set too long for + // one is step 7's claim, at a size where it really is too long. + expect((yield* drawerContent(terminal)).reached).toEqual(expected); + + // 2. What is visible holds focus, and the cell it is drawn in is its own + // target. Checked over the window as it stands, row by row, because a + // row drawn outside the rectangle the targets were published for is + // exactly the defect: visible, unfocusable, and answering a pointer + // with whatever is behind it. + const box = drawerBox(terminal.size); + const window = drawerMarkers(terminal); + // A walk over nothing would agree with every claim below it. + expect(window.length).toBeGreaterThan(0); + for (const row of window) { + yield* focusOn(terminal, row); + expect(focusedOn(terminal, row)).toBe(true); + const at = coordinateOf(terminal, row); + if (at === undefined) { + throw new Error(`${row} holds focus and is not on the screen`); + } + expect([row, at.row >= box.top && at.row < box.bottom]).toEqual([row, true]); + expect([row, at.column >= box.left && at.column < box.right]).toEqual([row, true]); + } + + // 3. One of those rows, chosen by pointing at it, is the position it + // names and not the group the rail draws it in. + const last = drawerMarkers(terminal)[drawerMarkers(terminal).length - 1]; + yield* click(terminal, last); + yield* until_(terminal, "a frozen position", (one) => historicalOn(one)); + terminal.feed("\x1b"); + yield* until_(terminal, "the drawer closing", (one) => !shows(one, "[close]")); + + // 4. A whole reading to carry across the sizes: an entry selected, that + // position being read, and a draft nobody has submitted. + yield* click(terminal, "1. [ok] entry-1"); + yield* until_(terminal, "entry-1 being the locus", (one) => + (selectedEntryOn(one) ?? "").includes("entry-1"), + ); + yield* focusDraft(terminal); + terminal.bytes(BYTES.encode(DRAFTED)); + yield* until_(terminal, "the draft on its row", (one) => draftText(one) === DRAFTED); + expect(historicalOn(terminal)).toBe(true); + expect(draftFocused(terminal)).toBe(true); + listed = bandOf(terminal); + expect(listed.some((row) => row.includes(HISTORY_TITLE))).toBe(true); + + // 5. The four sizes, in order. Each one is a profile: wide, medium, + // narrow, and a window too small to draw in at all. + for (const size of [{ columns: 120, rows: 30 }, NARROW, { columns: 60, rows: 15 }]) { + const from = terminal.presented.length; + terminal.resized(size); + yield* until_( + terminal, + `the frame at ${size.columns}x${size.rows}`, + (one) => + one.presented.length > from && + replay(one.presented.slice(from)).some((line) => line.trim().length > 0), + ); + const shown = replay(terminal.presented.slice(from)); + // Nothing drawn past the window's own edge, at any of them. + expect(shown.filter((line) => line.trimEnd().length > size.columns)).toEqual([]); + if (size.columns < NARROW.columns || size.rows < NARROW.rows) { + // Too small to draw in: the refusal and nothing else, which is the + // admission rule rather than a reading of this state. + expect(shown.some((line) => line.includes("Make the window larger"))).toBe(true); + continue; + } + // At a size it can draw at, the reading is the one that went in. + expect(markedIn(shown, "1. [ok] entry-1")).toBe(true); + expect(shown.some((line) => line.includes(`>> Draft: ${DRAFTED}`))).toBe(true); + } + + // 6. Wide again. The marker, the route, the draft and the focus are the + // ones the reader left, after a screen taken apart four times. + const back = terminal.presented.length; + terminal.resized({ columns: 160, rows: 36 }); + yield* until_( + terminal, + "the wide frame again", + (one) => one.presented.length > back && replay(one.presented.slice(back)).length === 36, + ); + expect(selectedEntryOn(terminal)).toContain("entry-1"); + expect(historicalOn(terminal)).toBe(true); + expect(draftText(terminal)).toBe(DRAFTED); + expect(draftFocused(terminal)).toBe(true); + // And the band is the band that went in: the same rail, mark for mark. + expect(bandOf(terminal)).toEqual(listed); + + // 7. Narrow, where the recorded order is longer than the window: the rest + // of it is reached through `[↓ later]`, and the set is the same one. + // The resize is not allowed to have cost a position either. + terminal.resized(NARROW); + yield* until_(terminal, "the narrow frame", (one) => one.size.columns === NARROW.columns); + yield* activate(terminal, "[history]"); + const narrow = yield* drawerContent(terminal); + expect(narrow.reached).toEqual(expected); + // The control did the reaching: one window held less than the whole set. + expect(narrow.first.length).toBeLessThan(expected.length); + + terminal.end(); + yield* running; + }); + + expect(ended?.refusal).toBeUndefined(); + expect(terminal.resets).toBe(1); + }); +}); + +describe("REPL journey: a cold process at an early position", () => { + it("H6: the settled head, the prefix, the same rail, and no work at all", function* () { + const first = recordingTerminal({ columns: 160, rows: 36 }); + const sizes = [{ columns: 160, rows: 36 }, { columns: 120, rows: 30 }, NARROW]; + let root: string | undefined; + let files: string[] = []; + let standing: string | undefined; + let expected: string[] = []; + let hot: string[][] = []; + + // A hot process: two entries, a position early in the recorded order, a + // draft, and a History drawer scrolled away from its first row. + yield* scoped(function* (): Operation { + yield* first.install(); + yield* immediateClock(); + yield* useTempFileCompiler(); + root = yield* useTemporaryHost(); + const terminal = first.terminal; + + const exited = exitRoute(); + const running = yield* spawn(function* (): Operation { + const ran = yield* runReplProgram({ profile: PROFILE }); + if (!ran.ok) { + throw ran.error; + } + exited.settle(ran.value); + }); + yield* untilDrawn(terminal); + files = yield* histories(root); + + // Four entries, so the first position is early in a long recorded order + // and that order is longer than a narrow drawer's window. + yield* submitted(terminal, COLD_ONE); + yield* settledEntry(terminal, 1, "ok"); + yield* submitted(terminal, COLD_TWO); + yield* settledEntry(terminal, 2, "ok"); + yield* submitted(terminal, COLD_THREE); + yield* settledEntry(terminal, 3, "ok"); + yield* submitted(terminal, COLD_FOUR); + yield* settledEntry(terminal, 4, "ok"); + expected = expectedRows(yield* projectionOf(root, files[0])); + + // The first position in the recorded order: entry 1's own admission, which + // the whole of entry 2 comes after. + yield* activate(terminal, "[history]"); + yield* focusOn(terminal, expected[0]); + terminal.feed("\r"); + yield* until_(terminal, "a frozen position", (one) => historicalOn(one)); + terminal.feed("\x1b"); + yield* until_(terminal, "the drawer closing", (one) => !shows(one, "[close]")); + + yield* focusDraft(terminal); + terminal.bytes(BYTES.encode(DRAFTED)); + yield* until_(terminal, "the draft on its row", (one) => draftText(one) === DRAFTED); + + // The rail this reading draws, at each size, read from that size's frames. + for (const size of sizes) { + hot.push(yield* bandAt(terminal, size)); + } + // Rails with marks on them, so the comparison in the cold process below + // is between two drawn rails rather than between two sets of blank rows. + for (const band of hot) { + expect(band.some((row) => row.includes("\u2501"))).toBe(true); + expect(band.some((row) => /[\u25c6\u25bc\u2503]/.test(row))).toBe(true); + } + // Back to the size the cold process will open at, so the comparison below + // is between two processes and not between two sizes. + yield* bandAt(terminal, sizes[0]); + + // And the drawer scrolled off its first row before this process leaves, so + // none of where it was looking is in what it leaves behind: the location + // names a position and a draft and says nothing about a window over a + // list. Scrolled at the narrow size, which is where the recorded order is + // longer than the window that shows it. + yield* bandAt(terminal, NARROW); + yield* activate(terminal, "[history]"); + const walked = yield* drawerContent(terminal); + expect(walked.reached).toEqual(expected); + expect(drawerMarkers(terminal)[0]).not.toBe(expected[0]); + terminal.feed("\x1b"); + yield* until_(terminal, "the drawer closing", (one) => !shows(one, "[close]")); + + terminal.end(); + yield* running; + standing = exited.location(); + }); + + const retained = root; + if (retained === undefined || standing === undefined) { + throw new Error("the first process created a repository and showed a location"); + } + // What the location names: the early position and the draft, and nothing + // about how anything was being looked at. + const stood = decodeLocation(standing); + expect(stood.ok).toBe(true); + if (stood.ok) { + expect(stood.value.draft).toBe(DRAFTED); + expect(stood.value.at).toBeDefined(); + } + const path = join(retained, "xmd", "repl", files[0]); + const before = yield* until(readFile(path, "utf8")); + const retainedFiles = yield* histories(retained); + + const second = recordingTerminal({ columns: 160, rows: 36 }); + let performed: Performed | undefined; + yield* scoped(function* (): Operation { + yield* second.install(); + yield* immediateClock(); + yield* reopening(retained); + performed = yield* countPerformed(); + const terminal = second.terminal; + + const exited = exitRoute(); + const running = yield* spawn(function* (): Operation { + const ran = yield* runReplProgram({ location: standing, profile: PROFILE }); + if (!ran.ok) { + throw ran.error; + } + exited.settle(ran.value); + }); + yield* untilDrawn(terminal); + + // 1. A settled head. Nothing is running in this process and nothing ever + // will, so the band says the execution is down — not live, and not a + // state inherited from the process that recorded it. + expect(bandOf(terminal).some((row) => row.includes("HEAD · SETTLED"))).toBe(true); + for (const live of ["HEAD · LIVE", "HEAD · SETTLING", "HEAD · EXPANSION PAUSED"]) { + expect([live, bandOf(terminal).some((row) => row.includes(live))]).toEqual([live, false]); + } + + // 2. The prefix this location names: entry 1 is there, and entry 2 — which + // the whole of this position predates — is not. + expect(shows(terminal, "1. [")).toBe(true); + expect(shows(terminal, "2. [")).toBe(false); + expect(historicalOn(terminal)).toBe(true); + expect(draftText(terminal)).toBe(DRAFTED); + + // 3. No badge of a live process on it: nothing to pause, nothing + // streaming, nothing to continue. + for (const badge of ["[pause]", "[continue]"]) { + expect([badge, shows(terminal, badge)]).toEqual([badge, false]); + } + expect(screenOf(terminal).some((line) => line.trim().startsWith("…"))).toBe(false); + + // 4. The same rail, mark for mark, at each of the same sizes: navigation + // is the whole recorded file, so a process that holds one prefix of it + // draws what the process that recorded all of it drew. + const cold: string[][] = []; + for (const size of sizes) { + cold.push(yield* bandAt(terminal, size)); + } + expect(cold).toEqual(hot); + yield* bandAt(terminal, sizes[0]); + + // 5. The same positions, by category and order, with nothing of a payload + // in any of them. And the window opens on the first one: the process + // that recorded this left its own scrolled to the end, and where it was + // looking is not part of the reading it handed on. + expect(shows(terminal, "[close]")).toBe(false); + yield* activate(terminal, "[history]"); + expect(drawerMarkers(terminal)[0]).toBe(expected[0]); + const walked = yield* drawerContent(terminal); + expect(walked.reached).toEqual(expected); + terminal.feed("\x1b"); + yield* until_(terminal, "the drawer closing", (one) => !shows(one, "[close]")); + + terminal.end(); + yield* running; + // The same reading went back out, which is what makes the claims above + // about this process's reading rather than about a frame it drew once. + expect(exited.location()).toBe(standing); + }); + + // 6. No work was performed to produce any of it: nothing compiled, no + // component source read, nobody asked. + expect(performed?.compiles).toBe(0); + expect(performed?.reads.filter((one) => one.endsWith(".md"))).toEqual([]); + expect(performed?.asked).toBe(0); + // And nothing was written: the same files, byte for byte. + expect(yield* histories(retained)).toEqual(retainedFiles); + expect(yield* until(readFile(path, "utf8"))).toBe(before); + expect(second.terminal.resets).toBe(1); + }); +}); + describe("REPL journey: a cold process over a multi-entry journal", () => { it("EC1: the same catalog and the same selected entry, with no work and no append", function* () { const first = recordingTerminal(); @@ -4468,7 +4916,12 @@ describe("REPL first use: UI1/UI5", () => { // Five band rows under it, each one a position this execution reached. const band = rows.slice(actions + 1, actions + 1 + HISTORY_ROWS); expect(band).toHaveLength(HISTORY_ROWS); - expect(band.some((row) => row.includes(model.checkpoints[0].label))).toBe(true); + // Re-anchored for #881 PR 3: the band listed checkpoint *labels* and now + // draws a measured rail — its title, its rule and a mark per position. + // What this case is about is the footer's order, and the band being + // there is what it needs; what the band says is `repl-history`'s. + expect(band.some((row) => row.includes(HISTORY_TITLE))).toBe(true); + expect(band.some((row) => row.includes("\u2500"))).toBe(true); // The draft, on the last row, alone. expect(rows[NARROW.rows - 1].trim().startsWith(">")).toBe(true); @@ -4934,15 +5387,21 @@ describe("REPL first use: UI15 drawer geometry", () => { // And the helper still answers with the drawer's own rows: every one of // them is a position this history holds, and none is a piece of the // sentence or of the transcript behind the box. - // The positions this frozen prefix holds — fewer than the live head's, which - // is what a prefix is — and every one of them a position rather than a - // piece of the sentence or of the transcript behind the box. + // + // Re-anchored for #881 PR 3, in two ways. A row used to be a bare label + // and now carries its place in the recorded order, so "holds no `\u00b7`" — + // which is how this told a row from the guidance sentence — would reject + // every row; the shape `N \u00b7 ` says the same thing and says + // more. And the drawer at a frozen position now lists the *whole* order + // rather than the prefix's share of it: the positions after the one being + // read are where a reader can go next, which is the navigation exception + // this slice exists for. const markers = drawerMarkers(terminal); expect(markers.length).toBeGreaterThan(0); - expect(markers).toContain("Entry 1 admitted"); - expect(markers.length).toBeLessThanOrEqual(listed.length); + expect(markers).toContain("1 \u00b7 Entry 1 admitted"); + expect(markers.length).toBe(listed.length); for (const marker of markers) { - expect([marker, marker.includes("·")]).toEqual([marker, false]); + expect([marker, /^\d+ \u00b7 \S/.test(marker)]).toEqual([marker, true]); expect([marker, marker.startsWith("History")]).toEqual([marker, false]); } diff --git a/packages/cli/tests/repl-presentation-style.test.ts b/packages/cli/tests/repl-presentation-style.test.ts index e760cb22a..3c8e6f031 100644 --- a/packages/cli/tests/repl-presentation-style.test.ts +++ b/packages/cli/tests/repl-presentation-style.test.ts @@ -58,8 +58,10 @@ import type { } 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 { FOOTER_ROWS, HISTORY_ROWS, NARROW } from "../src/repl/layout.ts"; +import { HISTORY_TITLE } from "../src/repl/history-rail.ts"; import { ACTION_ROW, DRAWER_WINDOW, READING_WINDOW } from "../src/repl/application.ts"; +import { navigationOf } from "../src/repl/navigation.ts"; import type { ReplBounds } from "../src/repl/layout.ts"; import { BOLD, REPL_PALETTE } from "../src/repl/presentation-style.ts"; import { @@ -80,6 +82,9 @@ import { viewportBounds, } from "./fixtures/repl/presentation.ts"; import { referenceEvents } from "./fixtures/repl/reference.ts"; +import { ordinaryEvaluationProfile } from "../src/evaluation-profile.ts"; +import { submitReplEntry } from "../src/repl/session.ts"; +import type { ReplSession } from "../src/repl/session.ts"; import type { CellStyle, Observed, Presenter, TerminalGrid } from "./fixtures/repl/presentation.ts"; const WIDE: ReplTerminalSize = { columns: 160, rows: 36 }; @@ -368,7 +373,18 @@ function reading( size: ReplTerminalSize = WIDE, focused?: string, ): ReplView { - const resolved = viewFor(state, model, live, size, focused); + // The navigation a session would publish for this reading: every position + // the file retained, with the head it is standing at. Derived from the + // model here because these cases build the model directly rather than + // running a session. + const resolved = viewFor( + state, + model, + live, + size, + focused, + navigationOf(model.checkpoints, model.settled ? "settled" : "unfinished"), + ); if (!resolved.ok) { throw resolved.error; } @@ -687,10 +703,28 @@ describe("REPL presentation: what one row's cells say it is", () => { expect(band.height).toBe(5); const row = placed(observed, "footer:input").y - band.height; expect(band.y).toBe(row); + // Re-anchored for #881 PR 3: the band was one accent and is now a + // rail whose cells say what each of them is — the title, the rule, + // an entry mark, a minor mark, the selection, the head. The claim is + // the same and is why either exists: every cell in this band is the + // band's own, and nothing foreign is painted here. + const own = new Set([ + REPL_PALETTE.historyTitle, + REPL_PALETTE.historyRail, + REPL_PALETTE.historyEntryEarlier, + REPL_PALETTE.historyTickEarlier, + REPL_PALETTE.historyMinorEarlier, + REPL_PALETTE.historyEntryLater, + REPL_PALETTE.historyMinorLater, + REPL_PALETTE.historySelected, + REPL_PALETTE.historyHeadLive, + REPL_PALETTE.historyHead, + ]); for (const written of presenter.grid.nonblank(band)) { const [at] = written.split("="); const [x, y] = at.split(",").map(Number); - expect(presenter.grid.styleAt(x, y).foreground).toBe(REPL_PALETTE.historical); + const ink = presenter.grid.styleAt(x, y).foreground; + expect([at, ink !== undefined && own.has(ink)]).toEqual([at, true]); } for (let x = band.x; x < band.x + band.width; x += 1) { expect(presenter.grid.styleAt(x, band.y).background).toBe(REPL_PALETTE.historySurface); @@ -1038,7 +1072,7 @@ describe("REPL presentation: the panes a reading is laid out in", () => { throw new Error("this frame published no History band"); } expect(band.height).toBe(HISTORY_ROWS); - expect(textOf(grid, band).startsWith(HISTORY_LABEL)).toBe(true); + expect(textOf(grid, band).startsWith(HISTORY_TITLE)).toBe(true); } }); @@ -1688,6 +1722,116 @@ describe("REPL presentation: focus marks the row without repainting it", () => { }); }); +/** + * A real session, held at a question it is actually waiting on. + * + * Everything else in this file hands the frame an observation it composed. + * This one does not: the entry is submitted into a live session, the `` + * inside it suspends for real, and the phase the badge is drawn from is the + * one this process's own observer recorded while that invocation waited. + */ +const HELD_SOURCE = [ + "```js eval", + `const decide = ${JSON.stringify({ + type: "object", + properties: { decision: { type: "string", enum: ["Approve", "Stop"] } }, + required: ["decision"], + additionalProperties: false, + })};`, + "```", + "", + 'Hold here?', + "", +].join("\n"); + +/** Wait until this session is asking something. */ +function* held(session: ReplSession): Operation { + for (let turn = 0; turn < 500; turn += 1) { + if (session.overlay.question !== undefined) { + return; + } + yield* sleep(0); + } + // The entry's own outcome, because the usual reason a question never + // arrives is that the entry failed before asking it. + throw new Error( + "this session never reached a question it had to wait at: " + + (session.model.entries[0]?.terminal?.message ?? "it is still running"), + ); +} + +describe("REPL presentation: a frame of a real held invocation", () => { + beforeAll(() => useTempFileCompiler()); + + it("P1-T4: the waiting invocation says so on its own row, in the archive's literal", () => + scoped(function* () { + const opened = yield* submitReplEntry({ + execution: { id: "held", stream: new InMemoryStream([]) }, + installations: [{ evaluation: ordinaryEvaluationProfile() }], + source: HELD_SOURCE, + }); + if (!opened.ok) { + throw opened.error; + } + const session = opened.value; + yield* held(session); + + const entry = session.model.entries[0]; + if (entry === undefined) { + throw new Error("a held session admitted no entry"); + } + // The overlay the program reads, with this process's actual lifecycle + // in it. Nothing here names a phase: what the badge says comes from the + // invocation that is waiting as this frame is composed. + const live: ReplLive = { + output: session.overlay.output, + question: session.overlay.question, + expansion: session.expansion.state, + pausable: session.controller !== undefined, + running: session.live, + agent: session.agent, + lifecycle: session.lifecycle, + }; + const resolved = viewFor( + selecting(stateWith({}), entry.key), + session.model, + live, + WIDE, + undefined, + session.navigation, + ); + if (!resolved.ok) { + throw resolved.error; + } + const presenter = yield* usePresenter(WIDE); + const observed = yield* presenter.commit(resolved.value); + const { grid } = presenter; + + const badge = `${LIFECYCLE.hold.glyph} ${LIFECYCLE.hold.word}`; + const rows = observed.keys + .filter((key) => key.startsWith("reading:")) + .map((key) => ({ key, bounds: observed.boundsOf(key) })); + const carrying = rows.find( + (one) => one.bounds !== undefined && textOf(grid, one.bounds).includes(badge), + ); + if (carrying?.bounds === undefined) { + throw new Error( + `no reading row says ${badge}; they say ${rows + .map((one) => (one.bounds === undefined ? "" : textOf(grid, one.bounds).trim())) + .join(" | ")}`, + ); + } + // The row it is on is the invocation that is waiting, not some other + // element of the same entry. + expect(textOf(grid, carrying.bounds)).toContain(" { beforeAll(() => useTempFileCompiler()); diff --git a/packages/cli/tests/repl-terminal.test.ts b/packages/cli/tests/repl-terminal.test.ts index 3d103171b..c324cf767 100644 --- a/packages/cli/tests/repl-terminal.test.ts +++ b/packages/cli/tests/repl-terminal.test.ts @@ -32,6 +32,7 @@ import { } from "effection"; import { HISTORY_ROWS, NARROW, profileFor } from "../src/repl/layout.ts"; +import { runText } from "../src/repl/description.ts"; import type { ReplBounds, ReplRegion } from "../src/repl/layout.ts"; import { ReplRenderError, resolvePointer, useReplRenderer } from "../src/repl/renderer.ts"; import type { ReplMeasured, ReplRendered, ReplRenderer } from "../src/repl/renderer.ts"; @@ -363,37 +364,22 @@ describe("REPL terminal: responsive semantic frames", () => { expect(frame.manifest.profile).toBe("wide"); }); - it("F1: compact geometry groups marker labels and keeps every marker's identity", function* () { - const history = Array.from({ length: 40 }, (_unused, index) => ({ - marker: `yield:entry-1:${index}`, - label: `entry-1 yield ${index}`, - })); - const wide = (yield* mounted({ ...FILLED, history }, { columns: 160, rows: 36 })).frame; - const narrow = yield* scoped(function* () { - return (yield* mounted({ ...FILLED, history }, NARROW)).frame; - }); - - // Every marker survives at both sizes, once each, under its own name. - for (const frame of [wide, narrow]) { - expect(frame.manifest.history.markers.map((marker) => marker.marker)).toEqual( - history.map((one) => one.marker), - ); - expect(frame.manifest.history.rows).toHaveLength(HISTORY_ROWS); - } - // Wide has room for its own labels; narrow shares them. - expect(wide.manifest.history.markers.every((marker) => marker.grouped.length === 0)).toBe(true); - expect(narrow.manifest.history.markers.some((marker) => marker.grouped.length > 0)).toBe(true); - const shared = narrow.manifest.history.markers.filter( - (marker) => marker.label === narrow.manifest.history.markers[0].label, - ); - expect(shared.length).toBeGreaterThan(1); - // A grouped marker names the others it shares a label with, so the identity - // of a compact position is still recoverable. - expect(narrow.manifest.history.markers[0].grouped).toEqual( - shared.slice(1).map((marker) => marker.marker), - ); - expect(narrow.manifest.history.markers[0].label).toContain("+"); - }); + // Retired for #881 PR 3: "F1: compact geometry groups marker labels and + // keeps every marker's identity". + // + // It asserted that a compact band shares *labels* and that each marker + // names the others it shares one with. The band no longer has labels: it + // places marks at engine-measured columns, and a group is the positions + // that landed on the same one. Its surviving claim — a compact band never + // costs a reader the ability to reach an exact position — is now + // `repl-history.test.ts`'s "groups collisions deterministically, and keeps + // every member", which makes it against the real engine at two widths and + // checks the ordinals as well as the markers. + // + // It is not re-anchored here because this suite drives the fixture surface + // rather than the application, and that surface carries an empty band: a + // version of this case against it would assert grouping that nothing had + // grouped. it("F1: resizing through every profile keeps selection, nodes and action identity", function* () { const driver = yield* driving({ columns: 160, rows: 36 }); @@ -487,16 +473,29 @@ describe("REPL terminal: responsive semantic frames", () => { // Each row is the full width, so a band that gets shorter cannot leave // the tail of the position that used to be there. for (const row of frame.manifest.history.rows) { - expect(row).toHaveLength(size.columns); + // Measured through the runs the band is now made of, which is what + // the boxes draw. + expect(runText(row)).toHaveLength(size.columns); } - // And what the terminal actually holds on those rows is the band. + // And what the terminal actually holds on those rows is the band's own + // surface, for the full width of the footer. + // + // Re-anchored for #881 PR 3: this read the first row for a checkpoint + // *label*, and the band no longer has any — it places marks at + // measured columns from a summary this fixture surface cannot + // prepare. What the band says is `repl-history.test.ts`'s; what this + // suite owns is where it is placed and how much of the footer it + // covers, which is what remains here. const band = grid.textIn({ x: footer.x, y: footer.y + 1, width: footer.width, height: HISTORY_ROWS, }); - expect(band[0]).toContain("root 1"); + expect(band).toHaveLength(HISTORY_ROWS); + for (const row of band) { + expect(row).toHaveLength(size.columns); + } }); } }); diff --git a/specs/repl-spec.md b/specs/repl-spec.md index 3839c7164..2a8729d56 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -432,9 +432,48 @@ outside `[A-Za-z0-9-._~:@]` is percent-encoded, so a location can never contain the quote that would close it. Source text, provider output and an event's own origin are content and are shown as written, URLs included. -The History band is five rows at every size and says which band it is on the -first of them; when the labels of several positions cannot all fit, they share a -label and every position keeps its own identity. +The History band is five rows at every size: its own name and the head on the +first, entry captions and which position is being read on the second, the tall +marks of entries on the third, the rule itself on the fourth, and the short +stems of everything else — with the head's own marker — on the fifth. + +It is a map of the whole recorded order, which is the one thing on this screen +not read at the selected prefix. The positions after the selected one are +where a reader can go next, so a rail that stopped at the selection would be a +map of the part of the file they had already reached. What crosses is a marker +and a kind per position, and nothing of what any of them holds. + +The map and the content are one reading, taken from one acknowledged array and +carried together through the frame. Nothing on this screen pairs a prefix with +a rail the file no longer has: a record retained while the terminal is being +measured changes the next reading, not half of this one. Where that reading +cannot be built at all, because the file holds a record this process can no +longer read, the whole screen is the refusal — not a rail standing beside a +prefix that still looks plausible. + +Where a mark goes is a proportion of the rail's measured columns: the first at +the start, the last at the end, the rest evenly between, and a lone position +where the head is. It says where something is in the order and nothing about +when it happened. Two positions that land on one column are one *visual* group +and stay two positions — the drawer offers each its own row, so a compact band +never costs a reader an exact position. An entry keeps its tall mark and a +minor position its stem even when they share a column. + +Everything the band reserves is measured. The right-hand caption is as wide as +the widest head label and the selection caption together, identically for every +head state, so a head changing from LIVE to SETTLING moves no mark and re-cuts +no caption; only the count, the selection or the size can do that. Entry +captions start one per entry, centred on its mark, and where two would touch +the leftmost pair becomes the span it actually covers — `Entry 4–7`, never a +number that is not there. A band that cannot fit its title and its head is the +frame refusal that already recovers on resize, never a clip. + +The selection is amber above the rule and at its junction, with its place in +the full order beside it; a live reading says `SELECTED · HEAD` instead and has +no diamond. The head keeps its own colour below the rail's end, so a selection +on that same column keeps its diamond above and the head its marker below. The +rail is read, not operated: nothing on it is a control, and every position is +reached through the drawer. The footer is seven rows at every size, in one order: the contextual status and action row, then the five History rows, then the draft. The draft owns the last of @@ -719,6 +758,34 @@ Sessions to follow a conversation and coming back returns you to the entry you left. It never carries focus, pause state, how far a window is scrolled, form internals, rendered cells or this process's overlay. +## What the head says the execution is doing + +Seven states and no eighth, in one precedence, each a fact about retained +records and about work this process actually owns: + +| | Reading | When | +| --- | --- | --- | +| 1 | `HEAD · SETTLING` | an outcome is retained and the entry's work is still coming down | +| 2 | `HEAD · EXPANSION PAUSED` | no outcome, and the controller is paused | +| 3 | `HEAD · PAUSING EXPANSION` | no outcome, and it is pausing | +| 4 | `HEAD · LIVE` | work this process owns, and none of the above | +| 5 | `HEAD · EMPTY` | nothing retained and nothing owned | +| 6 | `HEAD · SETTLED` | no owned work, and the latest entry has an outcome | +| 7 | `HEAD · UNFINISHED` | no owned work, and it has none | + +Owned work means every resource the entry acquired is still standing — its +outer Agent attachment and its consumers included. A body that returned and a +root Close that was recorded are both earlier than that, so neither of them +says an entry has finished; `SETTLING` is the window between them, and it is +the one a reader watching a run end is actually in. A retained success, a +failure and a cancellation all become a neutral `SETTLED`: which of them it +was belongs to the reading, not to the head. An entry with no outcome and +nothing holding it is `UNFINISHED` — stopped, never an inferred pause. + +Pausing stops expansion, not work that has already started elsewhere. A head +that changes while nothing is appended publishes through the wake the session +and the controller already send; History has no clock of its own. + ## History is immutable; the present is explicit A view frozen at a history position shows what the file held at that position and