From 3c019d0adb3cf1dcb919c5a78b382a5c0f33fde1 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 28 Sep 2026 17:32:13 -0400 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20Ask=20bounded=20Elicit=20forms?= =?UTF-8?q?=20in=20the=20REPL=20drawer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REPL's Elicit provider accepted any schema and asked for one string. It now reads the schema into a bounded form language — string fields, enums, minLength, annotations and one if/then condition — and refuses anything else by name and JSON path before it publishes a question or moves its counter. What judges a submission is Core's own compiled schema, reached through `prepareElicitation()` and `validateParsed()`, so an answer is judged by the thing that will judge it again on its way out. An invalid submission keeps the same question open and reports each issue against the field it belongs to. Form state is per field: values keyed by name, the field being edited, the messages the last submission was told, and how far the read-only message region is scrolled. None of it is durable and none of it is in the location. The drawer draws the whole question through a clamped window whose capacity comes from the drawer's own height, every field with its annotations and required marker, every enum value as its own control, and the validation messages. The one History control is reparented into the modal rather than duplicated beside it, and the footer offers the question as a single bounded line. When the drawer goes, focus goes to the invocation rather than to wherever the drawer was opened from. Which invocation an answer belongs to cannot be read when it is taken — the record appends afterwards — so the claim carries what will identify that record: the answers already retained, and the object that was sent. It waits through the frames until that record is projected, and the commit that puts focus where it asked is what spends it, so traversal from there is the person's. --- packages/cli/src/repl/application.ts | 593 ++++++++++- packages/cli/src/repl/components/actions.ts | 31 +- packages/cli/src/repl/components/rows.ts | 29 +- packages/cli/src/repl/elicitation.ts | 454 ++++++-- packages/cli/src/repl/layout.ts | 15 + packages/cli/src/repl/program.ts | 138 ++- packages/cli/tests/repl-forms.test.ts | 1048 +++++++++++++++++++ 7 files changed, 2168 insertions(+), 140 deletions(-) create mode 100644 packages/cli/tests/repl-forms.test.ts diff --git a/packages/cli/src/repl/application.ts b/packages/cli/src/repl/application.ts index 26ae5193..ccf3f19a 100644 --- a/packages/cli/src/repl/application.ts +++ b/packages/cli/src/repl/application.ts @@ -30,7 +30,7 @@ import type { Json } from "@executablemd/durable-streams"; import { describe as describeNode } from "./description.ts"; import type { ReplDescription } from "./description.ts"; -import { drawerWidth, HISTORY_ROWS, NARROW, surfaceWidth } from "./layout.ts"; +import { drawerHeight, drawerWidth, HISTORY_ROWS, NARROW, surfaceWidth } from "./layout.ts"; import type { ReplSurface as ReplPlacedSurface, ReplSurfaceCell } from "./layout.ts"; import type { ReplTerminalSize } from "./terminal.ts"; import { decodeLocation, encodeLocation, NO_LIVE, resolveLocation } from "./route.ts"; @@ -42,7 +42,7 @@ import type { ReplSurface, } from "./route.ts"; import type { ReplModel, ReplRow, ReplScope } from "./model.ts"; -import type { ReplQuestion } from "./elicitation.ts"; +import type { ReplFormField, ReplQuestion, ReplQuestionForm } from "./elicitation.ts"; import type { ExpansionState } from "./expansion.ts"; import type { ReplTree } from "./reconcile.ts"; import { DRAWER, FIELD, LINE, REFUSAL, SELECT_ROW } from "./components/rows.ts"; @@ -61,15 +61,84 @@ export interface ReplLive { readonly pausable: boolean; } +/** One thing the last submission said was wrong, as a reader sees it. */ +export interface ReplFormMessage { + /** The field it belongs to, or none for the object as a whole. */ + readonly field: string | undefined; + readonly message: string; +} + +/** + * The form being filled in, while a question is being asked. + * + * Values are keyed by field name because the fields are independent: one global + * string would make editing either field overwrite the other. None of this is + * durable, none of it is in the route, and all of it is discarded when the + * question ends or its drawer is closed. + */ +export interface ReplFormState { + /** What has been typed into each field so far, by field name. */ + readonly values: Readonly>; + /** Which field text and Backspace act on, or none yet. */ + readonly field: string | undefined; + /** What the last submission was told was wrong. */ + readonly messages: readonly ReplFormMessage[]; + /** How far the read-only message region has been scrolled, in lines. */ + readonly offset: number; +} + +/** A form nobody has touched. */ +export const EMPTY_FORM: ReplFormState = Object.freeze({ + values: Object.freeze({}), + field: undefined, + messages: Object.freeze([]), + offset: 0, +}); + +/** + * Which control focus goes back to, because the drawer that held it went. + * + * Freedom restores the control that was focused before a modal was pushed, which + * is where focus *was* rather than where the question now is: answering leaves a + * retained invocation to look at, and dismissing leaves one still being asked. + * Both are named here rather than resolved, because which row that is depends on + * what is mounted and only the view knows that. + */ +export type ReplFocusRestore = + /** + * The invocation whose answer this was, once its record is projected. + * + * Named by what it caused rather than by where it sits: the record appends + * after the drawer is gone, so the row does not exist yet at the moment the + * claim is made. `known` is every answer already retained when this one was + * taken and `answer` is exactly what was sent, which together name one + * record — not the newest one, and not an answer somebody else caused. + */ + | { + readonly kind: "answered"; + readonly known: readonly string[]; + readonly answer: Json; + } + /** The invocation still asking, now that its drawer is not up. */ + | { readonly kind: "asked" }; + /** Everything typed and not yet committed anywhere. */ export interface ReplState { readonly route: ReplRoute; /** The entry draft, before an entry exists. */ readonly draft: string; - /** The answer being typed into the waiting question. */ - readonly answer: string; + /** The form being filled into the waiting question. */ + readonly form: ReplFormState; /** Why the last action changed nothing, or none. */ readonly refusal: string | undefined; + /** + * Where focus starts again, for the one commit after a drawer went. + * + * One-shot and process-local. A claim repeated every frame would drag focus + * back after every Tab, so the next action clears it: from then on focus is + * the tree's, which is the only thing that knows where it is. + */ + readonly restore: ReplFocusRestore | undefined; } /** What the root must perform, because a component cannot. */ @@ -78,7 +147,7 @@ export type ReplIntent = | { readonly kind: "submit"; readonly source: string } | { readonly kind: "pause" } | { readonly kind: "continue" } - | { readonly kind: "answer"; readonly choice: string }; + | { readonly kind: "answer"; readonly values: Readonly> }; /** One reduction: the state that stands now, and what the root owes. */ export interface ReplTransition { @@ -141,16 +210,25 @@ export function admitted(state: ReplState): ReplState { /** * The state after a question accepted its answer. * + * `model` is the history as it stood *before* the answer and `answer` is the + * object the question took, because together they name the record this answer is + * about to add. + * * The question is over, so the drawer that was asking it is over too. Leaving * `+elicit` in the route would print a location naming a drawer the topology no * longer mounts — a URL that describes a screen nobody can be shown — and * leaving the typed text in `answer` would offer it again as though it were * still waiting to be sent. */ -export function answered(state: ReplState): ReplState { +export function answered(state: ReplState, model: ReplModel, answer: Json): ReplState { return Object.freeze({ ...state, - answer: "", + form: EMPTY_FORM, + // The answer is what there is to look at now, so focus goes to the + // invocation that has it rather than wherever the drawer was opened from. + // Which invocation that is cannot be read yet: its record appends after this, + // so the claim carries what will identify it when it arrives. + restore: Object.freeze({ kind: "answered", known: retainedAnswers(model), answer }), route: Object.freeze({ ...state.route, drawers: Object.freeze(state.route.drawers.filter((drawer) => drawer.kind !== "live-elicit")), @@ -178,8 +256,9 @@ export function initialState(execution: string): ReplState { return Object.freeze({ route: initialRoute(execution), draft: "", - answer: "", + form: EMPTY_FORM, refusal: undefined, + restore: undefined, }); } @@ -193,8 +272,9 @@ export function stateFor(location: string): Result { Object.freeze({ route: decoded.value, draft: decoded.value.draft ?? "", - answer: "", + form: EMPTY_FORM, refusal: undefined, + restore: undefined, }), ); } @@ -313,17 +393,25 @@ const EMPTY_MODEL: ReplModel = Object.freeze({ * the decision and the effect are separable and the decision is testable alone. */ export function reduceRepl( - state: ReplState, + given: ReplState, action: ReplAction, model: ReplModel, live: ReplLive, + // What the frame can hold, for the one decision that depends on it: how far + // a bounded message region may be scrolled. Narrow is the smallest accepted + // frame, so a caller that states none clamps to the tightest capacity. + size: ReplTerminalSize = NARROW, ): ReplTransition { + // Whoever pressed this key has moved on from wherever the last drawer put + // focus, so the claim does not outlive the commit it was made for. + const state = + given.restore === undefined ? given : Object.freeze({ ...given, restore: undefined }); const answering = state.route.drawers.some((drawer) => drawer.kind === "live-elicit"); switch (action.kind) { case "type": { if (answering) { - return settled({ ...state, answer: state.answer + action.text, refusal: undefined }); + return editing(state, live, action.field, (value) => value + action.text); } if (model.entry !== undefined) { return refuse(state, "this execution has admitted its entry, and an entry is immutable."); @@ -332,7 +420,7 @@ export function reduceRepl( } case "erase": { if (answering) { - return settled({ ...state, answer: shortened(state.answer), refusal: undefined }); + return editing(state, live, action.field, shortened); } if (model.entry !== undefined) { return refuse(state, "this execution has admitted its entry, and an entry is immutable."); @@ -401,7 +489,16 @@ export function reduceRepl( // Dismissing the question's drawer discards what was typed into it. It is // not an answer, and keeping it would offer it again as though it were. return closing.kind === "live-elicit" - ? { ...closed, state: Object.freeze({ ...closed.state, answer: "" }) } + ? { + ...closed, + // Still being asked, so what focus returns to is the invocation that + // is asking — the one control that opens this drawer again. + state: Object.freeze({ + ...closed.state, + form: EMPTY_FORM, + restore: Object.freeze({ kind: "asked" }), + }), + } : closed; } case "select-marker": { @@ -458,15 +555,116 @@ export function reduceRepl( if (live.question === undefined) { return refuse(state, "nothing is being asked right now."); } - if (state.answer.length === 0) { - return refuse(state, "type one of the offered choices first."); - } + // The whole object, however it was assembled. Whether it is an answer is + // the schema's to say, and the root asks it — a reducer that guessed here + // would be a second validator disagreeing with the first. return { state: Object.freeze({ ...state, refusal: undefined }), - intent: { kind: "answer", choice: state.answer }, + intent: { kind: "answer", values: state.form.values }, }; } + case "select-field": { + if (!answering || live.question === undefined) { + return refuse(state, "nothing is being asked right now."); + } + if (!live.question.form.fields.some((one) => one.name === action.field)) { + return refuse(state, "this question has no such field."); + } + return settled({ + ...state, + form: Object.freeze({ ...state.form, field: action.field }), + refusal: undefined, + }); + } + case "choose": { + if (!answering || live.question === undefined) { + return refuse(state, "nothing is being asked right now."); + } + const field = live.question.form.fields.find((one) => one.name === action.field); + if (field === undefined) { + return refuse(state, "this question has no such field."); + } + // Never a value the question did not offer: a stray identifier puts + // nothing into the form rather than something the schema will reject. + if (field.choices === undefined || !field.choices.includes(action.option)) { + return refuse(state, "this field does not offer that value."); + } + // Activating an option is an answer attempt, like Enter anywhere else in + // the form: the value goes in and the whole object is offered. Whether it + // is an answer is the schema's to say — choosing "Request changes" with no + // feedback yet leaves the same question open, carrying what it said. + const chosen = written(state.form, action.field, action.option); + return { + state: Object.freeze({ ...state, form: chosen, refusal: undefined }), + intent: { kind: "answer", values: chosen.values }, + }; + } + case "scroll": { + if (!answering) { + return refuse(state, "nothing is being asked right now."); + } + // Clamped at both ends, and stored clamped. An offset kept past the last + // window would take several presses to have any visible effect, so the + // value held is the one the region is actually showing. + const lines = live.question?.message.split("\n").length ?? 0; + const capacity = messageCapacity(size, live.question, state.form); + const furthest = Math.max(0, lines - capacity); + const offset = Math.min(Math.max(0, state.form.offset + action.delta), furthest); + return settled({ + ...state, + form: Object.freeze({ ...state.form, offset }), + refusal: undefined, + }); + } + } +} + +/** One field's value replaced, leaving every other field alone. */ +function written(form: ReplFormState, field: string, value: string): ReplFormState { + return Object.freeze({ + ...form, + values: Object.freeze({ ...form.values, [field]: value }), + // A value that has changed makes the last verdict about the old one stale, + // so the messages go rather than sitting under a form they no longer + // describe. + messages: Object.freeze([]), + }); +} + +/** + * The field text and Backspace act on. + * + * Whichever field has focus, or the first one the question declares — so a + * person who starts typing before selecting anything edits the field they are + * looking at rather than nothing. + */ +function fieldNamed(live: ReplLive, name: string): ReplFormField | undefined { + return live.question?.form.fields.find((one) => one.name === name); +} + +function focused(state: ReplState, live: ReplLive): ReplFormField | undefined { + const fields = live.question?.form.fields ?? []; + const named = fields.find((one) => one.name === state.form.field); + return named ?? fields[0]; +} + +/** Edit the focused field, and only it. */ +function editing( + state: ReplState, + live: ReplLive, + named: string | undefined, + change: (value: string) => string, +): ReplTransition { + const field = named === undefined ? focused(state, live) : fieldNamed(live, named); + if (field === undefined) { + return refuse(state, "nothing is being asked right now."); } + const form = written(state.form, field.name, change(state.form.values[field.name] ?? "")); + return settled({ + ...state, + form: Object.freeze({ ...form, field: field.name }), + refusal: undefined, + }); } function settled(state: ReplState): ReplTransition { @@ -556,19 +754,109 @@ function row( key: string, label: string, select: { readonly [name: string]: Json }, - options: { readonly focus?: true; readonly here?: string | undefined } = {}, + options: { + readonly focus?: true; + readonly here?: string | undefined; + /** The key this frame restores focus to, for the row that turns out to be it. */ + readonly claim?: string | undefined; + } = {}, ): Described { + const claims = options.focus === true || options.claim === key; return { key, description: describeNode({ key, component: SELECT_ROW, input: { label, ...select, ...(options.here === key ? { focused: true } : {}) }, - ...(options.focus === undefined ? {} : { focus: options.focus }), + ...(claims ? { focus: true } : {}), }), }; } +/** + * Which control this frame claims focus for, because a drawer went. + * + * Resolved against what is mounted rather than stored: a claim naming a row this + * view does not draw would be focus asked for on behalf of nothing, and the + * commit that honoured it would throw. So the claim is silent for as long as the + * row it names is not there — the answer's record has not been projected yet — + * and the hint waits rather than being spent on nothing. + */ +export function focusClaim(view: ReplView): string | undefined { + const { state, selection, live } = view; + if (state.restore === undefined) { + return undefined; + } + if (state.restore.kind === "asked") { + return live.question === undefined || state.route.at !== undefined ? undefined : "footer:asked"; + } + // The record this answer caused: one the history did not hold when the answer + // was taken, holding exactly what was sent. Not the newest record — by the time + // a frame can draw this row, another invocation may have settled after it. + const known = new Set(state.restore.known); + const answer = state.restore.answer; + const caused = (selection.scope?.elicitations ?? []).find( + (one) => !known.has(one.marker) && sameJson(one.answer, answer), + ); + return caused === undefined ? undefined : `elicit:${caused.marker}`; +} + +/** + * The state after a commit settled focus. + * + * A restoration claim is one claim, not a standing one. It survives the frames + * between the answer and its record — nothing can be focused that is not there + * yet — and is spent by the commit that puts focus where it asked. Focus + * traversal is not an action, so a claim nobody retired would pull somebody back + * after every Tab and traversal would never move. + */ +export function focusSettled(view: ReplView, focused: string | undefined): ReplState { + const claim = focusClaim(view); + if (view.state.restore === undefined || claim === undefined || focused !== claim) { + return view.state; + } + return Object.freeze({ ...view.state, restore: undefined }); +} + +/** Every answer this history already holds, wherever in the entry it holds it. */ +function retainedAnswers(model: ReplModel): readonly string[] { + const markers: string[] = []; + const walk = (scope: ReplScope): void => { + for (const elicitation of scope.elicitations) { + markers.push(elicitation.marker); + } + for (const child of scope.scopes) { + walk(child); + } + }; + if (model.entry !== undefined) { + walk(model.entry); + } + return Object.freeze(markers); +} + +/** Whether two Json values are the same value, whatever order they were written in. */ +function sameJson(left: Json, right: Json): boolean { + if (left === null || right === null || typeof left !== "object" || typeof right !== "object") { + return left === right; + } + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + return left.every((member, index) => sameJson(member, right[index] ?? null)); + } + const names = Object.keys(left); + if (names.length !== Object.keys(right).length) { + return false; + } + return names.every((name) => { + const mine = left[name]; + const theirs = right[name]; + return mine !== undefined && theirs !== undefined && sameJson(mine, theirs); + }); +} + function line(key: string, label: string): Described { return { key, @@ -592,14 +880,25 @@ function field( prompt: string, text: string, purpose: "draft" | "answer", - options: { readonly focus?: true; readonly here?: string | undefined } = {}, + options: { + readonly focus?: true; + readonly here?: string | undefined; + /** The form field this line edits, for a line that edits one. */ + readonly field?: string; + } = {}, ): Described { return { key, description: describeNode({ key, component: FIELD, - input: { prompt, text, purpose, ...(options.here === key ? { focused: true } : {}) }, + input: { + prompt, + text, + purpose, + ...(options.field === undefined ? {} : { field: options.field }), + ...(options.here === key ? { focused: true } : {}), + }, ...(options.focus === undefined ? {} : { focus: options.focus }), }), }; @@ -638,6 +937,7 @@ function described(view: ReplView): readonly Described[] { const items: Described[] = []; const { model, selection, live, state } = view; + const claim = focusClaim(view); items.push( row( @@ -728,7 +1028,7 @@ function described(view: ReplView): readonly Described[] { select: "recorded-elicit", marker: elicitation.marker, }, - { here: view.focused }, + { here: view.focused, claim }, ), ); } @@ -737,7 +1037,23 @@ function described(view: ReplView): readonly Described[] { // The way into history. The band above shows where the positions are; choosing // an exact one is a drawer, because a position is something you select and the // band is something you read. - items.push(row("footer:history", "[history]", { select: "history" }, { here: view.focused })); + // The one way into history, and one node. While a drawer is mounted it is + // reparented into the modal branch below rather than drawn again beside it: + // two nodes with one key would be two controls claiming one name, and leaving + // it out here would put it outside the active focus root where nothing could + // reach it. + const history = row( + "footer:history", + "[history]", + { select: "history" }, + { + here: view.focused, + }, + ); + const modal = view.selection.drawers.length > 0; + if (!modal) { + items.push(history); + } if (state.route.at !== undefined) { items.push(row("footer:live", "[live]", { select: "live" }, { here: view.focused })); } @@ -767,9 +1083,11 @@ function described(view: ReplView): readonly Described[] { items.push( row( "footer:asked", - `? ${live.question.message}`, + // One line, bounded. The whole message is in the drawer this opens; a + // footer that drew every line of a Plan draft would be the transcript. + `? ${headline(live.question.message)}`, { select: "live-elicit" }, - { here: view.focused }, + { here: view.focused, claim }, ), ); } @@ -798,7 +1116,10 @@ function described(view: ReplView): readonly Described[] { // not an assertion repeated every frame: the tree re-reads the claim at each // commit, and this screen commits on every frame, so a standing claim would // drag focus back here after every Tab and traversal would never move at all. - const claiming = state.route.drawers.length === 0 && view.focused === undefined; + // A restoration claim is somebody else's: exactly one description may claim + // focus, and a drawer that just went says which one it is. + const claiming = + state.route.drawers.length === 0 && view.focused === undefined && claim === undefined; items.push( field( "footer:input", @@ -809,7 +1130,7 @@ function described(view: ReplView): readonly Described[] { ), ); - const drawer = drawerFor(view); + const drawer = drawerFor(view, history); if (drawer !== undefined) { items.push(drawer); } @@ -823,7 +1144,7 @@ function described(view: ReplView): readonly Described[] { * cell is a row: a label holding three lines would show one of them, and a reader * would have no way to know the other two existed. */ -function drawerFor(view: ReplView): Described | undefined { +function drawerFor(view: ReplView, history: Described): Described | undefined { const open = view.selection.drawers[view.selection.drawers.length - 1]; if (open === undefined) { return undefined; @@ -873,28 +1194,124 @@ function drawerFor(view: ReplView): Described | undefined { if (question === undefined) { return undefined; } - title = question.message; - // The retained normalized schema, as a form: the one field it asks for and - // the exact values it will accept. + const form = question.form; + title = form.title ?? "Answer"; + // The whole message, every line of it, through a window that scrolls. A + // drawer that showed only the first line — or only the first window — + // would be hiding the draft the question is about. + const lines = question.message.split("\n"); + const window = messageWindow( + lines.length, + view.state.form.offset, + messageCapacity(view.size, question, view.state.form), + ); + children.push( + row( + "drawer:scroll:up", + pad("[^ earlier]", width), + { select: "scroll", delta: -1 }, + { + here: view.focused, + }, + ).description, + ); + for (const offset of window.shown) { + children.push(drawerLine(`drawer:message:${offset}`, lines[offset] ?? "", width).description); + } children.push( - drawerLine( - "drawer:form", - `${question.form.field}: ${question.form.choices.join(" | ")}`, - width, + row( + "drawer:scroll:down", + pad("[v later]", width), + { select: "scroll", delta: 1 }, + { + here: view.focused, + }, ).description, ); - // The same rule inside the modal: the field claims focus when the drawer - // opens, and afterwards traversal inside the drawer owns it. + if (form.description !== undefined) { + children.push(drawerLine("drawer:form:about", form.description, width).description); + } + // The same rule inside the modal: the first control claims focus when the + // drawer opens, and afterwards traversal inside the drawer owns it. const entering = view.focused === undefined || !view.focused.startsWith("drawer:"); - const claim: { readonly focus?: true } = entering ? { focus: true } : {}; + let claimed = false; + for (const one of form.fields) { + const value = view.state.form.values[one.name] ?? ""; + const marked = requiredNow(form, view.state.form.values, one) ? "*" : " "; + const label = one.title ?? one.name; + children.push( + row( + `drawer:field:${one.name}`, + pad(`${marked}${label}: ${value}`, width), + { select: "form-field", field: one.name }, + { here: view.focused }, + ).description, + ); + if (one.description !== undefined) { + children.push( + drawerLine(`drawer:field:${one.name}:about`, ` ${one.description}`, width).description, + ); + } + if (one.choices !== undefined) { + // What this field accepts, said once. The controls below are how a + // value is chosen; this is the line that names the whole set, and it + // is what a reader scanning the form reads first. + children.push( + drawerLine(`drawer:form:${one.name}`, `${one.name}: ${one.choices.join(" | ")}`, width) + .description, + ); + } + // Every offered value, each its own control. A form that drew only the + // first would be offering a choice nobody could make. + for (const option of one.choices ?? []) { + children.push( + row( + `drawer:choice:${one.name}:${option}`, + pad(` ${value === option ? "(x)" : "( )"} ${option}`, width), + { select: "form-choice", field: one.name, option }, + { here: view.focused }, + ).description, + ); + } + // The one editable line for this field, which is where text and Backspace + // land while it has focus. The first field's line claims focus when the + // drawer opens — including an enum's, because typing an offered value and + // pressing Enter is still a way to answer. + const focus: { readonly focus?: true } = entering && !claimed ? { focus: true } : {}; + if (entering && !claimed) { + claimed = true; + } + children.push( + field(`drawer:value:${one.name}`, " = ", value, "answer", { + ...focus, + here: view.focused, + }).description, + ); + } + // What the last submission was told, under the form it is about. + for (const [offset, message] of view.state.form.messages.entries()) { + children.push( + drawerLine( + `drawer:invalid:${offset}`, + message.field === undefined ? message.message : `${message.field}: ${message.message}`, + width, + ).description, + ); + } children.push( - field("drawer:answer", "= ", view.state.answer, "answer", { - ...claim, - here: view.focused, - }).description, + row( + "drawer:form:submit", + pad("[submit]", width), + { select: "form-submit" }, + { + here: view.focused, + }, + ).description, ); } + // The same node the footer would have drawn, inside the modal focus root. + children.push(history.description); children.push( row( "drawer:close", @@ -918,6 +1335,98 @@ function drawerFor(view: ReplView): Described | undefined { } /** One value, as the lines a drawer shows it on. */ +/** + * How many rows the form's own controls take, whatever the message is. + * + * Counted rather than assumed, because the message region has to give way to + * them: a fixed window would clip the fields, the submit control or the close + * control off the bottom of a narrow drawer, which is exactly the content a + * person needs in order to answer. + */ +function formRows(question: ReplQuestion | undefined, form: ReplFormState): number { + const fields = question?.form.fields ?? []; + let rows = 0; + for (const one of fields) { + // The field row, its editable line, its description when it has one, its + // enum summary when it is an enum, and one row per offered value. + rows += 2; + rows += one.description === undefined ? 0 : 1; + rows += one.choices === undefined ? 0 : 1 + one.choices.length; + } + rows += question?.form.description === undefined ? 0 : 1; + rows += form.messages.length; + // The two scroll controls, [submit], [close], and the reparented [history]. + return rows + 5; +} + +/** + * How many message lines this drawer can show at this size. + * + * Derived from the drawer's own height less what the form needs, so the region + * shrinks rather than pushing anything out of the frame. At least one line, + * because a message region showing nothing would say the message was empty. + */ +function messageCapacity( + size: ReplTerminalSize, + question: ReplQuestion | undefined, + form: ReplFormState, +): number { + return Math.max(1, drawerHeight(size) - formRows(question, form)); +} + +/** + * Which message lines are visible, clamped at both ends. + * + * Clamping rather than wrapping: a reader who holds a scroll control down + * reaches the end of the message and stays there, rather than arriving back at + * the top having skipped what was in between. + */ +function messageWindow( + total: number, + offset: number, + capacity: number, +): { readonly shown: readonly number[] } { + const last = Math.max(0, total - capacity); + const from = Math.min(Math.max(0, offset), last); + const shown: number[] = []; + for (let line = from; line < Math.min(total, from + capacity); line++) { + shown.push(line); + } + return { shown: Object.freeze(shown) }; +} + +/** The first line of a message, for a control that is one row tall. */ +function headline(message: string): string { + const [first = ""] = message.split("\n"); + return first.length > 60 ? `${first.slice(0, 59)}…` : first; +} + +function pad(label: string, width: number): string { + return width < 1 ? label : label.padEnd(width, " "); +} + +/** + * Whether this field is required as the form currently stands. + * + * The root's own `required`, plus whatever the one conditional adds while its + * field holds the value it tests for. Presentation only — what actually decides + * is the compiled schema, which judges the assembled object. + */ +function requiredNow( + form: ReplQuestionForm, + values: Readonly>, + field: ReplFormField, +): boolean { + if (field.required) { + return true; + } + const condition = form.condition; + if (condition === undefined || values[condition.field] !== condition.equals) { + return false; + } + return condition.requires.some((one) => one.name === field.name); +} + function detail(value: Json): readonly string[] { return (JSON.stringify(value, undefined, 2) ?? "null").split("\n"); } diff --git a/packages/cli/src/repl/components/actions.ts b/packages/cli/src/repl/components/actions.ts index 75e5de45..7244de5f 100644 --- a/packages/cli/src/repl/components/actions.ts +++ b/packages/cli/src/repl/components/actions.ts @@ -15,10 +15,17 @@ import type { ReplDrawerRef, ReplSurface } from "../route.ts"; export type ReplAction = - /** Text for whichever field has focus. */ - | { readonly kind: "type"; readonly text: string } - /** Remove the last decoded text unit from whichever field has focus. */ - | { readonly kind: "erase" } + /** + * Text for one named field, or for the entry draft when it names none. + * + * The name travels with the text because focus belongs to the mounted tree + * and this vocabulary does not: a control that emitted bare text would have + * its characters delivered to whichever field the application last recorded, + * which is not necessarily the one under the cursor. + */ + | { readonly kind: "type"; readonly text: string; readonly field?: string } + /** Remove the last Unicode scalar value from one named field, or the draft. */ + | { readonly kind: "erase"; readonly field?: string } /** Admit the draft as this execution's one entry. */ | { readonly kind: "submit" } | { readonly kind: "select-surface"; readonly surface: ReplSurface } @@ -33,5 +40,17 @@ export type ReplAction = | { readonly kind: "go-live" } | { readonly kind: "pause" } | { readonly kind: "continue" } - /** Answer the question waiting right now. */ - | { readonly kind: "answer" }; + /** Answer the question waiting right now, from the form as it stands. */ + | { readonly kind: "answer" } + /** Give this form field the focus that text and Backspace act on. */ + | { readonly kind: "select-field"; readonly field: string } + /** Put one offered enum value into one field. */ + | { readonly kind: "choose"; readonly field: string; readonly option: string } + /** + * Move the read-only message region by whole lines. + * + * A delta rather than a position, because the control that emits it knows + * which way it points and nothing else: how far the region can go is the + * frame's to decide, and clamping belongs where the height is known. + */ + | { readonly kind: "scroll"; readonly delta: number }; diff --git a/packages/cli/src/repl/components/rows.ts b/packages/cli/src/repl/components/rows.ts index e75b6e77..91a0afff 100644 --- a/packages/cli/src/repl/components/rows.ts +++ b/packages/cli/src/repl/components/rows.ts @@ -140,6 +140,26 @@ function activation(input: ReplViewData): ReplAction | undefined { if (select === "close") { return { kind: "close-drawer" }; } + if (select === "form-field") { + const field = named["field"]; + return typeof field === "string" ? { kind: "select-field", field } : undefined; + } + if (select === "form-choice") { + const field = named["field"]; + const option = named["option"]; + return typeof field === "string" && typeof option === "string" + ? { kind: "choose", field, option } + : undefined; + } + if (select === "form-submit") { + return { kind: "answer" }; + } + if (select === "scroll") { + // A direction, not a position: the control knows which way it points, and + // how far the region may travel is the frame's to decide. + const delta = named["delta"]; + return typeof delta === "number" ? { kind: "scroll", delta } : undefined; + } return undefined; } @@ -159,14 +179,19 @@ export const FIELD: ReplComponent = component({ node.render(rendered(node.input)); node.onInput((input: ReplViewData) => node.render(rendered(input))); node.claim((event: ReplInputEvent): ReplAction | undefined => { + // The field this line edits, read off its own description. Text reaches + // the field the person is actually on rather than whichever one the + // application last recorded as selected. + const named = optional(node.input, "field"); + const owns: { readonly field?: string } = named === undefined ? {} : { field: named }; if (event.kind === "text") { - return { kind: "type", text: event.text }; + return { kind: "type", text: event.text, ...owns }; } if (event.kind === "pointer") { return undefined; } if (event.key === "Backspace") { - return { kind: "erase" }; + return { kind: "erase", ...owns }; } if (event.key === "Enter") { return submission(node.input); diff --git a/packages/cli/src/repl/elicitation.ts b/packages/cli/src/repl/elicitation.ts index e438c72c..6b48da42 100644 --- a/packages/cli/src/repl/elicitation.ts +++ b/packages/cli/src/repl/elicitation.ts @@ -4,19 +4,33 @@ * A provider and nothing more. The document says what it is asking and what * shape the answer must have; this decides where the asking happens, publishes * the request so the application can draw it, and waits. Core normalized the - * schema, core validates what comes back, and core alone appends the answer — - * so a question this provider could not present never becomes a half-answered - * record. + * schema, Core compiles it, Core judges what comes back, and Core alone appends + * the answer — so a question this provider could not present never becomes a + * half-answered record. * - * ## One form, stated as a refusal + * ## The form language is closed, and says so * - * This slice presents exactly one shape: a closed object with one required - * string property whose values are an enum. Anything else is refused as an - * `ElicitationProviderError` *before* a value is yielded, because a provider - * that quietly rendered a schema it does not understand would collect an answer - * to a question nobody was shown. Refusing names the shape it can present, so - * the limit is legible rather than mysterious — and it is a limit rather than a - * general JSON Schema form toolkit, deliberately. + * This presents a bounded subset of JSON Schema: a closed object of string + * fields, each optionally an enum or a minimum length, with at most one + * conditional that makes further fields required when one field holds one exact + * value. That is what the packaged Plan and the generated README program ask + * for. + * + * Anything else is refused as an `ElicitationProviderError` *before* a value is + * yielded, before `asked` moves and before anything is published — and the + * refusal names the offending keyword and where it sits, so the limit is legible + * rather than mysterious. Refusing is deliberate: a provider that quietly + * rendered a keyword it does not understand would collect an answer to a + * question nobody was shown. + * + * ## Validation is Core's, twice + * + * The provider compiles the request's schema through Core's own preparation and + * judges each assembled object with Core's own validator before it resolves. An + * object that fails leaves the question exactly where it was — nothing is + * appended and nothing is resolved — and the normalized issues go back to the + * application to show. Core then judges the resolved answer again on its way + * out, which is the boundary that actually decides. * * ## Nothing here is durable * @@ -24,34 +38,73 @@ * pending request lives exactly as long as the suspended `action()` holding it: * if the run is halted the request disappears with it and no answer is * manufactured. Closing the drawer is a presentation decision and leaves the - * request waiting; only submitting a value answers it. + * request waiting; only a valid submission answers it. */ import { action, createSignal } from "effection"; import type { Operation, Stream } from "effection"; -import { Elicitation, ElicitationProviderError } from "@executablemd/core"; -import type { ElicitationRequest } from "@executablemd/core"; +import { Elicitation, ElicitationProviderError, prepareElicitation } from "@executablemd/core"; +import { validateParsed } from "@executablemd/core"; +import type { ElicitationRequest, NormalizedIssue } from "@executablemd/core"; import type { Json } from "@executablemd/durable-streams"; -/** The one question shape this REPL presents: one field, one list of choices. */ -export interface ReplQuestionForm { +/** What one field's value must also satisfy, when the schema says so. */ +export interface ReplFieldConstraint { + readonly name: string; + /** The shortest accepted value, when the schema states one. */ + readonly minLength: number | undefined; +} + +/** One field the form presents, in the order the schema declared it. */ +export interface ReplFormField extends ReplFieldConstraint { + /** The schema's own label for it, when it wrote one. */ + readonly title: string | undefined; + readonly description: string | undefined; + /** The exact values offered, when the field is an enum. */ + readonly choices: readonly string[] | undefined; + /** Whether the root requires it however the rest of the form is filled. */ + readonly required: boolean; +} + +/** What one conditional adds when its field holds one exact value. */ +export interface ReplFormCondition { readonly field: string; - readonly choices: readonly string[]; + readonly equals: string; + /** The fields that become required, with whatever `then` adds to them. */ + readonly requires: readonly ReplFieldConstraint[]; +} + +/** The whole question shape this REPL presents, parsed once. */ +export interface ReplQuestionForm { + readonly title: string | undefined; + readonly description: string | undefined; + readonly fields: readonly ReplFormField[]; + readonly condition: ReplFormCondition | undefined; } +/** What a submission did: it answered, or it is still the same question. */ +export type ReplFormOutcome = + /** + * Taken. `answer` is the exact object the question was resolved with, so + * whoever submitted it can recognise the record it causes among records it + * did not cause. + */ + | { readonly kind: "answered"; readonly answer: Json } + | { readonly kind: "invalid"; readonly issues: readonly NormalizedIssue[] }; + /** One question waiting for an answer in this process. */ export interface ReplQuestion { readonly message: string; - readonly schema: Json; readonly form: ReplQuestionForm; /** - * Submit one of the offered choices. + * Offer one assembled object as the answer. * - * A choice the form does not offer is not an answer: the request stays open - * and nothing is appended, which is what keeps a stray keystroke from binding - * a value the schema would then reject. + * Judged by the same compiled schema the request carries. A valid object + * resolves the wait exactly once; an invalid one changes nothing at all and + * comes back with the issues to show, so the question a person is looking at + * is still the question they are answering. */ - answer(choice: string): boolean; + submit(values: Readonly>): ReplFormOutcome; } /** What the application reads to draw the question, and to know there is one. */ @@ -64,47 +117,300 @@ export interface ReplElicitations { readonly asked: number; } +/** The keywords a root object may carry. */ +const ROOT_KEYWORDS = new Set([ + "type", + "additionalProperties", + "properties", + "required", + "if", + "then", + "title", + "description", +]); + +/** The keywords one string field may carry. */ +const FIELD_KEYWORDS = new Set(["type", "enum", "minLength", "title", "description"]); + +/** The keywords the `if` object may carry. */ +const CONDITION_KEYWORDS = new Set(["type", "properties", "required", "additionalProperties"]); + +/** The keywords the `then` object may carry. */ +const CONSEQUENCE_KEYWORDS = new Set(["type", "properties", "required"]); + /** - * The form one normalized schema describes, or none when this REPL cannot - * present it. + * The constraints one conditionally required field may restate. * - * Read from the normalized schema core compiled rather than from the source - * that produced it: what the person is shown has to be what the answer will be - * judged against. + * Narrower than a root field on purpose: what `then` adds is a requirement and + * a minimum length, and those are the two things the form both draws and has + * judged. Anything else would be a rule nothing here represents. */ -export function readQuestionForm(schema: Json): ReplQuestionForm | undefined { - if (!isObject(schema) || schema["type"] !== "object") { - return undefined; +const CONSEQUENCE_FIELD_KEYWORDS = new Set(["type", "minLength"]); + +function refuse(what: string, path: string): never { + throw new ElicitationProviderError( + `this REPL presents a bounded form language, and this schema ${what} at ${path}. It asks ` + + "for something this surface cannot draw, so nobody was asked. Supported: a closed " + + "object of string fields, each optionally an enum or a minLength, with at most one " + + "if/then that requires further string fields when one field holds one exact value.", + ); +} + +function isObject(value: Json | undefined): value is { [key: string]: Json } { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Every keyword on this object that the subset does not accept. */ +function refuseUnknown(subject: { [key: string]: Json }, allowed: Set, path: string): void { + for (const keyword of Object.keys(subject)) { + if (!allowed.has(keyword)) { + refuse(`uses the unsupported keyword "${keyword}"`, `${path}.${keyword}`); + } } - if (schema["additionalProperties"] !== false) { - return undefined; +} + +/** The annotations a reader is shown, which change no rule. */ +function annotations( + subject: { [key: string]: Json }, + path: string, +): { + title: string | undefined; + description: string | undefined; +} { + for (const key of ["title", "description"]) { + const value = subject[key]; + if (value !== undefined && typeof value !== "string") { + refuse(`declares a non-string "${key}"`, `${path}.${key}`); + } } - const properties = schema["properties"]; - const required = schema["required"]; - if (!isObject(properties) || !Array.isArray(required) || required.length !== 1) { + return { + title: typeof subject["title"] === "string" ? subject["title"] : undefined, + description: typeof subject["description"] === "string" ? subject["description"] : undefined, + }; +} + +/** The exact values one enum offers, or none when the field declares none. */ +function choicesOf(field: { [key: string]: Json }, path: string): readonly string[] | undefined { + const declared = field["enum"]; + if (declared === undefined) { return undefined; } - const names = Object.keys(properties); - const field = required[0]; - if (names.length !== 1 || typeof field !== "string" || names[0] !== field) { - return undefined; + if (!Array.isArray(declared) || declared.length === 0) { + refuse("declares an enum that is not a non-empty array", `${path}.enum`); } - const property = properties[field]; - if (!isObject(property) || property["type"] !== "string") { + const offered: string[] = []; + for (const [index, choice] of declared.entries()) { + if (typeof choice !== "string") { + refuse("declares a non-string enum member", `${path}.enum[${index}]`); + } + offered.push(choice); + } + return Object.freeze(offered); +} + +/** The shortest accepted value, when the field states one. */ +function minLengthOf(field: { [key: string]: Json }, path: string): number | undefined { + const declared = field["minLength"]; + if (declared === undefined) { return undefined; } - const choices = property["enum"]; - if (!Array.isArray(choices) || choices.length === 0) { + if (typeof declared !== "number" || !Number.isInteger(declared) || declared < 0) { + refuse("declares a minLength that is not a non-negative integer", `${path}.minLength`); + } + return declared; +} + +/** One list of names the schema declared, checked for shape rather than meaning. */ +function namesOf(declared: Json | undefined, path: string): readonly string[] { + if (!Array.isArray(declared)) { + refuse('declares a "required" that is not an array', path); + } + const names: string[] = []; + for (const [index, name] of declared.entries()) { + if (typeof name !== "string") { + refuse("declares a non-string required name", `${path}[${index}]`); + } + if (names.includes(name)) { + refuse(`declares "${name}" as required twice`, `${path}[${index}]`); + } + names.push(name); + } + return names; +} + +/** The one conditional this subset accepts, read from `if` and `then` together. */ +function conditionOf( + root: { [key: string]: Json }, + declared: ReadonlySet, +): ReplFormCondition | undefined { + const when = root["if"]; + const consequence = root["then"]; + if (when === undefined && consequence === undefined) { return undefined; } - const offered: string[] = []; - for (const choice of choices) { - if (typeof choice !== "string") { - return undefined; + if (when === undefined) { + refuse('declares a "then" with no "if"', "$.then"); + } + if (consequence === undefined) { + refuse('declares an "if" with no "then"', "$.if"); + } + if (!isObject(when)) { + refuse('declares an "if" that is not an object schema', "$.if"); + } + if (!isObject(consequence)) { + refuse('declares a "then" that is not an object schema', "$.then"); + } + refuseUnknown(when, CONDITION_KEYWORDS, "$.if"); + refuseUnknown(consequence, CONSEQUENCE_KEYWORDS, "$.then"); + const sides: readonly { readonly side: string; readonly schema: { [key: string]: Json } }[] = [ + { side: "if", schema: when }, + { side: "then", schema: consequence }, + ]; + for (const { side, schema } of sides) { + if (schema["type"] !== undefined && schema["type"] !== "object") { + refuse(`declares a "${side}" whose type is not object`, `$.${side}.type`); + } + } + if (when["additionalProperties"] !== undefined && when["additionalProperties"] !== false) { + refuse('declares an "if" that is not closed', "$.if.additionalProperties"); + } + + const tested = when["properties"]; + if (!isObject(tested)) { + refuse('declares an "if" with no tested properties', "$.if.properties"); + } + const names = Object.keys(tested); + if (names.length !== 1) { + refuse("tests more than one field in its condition", "$.if.properties"); + } + const field = names[0]!; + if (!declared.has(field)) { + refuse(`tests "${field}", which the root does not declare`, `$.if.properties.${field}`); + } + const test = tested[field]; + if (!isObject(test)) { + refuse("declares a condition that is not an object schema", `$.if.properties.${field}`); + } + refuseUnknown(test, new Set(["const", "type"]), `$.if.properties.${field}`); + const equals = test["const"]; + if (typeof equals !== "string") { + refuse( + "tests its field with something other than a string const", + `$.if.properties.${field}.const`, + ); + } + const requiredByCondition = namesOf(when["required"] ?? [field], "$.if.required"); + if (requiredByCondition.length !== 1 || requiredByCondition[0] !== field) { + refuse("requires something other than the field it tests", "$.if.required"); + } + + // What the condition adds. Each name has to be a field the root declared, and + // may restate its own string constraints; nothing new is introduced here. + const added = namesOf(consequence["required"], "$.then.required"); + const constraints: ReplFieldConstraint[] = []; + const restated = consequence["properties"]; + if (restated !== undefined && !isObject(restated)) { + refuse('declares a "then" properties that is not an object', "$.then.properties"); + } + for (const name of added) { + if (!declared.has(name)) { + refuse(`requires "${name}", which the root does not declare`, "$.then.required"); + } + const own = restated === undefined ? undefined : restated[name]; + if (own !== undefined && !isObject(own)) { + refuse( + "restates a field as something other than an object schema", + `$.then.properties.${name}`, + ); + } + if (own !== undefined) { + // Only the constraints this condition actually models. A conditional + // `enum`, title or description would be accepted and then never drawn or + // enforced by anything here, so it is refused rather than ignored. + refuseUnknown(own, CONSEQUENCE_FIELD_KEYWORDS, `$.then.properties.${name}`); + if (own["type"] !== undefined && own["type"] !== "string") { + refuse("restates a field as a type other than string", `$.then.properties.${name}.type`); + } + } + constraints.push( + Object.freeze({ + name, + minLength: own === undefined ? undefined : minLengthOf(own, `$.then.properties.${name}`), + }), + ); + } + if (restated !== undefined) { + for (const name of Object.keys(restated)) { + if (!added.includes(name)) { + refuse(`restates "${name}" without requiring it`, `$.then.properties.${name}`); + } } - offered.push(choice); } - return { field, choices: Object.freeze(offered) }; + return Object.freeze({ field, equals, requires: Object.freeze(constraints) }); +} + +/** + * The form one normalized schema describes, or a refusal naming why not. + * + * Read from the normalized schema Core compiled rather than from the source that + * produced it: what the person is shown has to be what the answer will be judged + * against. Nothing here retains or freezes the caller's object — every value is + * copied out. + */ +export function readQuestionForm(schema: Json): ReplQuestionForm { + if (!isObject(schema)) { + refuse("is not an object schema", "$"); + } + refuseUnknown(schema, ROOT_KEYWORDS, "$"); + if (schema["type"] !== "object") { + refuse('declares a root that is not type "object"', "$.type"); + } + if (schema["additionalProperties"] !== false) { + refuse("declares a root that is not closed", "$.additionalProperties"); + } + const properties = schema["properties"]; + if (!isObject(properties)) { + refuse("declares no properties", "$.properties"); + } + const order = Object.keys(properties); + if (order.length === 0) { + refuse("declares no properties", "$.properties"); + } + const declared = new Set(order); + const required = namesOf(schema["required"] ?? [], "$.required"); + for (const name of required) { + if (!declared.has(name)) { + refuse(`requires "${name}", which it does not declare`, "$.required"); + } + } + + const fields: ReplFormField[] = []; + for (const name of order) { + const path = `$.properties.${name}`; + const field = properties[name]; + if (!isObject(field)) { + refuse("declares a field that is not an object schema", path); + } + refuseUnknown(field, FIELD_KEYWORDS, path); + if (field["type"] !== "string") { + refuse("declares a field whose type is not string", `${path}.type`); + } + fields.push( + Object.freeze({ + name, + ...annotations(field, path), + choices: choicesOf(field, path), + minLength: minLengthOf(field, path), + required: required.includes(name), + }), + ); + } + + return Object.freeze({ + ...annotations(schema, "$"), + fields: Object.freeze(fields), + condition: conditionOf(schema, declared), + }); } /** @@ -127,26 +433,44 @@ export function* useReplElicitation(): Operation { yield* Elicitation.around( { *elicit([request]: [ElicitationRequest]) { + // The whole schema, before anything else happens. A refusal here has + // asked nobody, moved no counter and published no drawer. const form = readQuestionForm(request.schema); - if (form === undefined) { - throw new ElicitationProviderError( - "this REPL presents one question shape: an object with one required string " + - "property whose values are an enum, and no other properties. The document asked " + - "for something else, so nobody was asked.", - ); - } + // Core's own compiler, so what the person is judged against is the + // thing that will judge the answer on its way out. + const prepared = yield* prepareElicitation(request.schema, "Elicit"); asked++; return yield* action(function (resolve) { + let settled = false; const question: ReplQuestion = { message: request.message, - schema: request.schema, + // No schema member. The parsed form is everything a reader needs, + // and retaining the caller's object would hand a live reference to + // whatever built it out to the application. form, - answer(choice: string): boolean { - if (!form.choices.includes(choice)) { - return false; + submit(values: Readonly>): ReplFormOutcome { + if (settled) { + return { kind: "invalid", issues: [] }; } - resolve({ [form.field]: choice }); - return true; + // Every property the form actually holds, copied into a plain + // object — the empty string included. An optional field someone + // deliberately cleared is present and empty, which a schema may + // well accept; dropping it would answer a different question from + // the one on the screen. A field nobody has touched is absent. + const assembled: Record = {}; + for (const field of form.fields) { + const value = values[field.name]; + if (value !== undefined) { + assembled[field.name] = value; + } + } + const issues = validateParsed(prepared.validate, assembled); + if (issues.length > 0) { + return { kind: "invalid", issues: Object.freeze([...issues]) }; + } + settled = true; + resolve(assembled); + return { kind: "answered", answer: Object.freeze({ ...assembled }) }; }, }; publish(question); @@ -169,7 +493,3 @@ export function* useReplElicitation(): Operation { }, }; } - -function isObject(value: Json | undefined): value is { [key: string]: Json } { - return value !== null && typeof value === "object" && !Array.isArray(value); -} diff --git a/packages/cli/src/repl/layout.ts b/packages/cli/src/repl/layout.ts index 22c4b587..820ee886 100644 --- a/packages/cli/src/repl/layout.ts +++ b/packages/cli/src/repl/layout.ts @@ -170,6 +170,21 @@ export function drawerWidth(size: ReplTerminalSize): number { return size.columns - 2 * Math.floor(size.columns / 8); } +/** + * How tall the drawer layer is, at one size. + * + * The same arithmetic the placement below uses, exported because what a drawer + * can hold decides how much of a long message it may show: a region that drew + * more rows than this would clip its own controls off the bottom. + */ +export function drawerHeight(size: ReplTerminalSize): number { + if (profileFor(size) === "too-small") { + return 0; + } + const body = size.rows - FOOTER_ROWS; + return body - 2 * Math.floor(body / 8); +} + /** Which profile a size gets. */ export function profileFor(size: ReplTerminalSize): ReplProfile { if (size.columns < NARROW.columns || size.rows < NARROW.rows) { diff --git a/packages/cli/src/repl/program.ts b/packages/cli/src/repl/program.ts index 32ebd6a8..80f9fd49 100644 --- a/packages/cli/src/repl/program.ts +++ b/packages/cli/src/repl/program.ts @@ -44,6 +44,7 @@ import type { ExecutionInstallation } from "@executablemd/core/host"; import { admitted, answered, + focusSettled, describeApplication, initialState, reduceRepl, @@ -52,7 +53,15 @@ import { stateFor, viewFor, } from "./application.ts"; -import type { ReplAction, ReplIntent, ReplLive, ReplState, ReplView } from "./application.ts"; +import type { Json, NormalizedIssue } from "@executablemd/core"; +import type { + ReplAction, + ReplFormMessage, + ReplIntent, + ReplLive, + ReplState, + ReplView, +} from "./application.ts"; import { decodeLocation, encodeLocation, resolveLocation } from "./route.ts"; import { replRepository } from "./journal.ts"; import type { ReplExecution } from "./journal.ts"; @@ -396,7 +405,13 @@ function* drive( if (aimed !== undefined) { const dispatched = yield* tree.dispatch(aimed); if (dispatched.ok && dispatched.value.outcome === "action") { - const transition = reduceRepl(state, dispatched.value.action, model, liveOf(current)); + const transition = reduceRepl( + state, + dispatched.value.action, + model, + liveOf(current), + yield* screen.size(), + ); state = transition.state; const performed = yield* perform( transition.intent, @@ -411,9 +426,22 @@ function* drive( // The entry exists now, so the draft that became it is finished. state = admitted(state); } - if (performed.answered === true) { + if (performed.answered !== undefined) { // The question took it, so the drawer that was asking is over. - state = answered(state); + // 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); + } + if (performed.messages !== undefined) { + // Still the same question. What the schema said goes under the + // form, and every value stays where it was typed. + state = Object.freeze({ + ...state, + form: Object.freeze({ + ...state.form, + messages: Object.freeze([...performed.messages]), + }), + }); } if (performed.refusal !== undefined) { state = Object.freeze({ ...state, refusal: performed.refusal }); @@ -425,7 +453,13 @@ function* drive( if (delivered !== undefined) { const dispatched = yield* tree.dispatch(delivered); if (dispatched.ok && dispatched.value.outcome === "action") { - const transition = reduceRepl(state, dispatched.value.action, model, liveOf(current)); + const transition = reduceRepl( + state, + dispatched.value.action, + model, + liveOf(current), + yield* screen.size(), + ); state = transition.state; const performed = yield* perform( transition.intent, @@ -440,9 +474,22 @@ function* drive( // The entry exists now, so the draft that became it is finished. state = admitted(state); } - if (performed.answered === true) { + if (performed.answered !== undefined) { // The question took it, so the drawer that was asking is over. - state = answered(state); + // 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); + } + if (performed.messages !== undefined) { + // Still the same question. What the schema said goes under the + // form, and every value stays where it was typed. + state = Object.freeze({ + ...state, + form: Object.freeze({ + ...state.form, + messages: Object.freeze([...performed.messages]), + }), + }); } if (performed.refusal !== undefined) { state = Object.freeze({ ...state, refusal: performed.refusal }); @@ -499,16 +546,18 @@ function* drive( // moved, draw once more with where it actually is. Otherwise the marker is // always one keystroke behind, and a person reaching for a control would be // acting on the one after it. - const settledFocus = keyOfFocus(tree); + let settledFocus = keyOfFocus(tree); + // A focus claim this commit satisfied is spent here, at the commit that + // satisfied it: what the tree answers is the only thing that says whether + // the control a claim named actually took it. + state = focusSettled(view, settledFocus); if (settledFocus !== focused) { focused = settledFocus; - rendered = yield* paint( - frames, - tree, - renderer, - screen, - yield* build(state, model, current, yield* screen.size(), focused), - ); + const redrawn = yield* build(state, model, current, yield* screen.size(), focused); + rendered = yield* paint(frames, tree, renderer, screen, redrawn); + settledFocus = keyOfFocus(tree); + state = focusSettled(redrawn, settledFocus); + focused = settledFocus; } if (ended) { // End of input is a lifecycle outcome: the last frame is drawn, and then @@ -620,12 +669,47 @@ function* watch(session: ReplSession, wakes: Wakes): Operation { }); } +/** + * One normalized issue as a form message. + * + * The field is read from the issue's own instance path, so a message sits under + * the field it is about. An issue about the object as a whole — a missing + * required name — carries no field and is shown against the form. + */ +function reported(outcome: { readonly issues: readonly NormalizedIssue[] }): ReplFormMessage[] { + return outcome.issues.map((issue) => { + const named = /^\/([^/]+)/.exec(issue.instancePath)?.[1]; + const missing = + issue.keyword === "required" && isObject(issue.params) + ? issue.params["missingProperty"] + : undefined; + const field = named ?? (typeof missing === "string" ? missing : undefined); + return { ...(field === undefined ? { field: undefined } : { field }), message: issue.message }; + }); +} + +function isObject(value: Json): value is { [key: string]: Json } { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + /** What performing one intent produced. */ interface Performed { /** The session that stands now, when submitting produced a different one. */ readonly session?: ReplSession; - /** Whether a question took the answer it was given and is now over. */ - readonly answered?: boolean; + /** + * The exact object a question took, when one did and is now over. + * + * The object rather than a flag: the record it causes appends later, and this + * is what will tell that record apart from every other answer in the history. + */ + readonly answered?: Json; + /** + * Why the object it was given is not yet an answer. + * + * The question is untouched and still pending; these go back into application + * state so the form a person is looking at can say what is wrong with it. + */ + readonly messages?: readonly ReplFormMessage[]; /** Why it could not be done, for the screen to say. */ readonly refusal?: string; } @@ -657,13 +741,21 @@ function* perform( wakes.send({ kind: "session" }); return {}; case "answer": { - // A choice the form does not offer is not an answer: the question stays - // open, nothing is appended, and the drawer stays up holding what was - // typed so it can be corrected. Only an accepted answer ends the - // question, and the route has to end with it. - const accepted = session.overlay.question?.answer(intent.choice) ?? false; + // An object the schema rejects is not an answer: the question stays open, + // nothing is appended, and the drawer stays up holding what was filled in + // so it can be corrected. Only a valid object ends the question, and the + // route has to end with it. + // + // The schema decides, through the same compiled validator the request + // carries. Nothing here reads the form or judges a value. + const outcome = session.overlay.question?.submit(intent.values); wakes.send({ kind: "session" }); - return accepted ? { answered: true } : {}; + if (outcome === undefined) { + return {}; + } + return outcome.kind === "answered" + ? { answered: outcome.answer } + : { messages: reported(outcome) }; } case "submit": { const submitted = yield* submitReplEntry({ diff --git a/packages/cli/tests/repl-forms.test.ts b/packages/cli/tests/repl-forms.test.ts new file mode 100644 index 00000000..c414fef2 --- /dev/null +++ b/packages/cli/tests/repl-forms.test.ts @@ -0,0 +1,1048 @@ +/** + * Bounded Elicit forms (#854 F1, F2, F3). + * + * Three levels, each as small as the claim allows. F1 is the schema language on + * its own, because parsing is a pure question. F2 drives the real provider — the + * one `useReplElicitation()` installs — through the real reducer, so what judges + * a submission is the compiled schema and not a test's idea of one. F3 reads the + * described frame, because what a person can see and reach is a property of the + * rows, not of a screenshot. + */ + +import { beforeAll, describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { race, sleep, spawn, withResolvers } from "effection"; +import type { Operation, Result } from "effection"; +import { Elicitation, useTempFileCompiler } from "@executablemd/core"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { Json } from "@executablemd/core"; + +import { readQuestionForm, useReplElicitation } from "../src/repl/elicitation.ts"; +import type { ReplQuestion } from "../src/repl/elicitation.ts"; +import { + answered, + describeApplication, + EMPTY_FORM, + focusClaim, + focusSettled, + initialState, + reduceRepl, + viewFor, +} from "../src/repl/application.ts"; +import type { + ReplAction, + ReplLive, + ReplState, + ReplTransition, + ReplView, +} from "../src/repl/application.ts"; +import { NARROW } from "../src/repl/layout.ts"; +import { fields, readDescription } from "../src/repl/description.ts"; +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 type { ReplTree } from "../src/repl/reconcile.ts"; +import { submitReplEntry } from "../src/repl/session.ts"; +import type { ReplSession } from "../src/repl/session.ts"; +import type { ReplExecution } from "../src/repl/journal.ts"; +import { ordinaryEvaluationProfile } from "../src/evaluation-profile.ts"; +import { referenceEvents } from "./fixtures/repl/reference.ts"; + +/** The widest accepted frame, stated here because layout keeps it private. */ +const WIDE = { columns: 160, rows: 36 }; + +/** The packaged Plan's review schema, as `Plan.md` writes it. */ +const PLAN_SCHEMA: Json = { + type: "object", + properties: { + decision: { type: "string", enum: ["Approve", "Request changes", "Stop"] }, + feedback: { type: "string" }, + }, + required: ["decision"], + additionalProperties: false, + if: { + type: "object", + properties: { decision: { const: "Request changes" } }, + required: ["decision"], + }, + then: { + type: "object", + required: ["feedback"], + properties: { feedback: { type: "string", minLength: 1 } }, + }, +}; + +/** Project details: two required non-empty strings, both annotated. */ +const DETAILS_SCHEMA: Json = { + type: "object", + title: "Project details", + description: "Name the project and say what it is.", + properties: { + project: { type: "string", minLength: 1, title: "Project", description: "Its short name." }, + description: { type: "string", minLength: 1, title: "Description" }, + }, + required: ["project", "description"], + additionalProperties: false, +}; + +/** Confirmation: one required enum. */ +const CONFIRM_SCHEMA: Json = { + type: "object", + properties: { decision: { type: "string", enum: ["Approve", "Decline"] } }, + required: ["decision"], + additionalProperties: false, +}; + +const EMPTY_MODEL: ReplModel = Object.freeze({ + head: true, + entry: undefined, + settled: false, + terminal: undefined, + checkpoints: Object.freeze([]), + transcript: Object.freeze([]), + turns: Object.freeze([]), + sessions: Object.freeze([]), + selection: undefined, +}); + +/** A live reading with one question waiting. */ +function asking(question: ReplQuestion | undefined): ReplLive { + return { output: "", question, expansion: "playing", pausable: false }; +} + +describe("F1 — the bounded language is exact", () => { + it("F1: the Plan review schema parses into its complete form", function* () { + const form = readQuestionForm(PLAN_SCHEMA); + // Field order follows the schema's own property order. + expect(form.fields.map((one) => one.name)).toEqual(["decision", "feedback"]); + const [decision, feedback] = form.fields; + expect(decision?.choices).toEqual(["Approve", "Request changes", "Stop"]); + expect(decision?.required).toBe(true); + expect(feedback?.required).toBe(false); + expect(feedback?.choices).toBe(undefined); + // The conditional, exactly as written: which field, which value, and what + // becomes required with what constraint. + expect(form.condition).toEqual({ + field: "decision", + equals: "Request changes", + requires: [{ name: "feedback", minLength: 1 }], + }); + }); + + it("F1: project details and confirmation parse with their annotations", function* () { + const details = readQuestionForm(DETAILS_SCHEMA); + expect(details.title).toBe("Project details"); + expect(details.description).toBe("Name the project and say what it is."); + expect(details.fields.map((one) => one.name)).toEqual(["project", "description"]); + expect(details.fields.map((one) => one.title)).toEqual(["Project", "Description"]); + expect(details.fields[0]?.description).toBe("Its short name."); + expect(details.fields.map((one) => one.minLength)).toEqual([1, 1]); + expect(details.fields.every((one) => one.required)).toBe(true); + expect(details.condition).toBe(undefined); + + const confirm = readQuestionForm(CONFIRM_SCHEMA); + expect(confirm.fields).toHaveLength(1); + expect(confirm.fields[0]?.choices).toEqual(["Approve", "Decline"]); + }); + + it("F1: the parsed form is frozen, and the source schema is not", function* () { + const form = readQuestionForm(PLAN_SCHEMA); + expect(Object.isFrozen(form)).toBe(true); + expect(Object.isFrozen(form.fields)).toBe(true); + for (const field of form.fields) { + expect(Object.isFrozen(field)).toBe(true); + } + expect(Object.isFrozen(form.condition)).toBe(true); + // Nothing the caller owns was retained or frozen on its behalf. + expect(Object.isFrozen(PLAN_SCHEMA)).toBe(false); + }); + + const refusals: Array<[string, Json, string]> = [ + [ + "a field keyword it does not model", + { + type: "object", + properties: { feedback: { type: "string", pattern: "^x" } }, + additionalProperties: false, + }, + "$.properties.feedback.pattern", + ], + [ + "a root keyword it does not model", + { + type: "object", + properties: { a: { type: "string" } }, + additionalProperties: false, + oneOf: [], + }, + "$.oneOf", + ], + [ + "an open root", + { type: "object", properties: { a: { type: "string" } } }, + "$.additionalProperties", + ], + [ + "a nested object field", + { type: "object", properties: { a: { type: "object" } }, additionalProperties: false }, + "$.properties.a.type", + ], + [ + "a numeric field", + { type: "object", properties: { a: { type: "number" } }, additionalProperties: false }, + "$.properties.a.type", + ], + [ + "an empty enum", + { + type: "object", + properties: { a: { type: "string", enum: [] } }, + additionalProperties: false, + }, + "$.properties.a.enum", + ], + [ + "a mixed enum", + { + type: "object", + properties: { a: { type: "string", enum: ["x", 1] } }, + additionalProperties: false, + }, + "$.properties.a.enum[1]", + ], + [ + "a required name it does not declare", + { + type: "object", + properties: { a: { type: "string" } }, + required: ["b"], + additionalProperties: false, + }, + "$.required", + ], + [ + "a duplicate required name", + { + type: "object", + properties: { a: { type: "string" } }, + required: ["a", "a"], + additionalProperties: false, + }, + "$.required[1]", + ], + [ + "a then with no if", + { + type: "object", + properties: { a: { type: "string" } }, + additionalProperties: false, + then: { type: "object", required: ["a"] }, + }, + "$.then", + ], + [ + "an if with no then", + { + type: "object", + properties: { a: { type: "string" } }, + additionalProperties: false, + if: { type: "object", properties: { a: { const: "x" } }, required: ["a"] }, + }, + "$.if", + ], + [ + "a condition over a field the root does not declare", + { + type: "object", + properties: { a: { type: "string" } }, + additionalProperties: false, + if: { type: "object", properties: { b: { const: "x" } }, required: ["b"] }, + then: { type: "object", required: ["a"] }, + }, + "$.if.properties.b", + ], + [ + "a conditional property the root does not declare", + { + type: "object", + properties: { a: { type: "string" } }, + additionalProperties: false, + if: { type: "object", properties: { a: { const: "x" } }, required: ["a"] }, + then: { type: "object", required: ["b"] }, + }, + "$.then.required", + ], + [ + "a conditional constraint it does not model", + { + type: "object", + properties: { a: { type: "string" }, b: { type: "string" } }, + additionalProperties: false, + if: { type: "object", properties: { a: { const: "x" } }, required: ["a"] }, + then: { + type: "object", + required: ["b"], + properties: { b: { type: "string", enum: ["y"] } }, + }, + }, + "$.then.properties.b.enum", + ], + [ + "an if whose type is not object", + { + type: "object", + properties: { a: { type: "string" } }, + additionalProperties: false, + if: { type: "string", properties: { a: { const: "x" } }, required: ["a"] }, + then: { type: "object", required: ["a"] }, + }, + "$.if.type", + ], + [ + "a then whose type is not object", + { + type: "object", + properties: { a: { type: "string" } }, + additionalProperties: false, + if: { type: "object", properties: { a: { const: "x" } }, required: ["a"] }, + then: { type: "string", required: ["a"] }, + }, + "$.then.type", + ], + ]; + + for (const [what, schema, path] of refusals) { + it(`F1: refuses ${what}, naming ${path}`, function* () { + let refused: unknown; + try { + readQuestionForm(schema); + } catch (error) { + refused = error; + } + expect(refused).toBeInstanceOf(Error); + expect(refused instanceof Error ? refused.name : "").toBe("ElicitationProviderError"); + // The first offending keyword, and where it sits. + expect(refused instanceof Error ? refused.message : "").toContain(path); + }); + } + + it("F1: a refused schema publishes nothing and asks nobody", function* () { + const elicitation = yield* useReplElicitation(); + const refused: string[] = []; + const outcome = yield* spawn(function* () { + try { + yield* Elicitation.operations.elicit({ + message: "unsupported", + schema: { + type: "object", + properties: { a: { type: "string", pattern: "^x" } }, + additionalProperties: false, + }, + }); + } catch (error) { + // The refusal belongs to whoever asked. Caught here so it does not + // also end this test, which is about what the provider did *not* do. + refused.push(error instanceof Error ? error.name : String(error)); + } + }); + // One turn is enough for the provider to have refused. + yield* sleep(0); + // It refused before publishing, so nothing was ever pending and the + // counter never moved. + expect(refused).toEqual(["ElicitationProviderError"]); + expect(elicitation.pending).toBe(undefined); + expect(elicitation.asked).toBe(0); + yield* outcome; + }); +}); + +/** The request shape the Api takes, from a schema written as a literal here. */ +function parsed(schema: Json): { [key: string]: Json } { + if (schema === null || typeof schema !== "object" || Array.isArray(schema)) { + throw new Error("these schemas are objects"); + } + return schema; +} + +/** Everything one live question needs in order to be driven. */ +interface Asked { + readonly question: ReplQuestion; + /** + * What the provider resolved with, once it has. + * + * `unknown`, because that is what the Api resolves with and an expectation + * reads a value rather than being told what it is. Read it after a turn of the + * loop: `submit` resolves the provider's action, and the asking coroutine + * resumes on the next turn. + */ + readonly answer: () => unknown; +} + +/** + * Install the real provider, ask one real question, and hand back the pending + * one. + * + * The question is the provider's own: what judges a submission is the schema + * Core compiled from the request, reached through the same `submit` the + * application reaches. + */ +function* askingFor(schema: Json, message = "Review the draft"): Operation { + const elicitation = yield* useReplElicitation(); + const settled: { value?: unknown } = {}; + yield* spawn(function* () { + settled.value = yield* Elicitation.operations.elicit({ message, schema: parsed(schema) }); + }); + // The provider publishes before it suspends, so one turn of the loop is + // enough for the question to exist. + yield* sleep(0); + const question = elicitation.pending; + if (question === undefined) { + throw new Error("the provider published no question"); + } + return { question, answer: () => settled.value }; +} + +/** + * One state with the live Elicit drawer open, which is how a form is filled. + * + * Opened through the ordinary action against the live question, because that is + * the only way the drawer opens: a route holding `+elicit` with nothing being + * asked is refused. + */ +function opened(live: ReplLive): ReplState { + const transition = reduceRepl( + initialState("forms"), + { kind: "open-drawer", drawer: { kind: "live-elicit" } }, + EMPTY_MODEL, + live, + ); + if (transition.state.refusal !== undefined) { + throw new Error(`the drawer did not open: ${transition.state.refusal}`); + } + return transition.state; +} + +function drive( + state: ReplState, + live: ReplLive, + actions: readonly ReplAction[], +): { state: ReplState; transitions: ReplTransition[] } { + let current = state; + const transitions: ReplTransition[] = []; + for (const action of actions) { + const transition = reduceRepl(current, action, EMPTY_MODEL, live, NARROW); + transitions.push(transition); + current = transition.state; + } + return { state: current, transitions }; +} + +describe("F2 — invalid stays open; valid is exact", () => { + it("F2: choosing Request changes with no feedback keeps the question and says why", function* () { + const asked = yield* askingFor(PLAN_SCHEMA); + const live = asking(asked.question); + const { state, transitions } = drive(opened(live), live, [ + { kind: "choose", field: "decision", option: "Request changes" }, + ]); + // Activating an option offers the whole object, as Enter does. + const intent = transitions[0]?.intent; + expect(intent?.kind).toBe("answer"); + expect(intent?.kind === "answer" ? intent.values : {}).toEqual({ + decision: "Request changes", + }); + // The schema rejects it, so nothing is answered and the question stands. + const outcome = asked.question.submit(state.form.values); + expect(outcome.kind).toBe("invalid"); + yield* sleep(0); + expect(asked.answer()).toBe(undefined); + const reported = outcome.kind === "invalid" ? outcome.issues : []; + expect(reported.length).toBeGreaterThan(0); + expect(JSON.stringify(reported)).toContain("feedback"); + }); + + it("F2: entering feedback then submitting resolves with exactly that object", function* () { + const asked = yield* askingFor(PLAN_SCHEMA); + const live = asking(asked.question); + const { state } = drive(opened(live), live, [ + { kind: "choose", field: "decision", option: "Request changes" }, + { kind: "type", text: "needs work", field: "feedback" }, + ]); + expect(asked.question.submit(state.form.values).kind).toBe("answered"); + yield* sleep(0); + expect(asked.answer()).toEqual({ decision: "Request changes", feedback: "needs work" }); + }); + + it("F2: each field edits independently, whichever one the control names", function* () { + const asked = yield* askingFor(DETAILS_SCHEMA); + const live = asking(asked.question); + // Typed into the second field first, then the first: an application that + // kept one global answer, or one that sent text to whichever field it had + // last recorded, would put both strings in one place. + const { state } = drive(opened(live), live, [ + { kind: "type", text: "a REPL", field: "description" }, + { kind: "type", text: "xmd", field: "project" }, + ]); + expect(state.form.values).toEqual({ description: "a REPL", project: "xmd" }); + expect(asked.question.submit(state.form.values).kind).toBe("answered"); + yield* sleep(0); + expect(asked.answer()).toEqual({ project: "xmd", description: "a REPL" }); + }); + + it("F2: both details are required, and one missing keeps the question open", function* () { + const asked = yield* askingFor(DETAILS_SCHEMA); + const live = asking(asked.question); + const { state } = drive(opened(live), live, [{ kind: "type", text: "xmd", field: "project" }]); + expect(asked.question.submit(state.form.values).kind).toBe("invalid"); + yield* sleep(0); + expect(asked.answer()).toBe(undefined); + }); + + it("F2: confirmation returns exactly its offered decision", function* () { + const asked = yield* askingFor(CONFIRM_SCHEMA); + const live = asking(asked.question); + const { state } = drive(opened(live), live, [ + { kind: "choose", field: "decision", option: "Decline" }, + ]); + expect(asked.question.submit(state.form.values).kind).toBe("answered"); + yield* sleep(0); + expect(asked.answer()).toEqual({ decision: "Decline" }); + }); + + it("F2: an option the field does not offer puts nothing into the form", function* () { + const asked = yield* askingFor(CONFIRM_SCHEMA); + const live = asking(asked.question); + const { state, transitions } = drive(opened(live), live, [ + { kind: "choose", field: "decision", option: "Maybe" }, + ]); + expect(transitions[0]?.intent.kind).toBe("none"); + expect(state.form.values).toEqual({}); + expect(state.refusal).toBeDefined(); + }); + + it("F2: an optional field deliberately emptied is present and empty", function* () { + const asked = yield* askingFor(PLAN_SCHEMA); + const live = asking(asked.question); + // Typed and then erased: the person cleared it, which is not the same as + // never having touched it, and an optional empty string is valid here. + const { state } = drive(opened(live), live, [ + { kind: "choose", field: "decision", option: "Approve" }, + { kind: "type", text: "x", field: "feedback" }, + { kind: "erase", field: "feedback" }, + ]); + expect(state.form.values["feedback"]).toBe(""); + expect(asked.question.submit(state.form.values).kind).toBe("answered"); + yield* sleep(0); + expect(asked.answer()).toEqual({ decision: "Approve", feedback: "" }); + }); + + it("F2: closing discards the draft, answers nothing, and reopens empty", function* () { + const asked = yield* askingFor(PLAN_SCHEMA); + const live = asking(asked.question); + const { state, transitions } = drive(opened(live), live, [ + { kind: "type", text: "half written", field: "feedback" }, + { kind: "close-drawer" }, + ]); + expect(state.form).toEqual(EMPTY_FORM); + // Closing owes nobody anything: a dismissal that came back as an answer + // would send the half-written draft to the schema on its way out. + expect(transitions[1]?.intent.kind).toBe("none"); + yield* sleep(0); + expect(asked.answer()).toBe(undefined); + // Still pending, and reopening starts with nothing in it. + const reopened = drive(state, live, [{ kind: "open-drawer", drawer: { kind: "live-elicit" } }]); + expect(reopened.state.form.values).toEqual({}); + }); + + it("F2: an astral scalar survives typing, and one erase removes one scalar", function* () { + const asked = yield* askingFor(DETAILS_SCHEMA); + const live = asking(asked.question); + const typed = drive(opened(live), live, [{ kind: "type", text: "é漢🙂", field: "project" }]); + expect(typed.state.form.values["project"]).toBe("é漢🙂"); + // One Unicode scalar, not one UTF-16 code unit: erasing the emoji leaves + // the two characters before it whole rather than half a surrogate pair. + const erased = drive(typed.state, live, [{ kind: "erase", field: "project" }]); + expect(erased.state.form.values["project"]).toBe("é漢"); + }); +}); + +/** Every described row, flattened, with its key and label. */ +function rowsOf(descriptions: readonly ReplDescription[]): Array<{ + key: string; + label: string; +}> { + const found: Array<{ key: string; label: string }> = []; + const walk = (description: ReplDescription): void => { + const read = readDescription(description); + const named = fields(read.input); + const label = named?.["label"] ?? named?.["text"] ?? ""; + found.push({ key: read.key, label: typeof label === "string" ? label : "" }); + for (const child of read.children ?? []) { + walk(child); + } + }; + for (const description of descriptions) { + walk(description); + } + return found; +} + +/** The view this state reads as, or the failure that stopped it. */ +function reading( + state: ReplState, + live: ReplLive, + model: ReplModel = EMPTY_MODEL, + size = NARROW, + focused?: string, +): ReplView { + const resolved = viewFor(state, model, live, size, focused); + if (!resolved.ok) { + throw resolved.error; + } + return resolved.value; +} + +/** The descriptions this state produces, or the failure that stopped it. */ +function seen(state: ReplState, live: ReplLive, size = NARROW, focused?: string) { + return describeApplication(reading(state, live, EMPTY_MODEL, size, focused)); +} + +function framed(state: ReplState, live: ReplLive, size = NARROW, focused?: string) { + return rowsOf(seen(state, live, size, focused)); +} + +const DRAFT = Array.from({ length: 40 }, (_, line) => `draft line ${line}`).join("\n"); + +describe("F3 — complete content and reachable navigation", () => { + it("F3: every field, option and annotation is drawn, not just the first", function* () { + const asked = yield* askingFor(PLAN_SCHEMA); + const rows = framed(opened(asking(asked.question)), asking(asked.question)); + const keys = rows.map((one) => one.key); + // Both fields, and every offered value — not the first of either. + expect(keys).toContain("drawer:field:decision"); + expect(keys).toContain("drawer:field:feedback"); + for (const option of ["Approve", "Request changes", "Stop"]) { + expect(keys).toContain(`drawer:choice:decision:${option}`); + } + expect(keys).toContain("drawer:value:feedback"); + expect(keys).toContain("drawer:form:submit"); + expect(keys).toContain("drawer:close"); + }); + + it("F3: the whole message is reachable through the window, and clamps", function* () { + const asked = yield* askingFor(PLAN_SCHEMA, DRAFT); + const live = asking(asked.question); + const first = framed(opened(live), live); + const shown = (rows: ReturnType): string[] => + rows.filter((one) => one.key.startsWith("drawer:message:")).map((one) => one.label.trim()); + // Not every line at once, and the first window starts at the beginning. + expect(shown(first).length).toBeGreaterThan(0); + expect(shown(first).length).toBeLessThan(40); + expect(shown(first)[0]).toBe("draft line 0"); + + // Scrolled to the end, and clamped there rather than wrapping. + let state = opened(live); + for (let press = 0; press < 80; press++) { + state = reduceRepl(state, { kind: "scroll", delta: 1 }, EMPTY_MODEL, live, NARROW).state; + } + const end = shown(framed(state, live)); + expect(end[end.length - 1]).toBe("draft line 39"); + const held = state.form.offset; + state = reduceRepl(state, { kind: "scroll", delta: 1 }, EMPTY_MODEL, live, NARROW).state; + expect(state.form.offset).toBe(held); + + // One press back moves immediately, because the stored offset is clamped + // rather than sitting past the last window. + const back = reduceRepl(state, { kind: "scroll", delta: -1 }, EMPTY_MODEL, live, NARROW).state; + expect(back.form.offset).toBe(held - 1); + expect(shown(framed(back, live))).not.toEqual(end); + + // And scrolling changed no value and no route. + expect(back.form.values).toEqual({}); + expect(back.route.drawers.map((one) => one.kind)).toEqual(["live-elicit"]); + }); + + it("F3: the narrow drawer still draws its fields, submit and close", function* () { + const asked = yield* askingFor(PLAN_SCHEMA, DRAFT); + const live = asking(asked.question); + for (const size of [NARROW, WIDE]) { + const keys = framed(opened(live), live, size).map((one) => one.key); + // The message region gives way to the controls rather than pushing them + // out of a frame this small. + expect(keys).toContain("drawer:field:decision"); + expect(keys).toContain("drawer:field:feedback"); + expect(keys).toContain("drawer:form:submit"); + expect(keys).toContain("drawer:close"); + expect(keys).toContain("footer:history"); + } + }); + + it("F3: the one History control is inside the drawer while it is open", function* () { + const asked = yield* askingFor(PLAN_SCHEMA); + const live = asking(asked.question); + const descriptions = seen(opened(live), live); + // Exactly one node carries the key, and it is a child of the drawer + // rather than a sibling of it. + const all = rowsOf(descriptions).filter((one) => one.key === "footer:history"); + expect(all).toHaveLength(1); + const drawer = descriptions + .map((description) => readDescription(description)) + .find((read) => read.key === "drawer:open"); + expect(drawer).toBeDefined(); + const inside = rowsOf(drawer?.children ?? []).some((one) => one.key === "footer:history"); + expect(inside).toBe(true); + }); + + it("F3: the footer trigger is one bounded line, and the drawer holds it all", function* () { + const asked = yield* askingFor(PLAN_SCHEMA, DRAFT); + const live = asking(asked.question); + // Closed: the footer offers the question without becoming the transcript. + const closed = framed(initialState("forms"), live); + const trigger = closed.find((one) => one.key === "footer:asked"); + expect(trigger).toBeDefined(); + expect(trigger?.label.includes("\n")).toBe(false); + expect(trigger?.label).toContain("draft line 0"); + expect(trigger?.label).not.toContain("draft line 1"); + }); +}); + +describe("F3 — focus returns to the invocation, not to where the drawer came from", () => { + // The retained row focus lands on exists only once an answer is recorded, so + // the model here is a real execution's journal rather than a shape written by + // this test. + beforeAll(() => useTempFileCompiler()); + + it("F3: focus lands on the record this answer caused, once it appends", function* () { + // One real session over one real journal, asking three questions in a row. + // The record focus has to reach does not exist when the answer is taken — + // it appends when the invocation settles — so this drives the whole + // interval rather than a frame that already has everything in it. + const holder = execution(); + const session = granted( + yield* submitReplEntry({ + execution: holder, + installations: [{ evaluation: ordinaryEvaluationProfile() }], + source: THREE_QUESTIONS, + }), + ); + // Chained after the session's own observer, so a signal arrives with the + // session already reprojected — and so the session does not replace it. + const appended = elicitAppends(holder); + + // An answer this test did not cause, carrying the same value the one under + // test will carry. What excludes it is that it was already retained, not + // that it looks different. + const first = yield* waiting(session, "the first question"); + expect(first.message).toContain("First"); + expect(first.submit({ decision: "Approve" }).kind).toBe("answered"); + yield* appended(1); + expect(answers(session.model)).toHaveLength(1); + + // The question under test, in the mounted drawer. + const second = yield* waiting(session, "the question under test", first); + expect(second.message).toContain("Second"); + const held = liveReading(session); + const open = withScope(opened(held), ENTRY_SCOPE); + const tree = yield* useReplTree(); + yield* applied(tree, reading(open, held, session.model)); + const inside = keyed(tree); + expect(inside?.startsWith("drawer:")).toBe(true); + + // Answered by activating the option control the drawer mounted: Enter on + // it is one keystroke, and what it produces is the ordinary action. + yield* focusTo(tree, "drawer:choice:decision:Approve"); + const dispatched = yield* tree.dispatch({ kind: "key", key: "Enter" }); + if (!dispatched.ok || dispatched.value.outcome !== "action") { + throw new Error("the mounted option control produced no action"); + } + const transition = reduceRepl(open, dispatched.value.action, session.model, held, NARROW); + expect(transition.intent.kind).toBe("answer"); + if (transition.intent.kind !== "answer") { + throw new Error("activating an option offers the whole object"); + } + // The boundary the program performs it at, on the question this process is + // actually holding. + const outcome = session.overlay.question?.submit(transition.intent.values); + if (outcome?.kind !== "answered") { + throw new Error("the live question did not take its answer"); + } + // Nothing has appended yet: this is the interval the claim has to survive. + expect(answers(session.model)).toHaveLength(1); + let state = answered(transition.state, session.model, outcome.answer); + expect(state.restore?.kind).toBe("answered"); + + // A frame drawn in that interval claims nothing, because the row it would + // name is not there — and spends nothing either. + let view = reading(state, liveReading(session), session.model, NARROW, inside); + expect(focusClaim(view)).toBe(undefined); + yield* applied(tree, view); + const between = keyed(tree); + expect(between?.startsWith("elicit:")).toBe(false); + state = focusSettled(view, between); + expect(state.restore?.kind).toBe("answered"); + + // A third answer, with a different value, so the record under test is no + // longer the newest one by the time any frame can draw it. + const third = yield* waiting(session, "the third question", second); + expect(third.message).toContain("Third"); + expect(third.submit({ decision: "Stop" }).kind).toBe("answered"); + yield* appended(3); + + const retained = answers(session.model); + expect(retained).toHaveLength(3); + const caused = retained[1]; + const before = retained[0]; + const newest = retained[2]; + if (caused === undefined || before === undefined || newest === undefined) { + throw new Error("the entry retained three answers"); + } + // The one it did not cause has the same answer; the newest one is somebody + // else's. Identity here is both, or neither would be enough. + expect(before.answer).toEqual({ decision: "Approve" }); + expect(caused.answer).toEqual({ decision: "Approve" }); + expect(newest.answer).toEqual({ decision: "Stop" }); + + view = reading(state, liveReading(session), session.model, NARROW, between); + expect(focusClaim(view)).toBe(`elicit:${caused.marker}`); + yield* applied(tree, view); + const landed = keyed(tree); + expect(landed).toBe(`elicit:${caused.marker}`); + expect(landed).not.toBe(`elicit:${before.marker}`); + expect(landed).not.toBe(`elicit:${newest.marker}`); + + // Spent by the commit that satisfied it, so traversal from here is the + // person's: Tab moves, and the next frame leaves it where they moved it. + state = focusSettled(view, landed); + expect(state.restore).toBe(undefined); + const moved = yield* tabbed(tree); + expect(moved).not.toBe(landed); + yield* applied(tree, reading(state, liveReading(session), session.model, NARROW, moved)); + expect(keyed(tree)).toBe(moved); + }); + + it("F3: closing without answering moves focus to the invocation still asking", function* () { + const recorded = yield* retained(); + const asked = yield* askingFor(CONFIRM_SCHEMA); + const live = asking(asked.question); + const open = withScope(opened(live), recorded.scope); + const tree = yield* useReplTree(); + yield* applied(tree, reading(open, live, recorded.model)); + const inside = keyed(tree); + expect(inside?.startsWith("drawer:")).toBe(true); + + // Dismissed, not answered: the same question is still being asked, so the + // control that is asking it is where focus belongs — and it is the one + // control that opens this drawer again. + const closed = reduceRepl(open, { kind: "close-drawer" }, recorded.model, live, NARROW).state; + expect(closed.restore?.kind).toBe("asked"); + expect(closed.route.drawers).toEqual([]); + yield* applied(tree, reading(closed, live, recorded.model, NARROW, inside)); + expect(keyed(tree)).toBe("footer:asked"); + // Nothing was answered by closing it. + yield* sleep(0); + expect(asked.answer()).toBe(undefined); + }); + + it("F3: without the claim the drawer going leaves focus on an unrelated control", function* () { + const recorded = yield* retained(); + const asked = yield* askingFor(CONFIRM_SCHEMA); + const live = asking(asked.question); + const open = withScope(opened(live), recorded.scope); + const tree = yield* useReplTree(); + yield* applied(tree, reading(open, live, recorded.model)); + const inside = keyed(tree); + + // The same commit with the claim removed, which is what the tree does on + // its own: Freedom restores whatever held focus before the modal was + // pushed, and on this screen that is neither invocation. This is what the + // two rows above are distinguished from. + const taken = answered(open, recorded.model, { decision: "Approve" }); + const unclaimed = Object.freeze({ ...taken, restore: undefined }); + yield* applied(tree, reading(unclaimed, asking(undefined), recorded.model, NARROW, inside)); + const landed = keyed(tree); + expect(landed).not.toBe(`elicit:${recorded.marker}`); + expect(landed).toBe("sessions:heading"); + }); +}); + +/** One real journal with an answer in it, and the scope that shows it. */ +function* retained(): Operation<{ + readonly model: ReplModel; + readonly scope: string; + readonly marker: string; +}> { + const projected = projectRepl(yield* referenceEvents()); + if (!projected.ok) { + throw projected.error; + } + const entry = projected.value.entry; + const elicitation = entry?.elicitations[0]; + if (entry === undefined || elicitation === undefined) { + throw new Error("the reference execution recorded no answered elicitation"); + } + return { model: projected.value, scope: entry.key, marker: elicitation.marker }; +} + +/** The same state with one scope selected, which is what mounts its rows. */ +function withScope(state: ReplState, scope: string): ReplState { + return Object.freeze({ + ...state, + route: Object.freeze({ ...state.route, scopes: Object.freeze([scope]) }), + }); +} + +/** Commit one view into the real tree, refusing to assert past a rejected set. */ +function* applied(tree: ReplTree, view: ReplView): Operation { + const result = yield* tree.apply(describeApplication(view)); + if (!result.ok) { + throw result.error; + } +} + +/** The key of whatever holds focus now, as the root reads it. */ +function keyed(tree: ReplTree): string | undefined { + const node = tree.focused(); + return node === undefined ? undefined : tree.keyOf(node); +} + +/** One entry that asks three questions in a row, each from the same schema. */ +const THREE_QUESTIONS = [ + "```js eval", + `const decide = ${JSON.stringify({ + type: "object", + properties: { decision: { type: "string", enum: ["Approve", "Stop"] } }, + required: ["decision"], + additionalProperties: false, + })};`, + "```", + "", + 'First question', + "", + 'Second question', + "", + 'Third question', + "", + "{before.decision} {under.decision} {after.decision}", +].join("\n"); + +/** One execution with an empty journal of its own. */ +function execution(): ReplExecution { + return { id: "elicit-forms", stream: new InMemoryStream([]) }; +} + +/** The session, or the refusal that means there is nothing to drive. */ +function granted(result: Result): ReplSession { + if (!result.ok) { + throw result.error; + } + return result.value; +} + +/** This process's overlay, exactly as the program reads it into a view. */ +function liveReading(session: ReplSession): ReplLive { + return { + output: session.overlay.output, + question: session.overlay.question, + expansion: session.expansion.state, + pausable: session.controller !== undefined, + }; +} + +/** + * Wait until this process is asking a question that is not `previous`. + * + * The overlay is read rather than the change stream: a signal delivers to + * whoever is pulling at that moment, and the first question is published while + * the session is still being opened — before anything could have subscribed. + */ +function* waiting( + session: ReplSession, + what: string, + previous?: ReplQuestion, +): Operation { + for (let turn = 0; turn < 500; turn++) { + const question = session.overlay.question; + if (question !== undefined && question !== previous) { + return question; + } + yield* sleep(0); + } + throw new Error(`this process never asked ${what}`); +} + +/** + * Wait for the nth answer to be journaled. + * + * Chained onto the stream's own callback rather than watching the model: records + * only accumulate, so a count is something a test can wait for exactly, while a + * reprojection signal can be missed between reads. + */ +function elicitAppends(holder: ReplExecution): (count: number) => Operation { + const inner = holder.stream.onAppend; + const slots = new Map>>(); + const slot = (count: number): ReturnType> => { + const existing = slots.get(count); + if (existing !== undefined) { + return existing; + } + const created = withResolvers(); + slots.set(count, created); + return created; + }; + let seen = 0; + const written: string[] = []; + holder.stream.onAppend = (event) => { + inner?.(event); + written.push(event.type === "yield" ? event.description.type : "close"); + if (event.type === "yield" && event.description.type === "elicit") { + seen += 1; + slot(seen).resolve(); + } + }; + return function* (count: number): Operation { + if (seen >= count) { + return; + } + yield* race([ + slot(count).operation, + (function* (): Operation { + yield* sleep(5000); + throw new Error( + `only ${seen} of ${count} answers were journaled; the journal took ${written.join(", ")}`, + ); + })(), + ]); + }; +} + +/** Every answer the entry has retained, in the order they were recorded. */ +function answers(model: ReplModel): readonly ReplElicitation[] { + const found: ReplElicitation[] = []; + const walk = (scope: ReplScope): void => { + found.push(...scope.elicitations); + for (const child of scope.scopes) { + walk(child); + } + }; + if (model.entry !== undefined) { + walk(model.entry); + } + return found; +} + +/** Tab until the control this key names holds focus, the way a person reaches it. */ +function* focusTo(tree: ReplTree, key: string): Operation { + for (let press = 0; press < 200; press++) { + if (keyed(tree) === key) { + return; + } + yield* tree.dispatch({ kind: "key", key: "Tab" }); + } + throw new Error(`focus never reached ${key}`); +} + +/** One Tab, and where it left focus. */ +function* tabbed(tree: ReplTree): Operation { + yield* tree.dispatch({ kind: "key", key: "Tab" }); + return keyed(tree); +} From f2bab53db9d50f584d8e42f14ae41741f44c4795 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 28 Sep 2026 18:28:37 -0400 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=93=9D=20Say=20what=20the=20Elicit=20?= =?UTF-8?q?drawer=20now=20shows,=20and=20check=20the=20reader=20against=20?= =?UTF-8?q?it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bounded form language made two sentences false. The spec said the drawer shows "the one field the schema asks for", and the boundary row froze that reading: one field, its choices, and `undefined` for a schema of another shape. The drawer shows every field the schema declares, with its annotations, its required marker and the values it accepts, and an unsupported schema is refused by name and JSON path rather than read as none. D1 now asserts the complete form and that `{ type: "string" }` throws `ElicitationProviderError` naming `$.type`. The delivery gate found this: the focused three-file gate does not reach `repl-boundaries.test.ts`, so weights run 36489324287 was the first thing to run it. --- packages/cli/tests/repl-boundaries.test.ts | 50 ++++++++++++++++++---- specs/repl-spec.md | 20 +++++---- 2 files changed, 54 insertions(+), 16 deletions(-) diff --git a/packages/cli/tests/repl-boundaries.test.ts b/packages/cli/tests/repl-boundaries.test.ts index 5face6ee..16856a7a 100644 --- a/packages/cli/tests/repl-boundaries.test.ts +++ b/packages/cli/tests/repl-boundaries.test.ts @@ -320,19 +320,53 @@ describe("REPL documentation: what it says is what the code does", () => { it("D1: the spec's Elicit metadata statement matches the real reader", function* () { const spec = yield* read(join(CLI, "..", "..", "specs", "repl-spec.md")); - // The spec says the drawer shows the one field the schema asks for and the - // values it will accept. The reader is what decides that. - expect(spec).toContain("the one field the\nschema asks for"); + // The spec says the drawer shows every field the schema declares, with its + // annotations, whether it is required, and the values it will accept. The + // reader is what decides all of that. + expect(spec).toContain("every field the schema declares"); const form = readQuestionForm({ type: "object", - properties: { decision: { type: "string", enum: ["approve", "decline"] } }, + properties: { + decision: { + type: "string", + enum: ["approve", "decline"], + title: "Decision", + description: "What to do with the draft", + }, + }, required: ["decision"], additionalProperties: false, }); - expect(form).toEqual({ field: "decision", choices: ["approve", "decline"] }); - // And a schema of another shape is not presentable, which is why the spec - // says one field rather than a form in general. - expect(readQuestionForm({ type: "string" })).toBeUndefined(); + // The complete reading, member for member: a form that dropped an + // annotation, a required marker or the condition would be a drawer showing + // less than the spec says it shows. + expect(form).toEqual({ + title: undefined, + description: undefined, + condition: undefined, + fields: [ + { + name: "decision", + title: "Decision", + description: "What to do with the draft", + choices: ["approve", "decline"], + minLength: undefined, + required: true, + }, + ], + }); + // And a schema this language does not model is refused by name and path + // before anything is published, which is why the spec describes the fields + // a schema declares rather than any schema at all. + let refused: unknown; + try { + readQuestionForm({ type: "string" }); + } catch (error) { + refused = error; + } + expect(refused).toBeInstanceOf(Error); + expect(refused instanceof Error ? refused.name : "").toBe("ElicitationProviderError"); + expect(refused instanceof Error ? refused.message : "").toContain("$.type"); }); it("D1: Freedom's recorded provenance still matches its manifest", function* () { diff --git a/specs/repl-spec.md b/specs/repl-spec.md index 80bf47e8..55668275 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -23,14 +23,18 @@ scopes the entry admitted, the bindings its `eval` blocks published, the source generated fragment produced before that fragment was admitted, and each line the document rendered. -When the entry asks a question, its drawer opens: the message, the one field the -schema asks for, and exactly the values it will accept. Type one and press Enter. -The answer is recorded, and what the document renders after it changes because of -the recorded answer rather than because of anything this process remembered. -Escape closes the drawer without answering; the question stays open. An answer -the schema accepts ends the question, and the drawer goes with it: it leaves the -screen and it leaves the location, because a URL naming a drawer nothing mounts -describes a view nobody can be shown. +When the entry asks a question, its drawer opens: the whole message — scrollable +when it is longer than the drawer — and then every field the schema declares, +with its title, its description, whether it is required, and exactly the values +it will accept. Fill them in, or activate one of an enum's offered values, and +press Enter from any of them to offer the whole object. The answer is recorded, +and what the document renders after it changes because of the recorded answer +rather than because of anything this process remembered. An answer the schema +rejects keeps the question open and says what is wrong with it, under the field +it belongs to. Escape closes the drawer without answering; the question stays +open. An answer the schema accepts ends the question, and the drawer goes with +it: it leaves the screen and it leaves the location, because a URL naming a +drawer nothing mounts describes a view nobody can be shown. Press Enter on `[pause]` to stop expansion at its next boundary. `[continue]` exists only while a continuation is actually held — not while a pause is still From e33c6048da730d3e6a07f6a8d1975785a8f46f64 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 28 Sep 2026 22:15:38 -0400 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20Ask=20the=20live=20question?= =?UTF-8?q?=20the=20way=20the=20drawer=20now=20asks=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bounded form language replaced `ReplQuestion.answer(value)` with `submit(values)`, and one existing suite still called the old one. Its focused gate does not typecheck that file, so the first thing to run it was the test-weight measurement, which stopped with fourteen `TS2339`s and wrote no artifact. Every call now submits the object the form assembles, and the two rows that cared about the result say which result it was: `answered` for a value the schema accepts, `invalid` for one it does not — where the old boolean said only true or false. The form assertion says the whole form the reference schema reads as, member for member, rather than the single field and its choices. Tests only. No production code changes, and no behaviour with it. --- packages/cli/tests/repl-execution.test.ts | 49 ++++++++++++++++------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/packages/cli/tests/repl-execution.test.ts b/packages/cli/tests/repl-execution.test.ts index 41f863d9..c565ad5e 100644 --- a/packages/cli/tests/repl-execution.test.ts +++ b/packages/cli/tests/repl-execution.test.ts @@ -196,7 +196,7 @@ function* pausedOrDone(session: ReplSession): Operation { if (session.elicitation.pending !== undefined) { // A walk suspended on a question is not held: pause takes effect at the // walk's next boundary, which it only reaches once the question is over. - session.elicitation.pending.answer("approve"); + session.elicitation.pending.submit({ decision: "approve" }); } const next = yield* race([ states.next(), @@ -230,10 +230,27 @@ describe("REPL execution: submitting one entry", () => { const question = yield* nextQuestion(session); expect(question.message).toContain("Approve Ship the REPL?"); - expect(question.form).toEqual({ field: "decision", choices: ["approve", "decline"] }); + // The whole form the schema reads as, member for member: every field the + // drawer draws, with its annotations, its required marker and the values it + // accepts. + expect(question.form).toEqual({ + title: undefined, + description: undefined, + condition: undefined, + fields: [ + { + name: "decision", + title: undefined, + description: undefined, + choices: ["approve", "decline"], + minLength: undefined, + required: true, + }, + ], + }); expect(session.model.entry?.elicitations).toEqual([]); - expect(question.answer("approve")).toBe(true); + expect(question.submit({ decision: "approve" }).kind).toBe("answered"); yield* session.join(); const entry = session.model.entry; @@ -279,7 +296,7 @@ describe("REPL execution: submitting one entry", () => { expect(latest).toBe(session.overlay.output); expect(session.model.terminal).toBe(undefined); - question.answer("approve"); + question.submit({ decision: "approve" }); yield* session.join(); }); @@ -287,7 +304,7 @@ describe("REPL execution: submitting one entry", () => { const holder = execution(); const source = yield* referenceSource(); const session = opened(yield* submitReplEntry({ ...options(holder), source })); - (yield* nextQuestion(session)).answer("decline"); + (yield* nextQuestion(session)).submit({ decision: "decline" }); yield* session.join(); const generated = session.model.entry?.generated[0]; @@ -328,7 +345,7 @@ describe("REPL execution: submitting one entry", () => { expect(admitted).toBe(true); controlling(session).resume(); - (yield* nextQuestion(session)).answer("approve"); + (yield* nextQuestion(session)).submit({ decision: "approve" }); yield* session.join(); // The admitted fragment then ran where it was written: its rendering @@ -357,7 +374,9 @@ describe("REPL execution: submitting one entry", () => { const session = opened(yield* submitReplEntry({ ...options(holder), source })); const question = yield* nextQuestion(session); - expect(question.answer("maybe")).toBe(false); + // Not an offered value, so the schema rejects it: the question stays open and + // nothing is recorded. + expect(question.submit({ decision: "maybe" }).kind).toBe("invalid"); yield* sleep(10); // An incomplete interaction, not a rejected answer. The provider never @@ -370,7 +389,7 @@ describe("REPL execution: submitting one entry", () => { expect(counted(yield* readAll(holder), "elicit")).toBe(0); expect(session.model.settled).toBe(false); - question.answer("approve"); + question.submit({ decision: "approve" }); yield* session.join(); expect(counted(yield* readAll(holder), "elicit")).toBe(1); }); @@ -397,7 +416,7 @@ describe("REPL execution: reopening one history", () => { const performed = yield* countPerformed(); const session = opened(yield* openReplSession(options(holder))); - (yield* nextQuestion(session)).answer("approve"); + (yield* nextQuestion(session)).submit({ decision: "approve" }); yield* session.join(); const events = yield* readAll(holder); @@ -426,7 +445,7 @@ describe("REPL execution: reopening one history", () => { const asked = yield* countAsked(); const session = opened(yield* submitReplEntry({ ...options(holder), source })); - (yield* nextQuestion(session)).answer("approve"); + (yield* nextQuestion(session)).submit({ decision: "approve" }); yield* session.join(); // The positive control for every count the negatives rely on. Without it, a @@ -475,7 +494,7 @@ describe("REPL execution: reopening one history", () => { yield* scoped(function* () { const session = opened(yield* submitReplEntry({ ...options(holder), source })); expect(holder.stream.onAppend).not.toBe(null); - (yield* nextQuestion(session)).answer("approve"); + (yield* nextQuestion(session)).submit({ decision: "approve" }); yield* session.join(); ended = session; }); @@ -628,7 +647,7 @@ describe("REPL execution: pausing expansion", () => { // Answering lets the already-started operation settle. Its record reaches // the Journal while the pause request is outstanding, which is the whole // point: the expansion position and the Journal head are two positions. - question.answer("approve"); + question.submit({ decision: "approve" }); yield* pausedOrDone(session); const atPause = (yield* readAll(holder)).length; @@ -662,7 +681,7 @@ describe("REPL execution: pausing expansion", () => { expect(afterFirst).toBeGreaterThan(held); expect(controlling(session).released).toBe(afterFirst); - (yield* nextQuestion(session)).answer("approve"); + (yield* nextQuestion(session)).submit({ decision: "approve" }); yield* session.join(); }); @@ -681,7 +700,7 @@ describe("REPL execution: pausing expansion", () => { expect((yield* readAll(holder)).length).toBe(frozen); controlling(session).resume(); - (yield* nextQuestion(session)).answer("approve"); + (yield* nextQuestion(session)).submit({ decision: "approve" }); yield* session.join(); expect(session.model.settled).toBe(true); }); @@ -750,7 +769,7 @@ describe("REPL execution: the boundary inventory", () => { // The controller, not a snapshot of its crossings: `crossings` answers // with what has been crossed *so far*, and so far is nothing yet. const controller = controlling(session); - (yield* nextQuestion(session)).answer("approve"); + (yield* nextQuestion(session)).submit({ decision: "approve" }); yield* session.join(); const crossings = controller.crossings; From 2f0ba006efac10cacb38762e3c1f5b8c972717b4 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 30 Sep 2026 09:51:46 -0400 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=90=9B=20Scroll=20the=20whole=20quest?= =?UTF-8?q?ion,=20and=20refuse=20a=20condition=20Core=20reads=20differentl?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the drawer got wrong. **Everything the form holds now scrolls, not only the message.** At `72×20` the drawer is eleven rows, and the old shape reserved what was left after counting every form row — so at the minimum accepted terminal the frame stopped at `drawer:field:feedback` and `drawer:value:feedback`, `[submit]` and `[close]` were described but never placed. A row layout cannot place is not something a person can see or point at. The complete ordered content — the whole message, the form's description, every field with its annotation, offered values and editable line, every validation message, and `[submit]` — is built in order and then windowed through one bounded viewport. The two scroll controls, `[history]` and `[close]` sit outside that viewport, because they are how a person moves it and leaves. Rows outside the window are not described at all, so they are neither drawn nor pointable, and focus is claimed only by a row the viewport actually shows. The process-local `form.offset` now indexes that whole content, still clamped at both ends and still absent from the route and the Journal. **Two conditionals the form cannot represent are refused.** A tested type other than string is refused at `$.if.properties..type`, because Core evaluates that type while this form reduces the condition to a string equality. An `if` with no `required` is refused at `$.if.required` rather than defaulted to the tested field: `properties` does not require a property to exist, so Core applies `then` where the field is absent, and a form waiting for `a === "x"` would never ask for what Core already requires. Evidence, each row red under its own named control: - scrolling places every essential Plan control in a targetable cell of the real `72×20` frame, collected only from placed cells through `tree.keyOf` — red under **overfill-the-drawer**; - the viewport walks all forty message lines, and `[submit]` never appears before the last of them; - both refusals name their exact path, and two rows compare the reader against Core's own compiled validator: for a numeric tested type Core accepts `{ a: "x" }` with no consequential field, and with no `if.required` Core reports that field missing from `{}` — red under **accept-non-string-condition** and **invent-if-required**. --- packages/cli/src/repl/application.ts | 157 ++++++++--------- packages/cli/src/repl/elicitation.ts | 15 +- packages/cli/tests/repl-forms.test.ts | 235 +++++++++++++++++++++----- specs/repl-spec.md | 12 +- 4 files changed, 297 insertions(+), 122 deletions(-) diff --git a/packages/cli/src/repl/application.ts b/packages/cli/src/repl/application.ts index ccf3f19a..61abbbf2 100644 --- a/packages/cli/src/repl/application.ts +++ b/packages/cli/src/repl/application.ts @@ -398,7 +398,7 @@ export function reduceRepl( model: ReplModel, live: ReplLive, // What the frame can hold, for the one decision that depends on it: how far - // a bounded message region may be scrolled. Narrow is the smallest accepted + // the drawer's content may be scrolled. Narrow is the smallest accepted // frame, so a caller that states none clamps to the tightest capacity. size: ReplTerminalSize = NARROW, ): ReplTransition { @@ -606,9 +606,11 @@ export function reduceRepl( // Clamped at both ends, and stored clamped. An offset kept past the last // window would take several presses to have any visible effect, so the // value held is the one the region is actually showing. - const lines = live.question?.message.split("\n").length ?? 0; - const capacity = messageCapacity(size, live.question, state.form); - const furthest = Math.max(0, lines - capacity); + // Clamped against the whole ordered content, not only the message: the + // viewport is what moves, and the form rows are inside it. + const rows = drawerContentRows(live.question, state.form); + const capacity = drawerCapacity(size); + const furthest = Math.max(0, rows - capacity); const offset = Math.min(Math.max(0, state.form.offset + action.delta), furthest); return settled({ ...state, @@ -1199,12 +1201,20 @@ function drawerFor(view: ReplView, history: Described): Described | undefined { // The whole message, every line of it, through a window that scrolls. A // drawer that showed only the first line — or only the first window — // would be hiding the draft the question is about. + // One viewport over the whole ordered content. Everything a person has to + // read or reach — the complete message, the form's description, every + // field with its annotation, options, editable value, every validation + // message and [submit] — is built in order and then windowed. A drawer too + // short to hold all of it scrolls, rather than describing rows that layout + // has no frame to place. const lines = question.message.split("\n"); - const window = messageWindow( - lines.length, - view.state.form.offset, - messageCapacity(view.size, question, view.state.form), - ); + const capacity = drawerCapacity(view.size); + const last = Math.max(0, drawerContentRows(question, view.state.form) - capacity); + const from = Math.min(Math.max(0, view.state.form.offset), last); + const until = from + capacity; + const content: ReplDescription[] = []; + /** Whether the row about to be built lands inside the viewport. */ + const showing = (): boolean => content.length >= from && content.length < until; children.push( row( "drawer:scroll:up", @@ -1215,21 +1225,11 @@ function drawerFor(view: ReplView, history: Described): Described | undefined { }, ).description, ); - for (const offset of window.shown) { - children.push(drawerLine(`drawer:message:${offset}`, lines[offset] ?? "", width).description); + for (const [offset, text] of lines.entries()) { + content.push(drawerLine(`drawer:message:${offset}`, text, width).description); } - children.push( - row( - "drawer:scroll:down", - pad("[v later]", width), - { select: "scroll", delta: 1 }, - { - here: view.focused, - }, - ).description, - ); if (form.description !== undefined) { - children.push(drawerLine("drawer:form:about", form.description, width).description); + content.push(drawerLine("drawer:form:about", form.description, width).description); } // The same rule inside the modal: the first control claims focus when the // drawer opens, and afterwards traversal inside the drawer owns it. @@ -1239,7 +1239,7 @@ function drawerFor(view: ReplView, history: Described): Described | undefined { const value = view.state.form.values[one.name] ?? ""; const marked = requiredNow(form, view.state.form.values, one) ? "*" : " "; const label = one.title ?? one.name; - children.push( + content.push( row( `drawer:field:${one.name}`, pad(`${marked}${label}: ${value}`, width), @@ -1248,7 +1248,7 @@ function drawerFor(view: ReplView, history: Described): Described | undefined { ).description, ); if (one.description !== undefined) { - children.push( + content.push( drawerLine(`drawer:field:${one.name}:about`, ` ${one.description}`, width).description, ); } @@ -1256,7 +1256,7 @@ function drawerFor(view: ReplView, history: Described): Described | undefined { // What this field accepts, said once. The controls below are how a // value is chosen; this is the line that names the whole set, and it // is what a reader scanning the form reads first. - children.push( + content.push( drawerLine(`drawer:form:${one.name}`, `${one.name}: ${one.choices.join(" | ")}`, width) .description, ); @@ -1264,7 +1264,7 @@ function drawerFor(view: ReplView, history: Described): Described | undefined { // Every offered value, each its own control. A form that drew only the // first would be offering a choice nobody could make. for (const option of one.choices ?? []) { - children.push( + content.push( row( `drawer:choice:${one.name}:${option}`, pad(` ${value === option ? "(x)" : "( )"} ${option}`, width), @@ -1277,11 +1277,14 @@ function drawerFor(view: ReplView, history: Described): Described | undefined { // land while it has focus. The first field's line claims focus when the // drawer opens — including an enum's, because typing an offered value and // pressing Enter is still a way to answer. - const focus: { readonly focus?: true } = entering && !claimed ? { focus: true } : {}; - if (entering && !claimed) { + // Claimed only by a row the viewport actually shows: focusing one that + // scrolled out would put focus where nothing is placed. + const takes = entering && !claimed && showing(); + const focus: { readonly focus?: true } = takes ? { focus: true } : {}; + if (takes) { claimed = true; } - children.push( + content.push( field(`drawer:value:${one.name}`, " = ", value, "answer", { ...focus, here: view.focused, @@ -1290,7 +1293,7 @@ function drawerFor(view: ReplView, history: Described): Described | undefined { } // What the last submission was told, under the form it is about. for (const [offset, message] of view.state.form.messages.entries()) { - children.push( + content.push( drawerLine( `drawer:invalid:${offset}`, message.field === undefined ? message.message : `${message.field}: ${message.message}`, @@ -1298,7 +1301,7 @@ function drawerFor(view: ReplView, history: Described): Described | undefined { ).description, ); } - children.push( + content.push( row( "drawer:form:submit", pad("[submit]", width), @@ -1308,6 +1311,21 @@ function drawerFor(view: ReplView, history: Described): Described | undefined { }, ).description, ); + // Only what the viewport holds becomes a placed cell. A row outside it is + // not described at all, so it is neither drawn nor pointable. + for (const placed of content.slice(from, until)) { + children.push(placed); + } + children.push( + row( + "drawer:scroll:down", + pad("[v later]", width), + { select: "scroll", delta: 1 }, + { + here: view.focused, + }, + ).description, + ); } // The same node the footer would have drawn, inside the modal focus root. @@ -1334,65 +1352,49 @@ function drawerFor(view: ReplView, history: Described): Described | undefined { }; } -/** One value, as the lines a drawer shows it on. */ /** - * How many rows the form's own controls take, whatever the message is. + * The rows the drawer keeps whatever the viewport shows. * - * Counted rather than assumed, because the message region has to give way to - * them: a fixed window would clip the fields, the submit control or the close - * control off the bottom of a narrow drawer, which is exactly the content a - * person needs in order to answer. + * Its own title row, the two scroll controls and `[close]`: how a person moves + * the viewport and leaves, so none of them is ever inside the thing it moves. + * `[history]` is not among them — it is reparented into the drawer's subtree so + * it stays inside the active focus root, but layout places it in the footer + * region, where it costs the drawer no row. */ -function formRows(question: ReplQuestion | undefined, form: ReplFormState): number { - const fields = question?.form.fields ?? []; - let rows = 0; - for (const one of fields) { - // The field row, its editable line, its description when it has one, its - // enum summary when it is an enum, and one row per offered value. - rows += 2; - rows += one.description === undefined ? 0 : 1; - rows += one.choices === undefined ? 0 : 1 + one.choices.length; - } - rows += question?.form.description === undefined ? 0 : 1; - rows += form.messages.length; - // The two scroll controls, [submit], [close], and the reparented [history]. - return rows + 5; -} +const FIXED_DRAWER_ROWS = 4; /** - * How many message lines this drawer can show at this size. + * How many rows of ordered content this drawer can place at this size. * - * Derived from the drawer's own height less what the form needs, so the region - * shrinks rather than pushing anything out of the frame. At least one line, - * because a message region showing nothing would say the message was empty. + * At least one, because a viewport showing nothing would say the question was + * empty. */ -function messageCapacity( - size: ReplTerminalSize, - question: ReplQuestion | undefined, - form: ReplFormState, -): number { - return Math.max(1, drawerHeight(size) - formRows(question, form)); +function drawerCapacity(size: ReplTerminalSize): number { + return Math.max(1, drawerHeight(size) - FIXED_DRAWER_ROWS); } /** - * Which message lines are visible, clamped at both ends. + * How many rows the drawer's ordered content holds in total. * - * Clamping rather than wrapping: a reader who holds a scroll control down - * reaches the end of the message and stays there, rather than arriving back at - * the top having skipped what was in between. + * The complete message, the form's description, every field with its + * annotation, enum summary, offered values and editable line, every validation + * message, and `[submit]`. Counted the same way the rows are built, because the + * reducer clamps a scroll against this before any of them exist. */ -function messageWindow( - total: number, - offset: number, - capacity: number, -): { readonly shown: readonly number[] } { - const last = Math.max(0, total - capacity); - const from = Math.min(Math.max(0, offset), last); - const shown: number[] = []; - for (let line = from; line < Math.min(total, from + capacity); line++) { - shown.push(line); +function drawerContentRows(question: ReplQuestion | undefined, form: ReplFormState): number { + if (question === undefined) { + return 0; + } + let rows = question.message.split("\n").length; + rows += question.form.description === undefined ? 0 : 1; + for (const one of question.form.fields) { + rows += 2; + rows += one.description === undefined ? 0 : 1; + rows += one.choices === undefined ? 0 : 1 + one.choices.length; } - return { shown: Object.freeze(shown) }; + rows += form.messages.length; + // [submit] scrolls with the form it submits. + return rows + 1; } /** The first line of a message, for a control that is one row tall. */ @@ -1427,6 +1429,7 @@ function requiredNow( return condition.requires.some((one) => one.name === field.name); } +/** One value, as the lines a drawer shows it on. */ function detail(value: Json): readonly string[] { return (JSON.stringify(value, undefined, 2) ?? "null").split("\n"); } diff --git a/packages/cli/src/repl/elicitation.ts b/packages/cli/src/repl/elicitation.ts index 6b48da42..e7f42cce 100644 --- a/packages/cli/src/repl/elicitation.ts +++ b/packages/cli/src/repl/elicitation.ts @@ -292,6 +292,12 @@ function conditionOf( refuse("declares a condition that is not an object schema", `$.if.properties.${field}`); } refuseUnknown(test, new Set(["const", "type"]), `$.if.properties.${field}`); + // Core evaluates this type as part of the condition. This form reduces the + // whole condition to one string equality, so a tested type of anything else + // would draw a form that requires different fields than validation does. + if (test["type"] !== undefined && test["type"] !== "string") { + refuse("tests its field as a type other than string", `$.if.properties.${field}.type`); + } const equals = test["const"]; if (typeof equals !== "string") { refuse( @@ -299,7 +305,14 @@ function conditionOf( `$.if.properties.${field}.const`, ); } - const requiredByCondition = namesOf(when["required"] ?? [field], "$.if.required"); + // Not defaulted to the tested field. `properties` does not require a property + // to exist, so an `if` without `required` also matches an object where the + // field is absent — Core would apply `then` there, and this form would be + // waiting for a value the person never has to give. + if (when["required"] === undefined) { + refuse("tests a field without requiring it to be present", "$.if.required"); + } + const requiredByCondition = namesOf(when["required"], "$.if.required"); if (requiredByCondition.length !== 1 || requiredByCondition[0] !== field) { refuse("requires something other than the field it tests", "$.if.required"); } diff --git a/packages/cli/tests/repl-forms.test.ts b/packages/cli/tests/repl-forms.test.ts index c414fef2..46e1229b 100644 --- a/packages/cli/tests/repl-forms.test.ts +++ b/packages/cli/tests/repl-forms.test.ts @@ -13,7 +13,12 @@ import { beforeAll, describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { race, sleep, spawn, withResolvers } from "effection"; import type { Operation, Result } from "effection"; -import { Elicitation, useTempFileCompiler } from "@executablemd/core"; +import { + Elicitation, + prepareElicitation, + useTempFileCompiler, + validateParsed, +} from "@executablemd/core"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { Json } from "@executablemd/core"; @@ -36,12 +41,13 @@ import type { ReplTransition, ReplView, } from "../src/repl/application.ts"; -import { NARROW } from "../src/repl/layout.ts"; +import { layout, NARROW } from "../src/repl/layout.ts"; import { fields, readDescription } from "../src/repl/description.ts"; 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 { replSurface } from "../src/repl/application.ts"; import type { ReplTree } from "../src/repl/reconcile.ts"; import { submitReplEntry } from "../src/repl/session.ts"; import type { ReplSession } from "../src/repl/session.ts"; @@ -310,6 +316,37 @@ describe("F1 — the bounded language is exact", () => { }, "$.then.type", ], + [ + // Core evaluates the tested type. This form reduces the condition to a + // string equality, so a numeric test would draw a form whose required + // fields disagree with validation. + "a condition that tests a type other than string", + { + type: "object", + properties: { a: { type: "string" }, b: { type: "string" } }, + additionalProperties: false, + if: { + type: "object", + properties: { a: { type: "number", const: "x" } }, + required: ["a"], + }, + then: { type: "object", required: ["b"] }, + }, + "$.if.properties.a.type", + ], + [ + // `properties` does not require a property to exist, so this condition + // also matches an object with no `a` at all. + "a condition that does not require the field it tests", + { + type: "object", + properties: { a: { type: "string" }, b: { type: "string" } }, + additionalProperties: false, + if: { type: "object", properties: { a: { const: "x" } } }, + then: { type: "object", required: ["b"] }, + }, + "$.if.required", + ], ]; for (const [what, schema, path] of refusals) { @@ -614,69 +651,127 @@ function framed(state: ReplState, live: ReplLive, size = NARROW, focused?: strin const DRAFT = Array.from({ length: 40 }, (_, line) => `draft line ${line}`).join("\n"); describe("F3 — complete content and reachable navigation", () => { - it("F3: every field, option and annotation is drawn, not just the first", function* () { - const asked = yield* askingFor(PLAN_SCHEMA); - const rows = framed(opened(asking(asked.question)), asking(asked.question)); - const keys = rows.map((one) => one.key); - // Both fields, and every offered value — not the first of either. - expect(keys).toContain("drawer:field:decision"); - expect(keys).toContain("drawer:field:feedback"); - for (const option of ["Approve", "Request changes", "Stop"]) { - expect(keys).toContain(`drawer:choice:decision:${option}`); + /** Every key layout actually placed, with whether it can be pointed at. */ + function* placedKeys( + tree: ReplTree, + view: ReplView, + ): Operation> { + yield* applied(tree, view); + const frame = layout(NARROW, replSurface(tree, view)); + const keys = new Map(); + for (const cell of frame.cells) { + const key = tree.keyOf(cell.node); + if (key !== undefined) { + keys.set(key, cell.targetable); + } + } + return keys; + } + + /** The essential Plan controls a person has to be able to reach. */ + const ESSENTIAL = [ + "drawer:field:decision", + "drawer:choice:decision:Approve", + "drawer:choice:decision:Request changes", + "drawer:choice:decision:Stop", + "drawer:value:decision", + "drawer:field:feedback", + "drawer:value:feedback", + "drawer:form:submit", + "drawer:close", + "footer:history", + ]; + + it("F3: scrolling places every essential control in the narrow frame", function* () { + const asked = yield* askingFor(PLAN_SCHEMA, DRAFT); + const live = asking(asked.question); + const tree = yield* useReplTree(); + + // Walked from the first clamped position to the last, through the real + // tree and the real placement boundary. A described node layout never + // placed is not something a person can see or point at, so only placed + // cells count here. + let state = opened(live); + const reached = new Map(); + for (let press = 0; press < 120; press++) { + for (const [key, targetable] of yield* placedKeys( + tree, + reading(state, live, EMPTY_MODEL, NARROW, undefined), + )) { + if (targetable || !reached.has(key)) { + reached.set(key, targetable || (reached.get(key) ?? false)); + } + } + const next = reduceRepl(state, { kind: "scroll", delta: 1 }, EMPTY_MODEL, live, NARROW).state; + if (next.form.offset === state.form.offset) { + break; + } + state = next; + } + + for (const key of ESSENTIAL) { + expect([key, reached.has(key)]).toEqual([key, true]); + expect([key, reached.get(key)]).toEqual([key, true]); } - expect(keys).toContain("drawer:value:feedback"); - expect(keys).toContain("drawer:form:submit"); - expect(keys).toContain("drawer:close"); }); - it("F3: the whole message is reachable through the window, and clamps", function* () { + it("F3: the viewport walks the whole message before the form controls", function* () { const asked = yield* askingFor(PLAN_SCHEMA, DRAFT); const live = asking(asked.question); - const first = framed(opened(live), live); const shown = (rows: ReturnType): string[] => rows.filter((one) => one.key.startsWith("drawer:message:")).map((one) => one.label.trim()); + + const first = framed(opened(live), live); // Not every line at once, and the first window starts at the beginning. expect(shown(first).length).toBeGreaterThan(0); expect(shown(first).length).toBeLessThan(40); expect(shown(first)[0]).toBe("draft line 0"); - // Scrolled to the end, and clamped there rather than wrapping. + // Every message line is encountered, and all of them before the form's + // own controls come into view. let state = opened(live); - for (let press = 0; press < 80; press++) { - state = reduceRepl(state, { kind: "scroll", delta: 1 }, EMPTY_MODEL, live, NARROW).state; + const seenLines: string[] = []; + let sawSubmit = false; + let submitBeforeLastLine = false; + for (let press = 0; press < 120; press++) { + const rows = framed(state, live); + for (const label of shown(rows)) { + if (!seenLines.includes(label)) { + seenLines.push(label); + } + } + if (rows.some((one) => one.key === "drawer:form:submit")) { + sawSubmit = true; + if (seenLines.length < 40) { + submitBeforeLastLine = true; + } + } + const next = reduceRepl(state, { kind: "scroll", delta: 1 }, EMPTY_MODEL, live, NARROW).state; + if (next.form.offset === state.form.offset) { + break; + } + state = next; } - const end = shown(framed(state, live)); - expect(end[end.length - 1]).toBe("draft line 39"); + + expect(seenLines).toHaveLength(40); + expect(seenLines[0]).toBe("draft line 0"); + expect(seenLines[39]).toBe("draft line 39"); + expect(sawSubmit).toBe(true); + expect(submitBeforeLastLine).toBe(false); + + // Clamped at the end rather than wrapping, and one press back moves + // immediately because the stored offset was clamped. const held = state.form.offset; state = reduceRepl(state, { kind: "scroll", delta: 1 }, EMPTY_MODEL, live, NARROW).state; expect(state.form.offset).toBe(held); - - // One press back moves immediately, because the stored offset is clamped - // rather than sitting past the last window. const back = reduceRepl(state, { kind: "scroll", delta: -1 }, EMPTY_MODEL, live, NARROW).state; expect(back.form.offset).toBe(held - 1); - expect(shown(framed(back, live))).not.toEqual(end); // And scrolling changed no value and no route. expect(back.form.values).toEqual({}); expect(back.route.drawers.map((one) => one.kind)).toEqual(["live-elicit"]); }); - it("F3: the narrow drawer still draws its fields, submit and close", function* () { - const asked = yield* askingFor(PLAN_SCHEMA, DRAFT); - const live = asking(asked.question); - for (const size of [NARROW, WIDE]) { - const keys = framed(opened(live), live, size).map((one) => one.key); - // The message region gives way to the controls rather than pushing them - // out of a frame this small. - expect(keys).toContain("drawer:field:decision"); - expect(keys).toContain("drawer:field:feedback"); - expect(keys).toContain("drawer:form:submit"); - expect(keys).toContain("drawer:close"); - expect(keys).toContain("footer:history"); - } - }); - it("F3: the one History control is inside the drawer while it is open", function* () { const asked = yield* askingFor(PLAN_SCHEMA); const live = asking(asked.question); @@ -1046,3 +1141,63 @@ function* tabbed(tree: ReplTree): Operation { yield* tree.dispatch({ kind: "key", key: "Tab" }); return keyed(tree); } + +describe("F1 — the form's conditional means exactly what Core validates", () => { + beforeAll(() => useTempFileCompiler()); + + /** What this reader did with a schema, as a refusal path or a drawn condition. */ + function* readingOf(schema: Json): Operation { + try { + const form = readQuestionForm(schema); + return `drew a condition on ${form.condition?.field ?? "nothing"}`; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + } + + it("F1: a numeric tested type is false where a string equality would be true", function* () { + const schema: Json = { + type: "object", + properties: { a: { type: "string" }, b: { type: "string" } }, + additionalProperties: false, + if: { + type: "object", + properties: { a: { type: "number", const: "x" } }, + required: ["a"], + }, + // `b` is declared here because Core compiles in strict mode, which + // requires a `required` name to be defined in the same subschema. + then: { type: "object", required: ["b"], properties: { b: { type: "string" } } }, + }; + + // What Core actually does: `a` is the string "x", the tested type is + // number, so the condition is false, `then` never applies and `b` is not + // required. The object validates. + const prepared = yield* prepareElicitation(schema, "Elicit"); + expect(validateParsed(prepared.validate, { a: "x" })).toEqual([]); + + // A form that reduced this to `a === "x"` would demand `b` for an answer + // Core accepts without it, so the reader refuses rather than disagree. + expect(yield* readingOf(schema)).toContain("$.if.properties.a.type"); + }); + + it("F1: an if with no required also matches the field being absent", function* () { + const schema: Json = { + type: "object", + properties: { a: { type: "string" }, b: { type: "string" } }, + additionalProperties: false, + if: { type: "object", properties: { a: { const: "x" } } }, + then: { type: "object", required: ["b"], properties: { b: { type: "string" } } }, + }; + + // What Core actually does: `properties` does not require `a` to exist, so + // the condition holds for an object with no `a` at all, `then` applies and + // the missing `b` is reported. + const prepared = yield* prepareElicitation(schema, "Elicit"); + expect(validateParsed(prepared.validate, {}).length).toBeGreaterThan(0); + + // A form that waited for `a === "x"` before asking for `b` would never ask + // for a field Core already requires, so the reader refuses. + expect(yield* readingOf(schema)).toContain("$.if.required"); + }); +}); diff --git a/specs/repl-spec.md b/specs/repl-spec.md index 55668275..15e1cb1c 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -23,10 +23,14 @@ scopes the entry admitted, the bindings its `eval` blocks published, the source generated fragment produced before that fragment was admitted, and each line the document rendered. -When the entry asks a question, its drawer opens: the whole message — scrollable -when it is longer than the drawer — and then every field the schema declares, -with its title, its description, whether it is required, and exactly the values -it will accept. Fill them in, or activate one of an enum's offered values, and +When the entry asks a question, its drawer opens: the whole message, and then +every field the schema declares, with its title, its description, whether it is +required, and exactly the values it will accept. All of that is one ordered +whole, and it is that whole — not the message alone — that scrolls when it is +taller than the drawer. The earlier and later controls, the title and the close +control stay put while the content moves beneath them, so however short the +drawer is, scrolling reaches every field, every offered value and the control +that submits. Fill them in, or activate one of an enum's offered values, and press Enter from any of them to offer the whole object. The answer is recorded, and what the document renders after it changes because of the recorded answer rather than because of anything this process remembered. An answer the schema