diff --git a/architecture.md b/architecture.md index d9cd86fe8..28fa29340 100644 --- a/architecture.md +++ b/architecture.md @@ -6686,6 +6686,19 @@ pending one from starting rather than appending on its behalf. Settled segments are projected and never replayed; only an unfinished final segment is given back to Core to continue. +Entry Stop is the one way a running entry ends as interrupted. It cancels that +entry's task, retains a final interrupted outcome in the entry's own segment and +joins the task's cleanup, and only then do both facts above hold for a +successor. The outcome is an interruption, never a success inferred from +cancellation: effects the entry completed stand, output it produced stays +partial, and the segment is settled — projected like any other and never handed +back to Core, so the stopped entry cannot resume as that same entry. Exit is +different and keeps its recovery. It appends no outcome, so an entry it leaves +running is still the unfinished final segment, and reopening the history +continues it. Entry Stop belongs to the session that owns the entry; the +Sidekick's own Stop and its Follow setting are the response owner's, described +under [Follow and the two Stops](#follow-and-the-two-stops). + A location is resolved against the retained history before a session opens. Opening one starts or resumes the execution, and replay past the retained prefix appends — so a route naming a scope or drawer the file never held is answered @@ -7030,18 +7043,39 @@ one resolved reading → candidate rows + a layout manifest ``` **The engine places; this boundary only states constraints.** Profiles, column -widths, the drawer's integer rectangle and the footer's seven rows are product -facts declared once; where a row lands and how many rows a region holds are +widths, the drawer's integer rectangle and the fixed rows a composition keeps +above and below its body are product facts declared once; where a row lands and how many rows a region holds are answers the engine gives. Nothing computes a cell coordinate or a capacity twice, because a question asked in two places is a question answered two ways. **A column's width can be a legibility requirement rather than a share.** The -sidebar is a fixed thirty-six columns at both shared profiles, because the rows -in it are text whose meaning is lost when it is cut: the fitter keeps a turn's -state whole and degrades to a hard cut only when even that does not fit, so the -column has to be wide enough that it never does. The Transcript and the right -pane divide what is left. A geometry change that narrows this column is a change -to what the product can say. +left column is a fixed thirty-six columns at both shared profiles, because the +rows in it are text whose meaning is lost when it is cut: the fitter keeps a +turn's state whole and degrades to a hard cut only when even that does not fit, +so the column has to be wide enough that it never does. The centre and right +columns divide what is left — halved in the ordinary composition, and 66 and 58 +of 160 in the staged one below. A geometry change that narrows the left column +is a change to what the product can say. + +**One staged composition is under image review.** The screen the root draws is +the v2 composition, which is the wide screen #971 accepted: a Transcript of agent +work on the left, the selected work with its breadcrumbs, exact source, state, +results and its own multi-row Draft in the centre, and Bindings/Sidekick on the +right with the trays this build offers above the composer and the Sidekick's +Send/Stop beside them, under one identity row and above one compact History row +and one guidance row. It is selected by a view carrying the inert workspace +presentation, and the root supplies that presentation from what this execution +actually holds: the selected tray, no queued rows because this build has no +shared queue, and the entries its Sidekick's acknowledged Submits admitted. A +build without the continuation Follow governs offers neither Follow nor a QUEUE +tab, so neither is drawn and nothing dispatches them. It is not a second +pipeline: it is the same reading, prepared, measured, admitted, reconciled into +the one mounted tree and drawn by the same renderer pair, and every rule in this +section holds for it unchanged. Its first geometry at 160x36 — columns of 36, 66 +and 58 cells with their dividers, 33 body rows, a four-row Draft — and its cell +adaptations of the reference await the Product Owner's image acceptance; no +capture of it is a regression target until then, and the 120x30 and 72x20 +compositions follow that review. **A row's surface comes from its semantic role, never from its key or its label.** `surfaceOf()` maps roles to backgrounds, and a window's two navigation @@ -7803,8 +7837,8 @@ accepted separation rules out. A pane's title sits in a band with a surface of its own, and that band's bottom border is the header's accepted edge: the engine takes that row out of the band, so the rows below are built to what it leaves and the two passes account for the same decoration. A titled column therefore holds -one row fewer of its reading than an untitled one, and the footer keeps its seven -rows — the edge belongs to the header, not to the footer. A pane's own edges are not part of the room inside it: a bordered +one row fewer of its reading than an untitled one, and the rows below the body keep +their count — the edge belongs to the header, not to the footer. A pane's own edges are not part of the room inside it: a bordered pane carries a border-free content box, and rows are built to the width the engine reports for that box rather than to the pane's outer bound. A drawer's own decoration is held to the same rule: the rule along the top of its rectangle and @@ -7887,12 +7921,15 @@ The renderer stays model-blind — it receives ops and geometry, classifies no row text, and no production string carries an escape sequence. -The layout owns where things are, and the footer's seven rows are fixed in one -order: the contextual status and action row, the five History rows, and the draft. -Each is stated as geometry rather than inferred from a list's length, because the -one thing on that screen that is not a mounted cell is the History band — its -labels come from the model, so nothing else can say where it goes, and a renderer -left to place it on its own put it at the terminal's origin over the sidebar. A +The layout owns where things are. The ordinary composition fixes its footer's +seven rows in one order: the contextual status and action row, the five History +rows, and the draft. The staged composition fixes one identity row above the body +and two rows below it — compact History, with the contextual action controls +right-aligned on it, then the guidance — and draws the draft as a field of the +selected-work column. Each is stated as geometry rather than inferred from a +list's length, because History is not a mounted reading — its labels come from +the model, so nothing else can say where it goes, and a renderer left to place it +on its own put it at the terminal's origin over the left column. A control the row cannot hold whole is left out of the frame entirely, so what a pointer can reach and what a person can see are the same set. @@ -8588,7 +8625,7 @@ travels with the results, which is how a correction becomes possible at all, together with the statement that what already happened stands — so the reply continues rather than repeating effects, and earlier successes are not replayed. -**Stop ends the asking and nothing else.** The cycle runs in a task of its own +**Sidekick Stop ends the asking and nothing else.** The cycle runs in a task of its own precisely so Stop has something to halt: it suspends in three places a person may want out of — provider generation, a response's controls, and a Submit waiting for its entry — and none of them can be interrupted by a flag read @@ -8607,6 +8644,50 @@ only the waiting, and the submission's intent stays retained with no outcome — an operation that began and whose result nobody knows, which is exactly the state Stop leaves. +#### Follow and the two Stops + +**Follow is not in the shipped build.** The layout and the two Stops ship +first; the continuation Follow governs ships with the shared queue, so no screen +offers the choice today and nothing watches an entry to start a turn after it. +What this section states about Follow is the contract that delivery is held to +rather than behaviour a reader can exercise now. **The two Stops below are +implemented**: an entry's and the Sidekick's, each owning exactly what it says. + +Follow continues a Sidekick Send through the evaluation that Send invokes. It is +a setting of the running REPL: on when the REPL starts, kept across rounds and +navigation, and saved nowhere, so no preference, history or configuration record +carries it between sessions. A round is one admitted entry the Sidekick +submitted during Send, never a provider turn, and only such an entry takes part +— an entry sent from the Draft has no continuation. The continuation is created +and owned by that evaluation, so no observer, orchestration loop or scheduler +watches entry outcomes and starts turns on its own. + +When the exact submitted entry settles, and the current exchange and its cleanup +have joined, the continuation supplies two separate facts: the entry's actual +outcome — completed, failed or interrupted, each distinct — and its actual +output. The Submit answer itself stays the receipt above, the outcome and the +read instruction, so output never reaches the reply through status prose. With +Follow on, the next eligible queued message is served before another autonomous +round, under the queue's own ordering, holds and refusals (#964); nothing here +grants one origin priority over another or restarts a held queue. Turning Follow +off ends automatic continuation after the current round and does not interrupt +it. The authority of the surrounding contract is unchanged: fatal storage, +disclosure and cleanup failures keep their outcome, feedback whose acceptance is +unknown is never resent, and only explicit evaluated Output becomes the +assistant's chat — the facts of associated work are shown beside the +conversation as work, not as something the Sidekick said. + +There are two Stops, with two owners. Sidekick Stop is the response owner's: it +ends the current Send and its continuation, joins both, and leaves Follow on for +the next explicit Send. It is available while the reply is generating and while +the Sidekick's question is waiting, with or without an entry. An admitted entry +keeps running on its own terms, and its later settlement cannot restart the +continuation that was stopped. Entry Stop is the session's: it finishes the +running entry as interrupted, as [sequential entries](#sequential-entries-over-one-physical-journal) +states, and that interrupted outcome reaches the Sidekick as the round's result +while Follow stays on. Shared TODO context is not part of either: it belongs to +the shared TODO work (#901). + **The boundary is the exact entry's acknowledged admission, and an allocated task is not it.** A submission's task is started *before* the record that gives its entry a position, and that interval is real: the execution installs its @@ -8622,9 +8703,11 @@ does nothing at all, which is what keeps an admitted entry independent. Two consequences follow. A cancelled startup frees the slot it held, because it admitted nothing and there is no entry for anything to follow — whereas an -*entry* that was interrupted still occupies it, since nothing may follow an -entry without an outcome; the two are told apart by that same acknowledgement -and by nothing else. And the reservation withdraws itself as its submission +admitted *entry* holds it until it has an outcome and its cleanup has joined, +since nothing may follow an entry without one. An entry left unfinished by Exit +or by a lost process keeps holding it; one finished by Entry Stop releases it +once its interrupted outcome is retained and its cleanup has joined. The startup +and the entry are told apart by that same acknowledgement and by nothing else. And the reservation withdraws itself as its submission unwinds: `live` means the asker still wants this, and a cancelled submission is the asker not wanting it, so the reservation answers for itself instead of being revoked by a separate call that would have to be sequenced correctly. diff --git a/packages/cli/src/repl/application.ts b/packages/cli/src/repl/application.ts index ab03a3682..2bfaf7acf 100644 --- a/packages/cli/src/repl/application.ts +++ b/packages/cli/src/repl/application.ts @@ -53,6 +53,33 @@ import { tabProps, tabRowProps, viewportProps, + bandRowProps, + BODY_GUTTER, + fittedPartProps, + fittedRowProps, + flatten, + GROWING_STACK_PROPS, + inlineActionRowProps, + partsRowProps, + SPACER_PROPS, + statedActionRowProps, + statedPartProps, + WORKSPACE_DRAFT_GAP, + WORKSPACE_DRAFT_ROWS, + WORKSPACE_CHROME_ROWS, + WORKSPACE_LIST_GUTTER, + WORKSPACE_QUEUE_ROWS, + workspaceBodyRows, + TAB_GAP, + workspaceBlockProps, + bandBlockProps, + workspaceBodyProps, + workspaceColumnProps, + workspaceConversationWidth, + workspaceHeaderProps, + workspaceInsideWidth, + workspaceTranscriptWidth, + workspaceWorkWidth, } from "./layout.ts"; import type { ReplBounds, @@ -73,14 +100,19 @@ import type { ReplAdmission, ReplWindow } from "./layout-admission.ts"; import { ORDINARY, REPL_PALETTE, styleOf } from "./presentation-style.ts"; import type { ReplPresentationRole, ReplRowStyle } from "./presentation-style.ts"; import { jsonRuns, sourceRuns } from "./presentation-text.ts"; -import { NO_NAVIGATION, numbered, pointLabel } from "./navigation.ts"; +import { HEAD_LABELS, NO_NAVIGATION, numbered, pointLabel } from "./navigation.ts"; import type { ReplHistoryNavigation } from "./navigation.ts"; import { blankBand, EMPTY_BAND } from "./history-rail.ts"; import type { ReplPreparedRail } from "./history-rail.ts"; import { entryReading, NO_READING } from "./source-reading.ts"; import type { ReplReading } from "./source-reading.ts"; import { readingRuns } from "./fitting.ts"; -import type { ReplFittedLine, ReplPreparedReading, ReplPreparedRow } from "./fitting.ts"; +import type { + ReplFittedLabel, + ReplFittedLine, + ReplPreparedReading, + ReplPreparedRow, +} from "./fitting.ts"; // The one gate, reached from here as well as from the root. A pure decision // module: no session, no repository, no resolver and no authority crosses it, // so the reduction stays a reduction while still refusing on exactly the terms @@ -88,13 +120,7 @@ import type { ReplFittedLine, ReplPreparedReading, ReplPreparedRow } from "./fit import { cleared, clearedMessage, edited, initialRevisions, submittable } from "./sidekick/gate.ts"; import type { Instruction, Reservation, Revisions, Submitter } from "./sidekick/gate.ts"; import type { ReplTerminalSize } from "./terminal.ts"; -import { - decodeLocation, - encodeLocation, - NO_LIVE, - resolveLocation, - surfaceForDrawer, -} from "./route.ts"; +import { decodeLocation, encodeLocation, resolveLocation, surfaceForDrawer } from "./route.ts"; import type { ReplDrawerRef, ReplLiveAvailability, @@ -357,7 +383,57 @@ export interface ReplSidekickSetup { readonly failure: string | undefined; } +/** + * One acknowledged Submit, as the coordinates that identify what it admitted. + * + * A detached reading of a record this history already holds, carrying no + * handle, no source to re-run and nothing a caller could reach a writer + * through: which chat and which response performed the control, which entry + * slot it reserved, which entry key it named and the exact source it reserved + * that slot with. + * + * All five, because a retained intent is not by itself an admission. The slot + * says where in this history the reservation was taken, the key says what it + * named, and the source says what it reserved — an entry that is not the one + * this reservation named, or not at the slot it took, or not holding the source + * it reserved, is a different entry and this Submit admitted nothing a reader + * may be shown. + */ +export interface ReplSidekickSubmit { + readonly conversation: string; + readonly response: string; + /** How many entries preceded the reservation, which names the slot. */ + readonly ordinal: number; + readonly entry: string; + readonly source: string; + /** Whether this history retained what became of it. */ + readonly settled: boolean; + /** + * Whether that retained outcome observed an entry this control admitted. + * + * The history's own answer, written by the only thing that knew: a Submit + * observes its entry once it has one to observe, a submission refused before + * any entry started observes nothing, and a late reconciliation of a + * reservation nobody resolved writes the entry it found or nothing where it + * found the slot was never taken. + * + * Separate from whether what was admitted then succeeded. An entry that ran + * and failed was still admitted by the control that submitted it. + */ + readonly admitted: boolean; +} + export interface ReplSidekickReading { + /** + * Every Submit this history acknowledged, in its own record order. + * + * Beside the chat rather than inside it, because they answer different + * questions: a chat row is what a conversation said, and this is what a reply + * of that conversation actually did to this execution's entries. Absent is a + * reading that establishes none — not a reading that establishes there were + * none, which is why nothing reads its absence as an empty list of facts. + */ + readonly submits?: readonly ReplSidekickSubmit[]; /** * Every chat's acknowledged rows, oldest first, each naming its own chat. * @@ -1011,6 +1087,30 @@ export interface ReplState { * submission are paused — not refused forever, and not silently resumed. */ readonly takenOver: boolean; + /** + * Whether the Transcript's conversation selector is showing its choices. + * + * Process-local, like the question form and the Configure drawer: whether a + * chooser happens to be open is a fact about somebody standing in front of + * this terminal, and a location that carried it would describe a screen + * rather than a reading. Which conversation is *chosen* is the route's + * {@link ReplRoute.session}, and closing the chooser leaves it alone. + */ + readonly filtering: boolean; + /** + * Whether the Entries catalog is open over this reading. + * + * Process-local, beside the question form and the conversation chooser, and + * for the same reason: a drawer somebody has open is a fact about them + * standing in front of this terminal, not part of the location. A printed + * location carries which entry and which scope are being read — the + * destinations — and never the overlay somebody reached them through. + * + * It is a presentation and nothing more. Opening it selects the Entries + * reading and changes no scope, no marker, no filter and neither draft; what + * changes a destination is activating a row in it. + */ + readonly catalog: boolean; } /** What the root must perform, because a component cannot. */ @@ -1040,6 +1140,14 @@ export type ReplIntent = } /** Stop the Sidekick's own work, and join it. */ | { readonly kind: "stop-sidekick" } + /** + * Stop this exact entry, and join everything it owned. + * + * The key travels with it because the root performs this against a session + * that may have moved on: the frame that drew the control knew which entry + * it was drawing against, and that is the entry a person pressed Stop on. + */ + | { readonly kind: "stop-entry"; readonly entry: string } | { readonly kind: "pause" } | { readonly kind: "continue" } | { readonly kind: "answer"; readonly values: Readonly> } @@ -1191,6 +1299,230 @@ export interface ReplView { * rather than something the renderer decorates. */ readonly focused: string | undefined; + /** + * The staged workspace composition's own presentation, where a caller gave one. + * + * Inert: what Follow shows, which tray is selected, the messages waiting + * behind the active exchange and which entry a Sidekick message submitted. No + * owner, writer, reservation or provider is reachable through it. The root + * never supplies it, so the screen a command draws is the ordinary + * composition; a wide view carrying it is drawn as the #971 composition under + * image review. + */ + readonly workspace?: ReplWorkspace; +} + +/** One message waiting behind the active exchange, as the staged screen lists it. */ +export interface ReplQueuedMessage { + readonly key: string; + readonly origin: MessageOrigin; + readonly text: string; +} + +/** The entry one Sidekick message submitted, shown after that message as work. */ +interface ReplAssociatedWork { + /** The chat row the work follows, by the key the reading gives that row. */ + readonly message: string; + /** The entry, by its catalog key. Its state and output are read from the model. */ + readonly entry: string; +} + +/** What the staged workspace composition shows that no reading holds yet. */ +export interface ReplWorkspace { + /** + * Whether this execution offers Follow at all, and how it stands. + * + * Absent is a build that does not have the continuation Follow would govern. + * Its control is then not drawn: a checkbox for behaviour that is not here + * would be a choice somebody could make and nothing would happen, and + * drawing it unchecked would say this execution had turned something off. + */ + readonly follow?: boolean; + /** + * Which trays this execution offers, in the order they are named. + * + * Absent is TODO alone, which is what a build without the shared queue + * holds. A tab for a queue that does not exist is a tab a reader can select + * and find nothing behind. + */ + readonly trays?: readonly ("todo" | "queue")[]; + readonly tray: "todo" | "queue"; + readonly queued: readonly ReplQueuedMessage[]; + readonly work: readonly ReplAssociatedWork[]; + /** + * How far the tray's own window is scrolled through those messages. + * + * Part of this inert presentation rather than of the screen's state, for the + * reason the rest of it is: nothing here admits, orders, withdraws or holds a + * message. Admission clamps it against the room the frame measured, so a + * value past the end shows the last messages rather than nothing. + */ + readonly queueOffset?: number; +} + +/** The same view, drawn as the staged workspace composition where it is wide. */ +export function withWorkspace(view: ReplView, workspace: ReplWorkspace): ReplView { + return Object.freeze({ ...view, workspace }); +} + +/** + * What the ordinary root's own screen stages, from what it actually holds. + * + * Everything here is a fact this reading already carries or a part of the + * screen that is honestly empty. Nothing is supplied so that a control has + * something to point at: + * + * - **Associated work** is the association between a chat row and the entry + * that row's evaluation admitted. It is read out of the acknowledged + * Submits this history already holds — see {@link associatedWork} — so a + * reply that really admitted an entry shows that entry's own state and + * output after it, and a reply that admitted nothing shows nothing. No + * message is given an entry it did not submit. + * - **Queued messages** are the shared queue's, and there is no shared queue + * here. An empty tray is what this execution holds; a populated one would be + * a fiction in a screen somebody is working from. + * - **Follow** is not offered. What it governs — a reply that evaluates and + * admits a round, and the continuation after it — is a later delivery, and + * a checkbox for behaviour this build does not have would be a choice that + * does nothing however it was drawn. Its accepted setting is kept in the + * delivery record, not advertised here. + * - **QUEUE** is not offered either, and for the same reason: there is no + * shared queue in this build, and a tab a reader can select and find nothing + * behind is worse than no tab. + * + * So the tray is TODO, which is the inactive placeholder it has always been. + */ +export function workspaceOf( + /** + * The reading this frame is drawn from, which is the selected prefix. + * + * An association is only ever shown for an entry this reading holds: a + * person standing at an earlier position is reading a history in which the + * later entry was never admitted, and a row for it there would be a row + * about something that has not happened in the history they are looking at. + */ + model?: ReplModel, + /** What this execution's Sidekick acknowledged, where the frame has one. */ + sidekick?: ReplSidekickReading, +): ReplWorkspace { + return Object.freeze({ + tray: "todo" as const, + queued: Object.freeze([]), + work: associatedWork(model, sidekick), + }); +} + +/** + * The entries this reading's own acknowledged Submits admitted, by the row + * that performed each one. + * + * Every association is an identity this history already holds, matched whole. + * A reply names the chat and the response it was, which is the row's own key; + * a Submit record names the slot it reserved, the entry key it named and the + * exact source it reserved that slot with. An entry is this Submit's work when + * the reading holds one at that slot, under that key, with that source — all + * three, because any one of them alone would hand a reply somebody else's + * entry: + * + * - the **slot** alone would follow admission order, so a Submit whose + * reservation was never honoured would take whatever landed there next; + * - the **key** alone would survive a prefix that admitted a different entry + * under the same ordinal name; + * - the **source** alone is not an identity at all — two entries may hold the + * same source, and often do. + * + * And the three of them together are still not enough, because a person may + * type the very source a refused reply reserved and run it themselves at the + * same slot. So what the control itself came to is asked first, from the + * observation its own outcome retained. + * + * So a reservation nobody honoured, a refusal whose slot was later reused, a + * startup that never admitted, and an entry outside the selected prefix each + * produce no row, which is the truthful answer in all four cases. + * + * Nothing here reads a receipt's prose, infers from the latest entry, its order + * or its title, or supplies an entry for a reply that admitted none. What the + * row then *says* — running, completed, failed, interrupted, and whatever that + * entry actually printed — is read from the model where it is drawn, never from + * a reply's wording. + */ +function associatedWork( + model: ReplModel | undefined, + sidekick: ReplSidekickReading | undefined, +): readonly ReplAssociatedWork[] { + if (model === undefined || sidekick === undefined || sidekick.submits === undefined) { + return Object.freeze([]); + } + const found: ReplAssociatedWork[] = []; + for (const submit of sidekick.submits) { + // What this history says became of the control itself, before anything is + // asked about the slot. A settled Submit that observed no entry admitted + // none — it was refused before one started, or a reopening found the slot + // it reserved was never taken — and the slot being occupied afterwards is + // not an account of what this control did. Measured: a Submit refused in + // preflight, whose exact reserved source was then typed in and run by hand + // at the same slot under the same key, was credited with that person's + // entry and its output. + // + // An *unsettled* one is the other state and not the same one: this process + // is still in the submission, so what it admitted is the entry it reserved + // and the row says what that entry is doing. A reopening never reads one — + // the cold path resolves a reservation nobody answered, and what it finds + // is retained as the outcome this reads. + if (submit.settled && !submit.admitted) { + continue; + } + const admitted = model.entries[submit.ordinal]; + if ( + admitted === undefined || + admitted.key !== submit.entry || + admitted.source !== submit.source + ) { + continue; + } + // The row that performed it, by the identity a reply is drawn under. A + // response this reading composed no row for is a response nothing on this + // screen could put the work after. + const message = `chat:${submit.conversation}:${submit.response}`; + if (!sidekick.chat.some((row) => row.key === message)) { + continue; + } + if (found.some((one) => one.message === message && one.entry === admitted.key)) { + continue; + } + found.push(Object.freeze({ message, entry: admitted.key })); + } + return Object.freeze(found); +} + +/** The trays one execution offers, which is TODO alone unless it says more. */ +function traysOf(workspace: ReplWorkspace): readonly ("todo" | "queue")[] { + return workspace.trays ?? ["todo"]; +} + +/** Whether this execution offers the shared queue's tray at all. */ +function offersQueue(workspace: ReplWorkspace): boolean { + return traysOf(workspace).includes("queue"); +} + +/** + * Whether this view is drawn as the staged workspace composition. + * + * At every profile this product draws at, and only with a reading to draw: a + * refusal is the whole of its own screen, and below the minimum there is no + * frame to compose at all. The wide proportions are accepted; the medium and + * narrow ones are proposals under image review. + */ +function staged(view: ReplView): ReplWorkspace | undefined { + if (view.refusal !== undefined || profileFor(view.size) === "too-small") { + return undefined; + } + return view.workspace; +} + +/** The staged presentation one view carries, for a caller outside this module. */ +export function stagedWorkspace(view: ReplView): ReplWorkspace | undefined { + return staged(view); } /** @@ -1369,6 +1701,8 @@ export function initialState(execution: string): ReplState { message: "", revisions: initialRevisions(), takenOver: false, + filtering: false, + catalog: false, }); } @@ -1402,6 +1736,12 @@ export function stateFor(location: string): Result { message: "", revisions: initialRevisions(), takenOver: false, + // Closed, for the same reason: a reader arriving at a location is shown + // the conversation it names, not somebody else's open chooser. + filtering: false, + // And closed for the same reason again: a location names where somebody + // got to, never the overlay they opened on the way. + catalog: false, }), ); } @@ -1739,6 +2079,17 @@ export function reduceRepl( intent: { kind: "stop-sidekick" }, }; } + case "stop-entry": { + // The key is carried through exactly as the frame drew it. Which entry + // is actually running, whether the history has acknowledged it and + // whether it may be stopped at all are the session's to answer, and + // answering any of them here would be this screen deciding from a + // reading instead of from the session that owns the work. + return { + state: Object.freeze({ ...state, refusal: undefined }), + intent: { kind: "stop-entry", entry: action.entry }, + }; + } case "exit": { // No guard, from any state. Leaving is available while an entry runs, while // a question waits, while a request is pending and while a position is being @@ -1787,7 +2138,7 @@ export function reduceRepl( : moved; } case "select-scope": { - return navigate( + const chosen = navigate( state, model, { @@ -1800,6 +2151,36 @@ export function reduceRepl( }, live, ); + // A choice that was accepted is what the catalog was open for, so it + // closes behind it and the reading it chose is what a reader is left + // looking at. A refused one changes nothing, the catalog included: the + // row they pressed is still there to press again. + return chosen.state.refusal === undefined + ? { ...chosen, state: Object.freeze({ ...chosen.state, catalog: false }) } + : chosen; + } + case "open-catalog": { + // The Entries reading, selected explicitly, and then the overlay over it. + // Everything else this location names stands: the scope path, the history + // marker, the conversation filter, the saved chat, both drafts and + // whatever is running. Resolved through the same navigation every other + // destination goes through, so a reading this history cannot answer for + // refuses here rather than opening a catalog over nothing. + const opened = navigate( + atFirstEntry(state), + model, + { ...state.route, surface: "entries", view: undefined }, + live, + ); + return opened.state.refusal === undefined + ? { ...opened, state: Object.freeze({ ...opened.state, catalog: true }) } + : opened; + } + case "close-catalog": { + // Put away, and nothing else put away with it. The scope being read, the + // marker, the filter and both drafts are exactly as they were: dismissing + // a catalog is not choosing anything in it. + return settled(Object.freeze({ ...state, catalog: false })); } case "open-drawer": { if (action.drawer.kind === "live-elicit") { @@ -1913,10 +2294,26 @@ export function reduceRepl( // The window goes back to the first row, because a filtered reading is a // different list: row forty of everything is not row forty of one // conversation, and keeping the number would open somewhere nobody chose. - return navigate(atFirstRow(state), model, { ...state.route, session: action.session }, live); + return navigate( + chosen(atFirstRow(state)), + model, + { ...state.route, session: action.session }, + live, + ); } case "all-sessions": { - return navigate(atFirstRow(state), model, { ...state.route, session: undefined }, live); + return navigate( + chosen(atFirstRow(state)), + model, + { ...state.route, session: undefined }, + live, + ); + } + case "toggle-session-filter": { + // Presentation alone. The chosen conversation, the window, the surface, + // the scope path, the history position and both drafts stay exactly as + // they stand: putting the choices away is not choosing one of them. + return settled(Object.freeze({ ...state, filtering: !state.filtering })); } case "read-sidekick-turn": { // The reading moves and nothing else does: the surface, the selected @@ -2012,8 +2409,14 @@ export function reduceRepl( }); } case "scroll-entries": { - // The same rule over the catalog's own measured window. - const window = admission.windows.get(ENTRIES_WINDOW); + // The same rule over the catalog's own measured window — whichever + // window this frame actually measured it in. While the catalog is open + // over a staged composition the rows are the drawer's, and clamping + // against a permanent Entries column that screen does not draw would + // clamp against a capacity nobody measured. + const window = + admission.windows.get(state.catalog ? DRAWER_WINDOW : ENTRIES_WINDOW) ?? + admission.windows.get(ENTRIES_WINDOW); if (window === undefined) { return settled({ ...state, refusal: undefined }); } @@ -2065,6 +2468,14 @@ export function reduceRepl( model, { ...state.route, + // The surface this drawer belongs to, taken by the act of selecting + // the request. The grammar answers a permission on Sessions, and the + // ordinary composition only offers this control there — but the + // staged composition draws that same list as its Transcript column, + // so an activation from it would otherwise be refused for standing + // where the screen put it. Selecting a request is a statement about + // going to the request, so the location says so. + surface: "sessions", drawers: Object.freeze([...state.route.drawers, opening]), }, live, @@ -3092,6 +3503,17 @@ function revealedAt( capacity: number, /** The rows this frame fitted, so a reveal lands on a row that is drawn. */ fitted: readonly ReplFittedLine[] | undefined, + /** + * What this process is holding for the chat being read. + * + * The same staging the description pass gives the builder. Without it the row + * list this searches is a *different* list from the one the window counts + * rows of — measured, a twelve-turn chat the frame drew as 47 rows came back + * from here as 24, and the index found in the short list named a row the long + * one has somewhere else. A reveal is an index into the drawn list or it is + * nothing. + */ + staging: ReplChatStaging | undefined, ): number | undefined { if (capacity < 1 || reading === undefined) { return undefined; @@ -3123,6 +3545,7 @@ function revealedAt( Object.freeze({ ...reading, reading: reveal.conversation }), undefined, fitted, + staging, ); // The first candidate this frame actually draws, at its *first* row, which is // the one its key is on: a message cut into three rows is revealed from its @@ -3137,6 +3560,24 @@ function revealedAt( return Math.min(Math.max(0, at - capacity + 1), at); } +/** + * The same state with the Transcript's conversation choices put away. + * + * Choosing one is the whole of what the chooser is for, so it closes behind the + * choice rather than staying open over the reading it just changed. + */ +function chosen(state: ReplState): ReplState { + return state.filtering ? Object.freeze({ ...state, filtering: false }) : state; +} + +/** The same state with the catalog's own window back at its first row. */ +function atFirstEntry(state: ReplState): ReplState { + return Object.freeze({ + ...state, + viewports: Object.freeze({ ...state.viewports, entries: 0 }), + }); +} + /** The same state with the Sessions reading back at its first row. */ function atFirstRow(state: ReplState): ReplState { return Object.freeze({ @@ -3201,6 +3642,8 @@ type ReplChatComposed = readonly key: string; readonly text: string; readonly style: ReplRowStyle; + /** The origin drawn over the first four cells of its first row, where marked. */ + readonly mark?: ReplOriginMark; } | { readonly kind: "control"; readonly described: Described }; @@ -3208,7 +3651,17 @@ function sidekickComposedRows( reading: ReplSidekickReading | undefined, /** The key of whatever holds focus, for the one row that wears the marker. */ focused: string | undefined, + /** The staged composition's associated work, where this reading is drawn there. */ + staging?: ReplChatStaging, ): readonly ReplChatComposed[] { + if ( + staging !== undefined && + reading !== undefined && + reading.refusal === undefined && + (reading.setup === undefined || reading.setup.phase === "activated") + ) { + return workspaceChatComposed(reading, staging, focused); + } const items: ReplChatComposed[] = []; /** One row of readable text, which a width still has to cut into rows. */ const text = (key: string, label: string, style: ReplRowStyle): ReplChatComposed => @@ -3395,8 +3848,9 @@ function chatLinesOf( */ export function sidekickChatLines( reading: ReplSidekickReading | undefined, + staging?: ReplChatStaging, ): readonly { readonly key: string; readonly text: string }[] { - return sidekickComposedRows(reading, undefined).flatMap((one) => + return sidekickComposedRows(reading, undefined, staging).flatMap((one) => one.kind === "text" ? chatLinesOf(one.key, one.text) : [], ); } @@ -3427,8 +3881,9 @@ function sidekickReadingRows( focused: string | undefined, /** The lines this frame fitted, or none before it has fitted any. */ fitted?: readonly ReplFittedLine[] | undefined, + staging?: ReplChatStaging, ): readonly Described[] { - const composed = sidekickComposedRows(reading, focused); + const composed = sidekickComposedRows(reading, focused, staging); const cuts = new Map(); for (const cut of fitted ?? []) { const held = cuts.get(cut.line); @@ -3448,7 +3903,7 @@ function sidekickReadingRows( const pieces = cuts.get(own.key); if (pieces === undefined) { // No width was asked for, so this line stands as the one line it is. - rows.push(line(own.key, own.text, one.style)); + rows.push(chatRow(own.key, own.text, one, own.key === one.key)); continue; } for (const piece of pieces) { @@ -3456,13 +3911,41 @@ function sidekickReadingRows( // the room the cut already reserved for them: a wrapped message reads // as the one message it is, and a row drawn flush left would be as wide // as the fitting assumed it was narrow. - rows.push(line(piece.key, `${" ".repeat(piece.indent)}${piece.text}`, one.style)); + rows.push( + chatRow( + piece.key, + `${" ".repeat(piece.indent)}${piece.text}`, + one, + own.key === one.key && !piece.continuation, + ), + ); } } } return rows; } +/** + * One drawn row of a composed chat item, with its origin over the first row. + * + * Only the first row of a marked item carries the mark, and only where the room + * it was composed behind is still there: its continuations are set in to the + * same column instead. + */ +function chatRow( + key: string, + label: string, + one: { readonly style: ReplRowStyle; readonly mark?: ReplOriginMark }, + first: boolean, +): Described { + const mark = one.mark; + if (!first || mark === undefined || !label.startsWith(MARKER_ROOM)) { + return line(key, label, one.style); + } + const drawn = `${mark.text}${label.slice(MARKER_ROOM.length)}`; + return line(key, drawn, one.style, markedRuns(mark, drawn, one.style.role)); +} + /** * The Sidekick pane's own controls, which stay put while its chat moves. * @@ -3649,11 +4132,23 @@ function navigate( /** One text unit shorter, counted in scalar values rather than code units. */ function shortened(text: string): string { - const units = [...text]; - units.pop(); - return units.join(""); + // One grapheme, which is what a person pressed Backspace to remove. Code + // points are not it: `e` with two combining marks is one thing on the screen + // and three of them, so a reduction counting code points took three presses + // to remove one character and left two readings of it on the way — and a + // joined emoji came apart into the two it is joined from. `Intl.Segmenter` is + // the same boundary the fitter breaks rows on, so what a person deletes and + // what a row is cut at are one answer. + let last = 0; + for (const { index } of GRAPHEMES.segment(text)) { + last = index; + } + return text.slice(0, last); } +/** Where one grapheme ends and the next begins, for text somebody is editing. */ +const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + /** How a value reads in one line of a list. */ function summarize(value: Json): string { const written = JSON.stringify(cut(value)) ?? "null"; @@ -4136,7 +4631,12 @@ function statePhrase(readiness: ReplReadiness, narrow: boolean): string { * nothing here, because a row explaining every state at every moment is one * nobody reads. */ -function stateAdvice(readiness: ReplReadiness, narrow: boolean): string | undefined { +function stateAdvice( + readiness: ReplReadiness, + narrow: boolean, + /** Whether the list a request is answered from is drawn on this frame. */ + shown: boolean, +): string | undefined { switch (readiness.kind) { case "history": // Named without its brackets: a bracketed label in prose reads as a @@ -4153,9 +4653,13 @@ function stateAdvice(readiness: ReplReadiness, narrow: boolean): string | undefi case "answer": return "activate answer"; case "permission": - // Named, not routed to. The request belongs to Sessions and the control - // that answers it is there; saying so is not the same as going. - return "open Sessions"; + // Named, not routed to: saying where a request is answered is not the + // same as going there. Where this frame already draws that list — the + // staged composition gives it a column of its own — the request is in + // front of the person and the name of a surface to open would name a + // control this screen does not have. What is left to say is that the row + // is a thing to act on. + return shown ? "activate the request" : "open Sessions"; case "unfinished": return narrow ? "no successor" : "no successor can start"; case "settling": @@ -4310,6 +4814,35 @@ function requiredLength(parts: readonly GuidancePart[]): number { * The vocabulary is the profile's: the long phrases where there is a Transcript * column, and the short ones a narrow frame already says. There is no third. */ +/** + * Whether this frame says its refusal in the footer's own band. + * + * The staged composition keeps every footer control on one compact History row, + * which leaves a reason a handful of cells and an ellipsis — and a refusal + * truncated before its meaning is a keystroke that appeared to do nothing. The + * band below it is the full width of the frame and already pays a sentence out + * over as many rows as its words need, so that is where the reason goes. + * + * The standing hint waits while it does. A hint describes what a person could + * do; a refusal answers what they just did, and only one of those is news. + */ +function refusalInBand(view: ReplView): boolean { + return view.state.refusal !== undefined && staged(view) !== undefined; +} + +/** + * What the footer's own sentence says on this frame: the refusal, or the hint. + * + * One function for both, asked by the pass that fits the words and by the pass + * that describes the rows — a second answer is how a band comes to be measured + * for one sentence and drawn with another. + */ +export function footerSentence(view: ReplView): string { + return refusalInBand(view) + ? `! ${(view.state.refusal ?? "").split("\n").join(" ")}` + : guidanceSentence(view); +} + export function guidanceSentence(view: ReplView): string { const narrow = view.size.columns <= NARROW.columns; return guidanceParts(view, readinessOf(view), narrow) @@ -4362,7 +4895,7 @@ function guidanceParts( { text: compact ? "Tab to draft" : "Tab to the draft to type", required: true }, ]; } - const advice = stateAdvice(readiness, compact); + const advice = stateAdvice(readiness, compact, staged(view) !== undefined && !narrow); const said: GuidancePart[] = advice === undefined ? [state] : [state, { text: advice, required: true }]; if (readiness.kind === "history") { @@ -4590,6 +5123,10 @@ function described(view: ReplView, context: ReplPresentationContext): DescribedS * already are — described once, in the active focus root. */ const covered = presentedAsking(view) !== undefined; + // The staged composition draws the Transcript of agent work on the left and + // reaches the catalog through the selected work's breadcrumbs, so the two + // surface headings and the catalog's own rows have no region in it. + const workspace = staged(view); const showEntry = (!narrow || routed === "entries") && !(narrow && showSidekick); @@ -4607,14 +5144,23 @@ function described(view: ReplView, context: ReplPresentationContext): DescribedS styleOf("pane-heading", { selected: routed === "sessions" }), { here: view.focused }, ); + // The staged screen has no permanent catalog column, so its way to the + // catalog opens one over the reading. The ordinary composition still routes + // to its own Entries surface, where the column is already drawn. const toEntries = row( "entries:heading", `${selected(routed === "entries")}Entries`, - { select: "surface", surface: "entries" }, + workspace === undefined ? { select: "surface", surface: "entries" } : { select: "catalog" }, styleOf("pane-heading", { selected: routed === "entries" }), { here: view.focused }, ); - items.push(toSessions); + // The way between surfaces belongs to neither of them. A narrow frame mounts + // one outlet, so both controls are described there whichever composition is + // drawing it; a wider staged frame reaches the catalog through the selected + // work's own Entries control instead. + if (workspace === undefined || narrow) { + items.push(toSessions); + } // In a narrow frame the two are one navigation bar above the routed outlet. In // a sidebar each heading stays with the list it names, which is where a reader // looks for it. @@ -4645,6 +5191,7 @@ function described(view: ReplView, context: ReplPresentationContext): DescribedS claim, context.widths?.listBody, live.sidekick, + workspace !== undefined, ); // Outside the thing they move, like the drawer's: a control inside the // window would scroll away from whoever was reaching for it. @@ -4672,10 +5219,21 @@ function described(view: ReplView, context: ReplPresentationContext): DescribedS ), ); } - if (!narrow) { + if (workspace !== undefined) { + // Each heading belongs to the surface it names, so a narrow frame describes + // only the one its route is showing: a heading for an outlet this frame has + // no region for would be a row mounted nowhere. + items.push( + ...workspaceHeadings(view, context.selector, { + transcript: !narrow || routed === "sessions", + work: !narrow || (routed === "entries" && !showSidekick), + entries: !narrow, + }), + ); + } else if (!narrow) { items.push(toEntries); } - if (showEntry) { + if (showEntry && workspace === undefined) { // One window over the whole catalog, for the same reason the Sessions // reading has one: a list that described every entry and every nested scope // beneath them would have its tail placed nowhere, and a row layout cannot @@ -4734,7 +5292,9 @@ function described(view: ReplView, context: ReplPresentationContext): DescribedS // title described there would be a mounted node with no box to draw it in. // The two surface controls are that frame's own headings. if (!narrow) { - items.push(line("transcript:heading", "Transcript", styleOf("pane-heading"))); + if (workspace === undefined) { + items.push(line("transcript:heading", "Transcript", styleOf("pane-heading"))); + } // Both tabs, always, and they are the right column's heading: the pane named // itself above them until the two names together said Sidekick twice when the // Sidekick was the reading. One row, each name once, and which of them is @@ -4753,7 +5313,7 @@ function described(view: ReplView, context: ReplPresentationContext): DescribedS // And whether the Sidekick is waiting on an answer, said on the tab // while its pane is the hidden one: a question nobody can see // announced is a question nobody knows to go and answer. - `${pane === "sidekick" ? "Sidekick" : "Bindings"}${ + `${pane === "sidekick" ? (workspace === undefined ? "Sidekick" : "SIDEKICK") : workspace === undefined ? "Bindings" : "BINDINGS"}${ pane === "sidekick" && !here && live.sidekick?.question !== undefined ? " (question)" : "" @@ -4798,7 +5358,7 @@ function described(view: ReplView, context: ReplPresentationContext): DescribedS ); items.push( ...shown( - sidekickReadingRows(live.sidekick, view.focused, context.chat), + sidekickReadingRows(live.sidekick, view.focused, context.chat, chatStagingOf(view)), context, SIDEKICK_WINDOW, ), @@ -4812,7 +5372,11 @@ function described(view: ReplView, context: ReplPresentationContext): DescribedS { here: view.focused }, ), ); - items.push(...sidekickControls(state, live.sidekick, view, claim, context)); + items.push( + ...(workspace === undefined + ? sidekickControls(state, live.sidekick, view, claim, context) + : workspaceControls(state, live.sidekick, view, claim, context, workspace)), + ); } } @@ -5033,17 +5597,23 @@ function described(view: ReplView, context: ReplPresentationContext): DescribedS // One inert block, in the rows this frame fitted its words into. Without a // fitted list — the pass still asking how wide the surface is — it is the // one row that pass describes, so its own geometry question stays askable. - const said = context.guidance ?? [guidanceSentence(view)]; + const said = context.guidance ?? [footerSentence(view)]; + // A refusal in this band reads as the refusal it is, not as a hint. + const refusing = refusalInBand(view); for (const [at, own] of said.entries()) { items.push( - line(at === 0 ? "guidance" : `guidance:row:${at}`, own, styleOf("status", { inspected })), + line( + at === 0 ? "guidance" : `guidance:row:${at}`, + own, + refusing ? styleOf("failed-outcome", { inspected }) : styleOf("status", { inspected }), + ), ); } } // Why the last thing asked for changed nothing. Shown rather than swallowed: a // refusal nobody can read is a keystroke that appeared to do nothing. - if (state.refusal !== undefined && offering("footer:refused")) { + if (state.refusal !== undefined && !refusalInBand(view) && offering("footer:refused")) { // One line: the footer is seven rows and the controls live in them, so a // refusal that wrapped would push the draft off the screen it is about. // @@ -5079,22 +5649,30 @@ function described(view: ReplView, context: ReplPresentationContext): DescribedS // 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", - // Named, because the row below a reading has to say that it is where typing - // goes rather than one more line of that reading. The marker in front of it - // is still the focus cue `fieldText` writes, so this row reads `>> Draft: ` - // while it holds focus and ` > Draft: ` while it does not. - "> Draft: ", - state.draft, - "draft", - styleOf("draft"), - claiming - ? { focus: true, here: view.focused, classify: sourceRuns } - : { here: view.focused, classify: sourceRuns }, - ), - ); + if (workspace !== undefined) { + // With the selected work, which is the surface it is part of. A narrow + // frame showing the Transcript or the Sidekick has no region to draw it in. + if (!narrow || (routed === "entries" && !showSidekick)) { + items.push(...workspaceDraft(view, context, claiming)); + } + } else { + items.push( + field( + "footer:input", + // Named, because the row below a reading has to say that it is where typing + // goes rather than one more line of that reading. The marker in front of it + // is still the focus cue `fieldText` writes, so this row reads `>> Draft: ` + // while it holds focus and ` > Draft: ` while it does not. + "> Draft: ", + state.draft, + "draft", + styleOf("draft"), + claiming + ? { focus: true, here: view.focused, classify: sourceRuns } + : { here: view.focused, classify: sourceRuns }, + ), + ); + } const drawer = drawerFor(view, context, history, exit, drawable); if (drawer === undefined) { @@ -5237,11 +5815,24 @@ function sessionRows( * * Absent is no Sidekick installation at all, and the surface then draws * exactly the one entry reading it always did — no section headings, and the - * same empty placeholder. Present is the two labelled sections, because an - * execution that has a Sidekick has two owners doing agent work and one - * interleaved list would have to assert an order these two files do not share. + * same empty placeholder. Present is both owners' work: two labelled sections + * on the Sessions surface, and one combined list in the staged Transcript. + * Neither interleaves them — the entry turns keep their order and the chat's + * follow in theirs, because an interleaving would have to assert an order + * these two files do not share. */ sidekick: ReplSidekickReading | undefined, + /** + * Whether these are the staged composition's compact request and reply rows. + * + * The same rows, keys and actions, drawn as one list: the Transcript is a + * single reading, so neither owner is given a heading of its own. The + * conversation choices belong to the column's selector and are drawn here + * only while it is open, each request and reply is marked with who it came + * from, and a conversation is named once above the run of turns that share + * it. + */ + compact = false, ): readonly Described[] { const items: Described[] = []; const filter = state.route.session; @@ -5254,7 +5845,9 @@ function sessionRows( offered.push(key); } } - if (offered.length > 0) { + // The Sessions surface keeps its choices above its list; the staged + // Transcript keeps them behind its selector and shows them while it is open. + if (offered.length > 0 && (!compact || state.filtering)) { // All is a control rather than the absence of one: clearing a filter is // something a person does, and a list that could only be narrowed would // leave them holding a view they cannot get out of. @@ -5284,16 +5877,38 @@ function sessionRows( } // Which owner's work this is, said once above each list. Without a Sidekick // there is one owner and the surface says so by having no sections at all; - // with one there are two, in a fixed order, each keeping its own history's - // order rather than being merged into an order neither file establishes. - if (sidekick !== undefined) { + // with one the Sessions surface names two, in a fixed order, each keeping its + // own history's order rather than being merged into an order neither file + // establishes. + // + // The staged Transcript names none of them. It is one list — that is the + // reading it was asked for — and each row still says whose work it is and + // which conversation it was in, so owner authority is read from the rows + // rather than asserted by a heading over a block of them. + if (sidekick !== undefined && !compact) { items.push(line("sessions:section:entries", " Entry agents", styleOf("pane-heading"))); } const entries: Described[] = []; + let named: string | undefined; for (const turn of turns) { if (filter !== undefined && turn.sessionKey !== filter) { continue; } + if (compact) { + const whose = [turn.agent, turn.sessionKey].filter((fact) => fact !== undefined).join(" · "); + if (whose !== "" && whose !== named) { + entries.push( + line( + `sessions:turn:${turn.key}:whose`, + fitLine([{ text: CONTROL_ROOM }, { text: whose, elide: true }], width), + styleOf("metadata"), + ), + ); + } + named = whose; + entries.push(...compactTurn(turn, width, focused, claim)); + continue; + } entries.push( row( `sessions:turn:${turn.key}`, @@ -5409,12 +6024,15 @@ function sessionRows( // One owner, one list, and exactly the surface this has always been. return items; } - if (entries.length === 0) { + if (entries.length === 0 && !compact) { // Said rather than left blank: a section that drew nothing would read as a // gap, and a reader cannot tell a gap from a list that failed to appear. + // One list has no section to report empty, so it says so once at the end. items.push(line("sessions:entries:none", " No entry turns.", styleOf("metadata"))); } - items.push(line("sessions:section:sidekick", " Sidekick", styleOf("pane-heading"))); + if (!compact) { + items.push(line("sessions:section:sidekick", " Sidekick", styleOf("pane-heading"))); + } if (sidekick.refusal !== undefined) { // A chat history this version cannot read. Its own reason stands here, and // it does not masquerade as a Sidekick that has done no work: showing @@ -5427,9 +6045,38 @@ function sessionRows( } const chat = sidekick.turns.filter((turn) => filter === undefined || turn.sessionKey === filter); if (chat.length === 0) { + if (compact) { + // One list, so one placeholder, and only where the whole list is empty. + // A Transcript that said "no Sidekick turns" under an entry turn it is + // already showing would be naming a section this reading does not have. + if (entries.length === 0) { + items.push(line("sessions:none", `${CONTROL_ROOM}(none retained)`, styleOf("metadata"))); + } + return items; + } items.push(line("sessions:sidekick:none", " No Sidekick turns.", styleOf("metadata"))); return items; } + if (compact) { + let label: string | undefined; + for (const turn of chat) { + const whose = [turn.agent, turn.conversation, turn.sessionKey] + .filter((fact) => fact !== undefined) + .join(" \u00b7 "); + if (whose !== label) { + items.push( + line( + `sessions:${turn.key}:whose`, + fitLine([{ text: CONTROL_ROOM }, { text: whose, elide: true }], width), + styleOf("metadata"), + ), + ); + } + label = whose; + items.push(...compactSidekickTurn(turn, sidekick, width, focused)); + } + return items; + } for (const turn of chat) { items.push( row( @@ -5507,9 +6154,21 @@ function sessionRows( * clamps against cannot disagree with the list it is clamping: one definition of * what the reading is, asked twice. */ -function sessionContentRows(state: ReplState, model: ReplModel, live: ReplLive): number { - return sessionRows(state, chronology(model, live), undefined, undefined, undefined, live.sidekick) - .length; +function sessionContentRows( + state: ReplState, + model: ReplModel, + live: ReplLive, + compact = false, +): number { + return sessionRows( + state, + chronology(model, live), + undefined, + undefined, + undefined, + live.sidekick, + compact, + ).length; } /** @@ -5760,6 +6419,22 @@ function presentedConfiguring(view: ReplView): ReplConfiguring | undefined { return held.open && showingSidekick(view) && currentChat(view) ? held : undefined; } +/** + * Whether the Entries catalog is the thing presented over this reading. + * + * Three conditions, like the Sidekick's own drawers: no route drawer on top of + * it, the catalog actually open, and a staged composition to float it over. + * The ordinary composition keeps its permanent Entries column, so there is + * nothing for an overlay to stand in for there. + * + * It takes precedence over the Sidekick's pane-local form and Configure + * without answering or discarding either: both keep what is in them, and + * closing this gives them the screen back. + */ +function presentedCatalog(view: ReplView): boolean { + return view.selection.drawers.length === 0 && view.state.catalog && staged(view) !== undefined; +} + /** Whether this view's Sidekick pane is the one being drawn. */ function showingSidekick(view: ReplView): boolean { return profileFor(view.size) === "narrow" @@ -5778,6 +6453,7 @@ function showingSidekick(view: ReplView): boolean { function modalOpen(view: ReplView): boolean { return ( view.selection.drawers.length > 0 || + presentedCatalog(view) || presentedAsking(view) !== undefined || presentedConfiguring(view) !== undefined ); @@ -5801,12 +6477,19 @@ function modalOpen(view: ReplView): boolean { * pane's own, they begin at the top of it, and the gap is not in that pane. */ function presentedRect(view: ReplView, context: ReplPresentationContext): ReplBounds | undefined { - if (view.selection.drawers.length > 0) { - return context.drawer ?? drawerRect(view.size); + // Which body this layer covers: the staged composition keeps three rows + // around its body where the ordinary one keeps seven, so a rectangle derived + // from the other budget would hang off the bottom of this screen. + const workspace = staged(view) !== undefined; + // The catalog floats over the body, exactly where a route drawer does: it is + // a reading of the whole execution rather than of one pane, so a pane-local + // rectangle would put the list of every entry inside the Sidekick's column. + if (view.selection.drawers.length > 0 || presentedCatalog(view)) { + return context.drawer ?? drawerRect(view.size, workspace); } return presentedAsking(view) === undefined && presentedConfiguring(view) === undefined ? undefined - : sidekickDrawerRect(view.size); + : sidekickDrawerRect(view.size, workspace); } /** @@ -5831,15 +6514,25 @@ function drawerFor( exit: Described, drawable: boolean, ): { readonly drawer: Described; readonly rows: readonly Described[] } | undefined { - const held = drawerContent(view, context.widths?.drawer ?? 0, context.preview); + const held = drawerContent(view, context.widths?.drawer ?? 0, context.preview, context.notices); if (held === undefined) { return undefined; } const { title, titleStyle, dismissing, entering, content } = held; const width = context.widths?.drawer ?? 0; - // Which owner's form this is, for the scroll controls, the editable line the - // window claims focus for, and `[close]`. One drawer is presented at a time, - // so this is also what says the Sidekick's own is the one on screen. + // Which owner's drawer this is, for the scroll controls, the editable line + // the window claims focus for, and `[close]`. One drawer is presented at a + // time, so this is also what says the Sidekick's own is the one on screen. + // + // The catalog's window is the entries window somebody is already scrolling, + // so its controls carry that reading's own scroll rather than the generic + // one — the clamp is this drawer's measured capacity either way. It is asked + // first below because `drawerContent` asks it first: two orders over the same + // three readings is how a drawer comes to show one window's rows and move + // another one's. Holding all three at once needs a route none of these + // controls offers, so this is drift prevented rather than a state anybody + // has been in. + const catalog = presentedCatalog(view); const asking = presentedAsking(view)?.question.key; const configuring = presentedConfiguring(view) !== undefined; const rows: Described[] = []; @@ -5849,11 +6542,13 @@ function drawerFor( // Configure's window moves through a list of choices rather than through // a recorded order, so it is the one drawer that does not say Earlier. padControl(configuring ? SCROLL_UP : EARLIER, width), - asking !== undefined - ? { select: "sidekick-scroll", delta: -1 } + catalog + ? { select: "scroll-entries", delta: -1 } : configuring ? { select: "configure-scroll", delta: -1 } - : { select: "scroll", delta: -1 }, + : asking !== undefined + ? { select: "sidekick-scroll", delta: -1 } + : { select: "scroll", delta: -1 }, styleOf("action"), { here: view.focused }, ), @@ -5865,11 +6560,13 @@ function drawerFor( row( "drawer:scroll:down", padControl(configuring ? SCROLL_DOWN : LATER, width), - asking !== undefined - ? { select: "sidekick-scroll", delta: 1 } + catalog + ? { select: "scroll-entries", delta: 1 } : configuring ? { select: "configure-scroll", delta: 1 } - : { select: "scroll", delta: 1 }, + : asking !== undefined + ? { select: "sidekick-scroll", delta: 1 } + : { select: "scroll", delta: 1 }, styleOf("action"), { here: view.focused }, ), @@ -5911,15 +6608,19 @@ function drawerFor( padControl("[close]", width), dismissing !== undefined ? { select: "permission-dismiss", request: dismissing } - : // Closing one of the Sidekick's own drawers is not what closing the - // entry's means. The question form keeps its question and its values; - // Configure discards what was chosen and not applied, and saves and - // changes nothing. - configuring - ? { select: "configure-close" } - : asking === undefined - ? { select: "close" } - : { select: "sidekick-question-close" }, + : catalog + ? // Putting the catalog away chooses nothing: the scope being read, + // the marker, the filter and both drafts stand. + { select: "catalog-close" } + : // Closing one of the Sidekick's own drawers is not what closing the + // entry's means. The question form keeps its question and its + // values; Configure discards what was chosen and not applied, and + // saves and changes nothing. + configuring + ? { select: "configure-close" } + : asking === undefined + ? { select: "close" } + : { select: "sidekick-question-close" }, styleOf("action"), { here: view.focused }, ), @@ -5989,15 +6690,19 @@ function drawerContent( view: ReplView, width: number, preview?: readonly ReplFittedLine[] | undefined, + notices?: ReplPreparedNotices, ): DrawerContent | undefined { const open = view.selection.drawers[view.selection.drawers.length - 1]; + if (open === undefined && presentedCatalog(view)) { + return catalogContent(view, width); + } if (open === undefined) { // No route drawer, so this is one of the Sidekick's own two drawers if // anything is. Configure first: opening it hides the question form's // presentation, so both are never open at once. const settings = presentedConfiguring(view); if (settings !== undefined) { - return configureContent(view, settings, width); + return configureContent(view, settings, width, notices); } const asking = presentedAsking(view); return asking === undefined ? undefined : askingContent(view, asking, width, preview); @@ -6332,24 +7037,128 @@ function settingLine(settings: SessionConfiguration | undefined): string { * Where there is no saved verification this says so rather than borrowing the * request to stand in for it. */ -function configureContent(view: ReplView, held: ReplConfiguring, width: number): DrawerContent { - const content: DrawerRow[] = []; - const focused = view.focused; +/** + * The Entries catalog, as the rows one drawer window shows. + * + * The same rows the permanent column draws, built by the same function from + * the same reading: immutable admission order, each entry's own key and + * retained outcome, each nested scope under the entry that holds it, and the + * exact scope path on every row. The catalog is a *reading* of the selected + * acknowledged prefix, so an earlier marker shows what that prefix held and + * nothing of what came after it. + * + * An execution with no entry says so once, inertly. A placeholder is not a + * control: there is nothing to go to. + */ +function catalogContent(view: ReplView, width: number): DrawerContent { + const rows = entryContent(view.model, view.focused, view.selection.entry?.key, width); + const content: DrawerRow[] = + rows.length > 0 + ? rows.map((described) => ({ described })) + : [ + { + described: drawerLine( + "drawer:catalog:none", + "1. (not submitted)", + width, + styleOf("metadata"), + ), + }, + ]; + return { + title: drawerTitle("Entries", undefined, width), + titleStyle: styleOf("drawer-title"), + dismissing: undefined, + entering: false, + content: Object.freeze(content), + }; +} + +/** + * One notice this Configure drawer says: the row key it is drawn under and the + * whole text it says, before anything has decided how many rows that takes. + */ +export interface ReplDrawerNotice { + readonly key: string; + readonly text: string; +} + +/** The indent a drawer notice is set in from the drawer's own edge. */ +export const NOTICE_INDENT = " "; + +/** + * The notices this Configure drawer would say, in the order it says them. + * + * One answer for two readers: the preparation that fits them to the interior + * the frame measured, and the builder that draws the rows that fitting + * produced. A builder deciding for itself how wide a row may be would be a + * second opinion about the same text — and the opinion a pure builder can form + * counts code units rather than cells, which is how a seventy-character token + * came to be drawn as nothing at all. + */ +export function configureNoticesOf(view: ReplView): readonly ReplDrawerNotice[] { + const held = presentedConfiguring(view); + return held === undefined ? [] : noticesOf(view, held); +} + +/** The same, for a caller that already has the drawer this view is holding. */ +function noticesOf(view: ReplView, held: ReplConfiguring): readonly ReplDrawerNotice[] { const reading = view.live.sidekick; - content.push({ - described: field( - "drawer:configure:agent", - "> Agent: ", - held.agent, - "configure-agent", - styleOf("field-value"), - { here: focused }, - ), - }); - // Exactly what was asked for, and exactly what was verified. The second is - // not derived from the first: a request an agent has not run under yet is - // still only a request. - content.push({ + const found: ReplDrawerNotice[] = []; + if (held.refusal !== undefined) { + found.push({ key: "drawer:configure:refusal", text: held.refusal }); + } + const replacing = + held.agent !== "" && reading?.agent !== undefined && held.agent !== reading.agent; + const offered = replacing ? undefined : held.offered; + if (held.reading) { + found.push({ + key: "drawer:configure:asking", + text: "Reading what this agent offers...", + }); + } + if (replacing) { + found.push({ + key: "drawer:configure:replacing", + text: `${held.agent} has its own settings.`, + }); + } else if (offered === undefined) { + found.push({ + key: "drawer:configure:unread", + text: "What this agent offers has not been read yet.", + }); + } else if (offered.models.length === 0) { + found.push({ + key: "drawer:configure:no-models", + text: "This agent offers no model choice.", + }); + } + return Object.freeze(found); +} + +function configureContent( + view: ReplView, + held: ReplConfiguring, + width: number, + notices?: ReplPreparedNotices, +): DrawerContent { + const content: DrawerRow[] = []; + const focused = view.focused; + const reading = view.live.sidekick; + content.push({ + described: field( + "drawer:configure:agent", + "> Agent: ", + held.agent, + "configure-agent", + styleOf("field-value"), + { here: focused }, + ), + }); + // Exactly what was asked for, and exactly what was verified. The second is + // not derived from the first: a request an agent has not run under yet is + // still only a request. + content.push({ described: drawerLine( "drawer:configure:requested", ` Requested: ${settingLine(reading?.requested)}`, @@ -6366,14 +7175,15 @@ function configureContent(view: ReplView, held: ReplConfiguring, width: number): ), }); if (held.refusal !== undefined) { - content.push({ - described: drawerLine( + content.push( + ...noticeRows( "drawer:configure:refusal", - ` ${held.refusal}`, + held.refusal, width, styleOf("failed-outcome"), + notices, ), - }); + ); } // A different agent is a different conversation, and these choices were read // from the agent this one is with: they cannot be applied to another. So they @@ -6386,48 +7196,52 @@ function configureContent(view: ReplView, held: ReplConfiguring, width: number): // Said beside what is already in hand rather than instead of it. A refresh // that hid the list would take away the choices a person is looking at, // and already advertised choices stay usable while another read runs. - content.push({ - described: drawerLine( + content.push( + ...noticeRows( "drawer:configure:asking", - " Reading what this agent offers...", + "Reading what this agent offers...", width, styleOf("waiting"), + notices, ), - }); + ); } if (replacing) { // Said, so a reader knows why the choices went rather than wondering. - content.push({ - described: drawerLine( + content.push( + ...noticeRows( "drawer:configure:replacing", - ` ${held.agent} has its own settings.`, + `${held.agent} has its own settings.`, width, styleOf("metadata"), + notices, ), - }); + ); } else if (offered === undefined) { // Nothing read, and nothing invented to stand in for it. While the Sidekick // is working an inspection is one of the things its conversation cannot do, // so this says so rather than offering a list from somewhere else. - content.push({ - described: drawerLine( + content.push( + ...noticeRows( "drawer:configure:unread", - " What this agent offers has not been read yet.", + "What this agent offers has not been read yet.", width, styleOf("metadata"), + notices, ), - }); + ); } else if (offered.models.length === 0) { // `null` from the provider: an agent that offers no model choice at all, // which is a different answer from offering choices none of which is yours. - content.push({ - described: drawerLine( + content.push( + ...noticeRows( "drawer:configure:no-models", - " This agent offers no model choice.", + "This agent offers no model choice.", width, styleOf("metadata"), + notices, ), - }); + ); } else { content.push({ described: drawerLine("drawer:configure:models", " Model", width, styleOf("field-label")), @@ -6849,7 +7663,7 @@ function padControl(label: string, width: number): string { } /** The two columns `marked()` puts in front of a control's label. */ -const FOCUS_MARKER = 2; +export const FOCUS_MARKER = 2; /** * Whether this field is required as the form currently stands. @@ -6878,6 +7692,43 @@ function detail(value: Json): readonly string[] { return (JSON.stringify(value, undefined, 2) ?? "null").split("\n"); } +/** + * The rows one notice was fitted onto, keyed by the row it is drawn under. + * + * Prepared by the pass that has the drawer's measured interior and the engine + * to ask about graphemes; consumed here, so row counting, the window's + * admission, its scroll offsets and the final descriptions are all the same + * answer rather than four. + */ +export type ReplPreparedNotices = ReadonlyMap; + +/** + * Say one notice in a drawer, on the rows the frame fitted it onto. + * + * A drawer row is one row, so a notice longer than the drawer's interior has + * to be broken somewhere. It is broken by the engine, at this frame's measured + * width and on the original graphemes — measured, a 30-cell drawer was given a + * 72-cell `configuration refused:` notice and drew none of its token, and a + * row of twelve spaced CJK words measured 48 cells inside 30. + * + * Unprepared is the unmeasured pass, which describes no width-dependent text + * and bounds nothing: the notice stands as the one line it is, exactly as + * every other row of this module stands when no width has been answered yet. + */ +function noticeRows( + key: string, + text: string, + width: number, + style: ReplRowStyle, + notices?: ReplPreparedNotices, +): readonly DrawerRow[] { + const prepared = notices?.get(key); + const rows = prepared === undefined ? [`${NOTICE_INDENT}${text}`] : prepared; + return rows.map((row, offset) => ({ + described: drawerLine(offset === 0 ? key : `${key}:${offset}`, row, width, style), + })); +} + /** Every nested scope beneath one, with the key path that selects it. */ /** One selectable nested scope: what to call it, and the path that selects it. */ interface NestedScope { @@ -7086,6 +7937,15 @@ export const SIDEKICK_WINDOW = "sidekick"; export const ENTRIES_WINDOW = "entries"; /** The open drawer's content window, whichever reading it is showing. */ export const DRAWER_WINDOW = "drawer"; + +/** + * The staged tray's own window, over the messages waiting behind the exchange. + * + * Its own, because the conversation above it is a separate reading with a + * separate offset: one window meaning either would move whichever the frame + * measured last. + */ +export const QUEUE_WINDOW = "queue"; /** The entry reading's window: output and source together, in the transcript. */ export const READING_WINDOW = "reading"; /** The action row's own structural id, which admission measures against. */ @@ -7164,6 +8024,13 @@ export interface ReplMeasuredWidths { readonly body: number; /** The drawer's own interior. */ readonly drawer: number; + /** + * The staged queue's text column, between a message's origin and its remove + * control. Zero where the frame draws no queue. + */ + readonly queue?: number; + /** The row the guidance is drawn in, where a frame draws it apart from the work. */ + readonly guidance?: number; } /** @@ -7291,6 +8158,108 @@ export interface ReplPresentationContext { * counts, and nothing is admitted into a drawer from them. */ readonly drawer: ReplBounds | undefined; + /** + * The rows the staged Draft draws of the draft's own value, or none. + * + * Fitted to the selected work's measured width and windowed to the newest + * rows the Draft has room for. The value itself is untouched. + */ + readonly draft?: readonly string[] | undefined; + /** + * The staged queue's messages, fitted to the width their rows measured. + * + * A measuring pass carries none, and each message is then one blank row: the + * count is the geometry question and the text would be fitted to nothing. + */ + readonly queue?: readonly ReplFittedLine[] | undefined; + /** + * How wide the Draft's field is, once its row and its control are measured. + * + * Derived from the engine's answers for this exact frame — the heading row's + * width, the control's own fitted width and the row's declared gap — never + * from a label's characters. Absent before that measurement, where the field + * grows and the pass is only answering widths. + */ + readonly draftField?: number | undefined; + /** + * How wide a queued message's text is, once its control has been measured. + * + * Derived the way the Draft's field is, and for the same reason: the row + * holds the text and the control beside it, and a grown remainder is not + * always whole. + */ + readonly queueText?: number | undefined; + /** + * The staged headers whose two groups do not fit one row, by their own key. + * + * Measured by the preparation against the width the engine gave each header, + * so a header that wraps does so because a row of this frame could not hold + * it — not because a count of characters said so. Absent before that + * measurement, which is the pass that has no widths to ask about yet. + */ + readonly headers?: ReadonlySet | undefined; + /** + * Whether this pass exists only to have a queued row measured. + * + * One pass asks how wide that row's text and control are, and a different + * pass asks how many rows the tray can hold. They cannot be the same pass: a + * scrolling viewport is measured empty, and an empty viewport holds no row + * to measure. So a probe draws the sequence's first row and admits nothing, + * and every other pass leaves the overflowing viewport empty until a window + * says which rows go in it. + * + * Two passes set this, and neither admits anything: the one that answers + * what the widths are, before the queue has been fitted to any of them, and + * the one that has the engine size the control beside a fitted row. + */ + readonly queueProbe?: boolean | undefined; + /** + * How the non-wide History row divides itself, in whole cells. + * + * Measured once per preparation, from the two parts asked on their own, and + * then stated in every pass that follows — the empty capacity skeleton, the + * pass anything is admitted from and the committed description alike. A + * constraint one pass states and another does not is a control measured where + * it is not drawn. + */ + readonly historySplit?: { readonly map: number; readonly actions: number } | undefined; + /** + * The Transcript selector's own label and the cells the engine gave it. + * + * One answer for both, settled once per preparation and stated in every pass + * after it — the pass that decides whether this header wraps, the pass + * anything is admitted from and the committed description alike. The label + * says a conversation's name, which is a provider's session key and so a + * name nothing in this product bounds, and neither its length in code units + * nor a guess at a code point's width is how many cells it takes. + * + * None before the frame has measured, which is the pass that establishes the + * room this is fitted to and fits nothing itself. + */ + readonly selector?: ReplFittedLabel | undefined; + /** + * The drawer's notices, each fitted onto the rows this frame's measured + * interior has room for. None before the frame has measured one. + */ + readonly notices?: ReplPreparedNotices | undefined; + /** + * How wide the History row's map is, once its controls have been measured. + * + * Derived the way the Draft's field is, and for the same reason: the row + * holds the map and the controls beside it, and a map longer than its row is + * squeezed onto a fraction of a cell. Absent on the accepted wide screen, + * whose row fits its own map. + */ + readonly historyMap?: number | undefined; + /** + * How wide the accepted wide row's gap is, once its map has been measured. + * + * Declared so that the controls beside it are the row's one growing child: + * two growing siblings halve what the map leaves, and half of an odd number + * of cells is a position the renderer refuses. Absent until that measurement, + * and absent at every other profile, whose rows are arranged differently. + */ + readonly historyGap?: number | undefined; } /** One reading's descriptions, paired with where the engine places them. */ @@ -7354,6 +8323,14 @@ export function readingKeyOf(state: ReplState, open: ReplDrawerRef): string | un */ export function drawerOffsetOf(state: ReplState, open: ReplDrawerRef | undefined): number { if (open === undefined) { + // The catalog's own, and before the Sidekick's two: it takes the screen + // from them while it is open, so the window being moved is this one. The + // entries viewport is where opening it put them — `open-catalog` selects + // the Entries reading and starts it at its first row — and scrolling here + // moves that same viewport while the overlay is up. + if (state.catalog) { + return state.viewports.entries; + } // No route drawer, so one of the Sidekick's own drawers is being read if // any is. Its own offset, because each window is its own. if (state.configuring.open) { @@ -7430,7 +8407,11 @@ type ReplSlot = /** The Sidekick's own input row and its controls, below its chat. */ | "inspection-below" /** The chat window's own two controls, which surround it. */ - | "inspection-window"; + | "inspection-window" + /** The staged composition's own rows, placed by key. */ + | "workspace" + /** The tray window's two controls, which surround it rather than move in it. */ + | "queue-fixed"; function slotOf(key: string): ReplSlot | undefined { if (key === "drawer:open") { @@ -7519,6 +8500,24 @@ function slotOf(key: string): ReplSlot | undefined { if (key === "guidance" || key.startsWith("guidance:row:")) { return "located"; } + if (key === "queue:earlier" || key === "queue:later") { + return "queue-fixed"; + } + if ( + key === "transcript:title" || + key === "transcript:selector" || + key === "work:crumbs" || + key.startsWith("work:crumb:") || + key === "work:state" || + key === "draft:stop" || + key === "draft:send" || + key === "follow" || + key.startsWith("draft:value:") || + key.startsWith("tray:") || + key.startsWith("queue:") + ) { + return "workspace"; + } // Above the surface, where there is a row's full width for a sentence. On the // action row it would be one truncated line beside the control. if (key === "refusal" || key.startsWith("refusal:")) { @@ -7563,11 +8562,21 @@ function candidatesOf( * from the string would be classifying text it had already lost the facts for. */ runs: ReadonlyMap, + /** + * The keys this frame described inside the catalog drawer, where it drew one. + * + * The catalog shows the same rows the permanent Entries column shows, under + * the same keys, so that an entry is one identity however a reader reached + * it. Where the key cannot say which region a row landed in, the composition + * says — and it says it here rather than by renaming the rows, because a row + * renamed for the overlay would be a second identity for the same entry. + */ + catalogRows?: ReadonlySet | undefined, ): readonly ReplCandidate[] { const found: ReplCandidate[] = []; const walk = (description: ReplDescription): void => { const read = readDescription(description); - const slot = slotOf(read.key); + const slot = catalogRows?.has(read.key) === true ? "drawer" : slotOf(read.key); if (slot !== undefined) { found.push({ key: read.key, @@ -7727,7 +8736,20 @@ export function presentationFor( }; } - const candidates = candidatesOf(descriptions, styles, runs); + // Which keys this frame described *inside* the catalog drawer. The catalog + // reuses the permanent column's rows so that an entry is the same row with + // the same identity wherever it is read from — which means the key alone no + // longer says where it lands. While the catalog is open those same nodes + // belong to the drawer's window, and the permanent column they would + // otherwise classify into is a region this screen does not draw. + const catalogRows = presentedCatalog(view) + ? new Set( + entryContent(view.model, view.focused, view.selection.entry?.key, undefined).map( + (one) => one.key, + ), + ) + : undefined; + const candidates = candidatesOf(descriptions, styles, runs, catalogRows); const viewports: ReplViewportSlot[] = []; const regions: { region: ReplRegion; id: string }[] = []; /** The border-free inside of each pane that has edges. */ @@ -7783,6 +8805,15 @@ export function presentationFor( rowSlot: ReplSlot, window: string, rows?: number, + /** + * How wide a row in this window may be, where the region alone cannot say. + * + * The staged narrow outlet is one region for all three readings, and each + * of them publishes its own inset body — so the room is that body's, not + * the outlet's. Absent everywhere else, which keeps every other column + * reading exactly the width it always did. + */ + room?: number, ): readonly ReplBox[] => { const fixed = of(fixedSlot); // The window controls sit outside the thing they move, so they are siblings @@ -7793,18 +8824,18 @@ export function presentationFor( (candidate) => !candidate.key.endsWith(":earlier") && !candidate.key.endsWith(":later"), ); const id = `box:${window}:viewport`; - const room = bodyWidthOfRegion(region); + const inside = room ?? bodyWidthOfRegion(region); viewports.push(Object.freeze({ id, region, window })); return [ - ...rest.map((candidate) => rowBox(candidate, region, room)), - ...above.map((candidate) => rowBox(candidate, region, room)), + ...rest.map((candidate) => rowBox(candidate, region, inside)), + ...above.map((candidate) => rowBox(candidate, region, inside)), box({ id, region, props: viewportProps(rows), - children: of(rowSlot).map((candidate) => rowBox(candidate, region, room)), + children: of(rowSlot).map((candidate) => rowBox(candidate, region, inside)), }), - ...below.map((candidate) => rowBox(candidate, region, room)), + ...below.map((candidate) => rowBox(candidate, region, inside)), ]; }; @@ -8012,6 +9043,22 @@ export function presentationFor( }); }; + const workspace = staged(view); + if (workspace !== undefined) { + return workspacePresentation({ + view, + context, + descriptions, + candidates, + rail, + listColumn, + chatColumn, + viewports, + regions, + contents, + }); + } + const columns: ReplBox[] = []; if (profile === "narrow") { // Exactly the routed surface, and nothing else. A narrow screen that stacked @@ -8336,15 +9383,19 @@ export function admissionFor(input: { readonly preview?: readonly ReplFittedLine[] | undefined; /** The chat, fitted, which its own window admits rows of. */ readonly chat?: readonly ReplFittedLine[] | undefined; + /** The tray's messages, fitted, which its own window admits rows of. */ + readonly queue?: readonly ReplFittedLine[] | undefined; + /** The drawer's notices, fitted, which its own window admits rows of. */ + readonly notices?: ReplPreparedNotices | undefined; readonly boundsOf: (id: string) => ReplBounds | undefined; }): ReplAdmission { - const { view, manifest, widths, reading, preview, chat, boundsOf } = input; + const { view, manifest, widths, reading, preview, chat, queue, notices, boundsOf } = input; const windows = new Map(); for (const slot of manifest.viewports) { const capacity = capacityOf(boundsOf(slot.id)); // The count first, because a window that follows its newest rows needs to // know where they end before it can say where it is. - const total = totalOf(view, widths, reading, preview, chat, slot.window); + const total = totalOf(view, widths, reading, preview, chat, queue, notices, slot.window); windows.set( slot.window, admitRows({ @@ -8408,7 +9459,7 @@ function offsetFor( // asked for that turn and not for whatever arrived since. const reveal = view.state.revealing; if (reveal !== undefined) { - const at = revealedAt(reveal, view.live.sidekick, capacity, chat); + const at = revealedAt(reveal, view.live.sidekick, capacity, chat, chatStagingOf(view)); if (at !== undefined) { return at; } @@ -8455,6 +9506,9 @@ function chatRead(view: ReplView): string { /** How far one window is scrolled, as this process is holding it. */ function offsetOf(view: ReplView, window: string): number { + if (window === QUEUE_WINDOW) { + return staged(view)?.queueOffset ?? 0; + } if (window === SESSIONS_WINDOW) { return view.state.viewports.sessions; } @@ -8483,17 +9537,22 @@ function totalOf( reading: ReplPreparedReading | undefined, preview: readonly ReplFittedLine[] | undefined, chat: readonly ReplFittedLine[] | undefined, + queue: readonly ReplFittedLine[] | undefined, + notices: ReplPreparedNotices | undefined, window: string, ): number { + if (window === QUEUE_WINDOW) { + return queue?.length ?? 0; + } if (window === SESSIONS_WINDOW) { - return sessionContentRows(view.state, view.model, view.live); + return sessionContentRows(view.state, view.model, view.live, staged(view) !== undefined); } if (window === SIDEKICK_WINDOW) { // Counted by the builder that produces those rows, so what a scroll clamps // against cannot disagree with the list it is clamping — fitted rows // included, because a message that occupies three rows is three rows to // scroll past. - return sidekickReadingRows(view.live.sidekick, undefined, chat).length; + return sidekickReadingRows(view.live.sidekick, undefined, chat, chatStagingOf(view)).length; } if (window === ENTRIES_WINDOW) { return entriesRowCount(view.model); @@ -8504,7 +9563,2243 @@ function totalOf( if (window === READING_WINDOW) { return reading?.rows.length ?? 0; } - return drawerContent(view, widths?.drawer ?? 0, preview)?.content.length ?? 0; + // The same prepared rows the drawing pass consumes, so what this window + // admits and what it draws cannot disagree about how tall a notice is. + return drawerContent(view, widths?.drawer ?? 0, preview, notices)?.content.length ?? 0; +} + +/** + * The four cells a marked row's origin takes. + * + * A marked row is composed behind four blanks and the marker is drawn over + * them, so the text is measured and wrapped at exactly the width it is drawn + * at, and its continuation rows are set in to the same column as its first. + */ +const MARKER_ROOM = " "; + +/** The two cells in front of a control's label that its focus marker takes. */ +const CONTROL_ROOM = " "; + +/** Who one marked row came from: the marker it shows and the colour it takes. */ +interface ReplOriginMark { + readonly text: string; + readonly role: ReplPresentationRole; +} + +const PERSON_MARK: ReplOriginMark = Object.freeze({ text: "[U] ", role: "origin-person" }); +const SIDEKICK_MARK: ReplOriginMark = Object.freeze({ text: "[S] ", role: "origin-sidekick" }); +const AGENT_MARK: ReplOriginMark = Object.freeze({ text: "[A] ", role: "origin-agent" }); +const ATTACHMENT_MARK: ReplOriginMark = Object.freeze({ + text: "[C] ", + role: "origin-attachment", +}); +const WORK_MARK: ReplOriginMark = Object.freeze({ text: "[R] ", role: "origin-repl" }); + +/** The mark a message's own origin earns: a person here, or an attached client. */ +function originMark(origin: MessageOrigin | undefined): ReplOriginMark { + return origin?.kind === "acp" ? ATTACHMENT_MARK : PERSON_MARK; +} + +/** A label whose first four cells are its origin mark, as the runs that draw it. */ +function markedRuns( + mark: ReplOriginMark, + label: string, + role: ReplPresentationRole, +): readonly ReplTokenRun[] { + return tokenRuns([ + { text: mark.text, token: mark.role }, + { text: label.slice(mark.text.length), token: role }, + ]); +} + +/** What the staged chat adds to a reading: the work its messages submitted. */ +interface ReplChatStaging { + readonly model: ReplModel; + readonly live: ReplLive; + readonly work: readonly ReplAssociatedWork[]; +} + +function chatStagingOf(view: ReplView): ReplChatStaging | undefined { + const workspace = staged(view); + if (workspace === undefined) { + return undefined; + } + return { model: view.model, live: view.live, work: workspace.work }; +} + +/** + * What one entry is doing, in one word and the role that colours it. + * + * Read from the entry's own retained outcome, and from this process only for the + * final entry it is running. An entry with no outcome and nothing running it is + * unfinished, never assumed to be either. + */ +function workState( + entry: ReplEntry, + model: ReplModel, + live: ReplLive, +): { readonly word: string; readonly style: ReplRowStyle } { + const terminal = entry.terminal; + if (terminal !== undefined) { + if (terminal.status === "ok") { + return { word: "✓ COMPLETED", style: styleOf("successful-outcome") }; + } + // An interruption reads as its own outcome rather than as a failure: what + // the entry completed stands and what it printed is partial, which is not + // what a run going wrong says. + return terminal.status === "err" + ? { word: "× FAILED", style: styleOf("failed-outcome") } + : { word: "- INTERRUPTED", style: styleOf("interrupted-outcome") }; + } + const last = model.entries[model.entries.length - 1]; + if (live.running && last?.key === entry.key) { + return { word: "● ACTIVE", style: styleOf("lifecycle-active") }; + } + return { word: "UNFINISHED", style: styleOf("metadata") }; +} + +/** The rows of work one Sidekick message submitted, after that message. */ +function workComposed(staging: ReplChatStaging, key: string): readonly ReplChatComposed[] { + const found: ReplChatComposed[] = []; + for (const one of staging.work) { + if (`sidekick:${one.message}` !== key) { + continue; + } + const entry = staging.model.entries.find((candidate) => candidate.key === one.entry); + if (entry === undefined) { + // An entry this prefix does not hold is not work this reading can show. + continue; + } + const state = workState(entry, staging.model, staging.live); + found.push( + Object.freeze({ + kind: "text" as const, + key: `sidekick:work:${entry.key}`, + text: `${MARKER_ROOM}Entry ${entry.order} · ${state.word}`, + style: state.style, + mark: WORK_MARK, + }), + ); + // The record's own final line break ends its last line rather than adding one. + const recorded = entry.terminal?.output ?? ""; + const output = recorded.endsWith("\n") ? recorded.slice(0, -1) : recorded; + if (output !== "") { + found.push( + Object.freeze({ + kind: "text" as const, + key: `sidekick:work:${entry.key}:output`, + text: output + .split("\n") + .map((own) => `${MARKER_ROOM}${own}`) + .join("\n"), + style: styleOf("output"), + }), + ); + } + } + return found; +} + +/** + * The staged chat: each message marked with who it came from, and the work a + * message submitted shown after it as work rather than as something said. + * + * Only explicit evaluated Output is the Sidekick's own row. An entry's state and + * output are read from the model, never from a reply's wording. + */ +function workspaceChatComposed( + reading: ReplSidekickReading, + staging: ReplChatStaging, + /** The key of whatever holds focus, for the one row that wears the marker. */ + focused: string | undefined, +): readonly ReplChatComposed[] { + const items: ReplChatComposed[] = []; + const text = ( + key: string, + said: string, + style: ReplRowStyle, + mark: ReplOriginMark, + ): ReplChatComposed => + Object.freeze({ + kind: "text" as const, + key, + text: said + .split("\n") + .map((own) => `${MARKER_ROOM}${own}`) + .join("\n"), + style, + mark, + }); + const shownChat = reading.chat.filter((item) => item.conversation === reading.reading); + if (shownChat.length === 0) { + items.push(text("sidekick:empty", "No messages yet.", styleOf("metadata"), SIDEKICK_MARK)); + } + for (const item of shownChat) { + const key = `sidekick:${item.key}`; + if (item.kind === "person") { + items.push(text(key, item.text, styleOf("source"), originMark(item.origin))); + } else if (item.kind === "preview") { + // Said as the attempt it is. Nothing has been evaluated, no control has + // run and no turn is acknowledged, so this is not a reply yet — and a + // row that differed from one only by its colour would tell a reader + // nothing they could read. `(unfinished)` below is the other ending. + items.push( + text( + key, + item.incomplete === true + ? `${reading.agent} (unfinished): ${item.text}` + : `${reading.agent} (preview): ${item.text}`, + item.incomplete === true ? styleOf("failed-outcome") : styleOf("waiting"), + SIDEKICK_MARK, + ), + ); + } else if (item.kind === "saved") { + items.push( + text( + key, + item.automatic === true ? `Automatic feedback · ${item.text}` : item.text, + styleOf("metadata"), + SIDEKICK_MARK, + ), + ); + } else if (item.kind === "incomplete" || item.failure !== undefined) { + // Said the way the preview above it is said, and the way the ordinary + // pane says it: who answered, how far it got, then the reason. The mark + // in front already says this row is the Sidekick's, and the name is what + // says *which* agent answered in this chat — a conversation does not + // change agent in place, so a row that left it out would make two chats + // by two agents read identically. + items.push( + text( + key, + `${reading.agent} (${item.kind === "incomplete" ? "unfinished" : "failed"}): ${ + item.failure ?? "this turn did not complete" + }`, + styleOf("failed-outcome"), + SIDEKICK_MARK, + ), + ); + // And the way to ask for the same thing again, which is the one thing a + // reader can do about an attempt that did not finish. The ordinary pane + // offers it on the same condition — a request this history can name, in + // the conversation it is still continuing — and a staged pane that said + // the turn failed and then offered nothing would be the same reading + // with its one action taken away. + if (item.kind === "incomplete" && item.retry !== undefined && reading.current) { + items.push( + Object.freeze({ + kind: "control" as const, + described: row( + `sidekick:retry:${item.retry.message}`, + `${MARKER_ROOM}[retry]`, + { + select: "retry-message", + conversation: item.retry.conversation, + message: item.retry.message, + }, + styleOf("action"), + { here: focused }, + ), + }), + ); + } + } else if (item.hasOutput) { + items.push(text(key, item.text, styleOf("output"), SIDEKICK_MARK)); + } + items.push(...workComposed(staging, key)); + } + if (reading.busy) { + const running = staging.model.entries.find( + (entry) => + staging.work.some((one) => one.entry === entry.key) && + workState(entry, staging.model, staging.live).style.role === "lifecycle-active", + ); + items.push( + text( + "sidekick:status", + running === undefined ? "working" : `○ waiting for Entry ${running.order}`, + styleOf("waiting"), + SIDEKICK_MARK, + ), + ); + } + // One blank row between utterances and the work they started, as the + // reference separates them. Display only: a gap is no message. + const spaced: ReplChatComposed[] = []; + for (const [at, one] of items.entries()) { + const key = one.kind === "text" ? one.key : one.described.key; + const continues = one.kind === "text" && key.endsWith(":output"); + if (at > 0 && !continues) { + spaced.push( + Object.freeze({ kind: "text" as const, key: `${key}:gap`, text: "", style: ORDINARY }), + ); + } + spaced.push(one); + } + return spaced; } +/** + * The staged left column's own headings, and the selected work's. + * + * The Transcript names itself and its conversation selector; the selected work + * names the entry and the scopes being read as breadcrumbs, with the way to the + * Entries catalog in front of them and the entry's state at the end. + * + * The selector is a control, not a caption: activating it shows the + * conversations this reading actually holds, as the same choices the Sessions + * surface offers, and activating it again puts them away. The choices + * themselves are rows of the list it filters, because that is the thing they + * move — a chooser drawn in the heading band would be a list of controls in a + * region measured for one row. + */ +function workspaceHeadings( + view: ReplView, + /** + * The Transcript selector's label, as this frame fitted it. + * + * Given rather than computed, because fitting it is measurement: the name it + * says is a provider's session key, and how many cells that takes is an + * engine answer about graphemes rather than a count of code units. The same + * answer is given to every pass, so the row that is measured and the row that + * is drawn are one row. + * + * None before the frame has measured — that pass establishes the room this is + * fitted to, and a label shortened against no room would be shortened for + * nothing. It then says the whole name, which is the longest this can be and + * so the honest thing for a pass asking how much room a column wants. + */ + selector: ReplFittedLabel | undefined, + /** Which of the two headings this frame has a region for. */ + shown: { + readonly transcript: boolean; + readonly work: boolean; + /** Whether this heading owns the Entries control, or a surface bar does. */ + readonly entries: boolean; + }, +): readonly Described[] { + const items: Described[] = []; + if (shown.transcript) { + items.push(line("transcript:title", "TRANSCRIPT", styleOf("pane-heading"))); + items.push( + row( + "transcript:selector", + // The label this frame fitted, or the whole name where nothing has + // measured yet. What this says once a conversation has been chosen is + // that conversation's name, and a provider's session key is a name + // nothing here bounds — measured, one of them made this control 58 + // cells wide in a 34-cell column, which overflowed the row and left the + // engine placing it on a fraction of a cell, and a frame placed on + // fractions is one this product refuses to draw at all. The chevron + // stays whatever the name costs: a selector that gave up the mark + // saying it opens is one nobody can tell is one. + selector?.text ?? `${selectorName(view)}${SELECTOR_MARK}`, + { select: "session-filter" }, + styleOf("field-label", { selected: view.state.filtering }), + { here: view.focused }, + ), + ); + } + if (!shown.work) { + return items; + } + if (shown.entries) { + items.push( + row( + "entries:heading", + "Entries", + // The catalog, over this reading. The staged composition keeps no + // permanent column for it, so routing to a surface would be routing to + // a screen with nothing new on it. + { select: "catalog" }, + styleOf("navigate"), + { here: view.focused }, + ), + ); + } + const entry = + view.selection.entry === undefined + ? view.model.entries[view.model.entries.length - 1] + : view.model.entries.find((one) => one.key === view.selection.entry?.key); + if (entry === undefined) { + items.push(line("work:crumbs", "› No entry yet", styleOf("metadata"))); + return items; + } + const names = [ + `Entry ${entry.order}`, + ...view.selection.ancestry.slice(1).map((one) => one.name), + ]; + const separator = " › "; + // Each name with the separator that leads to it. The separator is stated once, + // on the part it leads to, and the single space after it is the one this row + // already puts between its parts — so the names drawn in order are the one + // string this row has always said, and nothing of it is spelled twice. + const spelled = (at: number): { text: string; runs: readonly ReplTokenRun[] } => { + const last = at === names.length - 1; + return { + text: `${separator.trimStart()}${names[at]}`, + runs: tokenRuns([ + { text: separator.trimStart(), token: "punctuation" }, + { text: names[at], token: last ? "xmd-tag" : "metadata" }, + ]), + }; + }; + // Every name but the innermost is somewhere to go: the reading is already at + // the last one, and a control that selected where you are would be a control + // that does nothing. Each ancestor selects the prefix of the path that ends + // at it, which is the same scope path the catalog's own rows select — so + // reaching one from the trail and reaching it from the catalog are the same + // act, and the route that comes out says so. + for (let at = 0; at < names.length - 1; at += 1) { + const part = spelled(at); + items.push( + row( + `work:crumb:${at}`, + part.text, + { select: "scope", scopes: view.state.route.scopes.slice(0, at + 1) }, + styleOf("metadata"), + { here: view.focused, runs: part.runs }, + ), + ); + } + const innermost = spelled(names.length - 1); + items.push(line("work:crumbs", innermost.text, styleOf("metadata"), innermost.runs)); + const state = workState(entry, view.model, view.live); + items.push(line("work:state", state.word, state.style)); + return items; +} + +/** + * One entry agent turn as the staged Transcript reads it: the request, marked + * as the agent's, with its state kept whole, then the reply beneath it. + * + * The same keys and the same activation the ordinary rows have, so selecting a + * turn goes where it always did. + */ +function compactTurn( + turn: ReplSessionTurn, + width: number | undefined, + focused: string | undefined, + claim: string | undefined, +): readonly Described[] { + const rows: Described[] = []; + const style = turnStyle(turn); + const label = fitControl( + [ + { text: AGENT_MARK.text }, + { text: headline(turn.prompt), elide: true }, + { text: " · " }, + { text: stateOf(turn), keep: true }, + ], + width, + ); + rows.push( + row( + `sessions:turn:${turn.key}`, + label, + turn.marker === undefined + ? { select: "surface", surface: "sessions" } + : { select: "marker", marker: turn.marker }, + style, + { here: focused, claim, runs: markedRuns(AGENT_MARK, label, style.role) }, + ), + ); + if (turn.text.length > 0) { + rows.push( + line( + `sessions:turn:${turn.key}:text`, + fitLine( + [{ text: `${CONTROL_ROOM}${MARKER_ROOM}` }, { text: headline(turn.text), elide: true }], + width, + ), + styleOf("output"), + ), + ); + } + if (turn.stopReason !== undefined) { + // What the provider said ended this turn, which is its own fact and not the + // same answer as the state beside the prompt: `completed, recorded` says + // this process saw the turn through and retained it, and the reason says + // why the provider stopped producing. The ordinary list states both; a + // staged one that kept only the first would drop the provider's own word + // for what happened. + rows.push( + line( + `sessions:turn:${turn.key}:stop`, + fitLine( + [ + { text: `${CONTROL_ROOM}${MARKER_ROOM}stopped: ` }, + { text: turn.stopReason, elide: true }, + ], + width, + ), + styleOf("metadata"), + ), + ); + } + if (turn.failure !== undefined) { + rows.push( + line( + `sessions:turn:${turn.key}:failed`, + fitLine( + [ + { text: `${CONTROL_ROOM}${MARKER_ROOM}` }, + { text: headline(turn.failure), elide: true }, + ], + width, + ), + styleOf("failed-outcome"), + ), + ); + } + const request = turn.request; + if (request !== undefined) { + // Focusable, because this list is where the request is drawn. The ordinary + // composition makes it a plain fact off the Sessions surface and a control + // on it — the surface being the one place a permission is answered. This + // composition has no second surface: the Transcript is the one list, and a + // request announced here with no way to reach it would be a screen that + // said somebody was waiting and offered nothing to do about it. + const asks: readonly ReplLabelPart[] = [ + { text: `${CONTROL_ROOM}${MARKER_ROOM}asks: ` }, + { text: headline(request.title ?? request.toolCallId), elide: true }, + ]; + rows.push( + row( + `sessions:request:${request.key}`, + fitControl(asks, width), + { select: "permission", request: request.key }, + styleOf("waiting"), + { here: focused }, + ), + ); + } + return rows; +} + +/** + * One Sidekick turn as the staged Transcript reads it: the message, marked with + * where it came from, then the reply, marked as the Sidekick's. + * + * The origin is the saved message's own, found by its conversation and turn; a + * turn whose message this reading does not hold is marked as a person's, which + * is what every message without an attachment is. + */ +function compactSidekickTurn( + turn: ReplSidekickTurnRow, + sidekick: ReplSidekickReading, + width: number | undefined, + focused: string | undefined, +): readonly Described[] { + const rows: Described[] = []; + const asked = sidekick.chat.find( + (item) => + item.kind === "person" && + item.conversation === turn.conversation && + item.turn !== null && + item.turn === turn.turn, + ); + const mark = originMark(asked?.origin); + const style = turn.running + ? styleOf("waiting") + : turn.failed + ? styleOf("failed-outcome") + : styleOf("source"); + const label = fitControl( + [ + { text: mark.text }, + { text: headline(turn.title), elide: true }, + { text: " · " }, + { text: turn.state, keep: true }, + ], + width, + ); + rows.push( + row( + `sessions:${turn.key}`, + label, + { + select: "sidekick-turn", + conversation: turn.conversation, + ...(turn.turn === null ? {} : { turn: turn.turn }), + }, + style, + { here: focused, runs: markedRuns(mark, label, style.role) }, + ), + ); + if (turn.text.length > 0) { + const said = fitLine( + [ + { text: `${CONTROL_ROOM}${SIDEKICK_MARK.text}` }, + { text: headline(turn.text), elide: true }, + ], + width, + ); + rows.push( + line( + `sessions:${turn.key}:text`, + said, + styleOf("output"), + tokenRuns([ + { text: CONTROL_ROOM, token: "output" }, + ...markedRuns(SIDEKICK_MARK, said.slice(CONTROL_ROOM.length), "output"), + ]), + ), + ); + } + if (turn.failure !== undefined) { + rows.push( + line( + `sessions:${turn.key}:failed`, + fitLine( + [ + { text: `${CONTROL_ROOM}${MARKER_ROOM}` }, + { text: headline(turn.failure), elide: true }, + ], + width, + ), + styleOf("failed-outcome"), + ), + ); + } + return rows; +} + +/** + * The staged Sidekick pane's own controls: the tray above the composer, Follow + * and the Sidekick's Send or Stop beside it, the waiting messages, and the + * composer itself. + * + * TODO, QUEUE, Follow and each message's remove control are mounted controls + * that ask the root for nothing: their behaviour is the queue contract's (#964) + * and the shared TODO's (#901), and none of it is wired here. Send and Stop are + * the Sidekick's own, with the actions they always had. + */ +function workspaceControls( + state: ReplState, + reading: ReplSidekickReading | undefined, + view: ReplView, + claim: string | undefined, + context: ReplPresentationContext, + workspace: ReplWorkspace, +): readonly Described[] { + const items: Described[] = []; + if (reading === undefined) { + return items; + } + if (state.route.sidekick !== undefined) { + items.push( + row( + "sidekick:back", + "[Back to current chat]", + { select: "sidekick-back" }, + styleOf("action"), + { + here: view.focused, + }, + ), + ); + } + if (reading.refusal !== undefined || !reading.current) { + return items; + } + items.push( + row( + "sidekick:configure", + `[configure ${reading.agent}]`, + { + select: "configure", + ...(reading.conversation === undefined ? {} : { conversation: reading.conversation }), + }, + styleOf("navigate"), + { here: view.focused, ...(claim === "sidekick:configure" ? { focus: true } : {}) }, + ), + ); + const question = reading.question; + if (question !== undefined) { + items.push( + row( + "sidekick:answer", + "[answer sidekick]", + { select: "sidekick-question", question: question.key }, + styleOf("waiting"), + { here: view.focused, ...(claim === "sidekick:answer" ? { focus: true } : {}) }, + ), + ); + } + // Only the trays this execution actually offers, and Follow only where it is + // offered at all. A build without the shared queue draws no QUEUE tab and no + // Follow checkbox: a control for behaviour that is not here is a choice + // somebody can make and nothing happens, however it is drawn. + for (const tray of traysOf(workspace)) { + const here = workspace.tray === tray; + items.push( + row( + `tray:${tray}`, + tray.toUpperCase(), + {}, + styleOf(here ? "pane-heading" : "metadata", { selected: here }), + { + here: view.focused, + }, + ), + ); + } + if (workspace.follow !== undefined) { + items.push( + row("follow", `[${workspace.follow ? "x" : " "}] Follow`, {}, styleOf("action"), { + here: view.focused, + }), + ); + } + items.push( + reading.busy + ? row("sidekick:stop", "[stop]", { select: "stop-sidekick" }, styleOf("failed-outcome"), { + here: view.focused, + }) + : row("sidekick:send", "[send]", { select: "send-message" }, styleOf("action"), { + here: view.focused, + }), + ); + if (workspace.tray === "queue" && offersQueue(workspace)) { + const window = context.admission.windows.get(QUEUE_WINDOW); + // Rows rather than messages. The window counts fitted rows, so the + // description selects the same fitted rows: a slice of messages would put + // more rows in the tray than it measured room for. + const sequence = queueSequence(workspace, context.queue); + // Whether this tray holds more rows than its column allocated it, answered + // from that declared allocation rather than from a window that has already + // been admitted. An admitted window is the later of the two answers, and + // the measurement that produces it needs this one — a capacity measured + // without the controls that will sit around the viewport is the capacity + // of a region nobody is going to draw. + const beside = items.filter( + (one) => one.key === "sidekick:back" || one.key === "sidekick:answer", + ).length; + const overflowing = trayAllocation(view, context, beside) !== undefined; + // Both controls where the tray holds more than it can show, and neither + // where it does not: a control that scrolls nothing is a Tab stop and a + // pointer target over a reading that is already whole. + if (overflowing) { + items.push( + row("queue:earlier", ` ${EARLIER}`, {}, styleOf("navigate"), { here: view.focused }), + ); + } + // Which rows this pass puts in the tray. + // + // A tray whose rows fit has no window and no viewport: its rows are its + // own content, and every pass describes all of them. An overflowing one is + // measured empty, because a scrolling viewport stretched by its own + // contents reports the room its contents wanted rather than the room the + // column gave it — and the window that admits rows into it is derived from + // that measurement. + // + // The width probe is the single exception, and it is not the pass the + // admission comes from: it draws the sequence's first row so the engine + // can say how wide that row's control is, which is what every row's text + // box is then stated from. + const shownRows = !overflowing + ? sequence + : window !== undefined + ? sequence.slice(window.from, window.from + window.count) + : context.queueProbe === true + ? sequence.slice(0, 1) + : []; + for (const one of shownRows) { + const mark = originMark(one.message.origin); + const marked = one.first && one.text.startsWith(MARKER_ROOM); + const said = marked ? `${mark.text}${one.text.slice(MARKER_ROOM.length)}` : one.text; + items.push( + line( + `queue:${one.message.key}:row:${one.offset}`, + said, + styleOf("queued"), + marked ? markedRuns(mark, said, "queued") : undefined, + ), + ); + // The control belongs to the row its message begins at and shares it, so + // it takes no row of the window, and a continuation shown without its + // beginning offers no control that would act on the wrong message. + if (one.first) { + items.push( + row( + `queue:${one.message.key}:remove`, + "X", + { name: "Remove message" }, + styleOf("metadata"), + { here: view.focused }, + ), + ); + } + } + if (overflowing) { + items.push(row("queue:later", ` ${LATER}`, {}, styleOf("navigate"), { here: view.focused })); + } + } + const claiming = claim === "sidekick:message"; + items.push( + field( + "sidekick:message", + " › message the sidekick", + state.message, + "message", + styleOf("draft"), + { + field: MESSAGE_FIELD, + here: view.focused, + block: true, + ...(claiming ? { focus: true } : {}), + }, + ), + ); + for (const [at, own] of messageValueRows(context).entries()) { + items.push(line(`sidekick:message:value:${at}`, own, styleOf("draft"))); + } + return items; +} + +/** + * The selected work's Draft: one field whose heading carries its own Send, or + * Stop while an entry is running, and whose value takes the rows beneath it. + * + * Send asks for exactly what Enter in the field asks for — the same submission + * action, through the same gate, against the same draft revision — so the two + * ways of submitting one text are one decision rather than two. Stop carries + * the key of the entry this frame is actually drawing it for, so what it stops + * is that entry and not whatever is running by the time the key is pressed. It + * is offered only at the live head — a frozen reading is of an entry this + * process may no longer be running, and a Stop there would be aimed at history. + */ +function workspaceDraft( + view: ReplView, + context: ReplPresentationContext, + claiming: boolean, +): readonly Described[] { + const items: Described[] = []; + items.push( + field("footer:input", " DRAFT", view.state.draft, "draft", styleOf("field-label"), { + here: view.focused, + block: true, + ...(claiming ? { focus: true } : {}), + }), + ); + // The entry a Stop here would be aimed at: the last one the live head + // admitted. Entries are serial, so only the last one can still be running — + // the same rule the entry reading uses to decide whose live output it is + // showing. A reading with no entry at all has nothing to aim a Stop at, and + // a frozen position is excluded below, because an entry read there may not be + // the one this process is running. + const open = view.model.entries[view.model.entries.length - 1]?.key; + items.push( + view.live.running && view.state.route.at === undefined && open !== undefined + ? row( + "draft:stop", + "[stop]", + { select: "stop-entry", entry: open }, + styleOf("failed-outcome"), + { here: view.focused }, + ) + : row("draft:send", "[send]", { select: "submit-draft" }, styleOf("action"), { + here: view.focused, + }), + ); + const rows = context.draft ?? ["", "", ""]; + for (const [at, own] of rows.entries()) { + items.push(line(`draft:value:${at}`, own, styleOf("draft"), sourceRuns(own))); + } + return items; +} + +/** The text column of the staged queue's first message, which its width is read from. */ +export const QUEUE_TEXT = "box:queue:text:0"; + +/** The staged guidance row's inside, which the guidance is fitted to. */ +export const GUIDANCE_ROW = "box:guidance:body"; + +/** The Draft's heading row, whose measured width its field is derived from. */ +export const DRAFT_HEADING = "box:draft:heading"; + +/** + * How wide a queued message's text is left beside its control. + * + * The row's own measured room, less the width the engine gave that control, + * less the gap the row declares. None before either has been measured. + */ +export function queueTextWidth( + manifest: ReplLayoutManifest, + widthOf: (id: string) => number | undefined, + room: number | undefined, +): number | undefined { + const control = flatten(manifest.root).find((one) => one.key?.endsWith(":remove") === true); + if (control === undefined || room === undefined || room < 1) { + return undefined; + } + const button = widthOf(control.id); + if (button === undefined || button < 1) { + return undefined; + } + return Math.max(0, room - button - WORKSPACE_DRAFT_GAP); +} + +/** + * How wide the Draft's field is left, for this exact measured frame. + * + * The heading row's own measured width, less the width the engine gave the + * control beside it, less the gap that row declares. None where this frame drew + * no Draft, or where the measurement has answered for neither box yet. + */ +export function draftFieldWidth( + manifest: ReplLayoutManifest, + widthOf: (id: string) => number | undefined, +): number | undefined { + const control = flatten(manifest.root).find( + (one) => one.key === "draft:stop" || one.key === "draft:send", + ); + if (control === undefined) { + return undefined; + } + const heading = widthOf(DRAFT_HEADING); + const button = widthOf(control.id); + if (heading === undefined || button === undefined || heading < 1 || button < 1) { + return undefined; + } + return Math.max(0, heading - button - WORKSPACE_DRAFT_GAP); +} + +/** The staged History row's map, which is drawn rather than mounted. */ +const HISTORY_MAP = "box:history:map"; + +/** The staged History row, which the map and the controls divide. */ +const HISTORY_ROW = "box:history"; + +/** The gap the accepted wide row keeps between its map and its controls. */ +const HISTORY_SPACE = "box:history:space"; + +/** + * What the compact History rail calls itself. + * + * Shorter than the drawer's own title, because this is one row shared with the + * footer's controls: the drawer has room to say what its list is ordered by and + * this has room to say which reading it belongs to. + */ +export const COMPACT_HISTORY_TITLE = "HISTORY"; + +/** The probe row the offered action group's own intrinsic width is read from. */ +export const HISTORY_PROBE_ACTIONS = "box:probe:history:actions"; + +/** The probe row the History map's own natural width is read from. */ +export const HISTORY_PROBE_MAP = "box:probe:history:map"; + +/** + * The History row's two parts, measured without each other's pressure. + * + * The non-wide row holds the map and the controls side by side, and the engine + * answers a *fit* child's width under whatever room its parent was given: a map + * fitted to its own long text takes the room first, the controls are left with + * less than they need, and what comes back for each of them is a compressed + * answer on part of a cell. Measured, a 72-cell row gave the map 29 and the + * controls 43 — and the controls, asked on their own, need 51. + * + * So they are asked on their own. One fitted row holding exactly the offered + * control nodes, their own labels, their own gap and the same leading spacer + * declared at zero; one fitted row holding the map's own runs. Both carry + * structural ids, mount nothing and publish no target: the question is how wide + * these things are, and the answer is the engine's rather than a count of + * characters. + * + * Built from the boxes the real composite built, so the labels measured here + * are the labels drawn there. What is copied is each child's own text and + * props — never the rectangle it was squeezed into, which is the number this + * exists to replace. + */ +export function historyProbeOf( + manifest: ReplLayoutManifest, + size: ReplTerminalSize, +): ReplBox | undefined { + const boxes = flatten(manifest.root); + const actions = boxes.find((one) => one.id === ACTION_ROW); + const map = boxes.find((one) => one.id === HISTORY_MAP); + if (actions === undefined || map === undefined) { + return undefined; + } + const children = actions.children.map((child) => + child.id === HISTORY_SPACE + ? // The leading spacer grows into whatever the row has spare. Declared at + // zero here, because what is being asked is the minimum the controls + // need and a growing child would answer with the whole row. + box({ id: `${HISTORY_PROBE_ACTIONS}:space`, props: statedPartProps(0) }) + : box({ + id: `${HISTORY_PROBE_ACTIONS}:${child.id}`, + props: child.props, + text: child.text, + ...(child.style === undefined ? {} : { style: child.style }), + ...(child.runs === undefined ? {} : { runs: child.runs }), + }), + ); + return box({ + id: "box:probe:history", + props: rootProps(size), + children: [ + box({ id: HISTORY_PROBE_ACTIONS, props: fittedRowProps(1), children }), + box({ + id: HISTORY_PROBE_MAP, + props: fittedPartProps(), + text: map.text, + ...(map.style === undefined ? {} : { style: map.style }), + ...(map.runs === undefined ? {} : { runs: map.runs }), + }), + ], + }); +} + +/** + * How the non-wide History row divides itself, in whole cells. + * + * The map yields first and the controls keep what they need: `M` is the map's + * own natural width, or what the row has left once the controls have theirs, + * whichever is smaller; `A` is the rest. Both are whole, because both come from + * whole measurements of complete labels — and the row states them rather than + * letting two fit children compete, which is what put a control on part of a + * cell. + * + * None where either measurement is missing or came back fractional. A frame + * that cannot be measured is refused by the existing path rather than drawn + * from a guess. + */ +export function historySplitOf( + widthOf: (id: string) => number | undefined, + room: number, +): { readonly map: number; readonly actions: number } | undefined { + const needed = widthOf(HISTORY_PROBE_ACTIONS); + const natural = widthOf(HISTORY_PROBE_MAP); + if (needed === undefined || natural === undefined || room < 1) { + return undefined; + } + if (!Number.isInteger(needed) || !Number.isInteger(natural)) { + return undefined; + } + const map = Math.min(natural, Math.max(0, room - needed)); + return { map, actions: room - map }; +} + +/** + * How wide the accepted wide row's gap is, for this exact measured frame. + * + * That row is a fitted map, this gap, and the controls — and the gap and the + * controls both grow, so the engine divides what the map leaves between them. + * It divides it in halves, and half of an odd number of cells is not a cell: + * measured, an ordinary root's 25-cell map left 135, which came out 67.5 and + * 67.5, and put History at x=92.5 where the renderer refuses to draw it. The + * accepted fixture's map happens to leave 126, so its halves were whole and + * nothing said the arrangement only worked for even remainders. + * + * So the gap is declared instead, and the controls stay the one growing child. + * This is an integer budget decided before anything is placed — the engine + * still measures where each control lands and how many of them there is room + * for, and every control is still fitted to its own label. The odd cell goes + * to the controls, which are what somebody is aiming at. + */ +export function historyGapWidth(widthOf: (id: string) => number | undefined): number | undefined { + const row = widthOf(HISTORY_ROW); + const map = widthOf(HISTORY_MAP); + if (row === undefined || map === undefined) { + return undefined; + } + // Both from this frame, and both whole: a width the engine had already + // divided into halves of a cell is not a budget to divide again. + if (!Number.isInteger(row) || !Number.isInteger(map)) { + return undefined; + } + return Math.floor(Math.max(0, row - map) / 2); +} + +/** + * How wide the History row's map is left, for this exact measured frame. + * + * The row's own width, less the width the engine gave the controls beside it. + * The map is the one thing on that row that may be shortened: the controls are + * fitted to their own labels, because a pointer lands on the label somebody is + * aiming at. + * + * It matters where the map is longer than what is left. A fitted box asked for + * more room than the row has is squeezed by the engine's own arithmetic, and + * that arithmetic is not whole — measured, a 72-column row holding a reading + * of a retained position wanted 52 cells of map beside 21 of controls and came + * back 51.0078125 wide, which the renderer refuses rather than rounds. Stated, + * the map is cut at a whole cell and keeps the stretches that fall inside it. + */ +export function historyMapWidth( + manifest: ReplLayoutManifest, + widthOf: (id: string) => number | undefined, + room: number | undefined, +): number | undefined { + if (flatten(manifest.root).every((one) => one.id !== HISTORY_MAP)) { + return undefined; + } + const actions = widthOf(ACTION_ROW); + if (actions === undefined || room === undefined || room < 1) { + return undefined; + } + // Whole cells, and the odd one to the controls. A measured remainder is not + // always integral — two growing siblings split what is left fractionally — + // and a map stated at a fraction leaves its controls sharing a fraction, + // which the renderer refuses rather than rounding. Rounding *down* here is + // what gives the controls the whole cell: the band loses a column of map + // before a control loses part of itself. + return Math.max(0, Math.floor(room - actions)); +} + +/** + * The staged composition's identity row: what this is and which execution. + * + * Read from the route, so it names nothing this reading does not hold. + */ +function identityRuns(view: ReplView): readonly ReplTokenRun[] { + return tokenRuns([ + { text: " XMD REPL", token: "output" }, + { text: " session ", token: "field-label" }, + { text: view.state.route.execution, token: "source" }, + ]); +} + +/** + * The compact History map: one mark per entry, in admission order, then the head. + * + * Built from the whole-file navigation, so it shows positions after the + * selection too — but only that they exist. An entry inside the selected prefix + * carries the outcome that prefix holds; a later one carries its number alone. + * Every exact position, an entry's and every minor one, is reached through the + * History drawer, which is what the action beside this map opens. + */ +function compactHistoryRuns(view: ReplView): readonly ReplTokenRun[] { + const runs: { text: string; token: ReplPresentationRole }[] = [ + { text: ` ${COMPACT_HISTORY_TITLE}`, token: "history-title" }, + ]; + const points = numbered(view.navigation); + const at = view.state.route.at; + const selectedOrdinal = + at === undefined ? undefined : points.find((one) => one.point.marker === at)?.ordinal; + const selectedEntry = + selectedOrdinal === undefined + ? undefined + : points.filter((one) => one.ordinal <= selectedOrdinal).pop()?.entry; + for (const one of points) { + if (one.point.kind !== "entry") { + continue; + } + const entry = view.model.entries.find((candidate) => candidate.order === one.entry); + const chosen = selectedEntry === one.entry; + runs.push({ text: " ", token: "history" }); + runs.push({ + text: String(one.entry), + token: chosen + ? "history-selected" + : entry === undefined + ? "history-entry-later" + : "history-entry-earlier", + }); + if (entry === undefined) { + continue; + } + const state = workState(entry, view.model, view.live); + runs.push({ text: ` ${state.word.slice(0, 1)}`, token: state.style.role }); + } + const head = view.navigation.head; + runs.push({ text: " ── ", token: "history-rail" }); + runs.push({ + text: at === undefined ? HEAD_LABELS[head] : `${HEAD_LABELS[head]} · reading earlier`, + token: head === "live" ? "history-head-live" : "history-head", + }); + return tokenRuns(runs); +} + +/** + * The staged composition's headers, as the two groups each of them holds. + * + * Named once, because two readers need the same answer: the preparation that + * measures whether a header's groups fit one row, and the builder that places + * them. The Draft's own heading is not here — its field grows into whatever its + * controls leave, so it has nothing to wrap. + */ +/** + * How many rows an overflowing tray takes, and how many of them are its window. + * + * Declared once, from the same parts the manifest places: the tray's own + * heading rows, the two rows its navigation takes, the window itself, and the + * composer's fitted rows. A content-fitted tray leaves the column dividing a + * remainder between a growing body and a fitted block, and that division is not + * whole — measured, it put the chat body at 18.0078125 rows and every row under + * it on a fraction of a cell. + * + * The window is bounded by {@link WORKSPACE_QUEUE_ROWS} and by what the column + * has left once its heading, the blank row above its body, the chat's own two + * controls and one row of chat are taken. Eight is a maximum rather than a + * promise: a narrower column allows fewer, and one with no room allows none, + * which admits no queue rows at all. + * + * None where the tray's rows fit what it has, so that tray keeps the + * content-sized placement the accepted screen was reviewed with. + */ +function trayAllocation( + view: ReplView, + context: ReplPresentationContext, + extras: number, +): { readonly tray: number; readonly queue: number } | undefined { + const workspace = staged(view); + if (workspace === undefined || workspace.tray !== "queue" || !offersQueue(workspace)) { + return undefined; + } + const wrapped = (id: string): number => (context.headers?.has(`header:${id}`) === true ? 2 : 1); + const composer = 1 + messageValueRows(context).length; + const navigation = 2; + const held = queueSequence(workspace, context.queue).length; + const column = + workspaceBodyRows(view.size) - wrapped("conversation") - 1 - navigation - 1 - extras; + const queue = Math.max( + 0, + Math.min(WORKSPACE_QUEUE_ROWS, column - wrapped("tray") - navigation - composer), + ); + if (held <= queue) { + return undefined; + } + return { tray: wrapped("tray") + navigation + queue + composer, queue }; +} + +/** One drawn row of the tray, and the message whose text it carries. */ +interface ReplQueuedRow { + readonly message: ReplQueuedMessage; + /** Which row of its own message this is; zero is where the message begins. */ + readonly offset: number; + readonly text: string; + /** Whether this row begins its message, which is where its control goes. */ + readonly first: boolean; +} + +/** + * Every row the tray's messages occupy, in order, as one sequence. + * + * The one list the window counts, slices and draws from, so the rows a capacity + * is measured against and the rows a frame shows cannot be two different lists. + * Before the queue is fitted each message is one blank row: that count is the + * geometry question, and its text would be fitted to nothing. + */ +function queueSequence( + workspace: ReplWorkspace, + fitted: readonly ReplFittedLine[] | undefined, +): readonly ReplQueuedRow[] { + const rows: ReplQueuedRow[] = []; + for (const message of workspace.queued) { + const pieces = + fitted === undefined + ? [{ text: "", indent: 0 }] + : fitted.filter((piece) => piece.line === `queue:${message.key}`); + for (const [offset, piece] of pieces.entries()) { + const text = `${" ".repeat(piece.indent)}${piece.text}`; + rows.push( + Object.freeze({ + message, + offset, + text, + // Where the message begins, which is a fact about the row rather + // than about its characters: a pass that has fitted no text yet + // still has a first row, and its control must exist there to be + // measured. + first: offset === 0, + }), + ); + } + } + return Object.freeze(rows); +} + +const WORKSPACE_HEADERS: readonly { + readonly id: string; + readonly gap: number; + readonly left: readonly string[]; + readonly right: readonly string[]; + /** The left group where one outlet is routed, if it differs. */ + readonly outletLeft?: readonly string[]; +}[] = Object.freeze([ + Object.freeze({ + id: "transcript", + gap: TAB_GAP, + left: Object.freeze(["transcript:title"]), + right: Object.freeze(["transcript:selector"]), + }), + Object.freeze({ + id: "work", + gap: 1, + // The Entries control where this heading owns it. A narrow frame's surface + // bar holds that control instead, and one key is one mounted node — so the + // outlet's own heading is the breadcrumb alone. The breadcrumb itself is a + // group rather than a key: every name but the innermost is a control that + // goes to it. + left: Object.freeze(["entries:heading", "work:crumb:*", "work:crumbs"]), + outletLeft: Object.freeze(["work:crumb:*", "work:crumbs"]), + right: Object.freeze(["work:state"]), + }), + Object.freeze({ + id: "conversation", + gap: TAB_GAP, + left: Object.freeze(["tab:bindings", "tab:sidekick"]), + right: Object.freeze(["sidekick:configure"]), + }), + Object.freeze({ + id: "tray", + gap: TAB_GAP, + // Every key this row can hold, in its order. A key the composition did not + // describe — a tray this execution does not offer, a Follow it does not + // have — contributes no part, so the row is built from what is actually + // there rather than from what a wider build would have put there. + left: Object.freeze(["tray:todo", "tray:queue"]), + right: Object.freeze(["follow", "sidekick:stop", "sidekick:send"]), + }), +]); + +/** One staged header's two groups, as the frame drawing it holds them. */ +function headerGroupsOf( + id: string, + outlet: boolean, +): + | { readonly gap: number; readonly left: readonly string[]; readonly right: readonly string[] } + | undefined { + const spec = WORKSPACE_HEADERS.find((one) => one.id === id); + if (spec === undefined) { + return undefined; + } + return { + gap: spec.gap, + left: outlet ? (spec.outletLeft ?? spec.left) : spec.left, + right: spec.right, + }; +} + +/** + * How many cells the Transcript's selector has to say its name in. + * + * Its own row's room, less the one gap the engine puts between that row's + * growing spacer and it. The header is two rows exactly when its two groups + * cannot share one, and on a row of its own the selector is the only thing + * beside that spacer — so this is the width it is given whichever of the two + * the frame draws: at a shared width the one-row case arises only when the + * label is short enough to fit beside the title as well. + * + * None before the frame has measured, which is the pass that fits nothing. + */ +/** + * The mark on the Transcript selector that says it opens. + * + * Kept whatever the name in front of it costs, because it is the only thing on + * that row saying the row is a way to something rather than a label. + */ +export const SELECTOR_MARK = " \u25be"; + +/** + * Which conversation the Transcript selector names, before anything fits it. + * + * The whole name, which is what a frame still measuring should be told: it is + * the longest this control can be, and a pass asking how much room the column + * wants should not be answered with a name already given up for a width that + * pass has not established. + */ +export function selectorName(view: ReplView): string { + if (view.state.route.session === undefined) { + return "All sessions"; + } + // The first line of it, and the whole of that line. A name is one row here, + // so a name holding a break is read to its first break — but nothing cuts it + // by length on the way to the fitter. `headline` does, at fifty-nine UTF-16 + // units, and that is a cut in the wrong units made in the wrong place: + // measured, a key that is one grapheme — an `e` under a hundred combining + // accents — arrived at the fitter already missing forty-two of them, so the + // fitter segmented a name this product had altered and reported an honest + // width for the wrong string. Fitting is where a name gives up columns, and + // it gives them up at grapheme boundaries against cells the engine measured. + const [first = ""] = view.state.route.session.split("\n"); + return first; +} + +export function selectorRoomOf(widths: ReplMeasuredWidths | undefined): number | undefined { + return widths === undefined + ? undefined + : Math.max(0, headerRoomOf("transcript", widths) - TAB_GAP); +} + +/** + * Every key one header group names, with a trailing `*` standing for a run. + * + * The breadcrumb's ancestors are as many as the scopes somebody selected, so + * the trail cannot be a fixed list of keys. A `*` names the run instead, and it + * expands in the order the rows were described — which is the order the trail + * reads in. Asked of what is actually there, so a reading with no nested scope + * contributes no ancestor and the row is the row it has always been. + */ +function headerKeysOf( + keys: readonly string[], + described: readonly { readonly key: string }[], +): readonly string[] { + return keys.flatMap((key) => { + if (!key.endsWith("*")) { + return [key]; + } + const prefix = key.slice(0, -1); + const found: string[] = []; + for (const one of described) { + if (one.key.startsWith(prefix) && !found.includes(one.key)) { + found.push(one.key); + } + } + return found; + }); +} + +/** Which measured width one staged header's row is built to. */ +function headerRoomOf(id: string, widths: ReplMeasuredWidths): number { + const pane = + id === "transcript" ? widths.list : id === "work" ? widths.surface : widths.inspection; + return pane - 2 * WORKSPACE_LIST_GUTTER; +} + +/** + * Each staged header as the one row it would have to be, and the room it has. + * + * Fitted by the preparation against the width the engine measured for it: a + * header whose two groups need more than that row is drawn as two rows + * instead, which is a row of the pane's own body rather than an overlap, a + * shortened label or a control left out. + * + * The texts are the ones the descriptions carry, read from a pass that has + * described them — never spelled a second time here, which is how the row that + * is measured and the row that is drawn stay the same row. + */ +export function workspaceHeaderLines( + view: ReplView, + context: ReplPresentationContext, + widths: ReplMeasuredWidths, +): readonly { readonly key: string; readonly text: string; readonly room: number }[] { + if (staged(view) === undefined) { + return []; + } + const screen = described(view, context); + // What each row would draw, answered by the same function the measuring pass + // asks: a row whose characters all mean one thing carries no runs, and a map + // built from runs alone would read those rows as empty. + const text = new Map( + screen.rows.map((one) => { + const read = readDescription(one.description); + return [one.key, measurementTextOf(read.component, read.input)]; + }), + ); + const said = (keys: readonly string[], gap: number): string => + keys + .map((key) => text.get(key)) + .filter((one): one is string => one !== undefined && one !== "") + .join(" ".repeat(gap)); + + const outlet = profileFor(view.size) === "narrow"; + const lines: { key: string; text: string; room: number }[] = []; + for (const header of WORKSPACE_HEADERS) { + const groups = headerGroupsOf(header.id, outlet); + if (groups === undefined) { + continue; + } + const left = said(headerKeysOf(groups.left, screen.rows), header.gap); + const right = said(headerKeysOf(groups.right, screen.rows), header.gap); + if (left === "" || right === "") { + continue; + } + const room = headerRoomOf(header.id, widths); + if (room < 1) { + continue; + } + lines.push({ + key: `header:${header.id}`, + // Two gaps, not one: the drawn row is the left group, the spacer that + // takes what is over, and the right group, and the engine puts its gap on + // both sides of that spacer. A text that counted one of them would say a + // row fits in the one case where it is exactly one gap too wide, and the + // engine would then squeeze a fitted label onto a fraction of a cell. + text: `${left}${" ".repeat(header.gap * 2)}${right}`, + room, + }); + } + return Object.freeze(lines); +} + +/** + * The staged workspace composition's boxes, from the same classified rows. + * + * One identity row, the body, and below it one compact History row carrying the + * contextual actions and one guidance row. The wide and medium profiles give + * the body three columns — the Transcript of agent work, the selected work with + * its Draft, and Bindings/Sidekick with the tray above its composer. The narrow + * profile routes one outlet instead, under the two surface controls, exactly as + * the ordinary composition does: the surface it is not showing is in no cell, + * no frame map and no target. + * + * The same measured bodies and windows the ordinary composition publishes, so + * preparation, admission and fitting read them by the same names. + */ +function workspacePresentation(input: { + readonly view: ReplView; + readonly context: ReplPresentationContext; + readonly descriptions: readonly ReplDescription[]; + readonly candidates: readonly ReplCandidate[]; + readonly rail: ReplPreparedRail | undefined; + readonly listColumn: ( + region: ReplRegion, + fixedSlot: ReplSlot, + rowSlot: ReplSlot, + window: string, + rows?: number, + room?: number, + ) => readonly ReplBox[]; + readonly chatColumn: (region: ReplRegion) => readonly ReplBox[]; + readonly viewports: ReplViewportSlot[]; + readonly regions: { region: ReplRegion; id: string }[]; + readonly contents: { region: ReplRegion; id: string; kind: "pane" | "body" }[]; +}): ReplPresentation { + const { view, context, descriptions, candidates, rail, viewports, regions, contents } = input; + const size = view.size; + const profile = profileFor(size); + const outlet = profile === "narrow"; + /** Whether this frame is the accepted wide screen, whose row placement is reviewed. */ + const accepted = profile === "wide"; + const of = (slot: ReplSlot): readonly ReplCandidate[] => + candidates.filter((candidate) => candidate.slot === slot); + const byKey = (key: string): readonly ReplCandidate[] => + candidates.filter((candidate) => candidate.key === key); + const startingWith = (prefix: string): readonly ReplCandidate[] => + candidates.filter((candidate) => candidate.key.startsWith(prefix)); + /** One part of a row, exactly as wide as what it says. */ + const part = (candidate: ReplCandidate, region: ReplRegion, surface?: number): ReplBox => + box({ + id: `box:${region}:${candidate.key}`, + key: candidate.key, + region, + props: fittedPartProps(surface), + text: candidate.text, + control: candidate.control, + style: candidate.style, + runs: candidate.runs, + }); + const spacer = (id: string, region: ReplRegion): ReplBox => + box({ id, region, props: SPACER_PROPS }); + const header = REPL_PALETTE.paneHeaderSurface; + /** + * How wide this header part is stated to be, or none to let it be measured. + * + * The Transcript's selector alone, and for two reasons that are really one. + * A fitted part asks the engine how wide its own text is, and that answer is + * not always whole: measured, this selector came back 58.005859375 cells wide + * and, shortened to its column, 32.0078125 — a fraction either way, and a + * frame placed on fractions is one this product refuses to draw. And the + * number it should be is already known, because fitting the label is what + * produced it: {@link ReplFittedLabel} carries the text and the cells the + * engine gave that text as one answer. + * + * Marker room is added here rather than measured. It is this screen's own + * two cells in front of every control's label, drawn by `row` and not part of + * the name — the label is fitted to what is left once they have theirs. + */ + const statedCells = (one: ReplCandidate): number | undefined => + one.key === "transcript:selector" && context.selector !== undefined + ? context.selector.cells + FOCUS_MARKER + : undefined; + const parts = (keys: readonly string[], region: ReplRegion, surface?: number): ReplBox[] => + keys.flatMap((key) => + byKey(key).map((one) => { + const cells = statedCells(one); + return cells === undefined + ? part(one, region, surface) + : box({ + id: `box:${region}:${one.key}`, + key: one.key, + region, + props: statedPartProps(cells), + text: one.text, + control: one.control, + style: one.style, + runs: one.runs, + }); + }), + ); + + /** + * One staged header, in the one row it fits or the two rows it needs. + * + * Two rows keep the groups in their order — the left group's row first, the + * right group's row under it, still at the trailing edge — so nothing moves + * out from under a reader who learned where it was at a wider size. The + * second row is a row of this pane, which is what makes it measured rather + * than an overlay. + */ + const headerRows = (id: string, region: ReplRegion, surface: number): readonly ReplBox[] => { + const spec = headerGroupsOf(id, outlet); + if (spec === undefined) { + return []; + } + const left = parts(headerKeysOf(spec.left, candidates), region, surface); + const right = parts(headerKeysOf(spec.right, candidates), region, surface); + if (left.length === 0 && right.length === 0) { + return []; + } + // The ids the accepted frame published, so a header that still fits one row + // is the same box it always was. The tray is a row of its own block rather + // than a pane heading, so it keeps that block's inset instead of a + // heading's. + const first = + id === "conversation" + ? TAB_ROW + : id === "tray" + ? "box:tray:controls" + : `box:${region}:header`; + const props = () => + id === "tray" ? partsRowProps(1, spec.gap) : workspaceHeaderProps(surface, spec.gap); + if ( + !(context.headers?.has(`header:${id}`) ?? false) || + left.length === 0 || + right.length === 0 + ) { + return [ + box({ + id: first, + region, + props: props(), + children: [...left, spacer(`${first}:space`, region), ...right], + }), + ]; + } + return [ + box({ + id: first, + region, + props: props(), + children: [...left, spacer(`${first}:space`, region)], + }), + box({ + id: `${first}:second`, + region, + props: props(), + children: [spacer(`${first}:second:space`, region), ...right], + }), + ]; + }; + + /** The Transcript of agent work: its heading, and the reading under it. */ + const transcriptColumn = ( + region: ReplRegion, + body: string, + inside?: number, + ): readonly ReplBox[] => { + contents.push({ region, id: body, kind: "body" }); + return [ + ...headerRows("transcript", region, header), + box({ + id: `${body}:gutter`, + region, + props: workspaceBodyProps(WORKSPACE_LIST_GUTTER, 1, inside), + children: [ + box({ + id: body, + region, + props: paneContentProps(), + children: input.listColumn( + region, + "sessions-fixed", + "sessions", + SESSIONS_WINDOW, + undefined, + context.widths?.listBody, + ), + }), + ], + }), + ]; + }; + + /** The selected work: its breadcrumbs and state, its reading, and the Draft. */ + const workColumn = (region: ReplRegion, body: string, inside?: number): readonly ReplBox[] => { + contents.push({ region, id: body, kind: "body" }); + const draft = of("draft"); + const value = startingWith("draft:value:"); + return [ + ...headerRows("work", region, header), + box({ + id: `${body}:gutter`, + region, + props: workspaceBodyProps(BODY_GUTTER, 1, inside), + children: [ + box({ + id: body, + region, + props: paneContentProps(), + children: input.listColumn( + region, + "reading-fixed", + "reading", + READING_WINDOW, + undefined, + context.widths?.surfaceBody, + ), + }), + ], + }), + ...(draft.length === 0 && value.length === 0 + ? [] + : [ + box({ + id: "box:draft", + region, + props: workspaceBlockProps( + WORKSPACE_DRAFT_ROWS, + REPL_PALETTE.draftSurface, + BODY_GUTTER, + ), + children: [ + box({ + id: DRAFT_HEADING, + region, + props: partsRowProps(1, WORKSPACE_DRAFT_GAP, context.widths?.surfaceBody), + children: [ + ...draft.map((one) => + box({ + id: `box:${region}:${one.key}`, + key: one.key, + region, + // What this row has left once the engine has measured + // the control beside it. Grown instead, the engine + // divides that remainder itself and the division is not + // always whole — measured, it left this field + // 52.00732421875 wide and put the control on a fraction + // of a cell. The control is still measured by its own + // label, so its hit box is still the label somebody + // aims at; only what is left over is stated. + props: + context.draftField === undefined + ? SPACER_PROPS + : statedPartProps(context.draftField), + text: one.text, + control: one.control, + style: one.style, + // No stretches: this row is drawn in one ink, which is + // what the reviewed screen shows — its marker and the + // composer's below it both take their own row's style, + // bolded where the focus is. Stating a width is what + // makes the builder apply a row's stretches, and the + // marker would otherwise take the focus ink here; the + // width the engine had already divided is the only + // thing this was meant to settle. + }), + ), + ...parts(["draft:stop", "draft:send"], region), + ], + }), + ...value.map((one) => rowBox(one, region, context.widths?.surfaceBody)), + ], + }), + ]), + ]; + }; + + /** Bindings or the Sidekick: the tabs, the reading, and the tray below it. */ + const conversationColumn = ( + region: ReplRegion, + body: string, + inside?: number, + ): readonly ReplBox[] => { + const showing = showingSidekick(view); + if (showing) { + contents.push({ region, id: body, kind: "body" }); + } + const room = context.widths?.body; + const tray = [ + ...startingWith("tray:"), + ...byKey("follow"), + ...byKey("sidekick:stop"), + ...byKey("sidekick:send"), + ]; + const held = staged(view); + // One box per row this frame described, walked in description order so the + // rows placed are exactly the rows the window admitted. A box per message + // reports its whole rectangle even where the window clipped it, and the + // row after it is then placed over the control below. + const described = candidates.filter( + (one) => + one.key.startsWith("queue:") && one.key !== "queue:earlier" && one.key !== "queue:later", + ); + let named = false; + const queueRows: ReplBox[] = []; + for (const [at, one] of described.entries()) { + if (one.key.endsWith(":remove")) { + continue; + } + const after = described[at + 1]; + const control = + after !== undefined && after.key.endsWith(":remove") ? [part(after, region)] : []; + // The first text box this frame draws is the one the preparation asks + // how wide a queued message's text may be; the rest carry their own ids. + const heading = !named; + if (heading) { + named = true; + contents.push({ region, id: QUEUE_TEXT, kind: "body" }); + } + queueRows.push( + box({ + id: `box:${region}:${one.key}:row`, + region, + props: partsRowProps(1, WORKSPACE_DRAFT_GAP, room), + children: [ + // The room this row's text has, as a box of its own: what is left + // once the engine has measured the control beside it, derived the + // way the Draft's field is — a grown remainder is not always whole, + // and measured it left this text 51.005859375 wide with the control + // on a fraction of a cell. The control is still sized by its own + // label. The row inside keeps the key, so its cell is drawn under + // the node that owns it while this box stays measurable. + box({ + id: heading ? QUEUE_TEXT : `box:${region}:${one.key}:text`, + region, + props: + context.queueText === undefined ? SPACER_PROPS : statedPartProps(context.queueText), + children: [rowBox(one, region, context.widths?.queue)], + }), + ...control, + ], + }), + ); + } + const queueViewport = `box:${QUEUE_WINDOW}:viewport`; + // The rows this body draws besides its chat and that chat's two controls. + const extras = byKey("sidekick:back").length + byKey("sidekick:answer").length; + const allocation = trayAllocation(view, context, extras); + if ( + held !== undefined && + held.tray === "queue" && + offersQueue(held) && + allocation !== undefined + ) { + viewports.push(Object.freeze({ id: queueViewport, region, window: QUEUE_WINDOW })); + } + const composer = [...byKey("sidekick:message"), ...startingWith("sidekick:message:value:")]; + return [ + ...headerRows("conversation", region, header), + box({ + id: showing ? `${body}:gutter` : "box:inspection:reading:gutter", + region, + props: workspaceBodyProps(WORKSPACE_LIST_GUTTER, 1, inside), + children: [ + box({ + id: showing ? body : "box:inspection:reading", + region, + props: paneContentProps(), + children: [ + ...input.chatColumn(region), + ...byKey("sidekick:back").map((one) => rowBox(one, region, room)), + ...byKey("sidekick:answer").map((one) => rowBox(one, region, room)), + ], + }), + ], + }), + ...(tray.length === 0 && composer.length === 0 + ? [] + : [ + box({ + id: "box:tray", + region, + props: workspaceBlockProps( + allocation?.tray ?? "fit", + REPL_PALETTE.composerSurface, + WORKSPACE_LIST_GUTTER, + inside, + ), + children: [ + ...headerRows("tray", region, REPL_PALETTE.composerSurface), + ...byKey("queue:earlier").map((one) => rowBox(one, region, room)), + // A tray whose rows fit places them where the accepted screen + // has them. One that does not is a window of the rows it was + // allocated, whose room the engine measures like any other. + ...(held?.tray === "queue" && offersQueue(held) && allocation !== undefined + ? [ + box({ + id: queueViewport, + region, + props: viewportProps(allocation.queue), + children: queueRows, + }), + ] + : queueRows), + ...byKey("queue:later").map((one) => rowBox(one, region, room)), + ...composer.map((one) => rowBox(one, region, room)), + ], + }), + ]), + ]; + }; + + const columns: ReplBox[] = []; + if (outlet) { + // One routed surface, and nothing else. The surface this route did not + // select is described nowhere, so it is in no cell and no target map. + regions.push({ region: "content", id: "box:content" }); + const routed = view.state.route.surface; + const routedRows = showingSidekick(view) + ? conversationColumn("content", SIDEKICK_BODY, size.columns) + : routed === "sessions" + ? transcriptColumn("content", SIDEBAR_BODY, size.columns) + : workColumn("content", TRANSCRIPT_BODY, size.columns); + columns.push( + box({ + id: "box:content", + region: "content", + // The whole terminal, stated: a growing outlet takes its minimum from + // its own content, and a breadcrumb or a control is content. + props: workspaceColumnProps(size.columns, REPL_PALETTE.applicationSurface, false), + children: [ + box({ + id: "box:content:surfaces", + region: "content", + props: workspaceHeaderProps(header, TAB_GAP), + children: parts(["sessions:heading", "entries:heading"], "content", header), + }), + ...routedRows, + ], + }), + ); + } else { + regions.push( + { region: "sidebar", id: "box:sidebar" }, + { region: "transcript", id: "box:transcript" }, + { region: "inspection", id: "box:inspection" }, + ); + contents.push({ region: "transcript", id: "box:transcript:content", kind: "pane" }); + contents.push({ region: "inspection", id: "box:inspection:content", kind: "pane" }); + columns.push( + box({ + id: "box:sidebar", + region: "sidebar", + props: workspaceColumnProps( + workspaceTranscriptWidth(size), + REPL_PALETTE.transcriptPaneSurface, + false, + ), + children: transcriptColumn( + "sidebar", + SIDEBAR_BODY, + workspaceInsideWidth(workspaceTranscriptWidth(size), false), + ), + }), + box({ + id: "box:transcript", + region: "transcript", + props: workspaceColumnProps( + workspaceWorkWidth(size), + REPL_PALETTE.applicationSurface, + true, + ), + children: [ + box({ + id: "box:transcript:content", + region: "transcript", + // Stated, so the width a header is fitted to is the width its row + // has: a growing content box reports its own content instead. + props: workspaceBodyProps(0, 0, workspaceInsideWidth(workspaceWorkWidth(size), true)), + children: workColumn( + "transcript", + TRANSCRIPT_BODY, + workspaceInsideWidth(workspaceWorkWidth(size), true), + ), + }), + ], + }), + box({ + id: "box:inspection", + region: "inspection", + props: workspaceColumnProps( + workspaceConversationWidth(size), + REPL_PALETTE.conversationSurface, + true, + ), + children: [ + box({ + id: "box:inspection:content", + region: "inspection", + props: workspaceBodyProps( + 0, + 0, + workspaceInsideWidth(workspaceConversationWidth(size), true), + ), + children: conversationColumn( + "inspection", + SIDEKICK_BODY, + workspaceInsideWidth(workspaceConversationWidth(size), true), + ), + }), + ], + }), + ); + } + + // Whichever drawer is presented, through the one calculation both use. + const rect = presentedRect(view, context); + const [title] = of("drawer-title"); + if (rect !== undefined && title !== undefined) { + const id = `box:${DRAWER_WINDOW}:viewport`; + viewports.push(Object.freeze({ id, region: "drawer", window: DRAWER_WINDOW })); + regions.push({ region: "drawer", id: "box:drawer:layer" }); + columns.push( + box({ + id: "box:drawer:layer", + region: "drawer", + props: drawerLayerProps(rect, context.capture), + children: [ + rowBox(title, "drawer", context.widths?.drawer), + ...of("drawer-above").map((one) => rowBox(one, "drawer", context.widths?.drawer)), + box({ + id, + region: "drawer", + props: viewportProps(), + children: of("drawer").map((one) => rowBox(one, "drawer", context.widths?.drawer)), + }), + ...of("drawer-below").map((one) => rowBox(one, "drawer", context.widths?.drawer)), + ], + }), + ); + } + + const offered = [...of("action"), ...of("status")]; + const placed = offered.filter( + (candidate) => context.measuring || context.admission.actions.has(candidate.key), + ); + const map = compactHistoryRuns(view); + const inspected = view.state.route.at !== undefined; + regions.push({ region: "footer", id: "box:footer" }); + return { + descriptions, + manifest: Object.freeze({ + profile, + size: Object.freeze({ ...size }), + root: box({ + id: "box:root", + props: rootProps(size), + children: [ + box({ + id: "box:identity", + props: bandRowProps(REPL_PALETTE.chromeSurface), + children: [ + box({ + id: "box:identity:text", + props: fittedPartProps(), + text: runText(identityRuns(view)), + runs: identityRuns(view), + style: styleOf("metadata"), + }), + ], + }), + box({ id: "box:body", props: bodyProps(false), children: columns }), + box({ + id: "box:footer", + region: "footer", + // Two rows, stated: the History row and the guidance row. A fitted + // stack takes its height from its content, and the engine's own + // arithmetic over that content is not always whole — measured, a + // fitted staged footer came back 1.9921875 tall and put every row + // of the body, the band and the guidance on a fraction of a cell. + props: stackProps(WORKSPACE_CHROME_ROWS - 1), + children: [ + box({ + id: "box:history", + region: "footer", + props: bandRowProps(REPL_PALETTE.historySurface), + children: [ + box({ + id: HISTORY_MAP, + region: "footer", + // The accepted wide row fits this map to its own text and + // has room for it. Every other profile states it, at the + // width this frame measured the row's two parts to need — + // the map's own natural width, or what the controls leave, + // whichever is smaller. Fitting it there instead makes its + // text a minimum that competes with complete control + // labels: measured, a 72-cell row gave a 29-cell map its + // whole text and left the controls 43 where they need 51, + // and every one of them came back on part of a cell. + props: + accepted || context.historySplit === undefined + ? fittedPartProps() + : statedPartProps(context.historySplit.map), + text: runText(map), + runs: map, + style: styleOf("history", { inspected }), + }), + // The accepted wide screen divides what the map leaves + // between a gap and this row, which is where its controls + // were reviewed. Two growing siblings split a remainder in + // the engine's own arithmetic and that arithmetic is not + // whole — measured, a 72-column row placed a control at + // x=41.9921875 — so every other profile states one growing + // child and puts the controls at the trailing edge. Whether + // the wide screen should follow them is a question for the + // image review, not one to answer by moving an accepted row. + ...(accepted + ? [ + context.historyGap === undefined + ? spacer(HISTORY_SPACE, "footer") + : box({ + id: HISTORY_SPACE, + region: "footer", + props: statedPartProps(context.historyGap), + }), + ] + : []), + box({ + id: ACTION_ROW, + region: "footer", + // Stated at what the offered controls actually need, so the + // engine is dividing a row that is big enough rather than + // compressing fitted children into one that is not. The + // leading spacer inside it absorbs whatever is genuinely + // spare, and every control stays fitted to its own label. + props: accepted + ? actionRowProps() + : context.historySplit === undefined + ? inlineActionRowProps() + : statedActionRowProps(context.historySplit.actions), + children: [ + ...(accepted ? [] : [spacer(HISTORY_SPACE, "footer")]), + ...placed.map((candidate) => + box({ + id: `box:action:${candidate.key}`, + key: candidate.key, + region: "footer", + props: CONTROL_PROPS, + text: candidate.text, + control: candidate.control, + style: candidate.style, + runs: candidate.runs, + }), + ), + ], + }), + ], + }), + box({ + id: "box:guidance", + region: "footer", + props: bandBlockProps(REPL_PALETTE.chromeSurface, WORKSPACE_LIST_GUTTER), + children: [ + box({ + id: GUIDANCE_ROW, + region: "footer", + props: GROWING_STACK_PROPS, + children: of("located").map((one) => + rowBox(one, "footer", context.widths?.guidance), + ), + }), + ], + }), + ], + }), + ], + }), + viewports: Object.freeze(viewports), + actions: Object.freeze({ + // The box whose trailing edge bounds this row. Where the controls are a + // group as wide as itself, that edge is the band's rather than the + // group's — a group bounded by its own width would admit every control + // it was offered, however little room the row had. + id: accepted ? ACTION_ROW : "box:history", + controls: Object.freeze( + offered.map((candidate) => ({ + key: candidate.key, + id: `box:action:${candidate.key}`, + control: candidate.control, + })), + ), + }), + regions: Object.freeze(regions.map((region) => Object.freeze(region))), + contents: Object.freeze(contents.map((content) => Object.freeze(content))), + history: rail ?? EMPTY_BAND, + }), + }; +} + +/** How many rows of the draft's own value the staged Draft shows. */ +const DRAFT_VALUE_ROWS = WORKSPACE_DRAFT_ROWS - 1; + +/** The chat lines this view's frame fits, the staged composition's included. */ +export function chatLinesFor( + view: ReplView, +): readonly { readonly key: string; readonly text: string }[] { + return sidekickChatLines(view.live.sidekick, chatStagingOf(view)); +} + +/** The staged queue's messages as the lines its text column fits, or none. */ +export function queueLinesOf( + view: ReplView, +): readonly { readonly key: string; readonly text: string }[] | undefined { + const workspace = staged(view); + if ( + workspace === undefined || + workspace.tray !== "queue" || + !offersQueue(workspace) || + !showingSidekick(view) + ) { + return undefined; + } + return workspace.queued.map((message) => ({ + key: `queue:${message.key}`, + text: `${MARKER_ROOM}${message.text}`, + })); +} + +/** + * The draft's own lines as the staged Draft fits them, or none. + * + * The newest ones, because those are the ones drawn. See below. + */ +export function draftLinesOf(view: ReplView): + | readonly { + readonly key: string; + readonly text: string; + readonly runs: readonly ReplTokenRun[]; + }[] + | undefined { + if (staged(view) === undefined) { + return undefined; + } + const lines = view.state.draft.split("\n"); + // Only the lines this block can draw. Every line is fitted on its own and + // every one of them is at least one row — a blank line included — so the last + // `DRAFT_VALUE_ROWS` lines already hold at least as many rows as the Draft + // shows, and the newest rows of those are the newest rows of the whole draft. + // The lines above them are measured through the engine only to be dropped: + // typing into a three-hundred-line draft cost 61.9ms a key at 160x36 against + // 12.2ms before this block existed, almost all of it in cuts nothing drew. + const drawn = lines.slice(Math.max(0, lines.length - DRAFT_VALUE_ROWS)); + const from = lines.length - drawn.length; + // Keyed by where the line is in the draft, not by where it is in this list: + // the key names the line somebody is editing. + return drawn.map((text, at) => ({ + key: `draft:${from + at}`, + text, + runs: sourceRuns(text), + })); +} + +/** + * The Draft's rows this frame draws: the newest that fit, then blank rows. + * + * The newest, because the end is where somebody is typing. Always exactly as + * many rows as the Draft has, so the block's height is the same whatever the + * draft holds. + */ +export function draftRowsOf(fitted: readonly ReplFittedLine[]): readonly string[] { + const drawn = fitted.map((one) => `${" ".repeat(one.indent)}${one.text}`); + const newest = drawn.slice(Math.max(0, drawn.length - DRAFT_VALUE_ROWS)); + return Object.freeze([ + ...newest, + ...Array.from({ length: DRAFT_VALUE_ROWS - newest.length }, () => ""), + ]); +} export type { ReplDrawerRef }; diff --git a/packages/cli/src/repl/components/actions.ts b/packages/cli/src/repl/components/actions.ts index 7ddccd13c..12f18e7e0 100644 --- a/packages/cli/src/repl/components/actions.ts +++ b/packages/cli/src/repl/components/actions.ts @@ -45,6 +45,27 @@ export type ReplAction = * all three would be three decisions behind one key. */ | { readonly kind: "stop-sidekick" } + /** + * Stop this exact entry's execution, and join everything it owned. + * + * Only that entry, and only the one this frame drew the control against: a + * press that arrives after it has finished, or from a frozen reading of an + * older one, names an entry the session is no longer running and stops + * nothing. It carries no record and no outcome — what a stopped entry + * retains is the root's to write, through the journal that root owns. + */ + | { readonly kind: "stop-entry"; readonly entry: string } + /** + * Open the Entries catalog over this reading. + * + * Presentation and a selection of the Entries reading, and nothing else: no + * scope, marker, conversation filter or draft moves, and nothing is + * submitted, appended or asked of a provider. What changes a destination is + * activating a row inside it. + */ + | { readonly kind: "open-catalog" } + /** Put the Entries catalog away, leaving the reading exactly as it stands. */ + | { readonly kind: "close-catalog" } | { readonly kind: "select-surface"; readonly surface: ReplSurface } /** * Read one of the two right-hand panes. @@ -227,6 +248,14 @@ export type ReplAction = | { readonly kind: "select-session"; readonly session: string } /** Show every conversation again. Distinct from selecting one, so it cannot be a key nobody has. */ | { readonly kind: "all-sessions" } + /** + * Show the Transcript's conversation choices, or put them away again. + * + * Presentation and nothing else: it starts no provider, writes no record and + * changes no filter. Closing the choices leaves whichever conversation is + * chosen exactly as it stands, because dismissing a chooser is not a choice. + */ + | { readonly kind: "toggle-session-filter" } /** Open the drawer over the one pending permission request this key names. */ | { readonly kind: "select-permission"; readonly request: string } /** Answer the selected request with one option the provider offered. */ diff --git a/packages/cli/src/repl/components/rows.ts b/packages/cli/src/repl/components/rows.ts index 80b5429e2..d13353888 100644 --- a/packages/cli/src/repl/components/rows.ts +++ b/packages/cli/src/repl/components/rows.ts @@ -343,6 +343,26 @@ function activation(input: ReplViewData): ReplAction | undefined { if (select === "all-sessions") { return { kind: "all-sessions" }; } + if (select === "session-filter") { + return { kind: "toggle-session-filter" }; + } + if (select === "submit-draft") { + return { kind: "submit" }; + } + if (select === "catalog") { + return { kind: "open-catalog" }; + } + if (select === "catalog-close") { + return { kind: "close-catalog" }; + } + if (select === "stop-entry") { + // The entry this control was drawn against, and only a real one: a row + // with no entry to name is a row that stops nothing. + const entry = named["entry"]; + return typeof entry === "string" && entry.length > 0 + ? { kind: "stop-entry", entry } + : undefined; + } // One saved Sidekick turn, by its own chat and canonical ordinal. Both, // because a turn number means nothing outside the conversation that holds // it, and a row that carried only one of them would open a chat at a turn diff --git a/packages/cli/src/repl/fitting.ts b/packages/cli/src/repl/fitting.ts index 4c5e0c8fb..1be225f0a 100644 --- a/packages/cli/src/repl/fitting.ts +++ b/packages/cli/src/repl/fitting.ts @@ -450,6 +450,92 @@ function* batchWidths( return Ok(answers); } +/** The mark a name wears where it gave up columns. */ +const ELISION = "\u2026"; + +/** One label fitted to a cell budget, with the cells the engine measured for it. */ +export interface ReplFittedLabel { + readonly text: string; + /** What the engine says it occupies, which is what a caller states. */ + readonly cells: number; +} + +/** + * One name fitted into a cell budget at grapheme boundaries, and its own width. + * + * The engine answers how wide text is and `Intl.Segmenter` decides where it may + * be cut. Those are the two facts a count of code units gets wrong, and it gets + * them wrong in opposite directions: a wide character occupies two cells and + * counts as one unit, and a base character with its combining marks occupies + * one cell and counts as several. Measured, a selector holding twenty-five + * `\u754c` declared twenty-nine cells and needed fifty-four — so nothing of it + * was drawn — and one holding combining accents declared more cells than it + * used and published a target reaching into the column beside it. A third cut + * an emoji sequence in half and drew two glyphs where its author wrote one. + * + * `keep` is the tail that never gives up its cells: the mark that says a + * control opens is not a detail of the name in front of it. `room` is the + * budget the two of them share. + * + * The answer carries the cells the engine measured for the whole of it, so the + * caller states a width rather than counting one — and the text and that width + * are one answer, which is what keeps a label and the box it is drawn in from + * disagreeing about how much room it needs. + */ +export function* fitCells( + renderer: ReplRenderer, + size: ReplTerminalSize, + name: string, + keep: string, + room: number, +): Operation> { + if (room < 1) { + return Ok(Object.freeze({ text: "", cells: 0 })); + } + const whole = `${name}${keep}`; + const measured = yield* widths(renderer, size, [whole, keep, ELISION]); + if (!measured.ok) { + return measured; + } + const [wholeCells, keepCells, markCells] = measured.value; + if (wholeCells <= room) { + return Ok(Object.freeze({ text: whole, cells: wholeCells })); + } + // What the name may take once the tail and the mark saying it was cut have + // had theirs. Where that leaves nothing, the name gives up every column it + // had and what the row says is the tail alone — the same rule the row's own + // vocabulary already follows, which is that what was named survives whole. + const budget = room - keepCells - markCells; + if (budget < 1) { + return Ok( + Object.freeze({ text: keepCells <= room ? keep : "", cells: Math.min(keepCells, room) }), + ); + } + // Narrow by halves over grapheme boundaries, asking the engine for one batch + // of candidates per round rather than one render per candidate. + const stops = graphemeStops(name); + let low = 0; + let high = stops.length - 1; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + const answer = yield* widths(renderer, size, [name.slice(0, stops[middle])]); + if (!answer.ok) { + return answer; + } + if (answer.value[0] <= budget) { + low = middle; + } else { + high = middle - 1; + } + } + const text = `${name.slice(0, stops[low])}${ELISION}${keep}`; + const final = yield* widths(renderer, size, [text]); + if (!final.ok) { + return final; + } + return Ok(Object.freeze({ text, cells: final.value[0] })); +} + /** * What a reading reserves before any of its text is fitted. * @@ -794,6 +880,14 @@ function piece(from: number, length: number, continuation: boolean, indent: numb * space. A word longer than the region has no such boundary and is broken at * the longest fitting grapheme instead, which is the only way text wider than * its pane can be read at all. + * + * Backing off is for a prefix, and what is left of a line is not one. Where the + * measured prefix reaches the end of the whole remainder, that remainder is the + * row: there is nothing after it for a boundary to put on a row of its own, and + * shortening it anyway would break a row the engine had already accepted — + * measured, a tail of `it asks for.` came out as `it asks ` and then `for.`. + * The end of the *remainder* is what decides it, never the end of the bounded + * window the search was asked over, which can stop short of it. */ function* cutAt( renderer: ReplRenderer, @@ -831,6 +925,9 @@ function* cutAt( ); } const longest = stops[low]; + if (longest === rest.length) { + return Ok(longest); + } const preferred = wordBoundary(rest, longest); return Ok(preferred === undefined ? longest : preferred); } diff --git a/packages/cli/src/repl/layout.ts b/packages/cli/src/repl/layout.ts index 6e5c18d54..c218b3053 100644 --- a/packages/cli/src/repl/layout.ts +++ b/packages/cli/src/repl/layout.ts @@ -316,13 +316,25 @@ export function inspectionWidth(size: ReplTerminalSize): number | undefined { * Over the body and above the footer rather than across it: a modal that covered * the draft would hide the thing the question is about. */ -export function drawerRect(size: ReplTerminalSize): ReplBounds | undefined { +export function drawerRect( + size: ReplTerminalSize, + /** + * Whether the staged composition's body is the one being covered. + * + * It keeps three rows around its body where the ordinary one keeps seven, and + * its body starts below an identity row rather than at the top of the frame — + * so a rectangle derived from the ordinary budget would hang off the bottom of + * this screen and cover a row of chrome this one does not have there. + */ + staged = false, +): ReplBounds | undefined { if (profileFor(size) === "too-small") { return undefined; } - const body = bodyRows(size); + const body = staged ? workspaceBodyRows(size) : bodyRows(size); + const top = staged ? 1 : 0; const x = Math.floor(size.columns / DRAWER_SHARE); - const y = Math.floor(body / DRAWER_SHARE); + const y = top + Math.floor(body / DRAWER_SHARE); return Object.freeze({ x, y, @@ -358,9 +370,17 @@ export function drawerRect(size: ReplTerminalSize): ReplBounds | undefined { export function routeDrawerRect( size: ReplTerminalSize, guidanceBottom: number | undefined, + staged = false, ): ReplBounds | undefined { - const base = drawerRect(size); - if (base === undefined || profileFor(size) === "narrow" || guidanceBottom === undefined) { + const base = drawerRect(size, staged); + // The staged composition draws its guidance below the body rather than above + // its reading, so there is no block in this layer's way to move clear of. + if ( + base === undefined || + staged || + profileFor(size) === "narrow" || + guidanceBottom === undefined + ) { return base; } const bottom = base.y + base.height; @@ -385,18 +405,18 @@ export function routeDrawerRect( * drawing and hit testing alike — a second opinion about where this rectangle * is would be a drawer whose cells and whose targets disagreed. */ -export function sidekickDrawerRect(size: ReplTerminalSize): ReplBounds | undefined { +export function sidekickDrawerRect(size: ReplTerminalSize, staged = false): ReplBounds | undefined { const profile = profileFor(size); if (profile === "too-small") { return undefined; } - const inspection = inspectionWidth(size); + const inspection = staged ? workspaceConversationWidth(size) : inspectionWidth(size); const width = inspection ?? size.columns; return Object.freeze({ x: size.columns - width, - y: 0, + y: staged ? 1 : 0, width, - height: bodyRows(size), + height: staged ? workspaceBodyRows(size) : bodyRows(size), }); } @@ -1035,3 +1055,339 @@ export function box(input: { children: Object.freeze(input.children === undefined ? [] : [...input.children]), }); } + +/** + * The staged workspace composition's column allocations at the wide profile. + * + * Each includes the one-cell divider that starts its column, and the centre + * takes what the two leave — 66 at 160 columns. These are the #971 reference's + * proportions, proposed for the Product Owner's image review rather than + * accepted geometry. + */ +export const WORKSPACE_TRANSCRIPT_COLUMNS = 36; +export const WORKSPACE_CONVERSATION_COLUMNS = 58; + +/** + * How wide the conversation column is at the medium profile. + * + * Thirty-two, which is what the selected work can spare. An entry reading + * reserves its rail, two separators and a column as wide as the widest badge + * it could ever hold — so the centre's declared width and its room for source + * are far apart, and at the pinned medium proportion of fifty the centre's + * thirty-four left seven cells of source. The conversation keeps its own rows + * readable at thirty-two because they are wrapped prose rather than source + * standing beside a reserved column. + */ +export const WORKSPACE_CONVERSATION_MEDIUM = 32; + +/** The selected work's Draft: its heading row and three value rows. */ +export const WORKSPACE_DRAFT_ROWS = 4; + +/** + * The cells between the Draft's field and the control beside it. + * + * Declared once, because two readers need the same answer: the row that places + * them, and the preparation that derives what the field has left once the + * engine has measured that control. + */ +export const WORKSPACE_DRAFT_GAP = 1; + +/** + * The most rows the tray gives the messages waiting behind the exchange. + * + * A declared budget, like the Draft's rows: the conversation above it is the + * reading somebody is having, and a tray sized by however many messages had + * arrived would push that reading off its own column. A tray holding no more + * than this shows all of it and keeps the appearance it was reviewed with; one + * holding more is a window, with the two controls that move it. + */ +export const WORKSPACE_QUEUE_ROWS = 8; + +/** + * The rows the staged composition keeps around its body. + * + * One identity row above it, and one compact History row and one guidance row + * below — three, where the ordinary composition's footer is seven. Stated here + * so the drawer rectangles and the body are measured against the same budget. + */ +export const WORKSPACE_CHROME_ROWS = 3; + +/** How many rows the staged body has: everything those three rows leave. */ +export function workspaceBodyRows(size: ReplTerminalSize): number { + return Math.max(0, size.rows - WORKSPACE_CHROME_ROWS); +} + +/** + * How wide the staged Transcript column is, or none where there is one outlet. + * + * A legibility requirement rather than a share, for the reason the ordinary + * sidebar's is: the rows in it are text whose meaning is lost when it is cut. + */ +export function workspaceTranscriptWidth(size: ReplTerminalSize): number | undefined { + const profile = profileFor(size); + return profile === "wide" || profile === "medium" ? WORKSPACE_TRANSCRIPT_COLUMNS : undefined; +} + +/** How wide the staged conversation column is, or none at one outlet. */ +export function workspaceConversationWidth(size: ReplTerminalSize): number | undefined { + const profile = profileFor(size); + if (profile === "wide") { + return WORKSPACE_CONVERSATION_COLUMNS; + } + return profile === "medium" ? WORKSPACE_CONVERSATION_MEDIUM : undefined; +} + +/** + * How wide the staged selected work is: whatever the other two columns leave. + * + * Stated rather than grown, and that is load-bearing. A growing column takes + * its minimum from its own content, and this column's heading is a breadcrumb + * as long as the scopes somebody selected — measured, a crumb trail made a + * 34-column column report 40 and pushed the conversation column off the frame. + * The product already decides the other two widths, so the remainder is a + * product fact rather than a second measurement. + * + * It is the remainder because this is the column whose room is spent before a + * reader sees any of it: the reading inside reserves a rail, two separators and + * the widest badge it could hold, and what survives that is the source. + */ +export function workspaceWorkWidth(size: ReplTerminalSize): number | undefined { + const left = workspaceTranscriptWidth(size); + const right = workspaceConversationWidth(size); + if (left === undefined || right === undefined) { + return undefined; + } + return Math.max(0, size.columns - left - right); +} + +/** + * How far a staged list sits in from its column's edges. + * + * The reference insets the Transcript and the conversation by one cell and the + * selected work and its Draft by two, which is {@link BODY_GUTTER}. + */ +export const WORKSPACE_LIST_GUTTER = 1; + +/** + * One full-width row above or below the body, on its own ground. + * + * Not clipped, here or in the rows below: a clipping parent resolves a growing + * child against its content rather than against the row, so each part states + * its own width instead. + */ +/** + * The band of prose below the body: as many rows as its words need, read in + * from the left edge the same one cell as the row above it. + * + * Inset on one side only, where a list is inset on both. A list is read beside + * its column's edge and wants the breathing room at each end; this band is one + * sentence paid out across the whole frame, and a gutter held back at its + * trailing edge reserves a column no reader ever sees. Measured, that reserved + * column is what a 72-cell frame could not spare: the narrow directions come to + * 71 cells with every fact in them, the band offered 70, and the fitter did + * what it is supposed to do with a sentence longer than its room — it moved the + * last words to a second row, which this two-row footer has nowhere to put. So + * the one thing the row exists to say last, the way back to the draft, was the + * thing that went. + */ +export function bandBlockProps(surface: number, indent: number): ReplBoxProps { + return { + layout: { + width: grow(), + height: fit(), + direction: "ttb", + padding: { left: Math.max(0, indent), right: 0 }, + }, + bg: terminalColour(surface), + }; +} + +export function bandRowProps(surface: number): ReplBoxProps { + return { + layout: { width: grow(), height: fixed(1), direction: "ltr" }, + bg: terminalColour(surface), + }; +} + +/** + * A staged column: its width, its ground and the rule that starts it. + * + * `width` undefined grows into what the other two columns leave. The rule is + * the reference's own column edge, drawn as one cell rather than a pixel line. + */ +export function workspaceColumnProps( + width: number | undefined, + surface: number, + divided: boolean, +): ReplBoxProps { + return { + ...columnProps(width), + bg: terminalColour(surface), + ...(divided ? { border: { color: terminalColour(REPL_PALETTE.paneDivider), left: 1 } } : {}), + }; +} + +/** One header row of a staged column: parts left and right with room between. */ +export function workspaceHeaderProps(surface: number, gap = 2): ReplBoxProps { + return { + layout: { + width: grow(), + height: fixed(1), + direction: "ltr", + gap, + padding: { left: WORKSPACE_LIST_GUTTER, right: WORKSPACE_LIST_GUTTER }, + }, + bg: terminalColour(surface), + }; +} + +/** What takes the room between the left and right parts of one row. */ +export const SPACER_PROPS: ReplBoxProps = Object.freeze({ + layout: Object.freeze({ width: grow(), height: fixed(1) }), +}); + +/** + * One part of a row at a width something else measured, one row tall. + * + * For the part that takes what a row has left: the engine divides a growing + * remainder itself, and that division is not always whole. + */ +export function statedPartProps(width: number): ReplBoxProps { + return { layout: { width: fixed(Math.max(0, width)), height: fixed(1) } }; +} + +/** One part of a row that is exactly as wide as its own text. */ +export function fittedPartProps(surface?: number): ReplBoxProps { + return { + layout: { width: fit(), height: fixed(1) }, + ...(surface === undefined ? {} : { bg: terminalColour(surface) }), + }; +} + +/** + * A staged column's body, inset by `gutter` cells on each side. + * + * `top` blank rows above it stand for the reference's twenty-pixel padding. + */ +export function workspaceBodyProps( + gutter: number, + top = 0, + /** + * How wide this body's column leaves it, where the product knows. + * + * Stated for the reason its column's width is: a growing box takes its + * minimum from its content, and a toolbar wider than the column it is in + * would push that column's rows past the frame. The gutter is still the + * engine's padding, so the room inside is still the engine's answer. + */ + width?: number, +): ReplBoxProps { + return { + layout: { + width: width === undefined ? grow() : fixed(Math.max(0, width)), + height: grow(), + direction: "ttb", + padding: { left: gutter, right: gutter, top }, + }, + }; +} + +/** How wide one staged column's inside is, once its divider is taken. */ +export function workspaceInsideWidth( + column: number | undefined, + divided: boolean, +): number | undefined { + return column === undefined ? undefined : Math.max(0, column - (divided ? 1 : 0)); +} + +/** A block of `rows` rows on its own ground, inset by `gutter`. */ +export function workspaceBlockProps( + rows: number | "fit", + surface: number, + gutter: number, + width?: number, +): ReplBoxProps { + return { + layout: { + width: width === undefined ? grow() : fixed(Math.max(0, width)), + height: rows === "fit" ? fit() : fixed(rows), + direction: "ttb", + padding: { left: gutter, right: gutter }, + }, + bg: terminalColour(surface), + }; +} + +/** + * Parts side by side, `height` rows tall and as wide as `width` states. + * + * A stated width is what leaves exactly one remainder for the row's growing + * part to take. Grown instead, the row resolves against the flow around it and + * that resolution is not always whole — measured, a Draft heading row left its + * field 52.00732421875 wide and its Stop at x=92.00732421875. + */ +export function partsRowProps(height: number | "fit" = 1, gap = 1, width?: number): ReplBoxProps { + return { + layout: { + width: width === undefined ? grow() : fixed(Math.max(0, width)), + height: height === "fit" ? fit() : fixed(height), + direction: "ltr", + gap, + }, + }; +} + +/** + * The staged contextual actions: the rest of the History row, controls at its + * trailing edge. + * + * Growing, and the only growing child of that row, because two growing + * siblings split the remainder in the engine's own arithmetic and that + * arithmetic is not whole — measured, a 72-column History row came back with a + * child at x=41.9921875. Its own leading spacer is likewise the only growing + * child inside it, which is what puts the controls at the edge without an + * alignment that places at half a cell. + * + * Not clipped, for the same reason: a clipping parent resolves a growing child + * against its content, and that spacer would collapse. A control the row cannot + * hold whole is left out by admission before it is ever described. + */ +export function inlineActionRowProps(): ReplBoxProps { + return { layout: { width: grow(), height: fixed(1), direction: "ltr", gap: 1 } }; +} + +/** + * The same row at a width this frame measured its own controls to need. + * + * Stated rather than grown, so the engine divides a row that is big enough + * instead of compressing fitted children into one that is not: a `fit` child's + * returned width is an answer about the room it was given, and a row too small + * for its controls hands every one of them part of a cell. The leading spacer + * inside it is what absorbs whatever is genuinely spare. + */ +export function statedActionRowProps(width: number): ReplBoxProps { + return { + layout: { + width: fixed(Math.max(0, width)), + height: fixed(1), + direction: "ltr", + gap: 1, + }, + }; +} + +/** + * A row exactly as wide as the parts in it, side by side. + * + * For a group placed at the trailing edge of a row whose other child grows: the + * growing child takes the whole integral remainder, so neither this group nor + * the row it is in can land on half a cell. + */ +export function fittedRowProps(gap = 1): ReplBoxProps { + return { layout: { width: fit(), height: fixed(1), direction: "ltr", gap } }; +} + +/** A part that grows into a row's remaining width and stacks its own rows. */ +export const GROWING_STACK_PROPS: ReplBoxProps = Object.freeze({ + layout: Object.freeze({ width: grow(), height: fit(), direction: "ttb" as const }), +}); diff --git a/packages/cli/src/repl/presentation-style.ts b/packages/cli/src/repl/presentation-style.ts index 19665fd65..85e560d2c 100644 --- a/packages/cli/src/repl/presentation-style.ts +++ b/packages/cli/src/repl/presentation-style.ts @@ -58,6 +58,14 @@ export type ReplPresentationRole = | "successful-outcome" /** A root that closed `err`, and a refusal of something asked for. */ | "failed-outcome" + /** + * A root that was stopped rather than one that failed. + * + * Its own reading, because an interruption is not the run going wrong: what + * it completed stands and what it printed is partial. The word says so too, + * so the colour is never the only thing that does. + */ + | "interrupted-outcome" /** Something that has not settled, and the affordance that attends to it. */ | "waiting" /** Something activating does. */ @@ -109,7 +117,21 @@ export type ReplPresentationRole = | "history-minor-later" | "history-selected" | "history-head-live" - | "history-head"; + | "history-head" + /** + * Who a message or a row of agent work came from, as its marker says. + * + * A person at this screen, the Sidekick, an entry's agent, an attached client + * and the REPL's own entry work. The marker spells the origin as well, so the + * colour is never the only thing saying it. + */ + | "origin-person" + | "origin-sidekick" + | "origin-agent" + | "origin-attachment" + | "origin-repl" + /** A message waiting behind the active exchange. */ + | "queued"; /** * One row's presentation facts, as the reading settles them. @@ -265,6 +287,34 @@ export const REPL_PALETTE = Object.freeze({ historyHeadLive: 0x7fd3e8, /** Every other head state, `SELECTED · HEAD`, and the empty message. */ historyHead: 0x7b858d, + + /** + * The staged workspace composition's own grounds, from the #971 reference. + * + * Separate members rather than new values for the ones above, because the + * ordinary composition keeps its accepted surfaces while this one is under + * image review. + */ + /** The identity row and the guidance row. */ + chromeSurface: 0x11171b, + /** The Transcript column. */ + transcriptPaneSurface: 0x0c1014, + /** The Bindings/Sidekick column. */ + conversationSurface: 0x0a0c0e, + /** A column's own header row. */ + paneHeaderSurface: 0x0e1215, + /** The tray and the Sidekick's composer. */ + composerSurface: 0x0d1215, + /** The one-cell rule between two columns. */ + paneDivider: 0x1c252b, + /** A message waiting behind the active exchange. */ + queued: 0xa4aeb6, + /** The origin markers, converted from the reference's OKLCH to sRGB. */ + originPerson: 0xe1e9ed, + originSidekick: 0x8dceee, + originAgent: 0xebb890, + originAttachment: 0xd4b6eb, + originRepl: 0x9fd3a9, }); /** @@ -405,6 +455,8 @@ function colourOf(role: ReplPresentationRole, inspected: boolean): number { return REPL_PALETTE.success; case "failed-outcome": return REPL_PALETTE.failure; + case "interrupted-outcome": + return REPL_PALETTE.muted; case "waiting": return REPL_PALETTE.waiting; case "history": @@ -451,6 +503,18 @@ function colourOf(role: ReplPresentationRole, inspected: boolean): number { return REPL_PALETTE.historyHeadLive; case "history-head": return REPL_PALETTE.historyHead; + case "origin-person": + return REPL_PALETTE.originPerson; + case "origin-sidekick": + return REPL_PALETTE.originSidekick; + case "origin-agent": + return REPL_PALETTE.originAgent; + case "origin-attachment": + return REPL_PALETTE.originAttachment; + case "origin-repl": + return REPL_PALETTE.originRepl; + case "queued": + return REPL_PALETTE.queued; case "source": case "status": case "draft": diff --git a/packages/cli/src/repl/program.ts b/packages/cli/src/repl/program.ts index 13ed04fbf..d6cb2a1f3 100644 --- a/packages/cli/src/repl/program.ts +++ b/packages/cli/src/repl/program.ts @@ -67,13 +67,29 @@ import { presentedDrawer, previewedMessage, MESSAGE_VALUE_ROWS, + footerSentence, guidanceSentence, readinessOf, readingOf, SIDEBAR_BODY, SIDEKICK_BODY, SIDEKICK_WINDOW, - sidekickChatLines, + chatLinesFor, + draftFieldWidth, + configureNoticesOf, + draftLinesOf, + NOTICE_INDENT, + historyGapWidth, + historyMapWidth, + historyProbeOf, + historySplitOf, + queueTextWidth, + draftRowsOf, + GUIDANCE_ROW, + QUEUE_TEXT, + queueLinesOf, + stagedWorkspace, + workspaceHeaderLines, readsGeometry, reduceRepl, refusedView, @@ -82,6 +98,12 @@ import { TRANSCRIPT_BODY, unavailable, viewFor, + withWorkspace, + workspaceOf, + FOCUS_MARKER, + selectorName, + selectorRoomOf, + SELECTOR_MARK, withoutAbsentEntry, withoutLiveDrawers, } from "./application.ts"; @@ -96,6 +118,7 @@ import type { ReplMeasuredWidths, ReplPreparing, ReplPresentation, + ReplPreparedNotices, ReplPresentationContext, ReplPresented, ReplSidekickChatRow, @@ -146,16 +169,15 @@ import { lifecycleRefusal, openReplSession } from "./session.ts"; import type { ReplReading, ReplSession, ReplSubmission } from "./session.ts"; import { useReplFrames } from "./frame.ts"; import type { ReplFrames } from "./frame.ts"; +import { committedOps, flatten, profileFor, routeDrawerRect, skeletonOps } from "./layout.ts"; import { - committedOps, - drawerRect, - flatten, - profileFor, - routeDrawerRect, - skeletonOps, -} from "./layout.ts"; -import { createFitStore, createPlainFitStore, fitPlain, prepareReading } from "./fitting.ts"; -import type { ReplFitStore, ReplPlainFitStore } from "./fitting.ts"; + createFitStore, + createPlainFitStore, + fitCells, + fitPlain, + prepareReading, +} from "./fitting.ts"; +import type { ReplFittedLabel, ReplFitStore, ReplPlainFitStore } from "./fitting.ts"; import { prepareRail } from "./history-rail.ts"; import { readingLines } from "./source-reading.ts"; import { sourceRuns } from "./presentation-text.ts"; @@ -670,6 +692,10 @@ function* drive( const messageFits = createPlainFitStore(); // And the directions', which change when focus or readiness does. const guidanceFits = createPlainFitStore(); + // And the staged Draft's, for the third time: a key typed into the Draft is + // not a key typed into the composer, and one store for both would evict the + // other's lines on every keystroke. + const draftFits = createPlainFitStore(); const outcome = yield* scoped(function* (): Operation> { const screen = yield* useReplScreen(); @@ -1295,6 +1321,7 @@ function* drive( chatFits, messageFits, guidanceFits, + draftFits, ); if (!ready.ok) { throw ready.error; @@ -1358,6 +1385,7 @@ function* drive( chatFits, messageFits, guidanceFits, + draftFits, ); if (!ready.ok) { throw ready.error; @@ -1466,6 +1494,9 @@ function* drive( return Ok({ text: saved.value, held }); }, }, + (refusal: string): void => { + sidekickReports.push({ kind: "refused", refusal }); + }, ); if (performed.answered !== undefined) { // The question took it, so the drawer that was asking is over. The model @@ -1560,6 +1591,7 @@ function* drive( chatFits, messageFits, guidanceFits, + draftFits, ); rendered = first.rendered; presenting = presentedDrawer(first.view); @@ -1578,6 +1610,7 @@ function* drive( chatFits, messageFits, guidanceFits, + draftFits, ); rendered = marked.rendered; presenting = presentedDrawer(marked.view); @@ -1804,6 +1837,7 @@ function* drive( chatFits, messageFits, guidanceFits, + draftFits, ); rendered = painted.rendered; presenting = presentedDrawer(painted.view); @@ -1832,6 +1866,7 @@ function* drive( chatFits, messageFits, guidanceFits, + draftFits, ); rendered = again.rendered; presenting = presentedDrawer(again.view); @@ -2002,6 +2037,23 @@ function sidekickReadingOf( const reading = selected ?? active; return Object.freeze({ chat: sidekickChatRows(read.value, owner.previewing), + // What the replies of this history actually did to this execution's + // entries, as the coordinates their records hold. Detached from the chat + // beside it: a row is what a conversation said, and these are what one of + // them admitted. + submits: Object.freeze( + read.value.submits.map((one) => + Object.freeze({ + conversation: one.conversation, + response: one.response, + ordinal: one.ordinal, + entry: one.entry, + source: one.source, + settled: one.settled, + admitted: one.admitted, + }), + ), + ), turns: sidekickTurnRows(read.value, owner.live), busy: owner.busy, // The agent of the chat being read, which is that chat's own: a @@ -2461,7 +2513,13 @@ function* build( focused, reading.navigation, ); - return view.ok ? view.value : refusedView(state, view.error.message, size); + // The staged composition, on the screen a person is actually working in. + // What it shows is read from this execution rather than supplied — the + // reading this frame was resolved at, and what this execution's Sidekick + // acknowledged doing to it: see `workspaceOf`. + return view.ok + ? withWorkspace(view.value, workspaceOf(reading.model, sidekick)) + : refusedView(state, view.error.message, size); } /** Wake the loop whenever this session's history or overlay moves. */ @@ -2714,6 +2772,14 @@ function* perform( */ retrying(conversation: string, message: number): Result<{ text: string; held: Instruction }>; }, + /** + * Say why something this performed refused, on the next pass of the loop. + * + * For the work that is spawned and answers later: the record a person reads + * is the screen's, and a refusal decided after this returned has no + * `Performed` left to travel on. + */ + refuse: (refusal: string) => void, ): Operation { switch (intent.kind) { case "none": @@ -2783,6 +2849,30 @@ function* perform( }); return { remounted: true }; } + case "stop-entry": { + // Spawned, for the reason the Sidekick's Stop is: this joins everything + // that entry owned — its root's own cancelled ending, its outcome, its + // output consumer, its Agent attachment — and answers only once all of + // it has come down. A person who pressed it keeps a usable keyboard + // while it does, and the screen wakes when it is over. + // + // The key is the one the frame drew the control against. Whether that is + // still the entry running, whether the history has acknowledged it and + // whether a stop may start at all are the session's answers, not this + // branch's: a refusal comes back and is shown rather than guessed at + // here. + const stopping = intent.entry; + yield* spawn(function* (): Operation { + const stopped = yield* session.stopEntry(stopping); + if (!stopped.ok) { + refuse(stopped.error.message); + } + wakes.send({ kind: "session" }); + }); + // The row beside the field is what this frame left mounted; the next + // event in this burst is dispatched against that. + return { remounted: true }; + } case "answer": { // 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 @@ -3042,6 +3132,7 @@ function* paint( chat?: ReplPlainFitStore, message?: ReplPlainFitStore, guidance?: ReplPlainFitStore, + draft?: ReplPlainFitStore, ): Operation { while (true) { const painted = yield* prepared( @@ -3054,6 +3145,7 @@ function* paint( chat, message, guidance, + draft, ); if (painted !== undefined) { return painted; @@ -3081,6 +3173,7 @@ function* prepared( chat?: ReplPlainFitStore, message?: ReplPlainFitStore, guidance?: ReplPlainFitStore, + draft?: ReplPlainFitStore, ): Operation { return yield* scoped(function* (): Operation { const held = yield* frames.subscribe({ owner: "repl", participants: [["repl"]] }); @@ -3113,6 +3206,7 @@ function* prepared( chat, message, guidance, + draft, ); if (committed.ok) { // 5. Present the copied bytes. 6. The caller retains the returned map. @@ -3238,11 +3332,12 @@ export function* commitReplFrame( chat?: ReplPlainFitStore, message?: ReplPlainFitStore, guidance?: ReplPlainFitStore, + draft?: ReplPlainFitStore, ): Operation> { // Measure before anything is mounted: this asks the engine how much room each // region has, so what a window shows is the frame's answer rather than this // boundary's guess. It mounts nothing and publishes nothing. - const prepared = yield* prepareFrame(renderer, view, store, chat, message, guidance); + const prepared = yield* prepareFrame(renderer, view, store, chat, message, guidance, draft); if (!prepared.ok) { return prepared; } @@ -3398,14 +3493,6 @@ function measuringAt( }; } -/** Whether these two rectangles are the same one, or both absent. */ -function sameBounds(one: ReplBounds | undefined, two: ReplBounds | undefined): boolean { - if (one === undefined || two === undefined) { - return one === two; - } - return one.x === two.x && one.y === two.y && one.width === two.width && one.height === two.height; -} - /** * The row below the whole fitted guidance block, as the engine placed it. * @@ -3430,6 +3517,10 @@ function guidanceBottomOf( if (box.key !== "guidance" && !box.key.startsWith("guidance:row:")) { continue; } + // Guidance drawn below the body is in no drawer's way. + if (box.region === "footer") { + continue; + } const bounds = measured.boundsOf(box.id); if (bounds === undefined) { continue; @@ -3490,22 +3581,135 @@ export function* prepareFrame( * store for both would evict the chat every time a reader moved. */ guidanceStore?: ReplPlainFitStore, + /** + * Where this command is holding the staged Draft's own cuts. + * + * Its own storage for the third time. The Draft is somebody's next entry and + * the composer is their next message: typing in one changes nothing about the + * other, and a shared store would evict whichever was not being typed into. + */ + draftStore?: ReplPlainFitStore, ): Operation> { // No widths at all for the pass that answers what the widths are. Every row // whose text has to fit a region is left out of it, because a growing column // takes its minimum from its widest child: one unbounded row would make the // column wider than its share and squeeze the column beside it, and the number // this pass reports would be the number that row caused. - const probe = presentationFor(view, measuringAt(undefined, false, undefined)); + // + // The tray's own row is the exception, and it is the reason this pass says + // so: the width this pass reports for a queued message's text is read off + // that row's box, and a tray already holding more rows than its column + // allocated it would otherwise describe none — leaving the queue no width to + // fit against, and so no fitted rows for any later pass to admit. It is a + // blank row until something has been fitted, so it makes no column wider. + const probe = presentationFor(view, { + ...measuringAt(undefined, false, undefined), + queueProbe: true, + }); const first = yield* renderer.measure(skeletonOps(probe.manifest.root), view.size); if (!first.ok) { return first; } const widths = widthsOf(probe.manifest, first.value); + // The staged queue, fitted to the text column its first message measured. + // Before every pass below, because each message is as many rows as its text + // needs and the chat above it is what is left. + const queueLines = queueLinesOf(view); + const queued = + queueLines === undefined || (widths.queue ?? 0) < 1 + ? Ok(undefined) + : yield* fitPlain(renderer, view.size, queueLines, widths.queue ?? 0); + if (!queued.ok) { + return queued; + } + // The Transcript's selector, fitted once. Before the header rows below, + // because what this answers is how wide that control is and the wrap decision + // is made against it — and before every pass after that, because a label one + // pass measured and another spelled differently is a control drawn where + // nothing measured it. + // + // Through the engine and at grapheme boundaries. The name is a provider's + // session key, which nothing in this product bounds and whose cells are not + // its code units in either direction: measured, twenty-five wide characters + // declared twenty-nine cells and needed fifty-four, and combining accents + // declared more than they used and published a target over the column beside + // them. + const selectorRoom = selectorRoomOf(widths); + let selector: ReplFittedLabel | undefined; + if (selectorRoom !== undefined && selectorRoom > 0) { + const fitted = yield* fitCells( + renderer, + view.size, + selectorName(view), + SELECTOR_MARK, + // What is left once this screen's own marker room in front of every + // control's label has had its two cells. + Math.max(0, selectorRoom - FOCUS_MARKER), + ); + if (!fitted.ok) { + return fitted; + } + selector = fitted.value; + } + // Which staged headers need two rows, asked of the engine rather than + // counted: each header's one-row text is fitted to the width the engine + // measured for it, and a header whose text needs more than one row is drawn + // as two rows of its own pane's body. + const headerLines = workspaceHeaderLines( + view, + { ...measuringAt(widths, false, undefined), ...(selector === undefined ? {} : { selector }) }, + widths, + ); + const wrapped = new Set(); + for (const line of headerLines) { + const fitted = yield* fitPlain( + renderer, + view.size, + [{ key: line.key, text: line.text }], + line.room, + ); + if (!fitted.ok) { + return fitted; + } + if (fitted.value.length > 1) { + wrapped.add(line.key); + } + } + // What the Draft's field has left, once this frame's own heading row and the + // control beside it have been measured. Settled here, before anything is + // mounted, and carried into every pass after it — the field is stated in the + // measuring ops and in the committed ops alike, because a width the two + // passes disagreed about is a control drawn where nothing measured it. + let field: number | undefined; + let messageText: number | undefined; + let historyRoom: number | undefined; + let historySpace: number | undefined; + /** + * How the non-wide History row divides itself, in whole cells. + * + * Copied out of one bounded measurement of the row's two parts asked on their + * own, before anything is admitted, and then stated in every pass after it. + * A `fit` child answers about the room its parent was given, so a map fitted + * beside the controls takes the room first and every control comes back on + * part of a cell; measuring them apart is what makes the split whole. + */ + let historyParts: { readonly map: number; readonly actions: number } | undefined; + const queuing = (context: ReplPresentationContext): ReplPresentationContext => ({ + ...context, + ...(queued.value === undefined ? {} : { queue: queued.value }), + ...(wrapped.size === 0 ? {} : { headers: wrapped }), + ...(historySpace === undefined ? {} : { historyGap: historySpace }), + ...(field === undefined ? {} : { draftField: field }), + ...(messageText === undefined ? {} : { queueText: messageText }), + ...(historyRoom === undefined ? {} : { historyMap: historyRoom }), + ...(historyParts === undefined ? {} : { historySplit: historyParts }), + ...(selector === undefined ? {} : { selector }), + }); + // The catalog without its controls: if it fits, it keeps its natural footprint // and the column keeps the rows those controls would have taken. - const trial = presentationFor(view, measuringAt(widths, false, undefined)); + const trial = presentationFor(view, queuing(measuringAt(widths, false, undefined))); const second = yield* renderer.measure(skeletonOps(trial.manifest.root), view.size); if (!second.ok) { return second; @@ -3517,14 +3721,15 @@ export function* prepareFrame( // The directions, fitted to the surface they are drawn on. Before the frame // below, because they are fixed content: the room the composer may grow into // is what is left once they have taken theirs. + const guidanceRoom = widths.guidance ?? widths.surfaceBody; const guidance = - widths.surfaceBody < 1 + widths.surfaceBody < 1 || guidanceRoom < 1 ? Ok(undefined) : yield* fitPlain( renderer, view.size, - [{ key: "guidance", text: guidanceSentence(view) }], - widths.surfaceBody, + [{ key: "guidance", text: footerSentence(view) }], + guidanceRoom, guidanceStore, ); if (!guidance.ok) { @@ -3554,14 +3759,53 @@ export function* prepareFrame( refused.value === undefined ? undefined : Object.freeze(refused.value.map((own) => `${" ".repeat(own.indent)}${own.text}`)); - const probed = presentationFor( - view, - measuringAt(widths, windowed, entriesRows, guidanceRows?.length ?? 1), - ); + // The width probe. Its only question is how wide a fitted control is, so it + // is the one pass that puts a queued row in the tray — and it is deliberately + // not the pass anything is admitted from. + const probed = presentationFor(view, { + ...queuing(measuringAt(widths, windowed, entriesRows, guidanceRows?.length ?? 1)), + queueProbe: true, + }); const fitted = yield* renderer.measure(skeletonOps(probed.manifest.root), view.size); if (!fitted.ok) { return fitted; } + field = draftFieldWidth(probed.manifest, (id) => fitted.value.boundsOf(id)?.width); + messageText = queueTextWidth( + probed.manifest, + (id) => fitted.value.boundsOf(id)?.width, + widths.body, + ); + // What the accepted wide row's gap is, from the row and the map this frame + // measured. Taken here, from the pass that already holds a fitted map and + // the whole footer, and before anything is admitted: the question is how to + // divide the room the map leaves, which no admitted set changes. + historySpace = historyGapWidth((id) => fitted.value.boundsOf(id)?.width); + historyRoom = historyMapWidth( + probed.manifest, + (id) => fitted.value.boundsOf(id)?.width, + view.size.columns, + ); + // And how the non-wide row divides itself, from its two parts asked on their + // own. One bounded measurement of a tree that mounts nothing and publishes no + // target: a fitted row holding exactly the offered control nodes with their + // own labels and gap, and a fitted row holding the map's own runs. What is + // copied out is two whole widths, before the next engine call takes the + // engine's memory of this one. + // + // Not a loop, and not derived from the row that was compressed: the question + // is what these things need, and a rectangle one of them was squeezed into is + // the answer this replaces. + if (profileFor(view.size) !== "wide") { + const parts = historyProbeOf(probed.manifest, view.size); + if (parts !== undefined) { + const apart = yield* renderer.measure(skeletonOps(parts), view.size); + if (!apart.ok) { + return apart; + } + historyParts = historySplitOf((id) => apart.value.boundsOf(id)?.width, view.size.columns); + } + } // Where a route drawer goes, now that the block it must stay clear of has // been fitted and placed. The guidance is as many rows as its words need, so // this is read from the rows the frame published rather than counted here. @@ -3571,17 +3815,24 @@ export function* prepareFrame( // that counts them has to be a pass that knows where it starts. Bounded, not // a loop — a floating layer takes no row out of any column's flow, so the // guidance it was moved clear of cannot move because it moved. - const envelope = routeDrawerRect(view.size, guidanceBottomOf(probed.manifest, fitted.value)); - const settled = sameBounds(envelope, drawerRect(view.size)); - const measured = settled - ? probed - : presentationFor( - view, - measuringAt(widths, windowed, entriesRows, guidanceRows?.length ?? 1, 1, envelope), - ); - const third = settled - ? fitted - : yield* renderer.measure(skeletonOps(measured.manifest.root), view.size); + const workspace = stagedWorkspace(view) !== undefined; + const envelope = routeDrawerRect( + view.size, + guidanceBottomOf(probed.manifest, fitted.value), + workspace, + ); + // The structural skeleton, under that envelope: every scrolling viewport + // empty, with the controls that sit around it already in place. This is the + // measurement an admission may be derived from, which is why it is measured + // again rather than reusing the probe above — the probe was holding a row + // inside the tray's viewport so the engine would size that row's control, + // and a viewport stretched by its own contents reports the room its contents + // wanted. Bounded: one pass, never a loop. + const measured = presentationFor( + view, + queuing(measuringAt(widths, windowed, entriesRows, guidanceRows?.length ?? 1, 1, envelope)), + ); + const third = yield* renderer.measure(skeletonOps(measured.manifest.root), view.size); if (!third.ok) { return third; } @@ -3647,8 +3898,7 @@ export function* prepareFrame( // every keystroke. Showing or revealing the chat publishes that window again, // and this is the pass that fits it against the geometry it has now. const presentsChat = measured.manifest.viewports.some((slot) => slot.window === SIDEKICK_WINDOW); - const chatLines = - chatRoom < 1 || !presentsChat ? undefined : sidekickChatLines(view.live.sidekick); + const chatLines = chatRoom < 1 || !presentsChat ? undefined : chatLinesFor(view); const chat = chatLines === undefined ? Ok(undefined) @@ -3704,13 +3954,15 @@ export function* prepareFrame( skeletonOps( presentationFor( view, - measuringAt( - widths, - windowed, - entriesRows, - guidanceRows?.length ?? 1, - shown.length, - envelope, + queuing( + measuringAt( + widths, + windowed, + entriesRows, + guidanceRows?.length ?? 1, + shown.length, + envelope, + ), ), ).manifest.root, ), @@ -3724,13 +3976,15 @@ export function* prepareFrame( ? measured : presentationFor( view, - measuringAt( - widths, - windowed, - entriesRows, - guidanceRows?.length ?? 1, - shown.length, - envelope, + queuing( + measuringAt( + widths, + windowed, + entriesRows, + guidanceRows?.length ?? 1, + shown.length, + envelope, + ), ), ); // The History band, measured against the terminal's own width. Prepared @@ -3741,8 +3995,13 @@ export function* prepareFrame( // sentence and publishes no footer at all, so preparing a rail for it // would turn a refusal the product already handles into a measurement // failure. + // And the staged composition has no band either: its footer carries one + // compact row built from the navigation itself, and nothing it draws reads a + // prepared rail. Measuring five rows for it is an engine render a frame + // spent on a band that is never drawn — 65ms over a sixteen-second typing + // workload. const rail = - profileFor(view.size) === "too-small" + workspace || profileFor(view.size) === "too-small" ? Ok(undefined) : yield* prepareRail( renderer, @@ -3754,6 +4013,29 @@ export function* prepareFrame( if (!rail.ok) { return rail; } + // The staged Draft's own value, fitted to the selected work it sits in. A + // fixed number of rows, so no measurement above depends on it. + // + // Through a store of its own, and over the lines that are drawn rather than + // over the whole draft: a key typed into a long draft was re-measuring every + // line of it through the engine, and the rows above the block were cut only + // to be dropped. + const draftLines = draftLinesOf(view); + const drafted = + draftLines === undefined || widths.surfaceBody < 1 + ? Ok(undefined) + : yield* fitPlain(renderer, view.size, draftLines, widths.surfaceBody, draftStore); + if (!drafted.ok) { + return drafted; + } + // The drawer's own notices, fitted to the interior this frame measured. Here + // rather than in the builder that draws them: the engine is what knows how + // many cells a grapheme takes, and the same answer has to serve the window's + // row count, its admission, its offsets and the rows finally described. + const notices = yield* prepareNotices(renderer, view, widths.drawer); + if (!notices.ok) { + return notices; + } const admission = admissionFor({ view, manifest: placed.manifest, @@ -3761,6 +4043,8 @@ export function* prepareFrame( reading: reading.value, preview: preview.value, chat: chat.value, + queue: queued.value, + notices: notices.value, boundsOf: (id: string) => geometry.value.boundsOf(id), }); const context: ReplPresentationContext = { @@ -3778,10 +4062,64 @@ export function* prepareFrame( refusal: refusalRows, rail: rail.value, drawer: envelope, + draftField: field, + queueText: messageText, + historyMap: historyRoom, + historyGap: historySpace, + historySplit: historyParts, + selector, + draft: drafted.value === undefined ? undefined : draftRowsOf(drafted.value), + notices: notices.value, + queue: queued.value, + headers: wrapped.size === 0 ? undefined : wrapped, }; return Ok({ context, admission, presentation: presentationFor(view, context) }); } +/** + * Fit each of the drawer's notices onto the rows its interior has room for. + * + * Through the engine and the original graphemes, which is what the width of a + * row actually is: a pure builder counting code units gave a seventy-character + * ASCII token a seventy-two cell row inside thirty and drew none of it, and + * twelve spaced CJK words a forty-eight cell row inside thirty. + * + * The declared indent is taken out of the room and put back on every row, so a + * wrapped notice still reads as one thing set in from the drawer's edge and the + * rows it is paid out over are each as wide as the drawer's own. A room of less + * than one cell is a frame that has measured nothing, and prepares nothing. + */ +function* prepareNotices( + renderer: ReplRenderer, + view: ReplView, + width: number, +): Operation> { + const notices = configureNoticesOf(view); + const room = width - NOTICE_INDENT.length; + if (notices.length === 0 || room < 1) { + return Ok(undefined); + } + const prepared = new Map(); + for (const notice of notices) { + const fitted = yield* fitPlain( + renderer, + view.size, + [{ key: notice.key, text: notice.text }], + room, + ); + if (!fitted.ok) { + return fitted; + } + prepared.set( + notice.key, + Object.freeze( + fitted.value.map((one) => `${NOTICE_INDENT}${" ".repeat(one.indent)}${one.text}`), + ), + ); + } + return Ok(prepared); +} + /** Which region's width the text that must fit one is bounded by. */ function widthsOf(manifest: ReplLayoutManifest, measured: ReplMeasured): ReplMeasuredWidths { // The inside of a pane where the pane has edges, and the pane itself where it @@ -3835,9 +4173,21 @@ function widthsOf(manifest: ReplLayoutManifest, measured: ReplMeasured): ReplMea listBody: bodyOf(SIDEBAR_BODY, "sidebar", "content"), body: bodyOf(SIDEKICK_BODY, "inspection", "content"), drawer: widthOf(measured, manifest.viewports.find((slot) => slot.region === "drawer")?.id), + queue: widthOf(measured, manifest.contents.find((content) => content.id === QUEUE_TEXT)?.id), + guidance: guidanceWidthOf(manifest, measured), }; } +/** + * The guidance row's own width, where a frame draws the guidance below the body. + * + * None otherwise, so the guidance is fitted to the selected work it is part of. + */ +function guidanceWidthOf(manifest: ReplLayoutManifest, measured: ReplMeasured): number | undefined { + const drawn = flatten(manifest.root).some((box) => box.id === GUIDANCE_ROW); + return drawn ? widthOf(measured, GUIDANCE_ROW) : undefined; +} + function widthOf(measured: ReplMeasured, id: string | undefined): number { if (id === undefined) { return 0; diff --git a/packages/cli/src/repl/session.ts b/packages/cli/src/repl/session.ts index ae146a97b..da217ad67 100644 --- a/packages/cli/src/repl/session.ts +++ b/packages/cli/src/repl/session.ts @@ -457,6 +457,26 @@ export interface ReplSession { starting?: StartingCheck, workspace?: true, ): Operation>; + /** + * Stop the entry this key names, and join everything it owned. + * + * The explicit Stop a person asks for, and nothing else: it is not response + * cancellation, not Exit and not teardown. The entry it ends is *final* — its + * root retains a cancelled close, the effects it already completed stand, the + * text it already printed stays partial, and it is never resumed as that + * entry. Answering `Ok` means the terminal was retained and every resource + * the entry held has been released, which is also when a successor may be + * admitted. + * + * The key is the caller's own rendered identity. A key this session is no + * longer running, a startup the history has not acknowledged, or no running + * entry at all starts nothing and answers why. Pressing it again while one is + * in progress joins that one. + * + * It writes no record. The root that owns this entry's journal writes the + * close, through the same acknowledged append path every other record takes. + */ + stopEntry(expectedEntryKey: string): Operation>; } /** @@ -567,11 +587,33 @@ export function openReplSession(options: ReplSessionOptions): Operation Operation>; + /** The one entry task a session owns, for as long as it owns one. */ interface ReplEntryTask { /** The admission-order key of the entry this task is running. */ readonly key: string; readonly task: Task; + /** + * This entry's own execution stop, once its startup has published one. + * + * Entry-local readiness, because admission can precede it: the record that + * gives this entry a position is appended by the execution's own setup + * import, and the handle exists from the moment `executeInstalled` returns — + * close together, and not ordered. A Stop that arrived in between waits here + * rather than finding nothing and deciding there is nothing to stop. + * + * It answers `Err` where this startup will never publish one, so the wait + * always ends. + */ + readonly stopping: Operation>; /** Resolves once this entry's work has passed what its segment retained. */ readonly admitted: Operation>; /** Resolves with how this entry's execution finished. */ @@ -600,6 +642,19 @@ interface ReplEntryTask { * be the answer to a question nobody had asked yet. */ arm(): void; + /** Publish this entry's own execution stop, from the startup that built it. */ + supplyStop(stop: ReplEntryStop): void; + /** Say this startup will never publish one, so a waiting Stop stops waiting. */ + failStop(error: Error): void; + /** + * Name the failure this task is about to be halted for. + * + * Called before the halt, so the settlement the unwind falls back to answers + * with the failure that caused it rather than with the general fact that the + * task reached no outcome of its own. The halt still runs, and the answer + * still comes after it — this only decides which error the answer carries. + */ + failing(error: Error): void; } function* start( @@ -734,6 +789,19 @@ function* start( let positioned = false; /** The one entry task this session owns, or none. */ let running: ReplEntryTask | undefined; + /** + * The explicit Stop this session is carrying out, while it is carrying one. + * + * Session-owned rather than caller-owned, so a second press joins the first + * instead of starting a second one, and so a caller that goes away does not + * abandon a stop half-way through its cleanup. It names the exact task it + * is stopping, which is also what keeps that task's slot held: a Stop owns + * the slot from the moment it starts until it has confirmed a terminal and + * joined the cleanup, and the task's own early release must not take it. + */ + let stopped: + | { readonly held: ReplEntryTask; readonly joined: Operation> } + | undefined; /** Whether this session still starts entry tasks. */ let admitting = true; /** How the last entry to finish finished. */ @@ -954,6 +1022,9 @@ function* start( ): Operation> { return submitting(source, pausing, starting, workspace); }, + stopEntry(expectedEntryKey: string): Operation> { + return stopEntry(expectedEntryKey); + }, }; function open(): void { @@ -1046,6 +1117,166 @@ function* start( return outcome; } + /** + * Carry out one explicit Stop of this exact entry, and join all of it. + * + * Four waits in one order, and the order is the whole of it. This entry's + * own execution handle — published by the startup that built it, waited for + * here because admission can arrive first. Then that execution's stop, + * which asks its root to end as cancelled, halts the document runner so the + * root's own finalizer appends that ending through the journal it still + * owns, and joins it. Then this entry's outcome. Then everything the entry + * task itself still holds: its output consumer, its Agent attachment, its + * installations. + * + * The slot stays occupied for all of it, so nothing is admitted into a stop + * that has not finished. It is released at the end, and only after a + * terminal has actually been retained and the cleanup has joined. + * + * A failure of the append or of the cleanup is the answer, and it also + * withdraws this session's authority to start anything else: a journal this + * process could not finish writing is not one to add to. A document that + * merely failed on its own is not that — the stop still happened, and that + * failure is the entry's own outcome. + */ + function* carryOutStop(held: ReplEntryTask): Operation> { + const handle = yield* held.stopping; + // Rechecked after the wait, by identity. Whatever this session was + // holding when the key was matched may have been replaced while this + // suspended, and a Stop that went on would be stopping whatever happens + // to be running now rather than what somebody pressed Stop on. + if (running !== held) { + return Err( + new ReplLifecycleError( + `this execution's ${held.key} is no longer the entry running here, so there is ` + + "nothing of it to stop.", + ), + ); + } + if (!handle.ok) { + return handle; + } + const canonical = yield* handle.value(); + // The entry's own outcome, then the task's own teardown. Two different + // moments: a root that has closed is still releasing what the entry + // acquired around it, and the slot belongs to the stop until both are + // past. + yield* held.finished; + yield* held.task.halt(); + if (!canonical.ok) { + // Admission is withdrawn before any guard is released, so no successor + // can be admitted in the window between this failing and this + // answering. + admitting = false; + return canonical; + } + // And the file has to say so. A canonical stop that answered `Ok` says + // the stop and the join completed; what makes this entry *settled* is a + // terminal the journal acknowledged, and only the acknowledged reading + // can establish one. The task's own completion cannot: it is this + // process's memory of how a task ended, and a halted task has one of + // those whether or not anything was ever written. + // + // Any retained terminal, not only a cancelled one: a normal ending whose + // append had already begun keeps the ending it was writing, and a stop + // that joined it stopped this entry exactly as truthfully. + const settlement = projectRepl(retained); + if (!settlement.ok) { + admitting = false; + return settlement; + } + const entry = settlement.value.entries.find((one) => one.key === held.key); + if (entry?.terminal === undefined) { + // Nothing was retained, so nothing settled. This session admits no + // successor into a segment that has no outcome, for the same reason an + // entry left unfinished by teardown permits none. + admitting = false; + return Err( + new ReplLifecycleError( + `this execution's ${held.key} retained no outcome, so it is unfinished and nothing ` + + "may follow it.", + ), + ); + } + return Ok(undefined); + } + + /** + * Stop the entry this key names, if it is the one actually running. + * + * The key is the caller's own rendered identity, which is what makes this + * refusable: a Stop drawn against an entry that has since finished, or + * against a frozen reading of an older one, names a key this session is no + * longer running and starts nothing. Nothing here is a Close — the root + * that owns the journal writes that, and this asks the execution that owns + * the root. + * + * A startup that was never acknowledged is not an entry: there is no + * position for anything to follow, and stopping it would be stopping work + * the history holds nothing of. Repeated presses for the same task join the + * one operation already running rather than starting a second. + */ + function* stopEntry(expectedEntryKey: string): Operation> { + const already = stopped; + if (already !== undefined) { + return already.held.key === expectedEntryKey + ? yield* already.joined + : Err( + new ReplLifecycleError( + `this execution's ${already.held.key} is being stopped, so ${expectedEntryKey} is ` + + "not the entry this session is running.", + ), + ); + } + const held = running; + if (!live || held === undefined) { + return Err( + new ReplLifecycleError("this session is running no entry, so there is nothing to stop."), + ); + } + if (held.key !== expectedEntryKey) { + return Err( + new ReplLifecycleError( + `this execution is running ${held.key} rather than ${expectedEntryKey}, so that ` + + "entry is not the one to stop.", + ), + ); + } + if (!held.placed()) { + return Err( + new ReplLifecycleError( + `this execution's ${expectedEntryKey} has not been acknowledged yet, so there is no ` + + "entry to stop.", + ), + ); + } + const settlement = withResolvers>(); + stopped = { held, joined: settlement.operation }; + // Run in the session's own scope, so a caller that goes away mid-stop + // leaves the stop running rather than abandoning a half-joined entry. + owner.run(function* () { + let answer: Result; + try { + answer = yield* carryOutStop(held); + } catch (error) { + admitting = false; + answer = Err(error instanceof Error ? error : new Error(String(error))); + } + // Released here and nowhere else: the terminal is retained and the + // cleanup has joined, so what follows reads the acknowledged history + // rather than this process's memory of it. Only this task's own slot — + // a stop that ran long enough for something else to occupy it clears + // nothing and disarms nobody. + if (running === held) { + running = undefined; + } + stopped = undefined; + reproject(); + settlement.resolve(answer); + }); + return yield* settlement.operation; + } + /** * The live head a submission would follow, or why there is no following it. * @@ -1196,8 +1427,37 @@ function* start( ): ReplEntryTask { const admission = withResolvers>(); const completion = withResolvers>(); + // This entry's own readiness for being stopped. Created before the task, + // because the task is what resolves it and a Stop may arrive first. + const stopping = withResolvers>(); + let published = false; + function supplyStop(stop: ReplEntryStop): void { + if (!published) { + published = true; + stopping.resolve(Ok(stop)); + } + } + function failStop(error: Error): void { + if (!published) { + published = true; + stopping.resolve(Err(error)); + } + } let admitted = false; let settled = false; + /** + * The failure this task is being halted for, where it is being halted + * for one. + * + * A session failure halts the entry it was running before it answers the + * submission, and the unwind of that halt reaches the settlement below + * first. Without this the fallback would be the one that settled, and + * the first failure — the one that actually ended the session — would + * arrive at a submission that already had an answer. Named rather than + * guessed: a task that simply never finished has no such error, and that + * is the case the fallback is for. + */ + let halting: Error | undefined; /** * Whether the history holds this exact entry. * @@ -1261,6 +1521,30 @@ function* start( positioned = false; lifecycle.open(key); const task = owner.run(function* () { + // Registered first, so it unwinds last: however this startup ends, a + // Stop waiting for a handle it was going to publish stops waiting. A + // handle already published is kept — this only answers for the case + // where there will never be one. + yield* ensure(() => { + failStop( + new ReplLifecycleError(`this execution's ${key} never reached an execution to stop.`), + ); + }); + // And however this task ends, whoever is waiting for its outcome gets + // one. An entry halted or failed part-way never reaches the settlement + // below, and a join that waited for it would wait for an answer that + // was never coming. It says nothing about the file: what the history + // holds is whatever was acknowledged, and this is a fact about the task. + yield* ensure(() => { + settle( + Err( + halting ?? + new ReplLifecycleError( + `this execution's ${key} did not reach an outcome of its own.`, + ), + ), + ); + }); // Registered before anything this task acquires, and therefore released // after all of it: destructors run in reverse order of registration. By // the time this runs the execution has been torn down, every provider, @@ -1279,7 +1563,12 @@ function* start( // is no entry for anything to follow — so holding the slot against it // would wedge the session over work that never happened. yield* ensure(() => { - if (running?.task === task && (settled || !placed)) { + // And never while an explicit Stop owns this task. That operation + // holds the slot across the whole of itself — the canonical stop, + // this entry's outcome, and every resource this task is still + // releasing right now — and clearing it here would let a successor be + // admitted into a stop that has not finished. + if (running?.task === task && (settled || !placed) && stopped?.held.task !== task) { running = undefined; } }); @@ -1327,7 +1616,7 @@ function* start( // scheduled to notice the admission instead would be ordered by the // scheduler rather than by the record, and the continuation it was // racing has no reason to lose. - const outcome = yield* runEntry(key, source, view, initialBindings, workspace); + const outcome = yield* runEntry(key, source, view, initialBindings, supplyStop, workspace); settle(outcome); // A settled entry is past whatever its segment retained, whichever way // it settled — including a failure that appended nothing at all, which @@ -1339,9 +1628,15 @@ function* start( task, admitted: admission.operation, finished: completion.operation, + stopping: stopping.operation, admit, settle, arm, + supplyStop, + failStop, + failing: (error: Error) => { + halting = error; + }, place: () => { placed = true; }, @@ -1406,6 +1701,14 @@ function* start( source: string, view: EntrySegmentStream, initialBindings: Readonly>, + /** + * Where this entry publishes its own execution's stop. + * + * Handed in rather than reached back for: the task is created before the + * record of it is, so a startup that looked the entry up to publish on it + * would be looking for something that may not be there yet. + */ + supplyStop: (stop: ReplEntryStop) => void, workspace?: true, ): Operation> { try { @@ -1479,6 +1782,11 @@ function* start( [...installations, lifecycle.installation, installation, ...owned], { initialBindings }, ); + // This entry's own stop, by value, as soon as there is an execution to + // stop. The handle is this exact run's: it closes over that run's + // runner task and its root writer, so nothing here has to name an + // execution, look one up or hold a stream to reach it. + supplyStop(() => started.stop()); // This entry's owned read, published for as long as this entry is the // one running and withdrawn with it. Registered after the execution // exists, so there is never a handle that answers about a root that @@ -1603,6 +1911,10 @@ function* start( open(); const held = running; if (held !== undefined) { + // Named before the halt, because the halt is what unwinds the task and + // the unwind is what settles it. The settlement below is still the one + // that answers where the halt did not reach one. + held.failing(error); yield* held.task.halt(); held.settle(Err(error)); } diff --git a/packages/cli/src/repl/sidekick/model.ts b/packages/cli/src/repl/sidekick/model.ts index 94de33de6..8cf41841c 100644 --- a/packages/cli/src/repl/sidekick/model.ts +++ b/packages/cli/src/repl/sidekick/model.ts @@ -343,6 +343,22 @@ export interface SidekickSubmitIntent { readonly retained: RetainedPrefix; /** Whether its own outcome was retained. */ readonly settled: boolean; + /** + * Whether that outcome retained an observation of the entry this admitted. + * + * A Submit writes one once there is an entry to observe — the submission is + * joined, and what that entry came to is carried beside the outcome's status. + * A submission refused before any entry started has nothing to observe and + * writes none, and a late reconciliation writes the entry it found or nothing + * where it found the slot was never taken. + * + * So this is the history's own answer to whether this control admitted an + * entry at all, separate from whether what it admitted then succeeded: an + * entry that ran and failed was still admitted by the control that submitted + * it. False while nothing is settled, which is the unresolved state rather + * than an answer. + */ + readonly admitted: boolean; } /** The model and effort this history last asked for. */ @@ -936,6 +952,18 @@ export function projectSidekick(records: readonly SidekickRecord[]): Result(); const operations: SidekickOperation[] = []; const settled = new Set(); + /** + * The controls whose outcome retained an observation of its own. + * + * A Submit writes one once it has an entry to observe: the submission is + * joined and what that entry came to is what the outcome carries beside its + * status. A submission refused before any entry started has nothing to + * observe and carries none — and a late reconciliation of a reservation + * nobody resolved writes the entry it found, or `null` where it found that + * the slot was never taken. So this is the history's own answer to "did this + * control actually admit an entry", written by the only thing that knew. + */ + const observed = new Set(); let preferences: SidekickPreferences | undefined; const delivered = new Map(); const unestablished: { conversation: string; turn: number | null }[] = []; @@ -1177,6 +1205,9 @@ export function projectSidekick(records: readonly SidekickRecord[]): Result { @@ -1422,6 +1456,7 @@ export function projectSidekick(records: readonly SidekickRecord[]): Result; + +/** One row's runs, as a record stores them. */ +export type AppearanceLine = z.infer["styleRuns"][number]; + +/** What one profile is expected to look like. */ +export type AppearanceRecord = z.infer; + +function at(name: string): string { + return fileURLToPath(new URL(`./appearance/${name}`, import.meta.url)); +} + +/** The recorded journal's own text, and what it hashes to. */ +export function* appearanceJournal(): Operation<{ + readonly text: string; + readonly sha256: string; + readonly events: readonly DurableEvent[]; +}> { + const text = yield* readTextFile(at("journal.jsonl")); + const events: DurableEvent[] = []; + for (const [order, line] of text.split("\n").entries()) { + if (line.trim().length === 0) { + continue; + } + // The product's own parser, not JSON.parse with a type written over it: a + // line this rejects is not an event, and a fixture that cast it would be + // describing a screen drawn from something else. + const parsed = parseDurableEvent(line); + if (!parsed.ok) { + throw new Error(`appearance journal line ${order + 1} is not a durable event`, { + cause: parsed.error, + }); + } + events.push(parsed.value); + } + return { + text, + sha256: createHash("sha256").update(text).digest("hex"), + events: Object.freeze(events), + }; +} + +/** That recording, through the canonical projection the product reads with. */ +export function* appearanceModel(): Operation { + const projected = projectRepl((yield* appearanceJournal()).events); + if (!projected.ok) { + throw projected.error; + } + return projected.value; +} + +/** One stored record, parsed into the structure it is supposed to be. */ +export function readAppearanceRecord(profile: string, text: string): AppearanceRecord { + const parsed = RECORD.safeParse(JSON.parse(text)); + if (!parsed.success) { + throw new Error(`the ${profile} appearance record is not one: ${parsed.error.message}`); + } + const record = parsed.data; + if (record.profile !== profile) { + throw new Error(`the ${profile} record says it is ${record.profile}`); + } + if (record.rows.length !== record.size.rows) { + throw new Error( + `the ${profile} record holds ${record.rows.length} rows for a screen of ${record.size.rows}`, + ); + } + return record; +} + +/** That record, read from the repository data beside this module. */ +export function* appearanceRecord(profile: string): Operation { + return readAppearanceRecord(profile, yield* readTextFile(at(`${profile}.json`))); +} + +/** The size one record is of. */ +export function sizeOf(record: AppearanceRecord): ReplTerminalSize { + return Object.freeze({ columns: record.size.columns, rows: record.size.rows }); +} + +/** One committed screen, in the two shapes a record is written in. */ +export function screenOf( + grid: TerminalGrid, + size: ReplTerminalSize, +): { readonly rows: readonly string[]; readonly runs: readonly AppearanceLine[] } { + const rows: string[] = []; + const runs: AppearanceLine[] = []; + for (let y = 0; y < size.rows; y += 1) { + let row = ""; + const line: AppearanceRun[] = []; + let start = 0; + const spell = (x: number) => { + const style = grid.styleAt(x, y); + const colour = (value: number | undefined) => + value === undefined ? "-" : value.toString(16).padStart(6, "0"); + return `${colour(style.foreground)}|${colour(style.background)}|${style.attrs.join(",")}`; + }; + for (let x = 0; x < size.columns; x += 1) { + row += grid.at(x, y); + if (x + 1 === size.columns || spell(x + 1) !== spell(start)) { + const [fg, bg, attrs] = spell(start).split("|"); + line.push({ x: start, width: x + 1 - start, fg, bg, attrs }); + start = x + 1; + } + } + rows.push(row); + runs.push({ y, runs: line }); + } + return { rows, runs }; +} + +/** + * What a candidate screen differs from a record by, in rows a person can read. + * + * Text first and style second, because a row whose words changed explains its + * own style runs; and bounded, because a record that drifted everywhere is a + * thing to look at rather than a thing to scroll. + */ +export function differences( + record: AppearanceRecord, + rows: readonly string[], + runs: readonly AppearanceLine[], +): readonly string[] { + const found: string[] = []; + for (let y = 0; y < record.rows.length; y += 1) { + const expected = record.rows[y] ?? ""; + const actual = rows[y] ?? ""; + if (expected !== actual) { + found.push( + `row ${y} text\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify( + actual, + )}`, + ); + } + } + const spell = (one: readonly AppearanceRun[]) => + one.map((run) => `${run.x}+${run.width}:${run.fg}/${run.bg}/${run.attrs}`).join(" "); + for (const line of record.styleRuns) { + const actual = runs.find((one) => one.y === line.y)?.runs ?? []; + if (spell(line.runs) !== spell(actual)) { + found.push( + `row ${line.y} style\n expected: ${spell(line.runs)}\n actual: ${spell(actual)}`, + ); + } + } + return found; +} diff --git a/packages/cli/tests/fixtures/repl/appearance/120x30.json b/packages/cli/tests/fixtures/repl/appearance/120x30.json new file mode 100644 index 000000000..9ea41a89d --- /dev/null +++ b/packages/cli/tests/fixtures/repl/appearance/120x30.json @@ -0,0 +1,2851 @@ +{ + "profile": "120x30", + "size": { + "columns": 120, + "rows": 30 + }, + "input": { + "journal": "journal.jsonl", + "journalSha256": "9d6e73ff3728849b931eebac177cb30de8f1dc0db11abdb9c2391f950b18c33f", + "workspace": "workspaceOf(model, CHECKPOINT_SIDEKICK)", + "initialFocus": "the Draft takes focus on its own; the committed frame is composed with the tree's answer", + "focused": "footer:input" + }, + "authority": { + "acceptedScreen": "packages/cli/tests/fixtures/repl/accepted-160.ts blob 83dd94534ecb70f7736b0d4f71d1b42ad3d414c3 at f0232b3e2611edb0edd2b7fd4de45cfae905d575, sha256 a8e16e57b770f5aec619e4074d0ea89f216c6560a235ec069c4ce5b9541b2459", + "reviewedCapture": "implementation/implementor-31/shipping/120x30/candidate-cells.json at 0c37c8344fd872c200c7ea7534b1fef5d9668b44, sha256 0cd10e406051f00ee65f1ac80e949f92cb318cbd26c4e792205af51dc1c58e34", + "amendments": "one-list Transcript; corrected sizes at 84092725; human-breadcrumb-acceptance-20261028.json sha256 24fab680a332abd8540a56ed2be4082a35c4a1bf31d2e1378e64a6c9a345233f; Follow and QUEUE omitted, TODO inactive", + "correspondence": "implementation/implementor-50/authority-map.json: every cell the capture also holds is compared at its own coordinate, run position and width included" + }, + "rows": [ + " XMD REPL session checkout-repair ", + " TRANSCRIPT All sessions ▾ │ Entries › Entry 2 › Checklist ● ACTIVE │ BINDINGS SIDEKICK ", + " │ │ [configure claude] ", + " [↑ earlier] │ [↑ earlier] │ ", + " codex · sess-architect-7f2c │ │ Output · live │ [↑ earlier] ", + " [A] Review checkout… · streaming │ │ Reading checkout.md and │ [U] Checkout fails: ", + " Reading checkout.md and arc… │ │ architecture.md │ cart.total is not bound. ", + " claude · chat · sess-sidekick-4… │ │ Source │ Can you fix it? ", + " [U] Checkout fails:… · completed │ │ # Review the checkout │ ", + " [S] The API response has subtot… │ │ total │ [S] The API response has ", + " [C] Ask th… · waiting on Entry 2 │ │ │ subtotal and tax but no ", + " [S] I started the Architect rev… │ │ ```js eval │ total, so I computed it ", + " │ │ output(\"Reading │ in checkout.md. ", + " │ │ checkout.md and │ ", + " │ │ architecture.md\"); │ [C] Ask the Architect to ", + " │ │ ``` │ review checkout.md ", + " │ │ │ before I merge. ", + " │ │ │ review and will say what ", + " │ │ │ it asks for. ", + " │ │ Compute the total │ ", + " │ │ once, after the │ [S] working ", + " │ [↓ later] │ [↓ later] ", + " │ > DRAFT [stop] │ TODO [stop] ", + " │ │ › message the sidekick ", + " │ │ renders. ", + " HISTORY 1 ✓ 2 ● ── HEAD · LIVE [history] [exit] [pause]", + " Entry 2 running · Enter unavailable until it finishes · Type here · Tab/Shift+Tab move " + ], + "styleRuns": [ + { + "y": 0, + "runs": [ + { + "x": 0, + "width": 9, + "fg": "e6ecf1", + "bg": "11171b", + "attrs": "" + }, + { + "x": 9, + "width": 10, + "fg": "8b959c", + "bg": "11171b", + "attrs": "1" + }, + { + "x": 19, + "width": 15, + "fg": "c8d2d9", + "bg": "11171b", + "attrs": "" + }, + { + "x": 34, + "width": 86, + "fg": "-", + "bg": "11171b", + "attrs": "" + } + ] + }, + { + "y": 1, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 1, + "width": 10, + "fg": "cfe0ea", + "bg": "0e1215", + "attrs": "1" + }, + { + "x": 11, + "width": 8, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 19, + "width": 16, + "fg": "8b959c", + "bg": "0e1215", + "attrs": "1" + }, + { + "x": 35, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 38, + "width": 9, + "fg": "cfe0ea", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 47, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 48, + "width": 2, + "fg": "7b858d", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 50, + "width": 2, + "fg": "7c868d", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 52, + "width": 7, + "fg": "7b858d", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 59, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 60, + "width": 2, + "fg": "7c868d", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 62, + "width": 9, + "fg": "7fd3e8", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 71, + "width": 8, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 79, + "width": 8, + "fg": "7fd3e8", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 87, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 90, + "width": 10, + "fg": "7b858d", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 102, + "width": 10, + "fg": "cfe0ea", + "bg": "0e1215", + "attrs": "1" + }, + { + "x": 112, + "width": 8, + "fg": "-", + "bg": "0e1215", + "attrs": "" + } + ] + }, + { + "y": 2, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 51, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 10, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 99, + "width": 20, + "fg": "cfe0ea", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 119, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + } + ] + }, + { + "y": 3, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 15, + "fg": "cfe0ea", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 16, + "width": 20, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 15, + "fg": "cfe0ea", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 54, + "width": 34, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 31, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 4, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 29, + "fg": "7b858d", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 30, + "width": 6, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 13, + "fg": "c99a3f", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 55, + "width": 31, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 90, + "width": 15, + "fg": "cfe0ea", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 105, + "width": 15, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 5, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 2, + "fg": "c99a3f", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 3, + "width": 4, + "fg": "ebb890", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 7, + "width": 28, + "fg": "c99a3f", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 35, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 24, + "fg": "e6ecf1", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 66, + "width": 20, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 90, + "width": 4, + "fg": "e1e9ed", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 94, + "width": 16, + "fg": "c8d2d9", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 110, + "width": 10, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 6, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 34, + "fg": "e6ecf1", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 35, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 15, + "fg": "e6ecf1", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 57, + "width": 29, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 90, + "width": 29, + "fg": "c8d2d9", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 119, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 7, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 34, + "fg": "7b858d", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 35, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 6, + "fg": "cfe0ea", + "bg": "0b0d0f", + "attrs": "1" + }, + { + "x": 48, + "width": 38, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 90, + "width": 19, + "fg": "c8d2d9", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 109, + "width": 11, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 8, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 2, + "fg": "c8d2d9", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 3, + "width": 4, + "fg": "e1e9ed", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 7, + "width": 28, + "fg": "c8d2d9", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 35, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 22, + "fg": "c7c4e6", + "bg": "0b0d0f", + "attrs": "1" + }, + { + "x": 64, + "width": 22, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 31, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 9, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 2, + "fg": "e6ecf1", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 3, + "width": 4, + "fg": "8dceee", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 7, + "width": 28, + "fg": "e6ecf1", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 35, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 5, + "fg": "c7c4e6", + "bg": "0b0d0f", + "attrs": "1" + }, + { + "x": 47, + "width": 39, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 90, + "width": 4, + "fg": "8dceee", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 94, + "width": 21, + "fg": "e6ecf1", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 115, + "width": 5, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 10, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 2, + "fg": "c99a3f", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 3, + "width": 4, + "fg": "d4b6eb", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 7, + "width": 28, + "fg": "c99a3f", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 35, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 46, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 90, + "width": 28, + "fg": "e6ecf1", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 118, + "width": 2, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 11, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 2, + "fg": "e6ecf1", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 3, + "width": 4, + "fg": "8dceee", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 7, + "width": 28, + "fg": "e6ecf1", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 35, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 10, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 52, + "width": 34, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 90, + "width": 28, + "fg": "e6ecf1", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 118, + "width": 2, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 12, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 16, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 58, + "width": 28, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 90, + "width": 19, + "fg": "e6ecf1", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 109, + "width": 11, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 13, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 16, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 58, + "width": 28, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 31, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 14, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 18, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 60, + "width": 26, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 90, + "width": 4, + "fg": "d4b6eb", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 94, + "width": 21, + "fg": "c8d2d9", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 115, + "width": 5, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 15, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 3, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 45, + "width": 41, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 90, + "width": 23, + "fg": "c8d2d9", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 113, + "width": 7, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 16, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 46, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 90, + "width": 19, + "fg": "c8d2d9", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 109, + "width": 11, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 17, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 1, + "fg": "4e8b9c", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 43, + "width": 9, + "fg": "7fd3e8", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 52, + "width": 1, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 53, + "width": 33, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 31, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 18, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 5, + "fg": "8fa7b8", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 47, + "width": 1, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 48, + "width": 16, + "fg": "d6b477", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 64, + "width": 1, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 65, + "width": 21, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 90, + "width": 4, + "fg": "8dceee", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 94, + "width": 24, + "fg": "e6ecf1", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 118, + "width": 2, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 19, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 5, + "fg": "8fa7b8", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 47, + "width": 1, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 48, + "width": 1, + "fg": "9b8ed0", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 49, + "width": 1, + "fg": "b7aee0", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 50, + "width": 1, + "fg": "9b8ed0", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 51, + "width": 1, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 52, + "width": 2, + "fg": "4e8b9c", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 54, + "width": 32, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 90, + "width": 29, + "fg": "e6ecf1", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 119, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 20, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 46, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 90, + "width": 16, + "fg": "e6ecf1", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 106, + "width": 14, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 21, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 18, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 60, + "width": 26, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 31, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 22, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 19, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 61, + "width": 25, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 90, + "width": 4, + "fg": "8dceee", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 94, + "width": 7, + "fg": "c99a3f", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 101, + "width": 19, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 23, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 13, + "fg": "cfe0ea", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 52, + "width": 36, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 90, + "width": 13, + "fg": "cfe0ea", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 17, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 24, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 7, + "fg": "8b959c", + "bg": "0a0d0f", + "attrs": "1" + }, + { + "x": 46, + "width": 32, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 78, + "width": 8, + "fg": "c2766e", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 2, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0d1215", + "attrs": "" + }, + { + "x": 90, + "width": 6, + "fg": "cfe0ea", + "bg": "122026", + "attrs": "1" + }, + { + "x": 96, + "width": 15, + "fg": "-", + "bg": "0d1215", + "attrs": "" + }, + { + "x": 111, + "width": 8, + "fg": "c2766e", + "bg": "0d1215", + "attrs": "" + }, + { + "x": 119, + "width": 1, + "fg": "-", + "bg": "0d1215", + "attrs": "" + } + ] + }, + { + "y": 25, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 51, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0d1215", + "attrs": "" + }, + { + "x": 90, + "width": 24, + "fg": "c8d2d9", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 114, + "width": 5, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 119, + "width": 1, + "fg": "-", + "bg": "0d1215", + "attrs": "" + } + ] + }, + { + "y": 26, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "4e8b9c", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 12, + "fg": "7fd3e8", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 52, + "width": 1, + "fg": "c8d2d9", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 53, + "width": 5, + "fg": "8fa7b8", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 58, + "width": 1, + "fg": "7c868d", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 59, + "width": 1, + "fg": "9b8ed0", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 60, + "width": 10, + "fg": "b7aee0", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 70, + "width": 1, + "fg": "9b8ed0", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 71, + "width": 1, + "fg": "c8d2d9", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 72, + "width": 16, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0d1215", + "attrs": "" + }, + { + "x": 90, + "width": 28, + "fg": "c8d2d9", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 118, + "width": 1, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 119, + "width": 1, + "fg": "-", + "bg": "0d1215", + "attrs": "" + } + ] + }, + { + "y": 27, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 13, + "fg": "cfe0ea", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 14, + "width": 22, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 17, + "fg": "c8d2d9", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 56, + "width": 32, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 88, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 89, + "width": 1, + "fg": "-", + "bg": "0d1215", + "attrs": "" + }, + { + "x": 90, + "width": 8, + "fg": "c8d2d9", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 98, + "width": 21, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 119, + "width": 1, + "fg": "-", + "bg": "0d1215", + "attrs": "" + } + ] + }, + { + "y": 28, + "runs": [ + { + "x": 0, + "width": 8, + "fg": "8b959c", + "bg": "08090b", + "attrs": "" + }, + { + "x": 8, + "width": 2, + "fg": "c9a86a", + "bg": "08090b", + "attrs": "" + }, + { + "x": 10, + "width": 1, + "fg": "b8c4cc", + "bg": "08090b", + "attrs": "" + }, + { + "x": 11, + "width": 2, + "fg": "5aa87c", + "bg": "08090b", + "attrs": "" + }, + { + "x": 13, + "width": 2, + "fg": "c9a86a", + "bg": "08090b", + "attrs": "" + }, + { + "x": 15, + "width": 1, + "fg": "b8c4cc", + "bg": "08090b", + "attrs": "" + }, + { + "x": 16, + "width": 2, + "fg": "7fd3e8", + "bg": "08090b", + "attrs": "" + }, + { + "x": 18, + "width": 5, + "fg": "141a1f", + "bg": "08090b", + "attrs": "" + }, + { + "x": 23, + "width": 11, + "fg": "7fd3e8", + "bg": "08090b", + "attrs": "" + }, + { + "x": 34, + "width": 56, + "fg": "-", + "bg": "08090b", + "attrs": "" + }, + { + "x": 90, + "width": 11, + "fg": "cfe0ea", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 101, + "width": 1, + "fg": "-", + "bg": "08090b", + "attrs": "" + }, + { + "x": 102, + "width": 8, + "fg": "cfe0ea", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 110, + "width": 1, + "fg": "-", + "bg": "08090b", + "attrs": "" + }, + { + "x": 111, + "width": 9, + "fg": "cfe0ea", + "bg": "0a0d0f", + "attrs": "" + } + ] + }, + { + "y": 29, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "11171b", + "attrs": "" + }, + { + "x": 1, + "width": 86, + "fg": "c8d2d9", + "bg": "11171b", + "attrs": "" + }, + { + "x": 87, + "width": 33, + "fg": "-", + "bg": "11171b", + "attrs": "" + } + ] + } + ], + "expectedSha256": "198b7523a91f93b6ae7308c4b9213714ab795a5f6818d9c15ab53d047ad2fa64" +} diff --git a/packages/cli/tests/fixtures/repl/appearance/160x36.json b/packages/cli/tests/fixtures/repl/appearance/160x36.json new file mode 100644 index 000000000..174a7b481 --- /dev/null +++ b/packages/cli/tests/fixtures/repl/appearance/160x36.json @@ -0,0 +1,3083 @@ +{ + "profile": "160x36", + "size": { + "columns": 160, + "rows": 36 + }, + "input": { + "journal": "journal.jsonl", + "journalSha256": "9d6e73ff3728849b931eebac177cb30de8f1dc0db11abdb9c2391f950b18c33f", + "workspace": "workspaceOf(model, CHECKPOINT_SIDEKICK)", + "initialFocus": "the Draft takes focus on its own; the committed frame is composed with the tree's answer", + "focused": "footer:input" + }, + "authority": { + "acceptedScreen": "packages/cli/tests/fixtures/repl/accepted-160.ts blob 83dd94534ecb70f7736b0d4f71d1b42ad3d414c3 at f0232b3e2611edb0edd2b7fd4de45cfae905d575, sha256 a8e16e57b770f5aec619e4074d0ea89f216c6560a235ec069c4ce5b9541b2459", + "reviewedCapture": "implementation/implementor-31/shipping/160x36/candidate-cells.json at 0c37c8344fd872c200c7ea7534b1fef5d9668b44, sha256 068318b5f162526f9d9f8be5c96a0ae1c240e9b85c879d6068a65864bdc132c8", + "amendments": "one-list Transcript; corrected sizes at 84092725; human-breadcrumb-acceptance-20261028.json sha256 24fab680a332abd8540a56ed2be4082a35c4a1bf31d2e1378e64a6c9a345233f; Follow and QUEUE omitted, TODO inactive", + "correspondence": "implementation/implementor-50/authority-map.json: every cell the capture also holds is compared at its own coordinate, run position and width included" + }, + "rows": [ + " XMD REPL session checkout-repair ", + " TRANSCRIPT All sessions ▾ │ Entries › Entry 2 › Checklist ● ACTIVE │ BINDINGS SIDEKICK [configure claude] ", + " │ │ ", + " [↑ earlier] │ [↑ earlier] │ [↑ earlier] ", + " codex · sess-architect-7f2c │ │ Output · live │ [U] Checkout fails: cart.total is not bound. Can you ", + " [A] Review checkout… · streaming │ │ Reading checkout.md and │ fix it? ", + " Reading checkout.md and arc… │ │ architecture.md │ ", + " claude · chat · sess-sidekick-4… │ │ Source │ [S] The API response has subtotal and tax but no ", + " [U] Checkout fails:… · completed │ │ # Review the checkout total │ total, so I computed it in checkout.md. ", + " [S] The API response has subtot… │ │ │ ", + " [C] Ask th… · waiting on Entry 2 │ │ ```js eval │ [C] Ask the Architect to review checkout.md before I ", + " [S] I started the Architect rev… │ │ output(\"Reading checkout.md and │ merge. ", + " │ │ architecture.md\"); │ ", + " │ │ ``` │ [S] I started the Architect review and will say what ", + " │ │ │ it asks for. ", + " │ │ │ [S] working ", + " │ │ │ ", + " │ │ Compute the total once, after the │ ", + " │ │ items are summed, so that checkout.md │ ", + " │ │ and cart.md can never disagree about │ ", + " │ │ what the customer pays. │ ", + " │ │ ", + " │ │ ", + " │ │ ", + " │ │ ", + " │ │ ", + " │ │ ", + " │ │ ", + " │ [↓ later] │ ", + " │ > DRAFT [stop] │ [↓ later] ", + " │ │ TODO [stop] ", + " │ │ › message the sidekick ", + " [↓ later] │ │ Also say what an empty cart renders. ", + " HISTORY 1 ✓ 2 ● ── HEAD · LIVE [history] [exit] [pause] ", + " Entry 2 running · Enter unavailable until it finishes · Type here · Tab/Shift+Tab move " + ], + "styleRuns": [ + { + "y": 0, + "runs": [ + { + "x": 0, + "width": 9, + "fg": "e6ecf1", + "bg": "11171b", + "attrs": "" + }, + { + "x": 9, + "width": 10, + "fg": "8b959c", + "bg": "11171b", + "attrs": "1" + }, + { + "x": 19, + "width": 15, + "fg": "c8d2d9", + "bg": "11171b", + "attrs": "" + }, + { + "x": 34, + "width": 126, + "fg": "-", + "bg": "11171b", + "attrs": "" + } + ] + }, + { + "y": 1, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 1, + "width": 10, + "fg": "cfe0ea", + "bg": "0e1215", + "attrs": "1" + }, + { + "x": 11, + "width": 8, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 19, + "width": 16, + "fg": "8b959c", + "bg": "0e1215", + "attrs": "1" + }, + { + "x": 35, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 38, + "width": 9, + "fg": "cfe0ea", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 47, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 48, + "width": 2, + "fg": "7b858d", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 50, + "width": 2, + "fg": "7c868d", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 52, + "width": 7, + "fg": "7b858d", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 59, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 60, + "width": 2, + "fg": "7c868d", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 62, + "width": 9, + "fg": "7fd3e8", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 71, + "width": 22, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 93, + "width": 8, + "fg": "7fd3e8", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 101, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 104, + "width": 10, + "fg": "7b858d", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 114, + "width": 2, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 116, + "width": 10, + "fg": "cfe0ea", + "bg": "0e1215", + "attrs": "1" + }, + { + "x": 126, + "width": 13, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 139, + "width": 20, + "fg": "cfe0ea", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 159, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + } + ] + }, + { + "y": 2, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 65, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 57, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 3, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 15, + "fg": "cfe0ea", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 16, + "width": 20, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 15, + "fg": "cfe0ea", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 54, + "width": 48, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 104, + "width": 15, + "fg": "cfe0ea", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 119, + "width": 41, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 4, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 29, + "fg": "7b858d", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 30, + "width": 6, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 13, + "fg": "c99a3f", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 55, + "width": 45, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 104, + "width": 4, + "fg": "e1e9ed", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 108, + "width": 49, + "fg": "c8d2d9", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 157, + "width": 3, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 5, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 2, + "fg": "c99a3f", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 3, + "width": 4, + "fg": "ebb890", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 7, + "width": 28, + "fg": "c99a3f", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 35, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 24, + "fg": "e6ecf1", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 66, + "width": 34, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 104, + "width": 11, + "fg": "c8d2d9", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 115, + "width": 45, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 6, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 34, + "fg": "e6ecf1", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 35, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 15, + "fg": "e6ecf1", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 57, + "width": 43, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 57, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 7, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 34, + "fg": "7b858d", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 35, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 6, + "fg": "cfe0ea", + "bg": "0b0d0f", + "attrs": "1" + }, + { + "x": 48, + "width": 52, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 104, + "width": 4, + "fg": "8dceee", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 108, + "width": 45, + "fg": "e6ecf1", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 153, + "width": 7, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 8, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 2, + "fg": "c8d2d9", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 3, + "width": 4, + "fg": "e1e9ed", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 7, + "width": 28, + "fg": "c8d2d9", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 35, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 27, + "fg": "c7c4e6", + "bg": "0b0d0f", + "attrs": "1" + }, + { + "x": 69, + "width": 31, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 104, + "width": 43, + "fg": "e6ecf1", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 147, + "width": 13, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 9, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 2, + "fg": "e6ecf1", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 3, + "width": 4, + "fg": "8dceee", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 7, + "width": 28, + "fg": "e6ecf1", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 35, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 60, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 57, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 10, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 2, + "fg": "c99a3f", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 3, + "width": 4, + "fg": "d4b6eb", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 7, + "width": 28, + "fg": "c99a3f", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 35, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 10, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 52, + "width": 48, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 104, + "width": 4, + "fg": "d4b6eb", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 108, + "width": 49, + "fg": "c8d2d9", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 157, + "width": 3, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 11, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 2, + "fg": "e6ecf1", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 3, + "width": 4, + "fg": "8dceee", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 7, + "width": 28, + "fg": "e6ecf1", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 35, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 32, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 74, + "width": 26, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 104, + "width": 10, + "fg": "c8d2d9", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 114, + "width": 46, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 12, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 18, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 60, + "width": 40, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 57, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 13, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 3, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 45, + "width": 55, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 104, + "width": 4, + "fg": "8dceee", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 108, + "width": 49, + "fg": "e6ecf1", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 157, + "width": 3, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 14, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 60, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 104, + "width": 16, + "fg": "e6ecf1", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 120, + "width": 40, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 15, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 1, + "fg": "4e8b9c", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 43, + "width": 9, + "fg": "7fd3e8", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 52, + "width": 1, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 53, + "width": 5, + "fg": "8fa7b8", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 58, + "width": 1, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 59, + "width": 16, + "fg": "d6b477", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 75, + "width": 1, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 76, + "width": 24, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 57, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 16, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 5, + "fg": "8fa7b8", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 47, + "width": 1, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 48, + "width": 1, + "fg": "9b8ed0", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 49, + "width": 1, + "fg": "b7aee0", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 50, + "width": 1, + "fg": "9b8ed0", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 51, + "width": 1, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 52, + "width": 2, + "fg": "4e8b9c", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 54, + "width": 46, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 104, + "width": 4, + "fg": "8dceee", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 108, + "width": 7, + "fg": "c99a3f", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 115, + "width": 45, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 17, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 60, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 57, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 18, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 37, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 79, + "width": 21, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 57, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 19, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 38, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 80, + "width": 20, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 57, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 20, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 37, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 79, + "width": 21, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 57, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 21, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 42, + "width": 23, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 65, + "width": 35, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 57, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 22, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 65, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 57, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 23, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 65, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 57, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 24, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 65, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 57, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 25, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 65, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 57, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 26, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 65, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 57, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 27, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 65, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 57, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 28, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 65, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 57, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 29, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 13, + "fg": "cfe0ea", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 52, + "width": 50, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 57, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 30, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 7, + "fg": "8b959c", + "bg": "0a0d0f", + "attrs": "1" + }, + { + "x": 46, + "width": 46, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 92, + "width": 8, + "fg": "c2766e", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 100, + "width": 2, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 1, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 104, + "width": 13, + "fg": "cfe0ea", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 117, + "width": 43, + "fg": "-", + "bg": "0a0c0e", + "attrs": "" + } + ] + }, + { + "y": 31, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "4e8b9c", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 8, + "fg": "7fd3e8", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 48, + "width": 1, + "fg": "c8d2d9", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 49, + "width": 4, + "fg": "8fa7b8", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 53, + "width": 1, + "fg": "7c868d", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 54, + "width": 1, + "fg": "9b8ed0", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 55, + "width": 4, + "fg": "b7aee0", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 59, + "width": 1, + "fg": "9b8ed0", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 60, + "width": 1, + "fg": "c8d2d9", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 61, + "width": 2, + "fg": "4e8b9c", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 63, + "width": 39, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 1, + "fg": "-", + "bg": "0d1215", + "attrs": "" + }, + { + "x": 104, + "width": 6, + "fg": "cfe0ea", + "bg": "122026", + "attrs": "1" + }, + { + "x": 110, + "width": 41, + "fg": "-", + "bg": "0d1215", + "attrs": "" + }, + { + "x": 151, + "width": 8, + "fg": "c2766e", + "bg": "0d1215", + "attrs": "" + }, + { + "x": 159, + "width": 1, + "fg": "-", + "bg": "0d1215", + "attrs": "" + } + ] + }, + { + "y": 32, + "runs": [ + { + "x": 0, + "width": 36, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 65, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 1, + "fg": "-", + "bg": "0d1215", + "attrs": "" + }, + { + "x": 104, + "width": 24, + "fg": "c8d2d9", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 128, + "width": 31, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 159, + "width": 1, + "fg": "-", + "bg": "0d1215", + "attrs": "" + } + ] + }, + { + "y": 33, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 1, + "width": 13, + "fg": "cfe0ea", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 14, + "width": 22, + "fg": "-", + "bg": "0c1014", + "attrs": "" + }, + { + "x": 36, + "width": 1, + "fg": "1c252b", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 2, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 39, + "width": 1, + "fg": "4e8b9c", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 40, + "width": 12, + "fg": "7fd3e8", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 52, + "width": 1, + "fg": "c8d2d9", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 53, + "width": 5, + "fg": "8fa7b8", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 58, + "width": 1, + "fg": "7c868d", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 59, + "width": 1, + "fg": "9b8ed0", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 60, + "width": 10, + "fg": "b7aee0", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 70, + "width": 1, + "fg": "9b8ed0", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 71, + "width": 1, + "fg": "c8d2d9", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 72, + "width": 8, + "fg": "8fa7b8", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 80, + "width": 1, + "fg": "7c868d", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 81, + "width": 5, + "fg": "d6b477", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 86, + "width": 1, + "fg": "c8d2d9", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 87, + "width": 2, + "fg": "4e8b9c", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 89, + "width": 13, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 102, + "width": 1, + "fg": "1c252b", + "bg": "0a0c0e", + "attrs": "" + }, + { + "x": 103, + "width": 1, + "fg": "-", + "bg": "0d1215", + "attrs": "" + }, + { + "x": 104, + "width": 36, + "fg": "c8d2d9", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 140, + "width": 19, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 159, + "width": 1, + "fg": "-", + "bg": "0d1215", + "attrs": "" + } + ] + }, + { + "y": 34, + "runs": [ + { + "x": 0, + "width": 8, + "fg": "8b959c", + "bg": "08090b", + "attrs": "" + }, + { + "x": 8, + "width": 2, + "fg": "c9a86a", + "bg": "08090b", + "attrs": "" + }, + { + "x": 10, + "width": 1, + "fg": "b8c4cc", + "bg": "08090b", + "attrs": "" + }, + { + "x": 11, + "width": 2, + "fg": "5aa87c", + "bg": "08090b", + "attrs": "" + }, + { + "x": 13, + "width": 2, + "fg": "c9a86a", + "bg": "08090b", + "attrs": "" + }, + { + "x": 15, + "width": 1, + "fg": "b8c4cc", + "bg": "08090b", + "attrs": "" + }, + { + "x": 16, + "width": 2, + "fg": "7fd3e8", + "bg": "08090b", + "attrs": "" + }, + { + "x": 18, + "width": 5, + "fg": "141a1f", + "bg": "08090b", + "attrs": "" + }, + { + "x": 23, + "width": 11, + "fg": "7fd3e8", + "bg": "08090b", + "attrs": "" + }, + { + "x": 34, + "width": 63, + "fg": "-", + "bg": "08090b", + "attrs": "" + }, + { + "x": 97, + "width": 11, + "fg": "cfe0ea", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 108, + "width": 1, + "fg": "-", + "bg": "08090b", + "attrs": "" + }, + { + "x": 109, + "width": 8, + "fg": "cfe0ea", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 117, + "width": 1, + "fg": "-", + "bg": "08090b", + "attrs": "" + }, + { + "x": 118, + "width": 9, + "fg": "cfe0ea", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 127, + "width": 33, + "fg": "-", + "bg": "08090b", + "attrs": "" + } + ] + }, + { + "y": 35, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "11171b", + "attrs": "" + }, + { + "x": 1, + "width": 86, + "fg": "c8d2d9", + "bg": "11171b", + "attrs": "" + }, + { + "x": 87, + "width": 73, + "fg": "-", + "bg": "11171b", + "attrs": "" + } + ] + } + ], + "expectedSha256": "d445738504751a81f01e0e408cae4dbb9024d971c3d5468f7d31f103b2d31be0" +} diff --git a/packages/cli/tests/fixtures/repl/appearance/72x20.json b/packages/cli/tests/fixtures/repl/appearance/72x20.json new file mode 100644 index 000000000..1cdcdda64 --- /dev/null +++ b/packages/cli/tests/fixtures/repl/appearance/72x20.json @@ -0,0 +1,1013 @@ +{ + "profile": "72x20", + "size": { + "columns": 72, + "rows": 20 + }, + "input": { + "journal": "journal.jsonl", + "journalSha256": "9d6e73ff3728849b931eebac177cb30de8f1dc0db11abdb9c2391f950b18c33f", + "workspace": "workspaceOf(model, CHECKPOINT_SIDEKICK)", + "initialFocus": "the Draft takes focus on its own; the committed frame is composed with the tree's answer", + "focused": "footer:input" + }, + "authority": { + "acceptedScreen": "packages/cli/tests/fixtures/repl/accepted-160.ts blob 83dd94534ecb70f7736b0d4f71d1b42ad3d414c3 at f0232b3e2611edb0edd2b7fd4de45cfae905d575, sha256 a8e16e57b770f5aec619e4074d0ea89f216c6560a235ec069c4ce5b9541b2459", + "reviewedCapture": "implementation/implementor-31/shipping/72x20/candidate-cells.json at 0c37c8344fd872c200c7ea7534b1fef5d9668b44, sha256 c9a72408086ea03ed21d4f5baa8a5d94bfb6e4d762d3d7578851381b5b729b7c", + "amendments": "one-list Transcript; corrected sizes at 84092725; human-breadcrumb-acceptance-20261028.json sha256 24fab680a332abd8540a56ed2be4082a35c4a1bf31d2e1378e64a6c9a345233f; Follow and QUEUE omitted, TODO inactive", + "correspondence": "implementation/implementor-50/authority-map.json: every cell the capture also holds is compared at its own coordinate, run position and width included" + }, + "rows": [ + " XMD REPL session checkout-repair ", + " Sessions * Entries ", + " › Entry 2 › Checklist ● ACTIVE ", + " ", + " [↑ earlier] ", + " │ Output · live ", + " │ Reading checkout.md and architecture.md ", + " │ Source ", + " │ # Review the checkout total ", + " │ ", + " │ ```js eval ", + " │ output(\"Reading checkout.md and ", + " │ architecture.md\"); ", + " [↓ later] ", + " > DRAFT [stop] ", + " ", + " ", + " ", + " HISTORY 1 ✓ 2 ● ── HEAD · LIVE [history] [exit] [pause]", + " Entry 2 running · Enter unavailable · Type here · Tab/Shift+Tab move " + ], + "styleRuns": [ + { + "y": 0, + "runs": [ + { + "x": 0, + "width": 9, + "fg": "e6ecf1", + "bg": "11171b", + "attrs": "" + }, + { + "x": 9, + "width": 10, + "fg": "8b959c", + "bg": "11171b", + "attrs": "1" + }, + { + "x": 19, + "width": 15, + "fg": "c8d2d9", + "bg": "11171b", + "attrs": "" + }, + { + "x": 34, + "width": 38, + "fg": "-", + "bg": "11171b", + "attrs": "" + } + ] + }, + { + "y": 1, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 1, + "width": 12, + "fg": "cfe0ea", + "bg": "0e1215", + "attrs": "1" + }, + { + "x": 13, + "width": 2, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 15, + "width": 11, + "fg": "cfe0ea", + "bg": "122026", + "attrs": "1" + }, + { + "x": 26, + "width": 46, + "fg": "-", + "bg": "0e1215", + "attrs": "" + } + ] + }, + { + "y": 2, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 1, + "width": 2, + "fg": "7b858d", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 3, + "width": 2, + "fg": "7c868d", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 5, + "width": 7, + "fg": "7b858d", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 12, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 13, + "width": 2, + "fg": "7c868d", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 15, + "width": 9, + "fg": "7fd3e8", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 24, + "width": 39, + "fg": "-", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 63, + "width": 8, + "fg": "7fd3e8", + "bg": "0e1215", + "attrs": "" + }, + { + "x": 71, + "width": 1, + "fg": "-", + "bg": "0e1215", + "attrs": "" + } + ] + }, + { + "y": 3, + "runs": [ + { + "x": 0, + "width": 72, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + } + ] + }, + { + "y": 4, + "runs": [ + { + "x": 0, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 2, + "width": 15, + "fg": "cfe0ea", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 17, + "width": 55, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + } + ] + }, + { + "y": 5, + "runs": [ + { + "x": 0, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 2, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 3, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 5, + "width": 13, + "fg": "c99a3f", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 18, + "width": 52, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 70, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + } + ] + }, + { + "y": 6, + "runs": [ + { + "x": 0, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 2, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 3, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 5, + "width": 39, + "fg": "e6ecf1", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 44, + "width": 26, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 70, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + } + ] + }, + { + "y": 7, + "runs": [ + { + "x": 0, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 2, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 3, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 5, + "width": 6, + "fg": "cfe0ea", + "bg": "0b0d0f", + "attrs": "1" + }, + { + "x": 11, + "width": 59, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 70, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + } + ] + }, + { + "y": 8, + "runs": [ + { + "x": 0, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 2, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 3, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 5, + "width": 27, + "fg": "c7c4e6", + "bg": "0b0d0f", + "attrs": "1" + }, + { + "x": 32, + "width": 38, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 70, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + } + ] + }, + { + "y": 9, + "runs": [ + { + "x": 0, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 2, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 3, + "width": 67, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 70, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + } + ] + }, + { + "y": 10, + "runs": [ + { + "x": 0, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 2, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 3, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 5, + "width": 10, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 15, + "width": 55, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 70, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + } + ] + }, + { + "y": 11, + "runs": [ + { + "x": 0, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 2, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 3, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 5, + "width": 32, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 37, + "width": 33, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 70, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + } + ] + }, + { + "y": 12, + "runs": [ + { + "x": 0, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 2, + "width": 1, + "fg": "252b30", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 3, + "width": 2, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 5, + "width": 18, + "fg": "c8d2d9", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 23, + "width": 47, + "fg": "7c868d", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 70, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + } + ] + }, + { + "y": 13, + "runs": [ + { + "x": 0, + "width": 2, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 2, + "width": 13, + "fg": "cfe0ea", + "bg": "0b0d0f", + "attrs": "" + }, + { + "x": 15, + "width": 57, + "fg": "-", + "bg": "0b0d0f", + "attrs": "" + } + ] + }, + { + "y": 14, + "runs": [ + { + "x": 0, + "width": 2, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 2, + "width": 7, + "fg": "8b959c", + "bg": "0a0d0f", + "attrs": "1" + }, + { + "x": 9, + "width": 53, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 62, + "width": 8, + "fg": "c2766e", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 70, + "width": 2, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + } + ] + }, + { + "y": 15, + "runs": [ + { + "x": 0, + "width": 2, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 2, + "width": 1, + "fg": "4e8b9c", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 3, + "width": 8, + "fg": "7fd3e8", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 11, + "width": 1, + "fg": "c8d2d9", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 12, + "width": 4, + "fg": "8fa7b8", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 16, + "width": 1, + "fg": "7c868d", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 17, + "width": 1, + "fg": "9b8ed0", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 18, + "width": 4, + "fg": "b7aee0", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 22, + "width": 1, + "fg": "9b8ed0", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 23, + "width": 1, + "fg": "c8d2d9", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 24, + "width": 2, + "fg": "4e8b9c", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 26, + "width": 46, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + } + ] + }, + { + "y": 16, + "runs": [ + { + "x": 0, + "width": 72, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + } + ] + }, + { + "y": 17, + "runs": [ + { + "x": 0, + "width": 2, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 2, + "width": 1, + "fg": "4e8b9c", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 3, + "width": 12, + "fg": "7fd3e8", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 15, + "width": 1, + "fg": "c8d2d9", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 16, + "width": 5, + "fg": "8fa7b8", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 21, + "width": 1, + "fg": "7c868d", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 22, + "width": 1, + "fg": "9b8ed0", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 23, + "width": 10, + "fg": "b7aee0", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 33, + "width": 1, + "fg": "9b8ed0", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 34, + "width": 1, + "fg": "c8d2d9", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 35, + "width": 8, + "fg": "8fa7b8", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 43, + "width": 1, + "fg": "7c868d", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 44, + "width": 5, + "fg": "d6b477", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 49, + "width": 1, + "fg": "c8d2d9", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 50, + "width": 2, + "fg": "4e8b9c", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 52, + "width": 20, + "fg": "-", + "bg": "0a0d0f", + "attrs": "" + } + ] + }, + { + "y": 18, + "runs": [ + { + "x": 0, + "width": 8, + "fg": "8b959c", + "bg": "08090b", + "attrs": "" + }, + { + "x": 8, + "width": 2, + "fg": "c9a86a", + "bg": "08090b", + "attrs": "" + }, + { + "x": 10, + "width": 1, + "fg": "b8c4cc", + "bg": "08090b", + "attrs": "" + }, + { + "x": 11, + "width": 2, + "fg": "5aa87c", + "bg": "08090b", + "attrs": "" + }, + { + "x": 13, + "width": 2, + "fg": "c9a86a", + "bg": "08090b", + "attrs": "" + }, + { + "x": 15, + "width": 1, + "fg": "b8c4cc", + "bg": "08090b", + "attrs": "" + }, + { + "x": 16, + "width": 2, + "fg": "7fd3e8", + "bg": "08090b", + "attrs": "" + }, + { + "x": 18, + "width": 5, + "fg": "141a1f", + "bg": "08090b", + "attrs": "" + }, + { + "x": 23, + "width": 11, + "fg": "7fd3e8", + "bg": "08090b", + "attrs": "" + }, + { + "x": 34, + "width": 8, + "fg": "-", + "bg": "08090b", + "attrs": "" + }, + { + "x": 42, + "width": 11, + "fg": "cfe0ea", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 53, + "width": 1, + "fg": "-", + "bg": "08090b", + "attrs": "" + }, + { + "x": 54, + "width": 8, + "fg": "cfe0ea", + "bg": "0a0d0f", + "attrs": "" + }, + { + "x": 62, + "width": 1, + "fg": "-", + "bg": "08090b", + "attrs": "" + }, + { + "x": 63, + "width": 9, + "fg": "cfe0ea", + "bg": "0a0d0f", + "attrs": "" + } + ] + }, + { + "y": 19, + "runs": [ + { + "x": 0, + "width": 1, + "fg": "-", + "bg": "11171b", + "attrs": "" + }, + { + "x": 1, + "width": 68, + "fg": "c8d2d9", + "bg": "11171b", + "attrs": "" + }, + { + "x": 69, + "width": 3, + "fg": "-", + "bg": "11171b", + "attrs": "" + } + ] + } + ], + "expectedSha256": "28215493f375e7999c98d3524a6d3ed8087900cc824880de4c50b3c5cddbd981" +} diff --git a/packages/cli/tests/fixtures/repl/appearance/journal.jsonl b/packages/cli/tests/fixtures/repl/appearance/journal.jsonl new file mode 100644 index 000000000..f073c8f10 --- /dev/null +++ b/packages/cli/tests/fixtures/repl/appearance/journal.jsonl @@ -0,0 +1,6 @@ +{"type":"yield","coroutineId":"root","description":{"type":"import_component","name":"__root__"},"result":{"status":"ok","value":{"kind":"repository","path":"","content":"```js eval\nconst subtotal = \"40.00\";\noutput(\"cart.total is now computed from subtotal and tax.\");\n```\n\nSubtotal: {subtotal}\n"}}} +{"type":"yield","coroutineId":"root","description":{"type":"eval","name":"eval:eval:root:0","language":"js","executablemd.source-position":{"path":"","offset":0,"line":1,"column":1},"executablemd.source-owner":{"coroutineId":"root","index":0}},"result":{"status":"ok","value":{"value":{"subtotal":"40.00","__output":"cart.total is now computed from subtotal and tax."}}}} +{"type":"close","coroutineId":"root","result":{"status":"ok","value":{"status":"ok","output":"cart.total is now computed from subtotal and tax.\nSubtotal: 40.00\n","value":"cart.total is now computed from subtotal and tax.\nSubtotal: 40.00\n","bindings":{"subtotal":"40.00"}}}} +{"type":"yield","coroutineId":"root","description":{"type":"import_component","name":"__root__"},"result":{"status":"ok","value":{"kind":"repository","path":"","content":"# Review the checkout total\n\n```js eval\noutput(\"Reading checkout.md and architecture.md\");\n```\n\n\n\nCompute the total once, after the items are summed, so that checkout.md and cart.md can never disagree about what the customer pays.\n"}}} +{"type":"yield","coroutineId":"root","description":{"type":"eval","name":"eval:eval:root:0","language":"js","executablemd.source-position":{"path":"","offset":29,"line":3,"column":1},"executablemd.source-owner":{"coroutineId":"root","index":0}},"result":{"status":"ok","value":{"value":{"__output":"Reading checkout.md and architecture.md"}}}} +{"type":"yield","coroutineId":"root","description":{"type":"import_component","name":"Checklist","executablemd.source-position":{"path":"","offset":96,"line":7,"column":1},"executablemd.source-owner":{"coroutineId":"root","index":0}},"result":{"status":"ok","value":{"kind":"repository","path":"/private/tmp/xmd-repl-layout-delivery-971-20261027-1/source/packages/cli/tests/fixtures/repl//Checklist.md","content":"---\ntitle: Remaining work on one plan\nrequired: [title, steps]\nprops:\n title:\n type: string\n steps:\n type: number\n---\n\n### {props.title}\n\n{props.steps} steps remain.\n"}}} diff --git a/packages/cli/tests/fixtures/repl/deferred-presentation.json b/packages/cli/tests/fixtures/repl/deferred-presentation.json new file mode 100644 index 000000000..e05c84c7e --- /dev/null +++ b/packages/cli/tests/fixtures/repl/deferred-presentation.json @@ -0,0 +1,149 @@ +{ + "schema": "repl-deferred-presentation.v1", + "note": "Presentation that leaves active input because the build does not offer what it draws. Nothing here is skipped or excluded: each row names the case it came from, where that source still is, the boundary that holds its generic obligation today, and what has to exist before it comes back. This file is data a reader opens; no test imports or executes it. Each activeEquivalent below names the case that holds the obligation today and the command result that selected it.", + "recoverFrom": { + "commit": "a410786aa93e5e746d57b98ed7240c5adbbd75c2", + "how": "git show a410786aa93e5e746d57b98ed7240c5adbbd75c2:" + }, + "entries": [ + { + "key": "DQ0", + "whatLeaves": "The populated queued rows, Follow and tray choices of the staged workspace: CHECKPOINT_WORKSPACE's queued messages, WRAPPING_QUEUED, OVERFLOW_WORKSPACE, queueAt, crowdedQueue, lastQueued and the prototype queue/Follow probes that read them.", + "owner": "Assignment C presentation, consuming #964", + "prerequisite": "An actual shared queue offer exists in the shipped build", + "originalCases": ["A4: four outcomes, a waiting question and an overflowing queue"], + "source": { + "path": "packages/cli/tests/fixtures/repl/workspace-checkpoint.ts", + "blob": "6e73495287fd7dd8dfb7fe9797a4479a21041577", + "sha256": "687a9ce9f2c776cf6d8a8c92ad38624a48e48a0f30a04df6ed0ba0b2de1006f1", + "caseSource": { + "path": "packages/cli/tests/repl-presentation-style.test.ts", + "blob": "47f70755a8308e41788f674dc0022916b6444dd0", + "sha256": "45518d1f78fae7590293ed818c39cc9909b36148bae1f3ed06889f007c58586c", + "lines": [5369, 5456] + } + }, + "historical": [ + "implementation/implementor-31/image-index.md", + "review/planner-7/image-index.md", + "discovery/planner-8/deferred-ledger.md" + ], + "activeEquivalent": "The staged workspace's work rows and the conversation beside them stay in active input as CHECKPOINT_CURRENT_WORKSPACE, read by \"A4: every outcome, work identity and the waiting question read from the current screen\" and \"R2: current chat traversal reads every outcome and keeps its composer\" in packages/cli/tests/repl-presentation-style.test.ts. The inactive TODO tray and the absence of a queue tray are asserted there and in \"A2: narrow routes one outlet, keeps both surface controls and the Draft\".", + "activeBoundary": "P", + "reactivation": { + "condition": "Restore only when actual queue offers exist, and compare against the profile evidence current at that time rather than these pixels.", + "command": "deno task test packages/cli/tests/repl-presentation-style.test.ts --filter \"/^Deferred presentation - shared queue$/\"", + "status": "deferred" + }, + "activeEquivalentReceipt": "P: ok | 27 passed (67 steps) | 0 failed", + "retiredAt": "the layout test cleanup on agent/repl-layout-971-20261027; the original source stays recoverable at the commit and blob above" + }, + { + "key": "DQ1", + "whatLeaves": "The queue portions of C1/C2 and of the mixed R2 case: queued origin and text wrapping with their blanks, finite overflow traversal, first-row X ownership, no duplicate continuation-row target, hidden row exclusion, and controls outside the viewport while chat and composer keep their room.", + "owner": "C / #964 UI integration, after #895 then #1003", + "prerequisite": "Shared queue ownership is actually implemented", + "originalCases": [ + "C1/C2: every surface is placed whole, wraps inside its region and keeps its styles", + "R2: a hidden message is reached with its own control, and all four outcomes read" + ], + "source": { + "path": "packages/cli/tests/repl-presentation-style.test.ts", + "blob": "47f70755a8308e41788f674dc0022916b6444dd0", + "sha256": "45518d1f78fae7590293ed818c39cc9909b36148bae1f3ed06889f007c58586c", + "lines": { + "C1/C2": [4414, 4632], + "R2 mixed": [5491, 5649] + } + }, + "historical": [ + "discovery/planner-8/obligations.md", + "discovery/planner-8/deferred-ledger.md" + ], + "activeEquivalent": "The rendered chat traversal, its four outcome words and inks, the exact work identities, the waiting question's control and the composer are read by \"A4: every outcome, work identity and the waiting question read from the current screen\" and \"R2: current chat traversal reads every outcome and keeps its composer\". The surfaces, source, graphemes and whole contained targets stay in \"C1/C2: every surface is placed whole, wraps inside its region and keeps its styles\", where the ink comparison now reads a person's message, a reply, a work row and the composer.", + "activeBoundary": "P/S/M", + "reactivation": { + "condition": "Reactivate the queue-specific mounted, input and window cases against actual shared queue ownership, keeping the chat-window equivalent active throughout.", + "command": "deno task test packages/cli/tests/repl-presentation-style.test.ts --filter \"/^Deferred presentation - shared queue$/\"", + "status": "deferred" + }, + "activeEquivalentReceipt": "P: ok | 27 passed (67 steps) | 0 failed", + "retiredAt": "the layout test cleanup on agent/repl-layout-971-20261027; the original source stays recoverable at the commit and blob above" + }, + { + "key": "DQ2", + "whatLeaves": "The early-overcapacity tray case and admission's queue-specific empty measurement: nonzero text width before the first fit, an empty moving region with its surrounding controls, and equality of the last measured bounds with the final viewport.", + "owner": "The same enabling queue delivery", + "prerequisite": "A shared queue the frame can overfill", + "originalCases": [ + "R2: a tray already over its budget before fitting still measures, fits and draws", + "A2: the measurement the queue is admitted from is empty, with its controls around it" + ], + "source": { + "style": { + "path": "packages/cli/tests/repl-presentation-style.test.ts", + "blob": "47f70755a8308e41788f674dc0022916b6444dd0", + "sha256": "45518d1f78fae7590293ed818c39cc9909b36148bae1f3ed06889f007c58586c", + "lines": [5658, 5722] + }, + "admission": { + "path": "packages/cli/tests/repl-layout-admission.test.ts", + "blob": "8fe50613bfdb299eefb68ca9863a4498001dc0ae", + "sha256": "c98346a9eaae7adb56fc267e6fbd34d1cb79769b209ebef8ee33dd2d78644df6", + "lines": [610, 689] + } + }, + "firstFailureRevisions": ["0c3d2e49", "878ab666", "cbd8a613"], + "fixedEvidenceRevision": "a410786aa93e5e746d57b98ed7240c5adbbd75c2", + "historical": [ + "discovery/planner-8/deferred-ledger.md", + "discovery/planner-8/obligations.md" + ], + "activeEquivalent": "\"A2: an overflowing Entries catalog is admitted from its last empty coherent measurement\" in packages/cli/tests/repl-layout-admission.test.ts: real prepareFrame and renderer.measure, nonzero measured viewport before admission, no moving rows in the admission-producing measurement, both window controls and the drawer's fixed chrome around it, the copied rectangle equal to the final viewport, controls outside it, and a row the first frame hides reached with its own whole target.", + "activeBoundary": "A", + "reactivation": { + "condition": "Reinstate the queue-specific counterexamples when a queue is offered; the generic boundary stays active either way.", + "commands": [ + "deno task test packages/cli/tests/repl-presentation-style.test.ts --filter \"/^Deferred presentation - early queue width$/\"", + "deno task test packages/cli/tests/repl-layout-admission.test.ts --filter \"/^Deferred presentation - queue measurement$/\"" + ], + "status": "deferred" + }, + "activeEquivalentReceipt": "A: ok | 6 passed (17 steps) | 0 failed", + "retiredAt": "the layout test cleanup on agent/repl-layout-971-20261027; the original source stays recoverable at the commit and blob above" + }, + { + "key": "DF1", + "whatLeaves": "Follow's checkbox, label and toolbar assertions, its narrow target assertions, and the Follow-bearing optional states they are driven from. These live inside the named cases rather than in cases of their own.", + "owner": "Assignment C plus #964 arbitration", + "prerequisite": "Evaluation-owned Sidekick Send continuation exists", + "originalCases": [ + "C1/C2: every surface is placed whole, wraps inside its region and keeps its styles", + "TE4: a narrow frame routes one body, keeps seven rows and draws no wide chrome" + ], + "source": { + "path": "packages/cli/tests/repl-presentation-style.test.ts", + "blob": "47f70755a8308e41788f674dc0022916b6444dd0", + "sha256": "45518d1f78fae7590293ed818c39cc9909b36148bae1f3ed06889f007c58586c", + "lines": { + "C1/C2": [4414, 4632], + "TE4 narrow": [2879, 2972] + } + }, + "historical": [ + "discovery/planner-8/deferred-ledger.md", + "implementation/implementor-31/image-index.md" + ], + "activeEquivalent": "The current Sidekick composer and its own Stop are asserted in \"C1/C2: every surface is placed whole, wraps inside its region and keeps its styles\", \"A2: narrow routes one outlet, keeps both surface controls and the Draft\" and \"A3: 160 to 120 to 72, below the minimum, and back\" in packages/cli/tests/repl-sidekick-presentation.test.ts, each of which also asserts that no Follow control is drawn at any size, before or after a refusal.", + "activeBoundary": "V/P/A/S", + "reactivation": { + "condition": "Reactivate against evaluation-owned Sidekick Send continuation, the default-on runtime-local setting, actual outcome and output, and joined exchanges with queued-eligible-message precedence. A Draft Send starts no continuation and no scheduler or persistence is introduced.", + "command": "deno task test packages/cli/tests/repl-presentation-style.test.ts --filter \"/^Deferred presentation - Follow offers$/\"", + "status": "deferred" + }, + "activeEquivalentReceipt": "P: ok | 27 passed (67 steps) | 0 failed; S: ok | 24 passed (77 steps) | 0 failed", + "retiredAt": "the layout test cleanup on agent/repl-layout-971-20261027; the original source stays recoverable at the commit and blob above" + } + ], + "neverDeferred": "Current Agent queued-provider, streaming and recorded turns, three concurrent conversations, M3's exact admission and refusal, pre-admission cancellation, admitted-entry independence, both Stops, exact Elicit and permission ownership, and joined cleanup all stay active." +} diff --git a/packages/cli/tests/fixtures/repl/issue-883/mounted-program.ts b/packages/cli/tests/fixtures/repl/issue-883/mounted-program.ts index 2532495a7..5cd5835d4 100644 --- a/packages/cli/tests/fixtures/repl/issue-883/mounted-program.ts +++ b/packages/cli/tests/fixtures/repl/issue-883/mounted-program.ts @@ -38,7 +38,8 @@ import type { SidekickRecord } from "../../../../src/repl/sidekick/history.ts"; import { projectRepl } from "../../../../src/repl/model.ts"; import type { ReplModel } from "../../../../src/repl/model.ts"; import { REPL_DIRECTORY } from "../../../../src/repl/storage.ts"; -import { FOOTER_ROWS } from "../../../../src/repl/layout.ts"; +import { drawerRect, FOOTER_ROWS } from "../../../../src/repl/layout.ts"; +import { COMPACT_HISTORY_TITLE } from "../../../../src/repl/application.ts"; import type { PairedReplRepository } from "../../../../src/repl/sidekick/repository.ts"; import { API, useHostFiles } from "@executablemd/runtime"; import { ReplEntryHost } from "../../../../src/repl-entry-installation.ts"; @@ -50,10 +51,10 @@ import type { FakeAcp } from "../../../support/fake-acp.ts"; import { useTemporary } from "./sidekick-harness.ts"; /** The draft row's own prompt, which is how its row is told from the other one. */ -export const DRAFT_PROMPT = "Draft: "; +export const DRAFT_PROMPT = " DRAFT"; /** The Sidekick's message box, by the prompt that names which destination it is. */ -export const MESSAGE_PROMPT = "Message Sidekick: "; +export const MESSAGE_PROMPT = " › message the sidekick"; const BYTES = new TextEncoder(); const TEXT = new TextDecoder(); @@ -76,6 +77,30 @@ export function statusOn(terminal: Terminal): string | undefined { * smaller terminal no longer has. The engine redraws completely after a size * change, so the presentations from the resize onward are a whole screen. */ +/** + * How many cells one character occupies. + * + * A terminal advances two columns for an East Asian wide or fullwidth + * character and one for everything else. A replay that advanced one for all of + * them left the second cell of every wide glyph holding whatever was there + * before — measured, a drawer row of wide words came back as `界g界n 界o界f`, + * with letters from the frame underneath showing between them. Replaying the + * same bytes with this advance gives the row the product actually wrote. + */ +function cellsOf(character: string): number { + return WIDE_CHARACTERS.test(character) ? 2 : 1; +} + +/** + * The ranges a terminal gives two columns to. + * + * East Asian Wide and Fullwidth, as the Unicode property names them. Written + * out rather than asked of an API because no runtime this suite runs on + * answers the question directly. + */ +const WIDE_CHARACTERS = + /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6]|[\u{1F300}-\u{1F64F}]|[\u{1F900}-\u{1F9FF}]|[\u{20000}-\u{3FFFD}]/u; + export function replay(written: readonly Uint8Array[]): string[] { const rows: string[][] = []; let row = 0; @@ -90,7 +115,14 @@ export function replay(written: readonly Uint8Array[]): string[] { line.push(" "); } line[column] = character; - column += 1; + // The cells this character actually takes. A wide glyph owns the column + // after it as well, and that column is the glyph's rather than whatever + // the frame beneath it last wrote there. + const cells = cellsOf(character); + for (let after = 1; after < cells; after += 1) { + line[column + after] = ""; + } + column += cells; }; const text = written.map((bytes) => TEXT.decode(bytes)).join(""); @@ -212,6 +244,18 @@ export interface Terminal { fed: number; readonly raw: boolean[]; resets: number; + /** + * How many presentations had already been made when this terminal was last + * resized. + * + * The screen a reader is looking at begins there. This renderer writes diffs + * and sends no clear when the window changes, so the cells of the frame + * before a resize are still in the buffer — and after a shrink some of them + * are cells the new frame never writes to. The engine redraws the whole + * terminal after a size change, so the presentations from that point on are a + * complete screen and the ones before it are a screen that is gone. + */ + resizedAt: number; listeners: number; readers: number; feed(text: string): void; @@ -266,6 +310,7 @@ export function recordingTerminal( fed: 0, raw: [], resets: 0, + resizedAt: 0, listeners: 0, readers: 0, feed(text: string): void { @@ -286,6 +331,7 @@ export function recordingTerminal( }, resized(next: ReplTerminalSize): void { terminal.size = next; + terminal.resizedAt = terminal.presented.length; for (const watcher of watchers) { watcher(); } @@ -415,7 +461,18 @@ export function recordingTerminal( * screen an assertion about the screen. */ export function screenOf(terminal: Terminal): string[] { - return replay(terminal.presented); + // Bounded by the terminal these frames are being drawn into. The replay + // accumulates every presentation, and this renderer sends no clear when the + // window changes size — so after a shrink the buffer still holds the rows and + // the columns the larger screen had, and nobody is looking at those. A + // reading taken from them is a reading of a screen that is gone: measured, a + // narrow frame's routing bar came back with a wide frame's right-hand pane + // still on the end of it, and the row no longer ended in the word a reader + // was looking for. + const size = terminal.size; + return replay(terminal.presented.slice(terminal.resizedAt)) + .slice(0, size.rows) + .map((line) => (line.length > size.columns ? line.slice(0, size.columns) : line)); } /** Whether any row of the screen contains this text. */ @@ -423,6 +480,29 @@ export function shows(terminal: Terminal, expected: string): boolean { return screenOf(terminal).some((line) => line.includes(expected)); } +/** + * How the staged chat says who said a row: a mark, and then the words. + * + * The pane attributes every utterance — it has to, because a person's messages, + * the Sidekick's replies, an attached client's and the REPL's own work all + * share one list. It spells that attribution as a mark rather than as a name + * prefix, and the mark is accompanied by the colour rather than replaced by it, + * so a reader is told twice. These two are the ones a journey asserts against: + * what a person at this screen said, and what the Sidekick answered. + */ +const SAID_BY_PERSON = "[U] "; +const SAID_BY_SIDEKICK = "[S] "; + +/** Whether the screen shows this utterance, attributed to a person here. */ +export function saidByPerson(terminal: Terminal, text: string): boolean { + return shows(terminal, `${SAID_BY_PERSON}${text}`); +} + +/** Whether the screen shows this utterance, attributed to the Sidekick. */ +export function saidBySidekick(terminal: Terminal, text: string): boolean { + return shows(terminal, `${SAID_BY_SIDEKICK}${text}`); +} + /** * Tab until the control this key names holds focus. * @@ -458,7 +538,7 @@ export function* focusDraft(terminal: Terminal, limit = 240): Operation { // destinations now — `Draft` and `Message Sidekick` — and both wear the // same focus marker, so a helper looking for the marker alone would stop at // whichever one it reached first and type a document into a chat box. - if (screenOf(terminal).some((line) => line.includes(`>> ${DRAFT_PROMPT}`))) { + if (screenOf(terminal).some((line) => line.includes(`>${DRAFT_PROMPT}`))) { return; } terminal.feed("\t"); @@ -488,7 +568,15 @@ export function focusedOn(terminal: Terminal, label: string): boolean { // position its number in the full list — `12 · Entry 3 admitted` — which // is another prefix the product puts there, like the two above. const named = beyond.replace(/^\d+ \u00b7 /, ""); - if (after.startsWith(label) || beyond.startsWith(label) || named.startsWith(label)) { + // Case-folded, because the staged panes name their own tabs in capitals + // — `BINDINGS`, `SIDEKICK`, `QUEUE` — while a caller names the thing it + // is reaching for the way a person would say it. + const wanted = label.toLowerCase(); + if ( + after.toLowerCase().startsWith(wanted) || + beyond.toLowerCase().startsWith(wanted) || + named.toLowerCase().startsWith(wanted) + ) { return true; } } @@ -537,7 +625,7 @@ export function* focusMessage(terminal: Terminal, limit = 240): Operation * Here the two markers are spelled out: the row's own, and the one focus adds. */ export function focusedField(terminal: Terminal, prompt: string): boolean { - return screenOf(terminal).some((line) => line.includes(`>> ${prompt}`)); + return screenOf(terminal).some((line) => line.includes(`>${prompt}`)); } /** @@ -547,8 +635,65 @@ export function focusedField(terminal: Terminal, prompt: string): boolean { * count of the rest — the field draws one row and says so. */ export function draftText(terminal: Terminal): string { - const row = draftRow(terminal); - return row.slice(row.indexOf(DRAFT_PROMPT) + DRAFT_PROMPT.length).trimEnd(); + return draftRowsIn(screenOf(terminal)).join("\n").trim(); +} + +/** + * The rows the draft is drawn in, beneath its own name. + * + * The draft is a block, like the Sidekick's composer beside it: its name and + * focus marker are one row and its value is fitted into rows of its own under + * them. So this reads the rows after that name, up to the first control or + * blank stretch below them. + */ +export function draftRowsIn(rows: readonly string[]): readonly string[] { + const at = rows.findIndex((line) => line.includes(DRAFT_PROMPT)); + if (at === -1) { + return []; + } + // Where this field's own content begins: the column its name starts at, one + // past the marker in front of it. + const from = Math.max(0, rows[at].indexOf(DRAFT_PROMPT) - 1); + const borders = bordersBefore(rows[at], from); + // Where the field's own allocation ends, read from the row that names it: the + // Draft's heading shares its row with the control that acts on the draft, and + // the field is as wide as what that control leaves. The first control on that + // row is the Draft's own — nothing of this screen is drawn to the left of a + // pane's own field — and the column it starts at is where the value stops. + // + // Without it the value rows run to whatever is beside them. A routed outlet + // draws no divider to stop at, and a drawer in the pane alongside overwrites + // the one a shared width draws: measured, a draft holding `mine` came back as + // `mine [v later]`, which is a control of the form + // open in the next column. + const named = rows[at].slice(from); + const beside = /\s\[[^\]]*\]/.exec(named); + const allocated = beside === null ? undefined : beside.index; + const value: string[] = []; + for (const line of rows.slice(at + 1)) { + // This column's own rows only, for the reason the composer's reader stops: + // the rows below this pane run the whole width and are nobody's column. A + // routed outlet draws no divider, so they are recognised by what they say + // as well — a rail or a direction is not the end of somebody's draft. + if (bordersBefore(line, from) < borders || footerRow(line)) { + break; + } + // Cut at this column's own border: the draft sits in the middle column of + // three, and a row read to the end of the screen carries the pane beside + // it as well. + const within = line.slice(from); + const edge = within.indexOf("│"); + const bounded = edge === -1 ? within : within.slice(0, edge); + const own = (allocated === undefined ? bounded : bounded.slice(0, allocated)).trimEnd(); + if (own.trim().startsWith("[")) { + break; + } + value.push(own); + } + while (value.length > 0 && value[value.length - 1].trim() === "") { + value.pop(); + } + return value; } /** @@ -574,6 +719,33 @@ export function messageRows(terminal: Terminal): readonly string[] { * caller that captured the rows of a resize reads them rather than the buffer * the size before it also drew into. */ +/** + * How many pane borders stand to the left of this column on this row. + * + * What makes a row part of a column rather than beside it. A block's value is + * drawn in its own pane, and every row of that pane has the same borders in + * front of it; a row that spans the whole screen — the History rail, the + * guidance line — has fewer, and is not this block's row however its + * characters happen to line up. A screen with no columns at all answers zero + * for every row, which is what keeps a routed narrow outlet reading as it did. + */ +function bordersBefore(line: string, column: number): number { + return line.slice(0, column).split("\u2502").length - 1; +} + +/** + * Whether this row belongs to the screen's footer rather than to any pane. + * + * The compact History band and the guidance below it run the whole width and + * are nobody's column, so a block reader that walked into them would read a + * rail or a direction as the end of somebody's draft. Recognised by what they + * actually say — the band names itself and carries the way out — because a + * count of rows from the bottom is the layout's business and not a reader's. + */ +function footerRow(line: string): boolean { + return line.includes("HISTORY") || line.includes("[exit]") || line.includes("Tab/Shift+Tab"); +} + export function messageRowsIn(rows: readonly string[]): readonly string[] { const at = rows.findIndex((line) => line.includes(MESSAGE_PROMPT)); if (at === -1) { @@ -583,9 +755,21 @@ export function messageRowsIn(rows: readonly string[]): readonly string[] { // followed by its prompt — `>> ` or ` > ` — so the column three before the // prompt is the column every value row starts at. const from = Math.max(0, rows[at].indexOf(MESSAGE_PROMPT) - 3); + const borders = bordersBefore(rows[at], from); const value: string[] = []; for (const line of rows.slice(at + 1)) { - const own = line.slice(from).trimEnd(); + // This pane's own rows only. The rows below it belong to the screen rather + // than to this column — the History rail and the guidance line run the + // whole width — and reading one of those from this column's offset splices + // a footer into somebody's message. A routed outlet draws no divider at + // all, so the footer is recognised by what it says as well as by that. + if (bordersBefore(line, from) < borders || footerRow(line)) { + break; + } + // Past this column's own border, where the staged screen divides its panes. + const within = line.slice(from).replace(/^\s*│/, ""); + const edge = within.indexOf("│"); + const own = (edge === -1 ? within : within.slice(0, edge)).trimEnd(); const text = own.trim(); // The composer's own rows end where this pane's controls and status begin. if (text.startsWith("[") || text.endsWith("is working...")) { @@ -596,6 +780,25 @@ export function messageRowsIn(rows: readonly string[]): readonly string[] { return value; } +/** + * Whether a block drawn over several rows is showing this text. + * + * A field whose value is wider than its column is *fitted* into rows, and a + * fitter's cut is not a character: the row break stands where a space was, and + * the space is gone. So the screen cannot hand back the exact value once it has + * wrapped, and comparing the rejoined rows to the text character for character + * would be asserting where the fitter happened to break rather than what the + * field holds. Both sides are flattened to single spaces, which is what makes + * this a question about the words on the screen. + * + * For what a value *is* — byte for byte — read it where it is held: the entry + * a draft became records its own source. + */ +export function blockSays(shown: string, expected: string): boolean { + const flat = (text: string): string => text.replace(/\s+/g, " ").trim(); + return flat(shown) === flat(expected); +} + /** Exactly what the Sidekick's message box is showing, as its rows show it. */ export function messageText(terminal: Terminal): string { return messageTextIn(screenOf(terminal)); @@ -670,6 +873,129 @@ export function* untilDrawn(terminal: Terminal): Operation { ); } +/** + * The mark the compact History rail gives one entry, or none where it names + * none. + * + * The rail marks every entry this file holds with the state the prefix being + * read holds it in, written as the first character of that state's own word: a + * glyph where the word has one, and a letter where it does not. It says so + * whichever entry is being read and whether or not a catalog is open, which is + * what makes it the thing to wait on — the catalog's own row spells the same + * fact `[ok]`, and the catalog is a drawer somebody has to open. + */ +export function railMarkOn(terminal: Terminal, order: number): string | undefined { + for (const line of screenOf(terminal)) { + const at = line.indexOf(COMPACT_HISTORY_TITLE); + if (at === -1) { + continue; + } + // Up to the rule, which is where the rail ends and where the reading is + // begins. A number past it is part of that sentence, not an entry. + const band = line.slice(at + COMPACT_HISTORY_TITLE.length); + const rail = band.split("\u2500\u2500")[0] ?? ""; + const match = new RegExp(`(?:^|\\s)${order}\\s*([\\u2713\\u00d7\\u25cf\\u002dU])`).exec(rail); + return match === null ? undefined : match[1]; + } + return undefined; +} + +/** How this suite names an outcome, and the mark the rail gives it. */ +export const RAIL_MARKS: Readonly> = Object.freeze({ + ok: "\u2713", + err: "\u00d7", + cancelled: "-", + unfinished: "\u25cf", +}); + +/** + * Wait until this entry has reached this outcome, as the rail says. + * + * The rail rather than the catalog, for the reason {@link railMarkOn} gives. + */ +export function settledEntry(terminal: Terminal, order: number, outcome: string): Operation { + const mark = RAIL_MARKS[outcome] ?? outcome; + return awaiting( + terminal, + `entry ${order} reaching ${outcome}`, + (one) => railMarkOn(one, order) === mark, + ); +} + +/** + * Whether the Entries catalog is the drawer this frame is showing. + * + * Asked of the drawer's own rectangle. A row of this screen crosses every + * column it has — the reading beside a drawer is drawn on the same rows as the + * drawer itself — so a reader looking at whole rows finds the word `Entries` + * with a transcript column in front of it and a Sidekick pane behind it, and + * cannot tell the drawer's title from the narrow frame's routing bar or from + * the selected work's breadcrumb. Inside the rectangle there is only one of + * them. + */ +export function catalogShowing(terminal: Terminal): boolean { + const rect = drawerRect(terminal.size, true); + if (rect === undefined) { + return false; + } + const rows = screenOf(terminal); + let titled = false; + let closable = false; + for (let row = rect.y; row < rect.y + rect.height; row += 1) { + const text = (rows[row] ?? "").slice(rect.x, rect.x + rect.width).trim(); + if (/^Entries\b/.test(text)) { + titled = true; + } + if (text.includes("[close]")) { + closable = true; + } + } + return titled && closable; +} + +/** + * Open the Entries catalog, the way a person does. + * + * The staged screen keeps no permanent catalog column, so the catalog is a + * drawer somebody opens over the reading. Every row that reads a catalog row + * opens it first and closes it after: one left open would cover the pane the + * next assertion is about. + */ +export function* openCatalog(terminal: Terminal): Operation { + if (catalogShowing(terminal)) { + return; + } + yield* activate(terminal, "Entries"); + yield* awaiting(terminal, "the catalog opening", (one) => catalogShowing(one)); +} + +/** Put the catalog away again, which chooses nothing. */ +export function* closeCatalog(terminal: Terminal): Operation { + if (!catalogShowing(terminal)) { + return; + } + yield* activate(terminal, "[close]"); + yield* awaiting(terminal, "the catalog closing", (one) => !catalogShowing(one)); +} + +/** Whether this label is the row being read, by the selection channel. */ +export function marked(terminal: Terminal, label: string): boolean { + return markedIn(screenOf(terminal), label); +} + +/** + * The same question, of rows a caller has already chosen. + * + * The two columns immediately before the label, so a focus marker beside them + * changes nothing: the two channels are independent. + */ +export function markedIn(rows: readonly string[], label: string): boolean { + return rows.some((line) => { + const at = line.indexOf(label); + return at >= 2 && line.slice(at - 2, at) === "* "; + }); +} + /** How long any mounted wait gives something before it says it never happened. */ export const MOUNTED_DEADLINE_MS = 15_000; @@ -723,6 +1049,28 @@ export function* awaited(what: string, settled_: () => boolean): Operation throw new Error(`${what} never happened within ${MOUNTED_DEADLINE_MS}ms`); } +/** + * The same wait again, for a fact only an operation can read. + * + * What is on disk is the case in point. A row drawn on the screen and a record + * published in the history are two different moments: the Sidekick's words are + * shown as soon as it has said them, and the reply they belong to is saved + * after the entry it carries has been evaluated. A row that waited for the + * screen and then read the file would be asking about the second moment having + * waited for the first. + */ +export function* awaitedFact(what: string, held: () => Operation): Operation { + const deadline = Date.now() + MOUNTED_DEADLINE_MS; + while (Date.now() < deadline) { + if (yield* held()) { + return; + } + yield* sleep(10); + yield* settled(20); + } + throw new Error(`${what} never happened within ${MOUNTED_DEADLINE_MS}ms`); +} + /** Where this text is on the screen, if it is anywhere. */ export function coordinateOf( terminal: Terminal, @@ -1321,10 +1669,39 @@ export function* sendMessage(repl: MountedRepl, text: string): Operation { yield* awaiting(repl.terminal, `the message box holding ${JSON.stringify(text)}`, (one) => messageText(one).includes(text), ); - yield* click(repl.terminal, "[send]"); + // This screen has two Sends — the entry draft's and this composer's — so + // the column the Sidekick's own pane begins at is what names which one. + // Clicking the leftmost would submit a draft instead of sending a message. + yield* click(repl.terminal, "[send]", sidekickColumn(repl.terminal)); yield* settled(60); } +/** + * The column the Sidekick's own pane starts at, for a label it shares. + * + * Read from the composer's own row, which is the one row only that pane draws. + */ +export function sidekickColumn(terminal: Terminal): number { + return sidekickColumnIn(screenOf(terminal)); +} + +/** The same, out of rows a caller already has. */ +export function sidekickColumnIn(rows: readonly string[]): number { + const row = rows.find((line) => line.includes(MESSAGE_PROMPT)); + return row === undefined ? 0 : Math.max(0, row.indexOf(MESSAGE_PROMPT)); +} + +/** The Sidekick's own pane, as the rows it draws and nothing beside it. */ +export function sidekickPaneIn(rows: readonly string[]): readonly string[] { + const from = sidekickColumnIn(rows); + return rows.map((line) => line.slice(from)); +} + +/** Exactly what the entry draft is showing, out of rows a caller already has. */ +export function draftTextIn(rows: readonly string[]): string { + return draftRowsIn(rows).join("\n").trim(); +} + /** * Whether any of these rows contains this text. * @@ -1369,8 +1746,14 @@ export function* resizeTo(terminal: Terminal, size: ReplTerminalSize): Operation const from = terminal.presented.length; const rows = (): string[] => replay(terminal.presented.slice(from)); terminal.resized(size); + // A whole frame is one whose footer has been drawn, witnessed by the way out + // being somewhere in that band. Which row of the band holds it is the + // layout's business — the staged screen puts the way out on its History row + // — so this asks whether the band is there rather than pinning a row of it. yield* awaited(`a whole frame at ${size.columns}x${size.rows}`, () => - (rows()[Math.max(0, size.rows - FOOTER_ROWS)] ?? "").includes("[exit]"), + rows() + .slice(Math.max(0, size.rows - FOOTER_ROWS)) + .some((line) => line.includes("[exit]")), ); return rows; } diff --git a/packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts b/packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts index 34a6403f5..97f51f685 100644 --- a/packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts +++ b/packages/cli/tests/fixtures/repl/issue-883/recovery-phase.ts @@ -685,9 +685,10 @@ function* phases(): Operation { records: yield* until(readFile(location.entries, "utf8")), }; } - yield* mounted.awaiting(repl.terminal, "the resumed workspace suffix", (one) => - mounted.shows(one, "1. [ok] entry-1"), - ); + // The History rail's own mark for the first entry. `1. [ok] entry-1` is a + // row of the Entries catalog, which this screen keeps in a drawer — so the + // settled state is read where the screen states it. + yield* mounted.settledEntry(repl.terminal, 1, "ok"); const read = yield* mounted.mountedReading(repl); return { execution: id, diff --git a/packages/cli/tests/fixtures/repl/presentation.ts b/packages/cli/tests/fixtures/repl/presentation.ts index 0ff364886..8d6a2c259 100644 --- a/packages/cli/tests/fixtures/repl/presentation.ts +++ b/packages/cli/tests/fixtures/repl/presentation.ts @@ -122,6 +122,30 @@ export function createGrid(): TerminalGrid { let column = 0; let current: CellStyle = DEFAULT_STYLE; + /** + * How many cells one character occupies. + * + * A terminal advances two columns for an East Asian wide or fullwidth + * character and one for everything else. A replay that advanced one for all of + * them left the second cell of every wide glyph holding whatever was there + * before — measured, a drawer row of wide words came back as `界g界n 界o界f`, + * with letters from the frame underneath showing between them. Replaying the + * same bytes with this advance gives the row the product actually wrote. + */ + function cellsOf(character: string): number { + return WIDE_CHARACTERS.test(character) ? 2 : 1; + } + + /** + * The ranges a terminal gives two columns to. + * + * East Asian Wide and Fullwidth, as the Unicode property names them. Written + * out rather than asked of an API because no runtime this suite runs on + * answers the question directly. + */ + const WIDE_CHARACTERS = + /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6]|[\u{1F300}-\u{1F64F}]|[\u{1F900}-\u{1F9FF}]|[\u{20000}-\u{3FFFD}]/u; + const put = (character: string): void => { while (cells.length <= row) { cells.push([]); @@ -135,7 +159,15 @@ export function createGrid(): TerminalGrid { } line[column] = character; styled[column] = current; - column += 1; + // The cells this character actually takes: a wide glyph owns the column + // after it, and that column is the glyph's rather than whatever the frame + // beneath it last wrote there. + const taken = cellsOf(character); + for (let after = 1; after < taken; after += 1) { + line[column + after] = ""; + styled[column + after] = current; + } + column += taken; }; const read = (x: number, y: number): string => cells[y]?.[x] ?? " "; diff --git a/packages/cli/tests/fixtures/repl/workspace-checkpoint.ts b/packages/cli/tests/fixtures/repl/workspace-checkpoint.ts new file mode 100644 index 000000000..ffeb5ef8d --- /dev/null +++ b/packages/cli/tests/fixtures/repl/workspace-checkpoint.ts @@ -0,0 +1,676 @@ +/** + * The one controlled reading the #971 staged workspace screen is reviewed at. + * + * Two entries run for real over one journal: the first settles `ok` with output + * of its own, and the second is truncated before its root closes, which is what + * a prefix read while an entry is running holds. Everything else is the inert + * view data a frame is handed — a live overlay, a Sidekick reading and the + * staged workspace presentation — so the screen is drawn by the production + * pipeline from facts rather than from a picture of one. + * + * Frozen: the same reading every time it is asked for, and no network, native + * Agent or filesystem effect is needed to build it. + */ + +import { collect, inlineSource } from "@executablemd/core"; +import { executeInstalled } from "@executablemd/core/host"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent, DurableStream, Json } from "@executablemd/durable-streams"; +import { fileURLToPath } from "node:url"; +import type { Operation } from "effection"; + +import { entryInitialBindings, projectRepl } from "../../../src/repl/model.ts"; +import type { ReplModel } from "../../../src/repl/model.ts"; +import { EntrySegmentStream } from "../../../src/repl/entries.ts"; +import { initialState, NO_AGENT, viewFor, withWorkspace } from "../../../src/repl/application.ts"; +import { readQuestionForm } from "../../../src/repl/elicitation.ts"; +import type { ReplQuestionForm } from "../../../src/repl/elicitation.ts"; +import type { + ReplLive, + ReplQueuedMessage, + ReplSidekickChatRow, + ReplSidekickReading, + ReplState, + ReplView, + ReplWorkspace, +} from "../../../src/repl/application.ts"; +import { NO_LIFECYCLE } from "../../../src/repl/lifecycle.ts"; +import { navigationOf } from "../../../src/repl/navigation.ts"; +import type { ReplTerminalSize } from "../../../src/repl/terminal.ts"; +import type { Observed, Presenter } from "./presentation.ts"; + +/** The size the accepted checkpoint is reviewed at. */ +export const CHECKPOINT_SIZE: ReplTerminalSize = Object.freeze({ columns: 160, rows: 36 }); + +/** The medium review size, whose staged proportions are a proposal. */ +export const CHECKPOINT_MEDIUM: ReplTerminalSize = Object.freeze({ columns: 120, rows: 30 }); + +/** The narrow review size, which routes one outlet. */ +export const CHECKPOINT_NARROW: ReplTerminalSize = Object.freeze({ columns: 72, rows: 20 }); + +/** One size below the narrow minimum, where the frame refuses and recovers. */ +export const CHECKPOINT_BELOW: ReplTerminalSize = Object.freeze({ columns: 71, rows: 19 }); + +/** The three named review sizes, largest first. */ +export const CHECKPOINT_SIZES: readonly ReplTerminalSize[] = Object.freeze([ + CHECKPOINT_SIZE, + CHECKPOINT_MEDIUM, + CHECKPOINT_NARROW, +]); + +/** The execution the reading belongs to, which the identity row names. */ +export const CHECKPOINT_EXECUTION = "checkout-repair"; + +/** + * Where the component the running entry invokes is read from. + * + * This directory: `Checklist.md` beside this file is an existing fixture + * component, so the nested scope the screen selects is admitted by a real + * import of a real file rather than by a record written by hand. + */ +const CHECKPOINT_DIRECTORY = fileURLToPath(new URL("./", import.meta.url)); + +/** The completed entry: it binds a value and renders output of its own. */ +const COMPLETED = [ + "```js eval", + 'const subtotal = "40.00";', + 'output("cart.total is now computed from subtotal and tax.");', + "```", + "", + "Subtotal: {subtotal}", + "", +].join("\n"); + +/** + * The running entry's exact source. + * + * It invokes one component, so the entry admits a nested scope the screen can + * select and show as a breadcrumb. One line is longer than the selected work's + * pane, so it wraps at a measured word boundary, and a run of spaces inside it + * must survive the reading. + */ +export const RUNNING_SOURCE = [ + "# Review the checkout total", + "", + "```js eval", + 'output("Reading checkout.md and architecture.md");', + "```", + "", + '', + "", + "Compute the total once, after the items are summed, so that checkout.md and cart.md can never disagree about what the customer pays.", + "", +].join("\n"); + +/** The line of {@link RUNNING_SOURCE} that must wrap, exactly as written. */ +export const WRAPPING_LINE = + "Compute the total once, after the items are summed, so that checkout.md and cart.md can never disagree about what the customer pays."; + +/** The Draft: three rows of value, the middle one blank. */ +export const CHECKPOINT_DRAFT = [ + "", + "", + '', +].join("\n"); + +/** What is typed in the Sidekick's composer and has not been sent. */ +export const CHECKPOINT_MESSAGE = "Also say what an empty cart renders."; + +/** The staged workspace presentation the checkpoint shows. */ +export const CHECKPOINT_WORKSPACE: ReplWorkspace = Object.freeze({ + // What this build offers: the inactive tray, no shared queue behind it and + // no Follow control over it. The populated queue this fixture used to stage — + // its three rows, their origins, the wrapping one, the queue tray and the + // Follow flag — is DQ0 and DF1 in `deferred-presentation.json`, with the + // commit and blob it can be read back from. + tray: "todo", + queued: Object.freeze([]), + work: Object.freeze([ + Object.freeze({ message: "chat:response:1", entry: "entry-1" }), + Object.freeze({ message: "chat:response:2", entry: "entry-2" }), + ]), +}); + +/** + * The same staged screen, as this build actually offers it. + * + * The work rows and the conversation beside them are what a person is looking + * at today. The shared message queue is not: no tray for it, no rows in it and + * no Follow control over it, because this build has none of those and a screen + * that drew them would be drawing a promise. What left is recorded in + * `deferred-presentation.json` under DQ0 and DF1, with the fixture it came + * from. + */ +export const CHECKPOINT_CURRENT_WORKSPACE: ReplWorkspace = Object.freeze({ + tray: "todo", + queued: Object.freeze([]), + // One row per entry this conversation admitted, which is where the four + // outcomes a reading can hold are read from. + work: Object.freeze([ + Object.freeze({ message: "chat:response:1", entry: "entry-1" }), + Object.freeze({ message: "chat:response:1", entry: "entry-2" }), + Object.freeze({ message: "chat:response:2", entry: "entry-3" }), + Object.freeze({ message: "chat:response:2", entry: "entry-4" }), + ]), +}); + +const CONVERSATION = "chat"; + +function person(turn: number, text: string, origin: ReplSidekickChatRow["origin"]) { + return Object.freeze({ + key: `${CONVERSATION}:message:${turn}`, + conversation: CONVERSATION, + turn, + kind: "person" as const, + text, + hasOutput: false, + failure: undefined, + origin, + }); +} + +function reply(turn: number, text: string) { + return Object.freeze({ + key: `${CONVERSATION}:response:${turn}`, + conversation: CONVERSATION, + turn, + kind: "reply" as const, + text, + hasOutput: true, + failure: undefined, + }); +} + +/** + * The Sidekick's reading: two exchanges, one from this screen and one from an + * attached client, each with explicit evaluated Output, and a Send still active. + */ +export const CHECKPOINT_SIDEKICK: ReplSidekickReading = Object.freeze({ + agent: "claude", + busy: true, + refusal: undefined, + question: undefined, + requested: undefined, + verified: undefined, + conversation: CONVERSATION, + reading: CONVERSATION, + current: true, + availability: Object.freeze({ + chats: Object.freeze([CONVERSATION]), + sessions: Object.freeze(["sess-sidekick-41ab"]), + refusal: undefined, + }), + chat: Object.freeze([ + person(1, "Checkout fails: cart.total is not bound. Can you fix it?", { kind: "ui" }), + reply( + 1, + "The API response has subtotal and tax but no total, so I computed it in checkout.md.", + ), + person(2, "Ask the Architect to review checkout.md before I merge.", { + kind: "acp", + attachment: "ci-bot", + }), + reply(2, "I started the Architect review and will say what it asks for."), + ]), + turns: Object.freeze([ + Object.freeze({ + key: "chat:turn:1", + conversation: CONVERSATION, + agent: "claude", + turn: 1, + title: "Checkout fails: cart.total is not bound. Can you fix it?", + state: "completed", + running: false, + failed: false, + sessionKey: "sess-sidekick-41ab", + text: "The API response has subtotal and tax but no total.", + failure: undefined, + }), + Object.freeze({ + key: "chat:turn:2", + conversation: CONVERSATION, + agent: "claude", + turn: 2, + title: "Ask the Architect to review checkout.md before I merge.", + state: "waiting on Entry 2", + running: true, + failed: false, + sessionKey: "sess-sidekick-41ab", + text: "I started the Architect review.", + failure: undefined, + }), + ]), +}); + +/** + * This process running the second entry: partial output on the overlay and one + * entry agent turn streaming in its own provider conversation. + */ +export const CHECKPOINT_LIVE: ReplLive = Object.freeze({ + output: "Reading checkout.md and architecture.md", + question: undefined, + expansion: "playing", + pausable: true, + running: true, + lifecycle: NO_LIFECYCLE, + sidekick: CHECKPOINT_SIDEKICK, + agent: Object.freeze({ + ...NO_AGENT, + turns: Object.freeze([ + Object.freeze({ + key: "turn:architect:1", + entry: "entry-2", + prompt: "Review checkout.md against architecture.md and list each requested change.", + state: "active" as const, + text: "Reading checkout.md and architecture.md", + agent: "codex", + sessionKey: "sess-architect-7f2c", + agentSessionId: undefined, + status: undefined, + stopReason: undefined, + failure: undefined, + }), + ]), + slots: Object.freeze([ + Object.freeze({ + key: "turn:architect:1", + entry: "entry-2", + order: 1, + durable: undefined, + last: undefined, + }), + ]), + }), +}); + +function* runEntry( + physical: DurableStream, + source: string, + initialBindings: Readonly> = {}, +): Operation { + const view = new EntrySegmentStream(physical); + try { + const execution = yield* executeInstalled( + { ...inlineSource(source), stream: view, includes: [CHECKPOINT_DIRECTORY] }, + [], + { initialBindings }, + ); + yield* collect(execution); + } catch { + // How far an entry got is the record the catalog reads. + } +} + +function projected(events: readonly DurableEvent[], selection?: string): ReplModel { + const result = projectRepl(events, selection); + if (!result.ok) { + throw result.error; + } + return result.value; +} + +/** + * The journal this screen reads: a completed first entry, then a second that + * has not closed. + * + * The second runs whole in a stream of its own and is cut before its root's + * close, so its segment is exactly what a prefix read mid-run holds. Returned + * as events rather than as a model, because the same events are projected + * twice: once at the present, and once at a position somebody moved to. + */ +export function* checkpointEvents(): Operation { + const physical = new InMemoryStream(); + yield* runEntry(physical, COMPLETED); + const settled = yield* physical.readAll(); + const running = new InMemoryStream([...settled]); + yield* runEntry(running, RUNNING_SOURCE, entryInitialBindings(projected(settled))); + const whole = yield* running.readAll(); + const closes = whole + .map((event, at) => ({ event, at })) + .filter((one) => one.event.type === "close"); + const last = closes[closes.length - 1]; + if (last === undefined) { + throw new Error("this checkpoint journal closed nothing"); + } + return Object.freeze(whole.slice(0, last.at)); +} + +export function* checkpointModel(): Operation { + return projected(yield* checkpointEvents()); +} + +/** + * The same journal read at a position somebody moved to, and that position. + * + * A route may only name the position its model was projected at, so a reading + * of a retained position is a *different projection* of the same events rather + * than a marker set on the model of the present. This returns both halves of + * that pair, so a caller cannot hold one without the other. + * + * The position is the one where the selected entry's own nested scope was + * admitted: the reading this screen shows is that entry and that scope, so a + * prefix that stopped before the scope would be a reading of something else. + */ +/** + * A catalog with more entries in it than a window can show at once. + * + * Every one of them really ran: this is the same `runEntry` the rest of this + * fixture builds with, over a source small enough that a row of the catalog is + * all there is to say about it. What the count is for is the window — a + * catalog that fits needs no admission decision, and the measurement this + * feeds is about the one that does not. + */ +export function* checkpointCatalogModel(count: number): Operation { + const physical = new InMemoryStream(); + for (let at = 1; at <= count; at += 1) { + const settled = yield* physical.readAll(); + yield* runEntry( + physical, + `# Entry ${at}\n\nThe ${at}\u202fth reading of this history.\n`, + settled.length === 0 ? {} : entryInitialBindings(projected(settled)), + ); + } + return projected(yield* physical.readAll()); +} + +export function* checkpointSelected(): Operation<{ + readonly model: ReplModel; + readonly at: string; +}> { + const events = yield* checkpointEvents(); + const head = projected(events); + const scope = head.checkpoints.filter((one) => one.kind === "scope").pop(); + if (scope === undefined) { + throw new Error("this checkpoint journal retained no scope to read at"); + } + return { model: projected(events, scope.marker), at: scope.marker }; +} + +/** + * The entry and the nested scope this screen selects, read from the model. + * + * The running entry and the first scope its own import admitted — real keys + * from the projected history, so a fixture that stopped admitting that scope + * fails here instead of quietly selecting the entry's root. + */ +export function checkpointScopes(model: ReplModel): readonly string[] { + const entry = model.entries[model.entries.length - 1]; + if (entry === undefined) { + throw new Error("this checkpoint model admitted no entry"); + } + const nested = entry.scope.scopes[0]; + if (nested === undefined) { + // A reading whose final entry admitted none selects that entry alone. The + // accepted screen's entry does admit one, and its own case says so. + return Object.freeze([entry.key]); + } + return Object.freeze([entry.key, nested.key]); +} + +/** How one checkpoint reading differs from the accepted 160x36 one. */ +export interface CheckpointOptions { + /** + * The exact state to read, where a caller is carrying one of its own. + * + * A resize journey supplies this: it seeds a reading once and then carries it + * from size to size, so what the screen keeps is a person's own reading + * rather than the default this fixture would build again. + */ + readonly state?: ReplState; + /** + * The key the mounted tree already holds focus on, where it holds one. + * + * Supplied after the opening frame, so a later frame composes from the tree's + * own answer instead of letting the Draft take focus a second time. + */ + readonly focused?: string; + readonly size?: ReplTerminalSize; + /** Which surface a narrow frame routes; wider frames show all three columns. */ + readonly surface?: "sessions" | "entries"; + /** Set to route a narrow outlet to the Sidekick, as `view=sidekick` does. */ + readonly sidekick?: true; + /** A reading of its own, for the state and overflow screens. */ + readonly model?: ReplModel; + readonly live?: ReplLive; + readonly workspace?: ReplWorkspace; +} + +/** + * One entry that fails: its root closes `err` having recorded nothing to show. + * + * A schema outside the REPL's own form language, which is the refusal the + * existing style fixtures use for the same purpose. + */ +const FAILING = [ + "# A refused question", + "", + 'Outside the form language.', + "", +].join("\n"); + +/** One entry that was stopped part-way, and what it had done by then. */ +const STOPPED = [ + "```js eval", + 'output("Rounding tax once, after the items are summed.");', + "```", + "", +].join("\n"); + +/** Where the last root close of one journal is. */ +function lastClose(events: readonly DurableEvent[]): number { + const closes = events + .map((event, at) => ({ event, at })) + .filter((one) => one.event.type === "close"); + const last = closes[closes.length - 1]; + if (last === undefined) { + throw new Error("this journal holds no close"); + } + return last.at; +} + +/** + * Four entries whose outcomes are the four a reading can hold at once, with the + * fourth still running. + * + * Every one of them really executed. The third's root close is rewritten as a + * cancelled one — the shape an interrupted entry's record has, and the same + * rewrite the entries regressions use — because an entry that stopped is a + * record this screen must be able to read, not an execution a fixture can + * perform. + */ +export function* checkpointStateModel(): Operation { + const first = new InMemoryStream(); + yield* runEntry(first, COMPLETED); + const afterFirst = yield* first.readAll(); + + const second = new InMemoryStream([...afterFirst]); + yield* runEntry(second, FAILING, entryInitialBindings(projected(afterFirst))); + const afterSecond = yield* second.readAll(); + + const third = new InMemoryStream([...afterSecond]); + yield* runEntry(third, STOPPED, entryInitialBindings(projected(afterSecond))); + const ran = yield* third.readAll(); + const stopped = ran.map((event, at) => + at === lastClose(ran) ? { ...event, result: { status: "cancelled" as const } } : event, + ); + + const fourth = new InMemoryStream([...stopped]); + yield* runEntry(fourth, RUNNING_SOURCE, entryInitialBindings(projected(stopped))); + const whole = yield* fourth.readAll(); + return projected(whole.slice(0, lastClose(whole))); +} + +/** The schema this screen's question is asked through. */ +const QUESTION_SCHEMA: Json = Object.freeze({ + type: "object", + additionalProperties: false, + required: ["rule"], + properties: Object.freeze({ + rule: Object.freeze({ type: "string", title: "Rounding rule" }), + }), +}); + +/** The key the live question and the open form both name. */ +export const CHECKPOINT_QUESTION = "ask-1"; + +/** + * The Sidekick's waiting question, as the product's own parser reads it. + * + * Compiled from the schema above through `readQuestionForm`, so the form this + * screen draws is one the REPL's form language actually supports — a form + * asserted into shape would draw fields the parser would have refused. + */ +export const CHECKPOINT_QUESTION_FORM: ReplQuestionForm = readQuestionForm(QUESTION_SCHEMA); + +/** The same Sidekick, with a question of its own waiting to be answered. */ +export const CHECKPOINT_ASKING: ReplLive = Object.freeze({ + ...CHECKPOINT_LIVE, + sidekick: Object.freeze({ + ...CHECKPOINT_SIDEKICK, + question: Object.freeze({ + key: CHECKPOINT_QUESTION, + message: "Which rounding rule should the tax use?", + form: CHECKPOINT_QUESTION_FORM, + }), + }), +}); + +/** + * The same reading with its conversation held at one position. + * + * The chat ordinarily follows its newest rows, so a reader looking at an + * earlier outcome has stopped following: this is that reader's own state. + */ +export function chatAt( + model: ReplModel, + offset: number, + options: CheckpointOptions = {}, +): ReplState { + const base = checkpointState(model, { ...options, state: undefined }); + const chat = CHECKPOINT_SIDEKICK.reading ?? ""; + return Object.freeze({ + ...base, + viewports: Object.freeze({ + ...base.viewports, + sidekick: Object.freeze({ ...base.viewports.sidekick, [chat]: offset }), + sidekickFollowing: Object.freeze({ ...base.viewports.sidekickFollowing, [chat]: false }), + }), + }); +} + +/** The state the checkpoint reads: the nested scope selected, both texts typed. */ +export function checkpointState(model: ReplModel, options: CheckpointOptions = {}): ReplState { + if (options.state !== undefined) { + return options.state; + } + const base = initialState(CHECKPOINT_EXECUTION); + return Object.freeze({ + ...base, + draft: CHECKPOINT_DRAFT, + message: CHECKPOINT_MESSAGE, + route: Object.freeze({ + ...base.route, + right: "sidekick" as const, + surface: options.surface ?? base.route.surface, + ...(options.sidekick === true ? { view: "sidekick" as const } : {}), + scopes: checkpointScopes(model), + }), + }); +} + +/** + * The checkpoint view of one model, as the control named by `focused` holds it. + * + * `focused` is the key the *mounted tree* answered with, never a label chosen + * here: a view says which control the cue is drawn on, and the tree is what + * decides where a keystroke lands. + */ +export function checkpointViewOf( + model: ReplModel, + focused: string | undefined, + options: CheckpointOptions = {}, +): ReplView { + const resolved = viewFor( + checkpointState(model, options), + model, + options.live ?? CHECKPOINT_LIVE, + options.size ?? CHECKPOINT_SIZE, + focused, + navigationOf(model.checkpoints, "live"), + ); + if (!resolved.ok) { + throw resolved.error; + } + return withWorkspace(resolved.value, options.workspace ?? CHECKPOINT_WORKSPACE); +} + +/** One committed checkpoint screen, and the facts it was committed from. */ +export interface CheckpointFrame { + readonly model: ReplModel; + readonly view: ReplView; + /** The key the mounted tree holds focus on, as the tree answered. */ + readonly focused: string | undefined; + readonly observed: Observed; +} + +/** + * Commit the checkpoint screen, in the two frames the product itself takes. + * + * The first frame carries no focused key, so the Draft takes focus on its own + * exactly as it does when a command opens. The tree is then asked where focus + * actually ended up, and the frame under review is composed with that answer — + * so the cue drawn on the Draft is the cue of the control a keystroke reaches, + * rather than a label a fixture asserted into the view. + * + * One presenter, one tree and one grid throughout, because this renderer writes + * diffs: the screen is what the frames have left in the grid together. + */ +export function* commitCheckpoint( + presenter: Presenter, + options: CheckpointOptions = {}, +): Operation { + const model = options.model ?? (yield* checkpointModel()); + // The opening frame, where the Draft's own claim takes focus — and only + // where nobody is carrying focus already. A caller resizing a screen passes + // the tree's current answer instead, because offering the opening claim again + // at every size would restore the Draft rather than keep what was focused. + if (options.focused === undefined) { + yield* presenter.commit(checkpointViewOf(model, undefined, options)); + } + const node = presenter.tree.focused(); + const focused = node === undefined ? undefined : presenter.tree.keyOf(node); + const view = checkpointViewOf(model, focused, options); + const observed = yield* presenter.commit(view); + return { model, view, focused, observed }; +} + +/** + * One reading a person has actually moved to, seeded once. + * + * The exact position the given model holds, the entry and nested scope + * selected under it, and texts neither composer started with — so a frame that + * rebuilt the default reading instead of carrying this one is visibly a + * different screen. Given a prefix projection, that position is a real + * retained marker; given the present, it is the present. + */ +export function seededState(model: ReplModel, options: CheckpointOptions = {}): ReplState { + const base = checkpointState(model, { ...options, state: undefined }); + return Object.freeze({ + ...base, + draft: SEEDED_DRAFT, + message: SEEDED_MESSAGE, + // The position this model was projected at, which is the only one + // resolution accepts: a route naming any other marker is refused until the + // prefix it names has been projected. So this is a retained position + // exactly when the model it is given is a projection of one — + // `checkpointSelected` returns that pair, and `checkpointModel` returns + // the present. + route: Object.freeze({ ...base.route, at: model.selection }), + }); +} + +/** The Draft text the seeded reading carries, which no default supplies. */ +export const SEEDED_DRAFT = ""; + +/** The composer text the seeded reading carries. */ +export const SEEDED_MESSAGE = "And say what the coupon does to the total."; diff --git a/packages/cli/tests/repl-agent-interface.test.ts b/packages/cli/tests/repl-agent-interface.test.ts index 7fed82d2f..08881cb8f 100644 --- a/packages/cli/tests/repl-agent-interface.test.ts +++ b/packages/cli/tests/repl-agent-interface.test.ts @@ -771,17 +771,14 @@ function* painted( return (yield* drawn(tree, view, renderer)).bytes; } -/** The row this screen is drawing the draft on. */ -function draftRowIn(rows: readonly string[]): string { - const found = rows.find((row) => row.includes(DRAFT_PROMPT)); - if (found === undefined) { - throw new Error("the screen is drawing no draft row"); - } - return found; -} - -/** What the draft row is called, which is how this suite finds it. */ -const DRAFT_PROMPT = "Draft: "; +/** + * What the draft row is called, which is how this suite finds it. + * + * The draft is a block now: its name and focus marker are one row, and what + * somebody typed is fitted into rows of its own beneath them. So this names the + * row, and {@link draftOn} reads the rows under it. + */ +const DRAFT_PROMPT = " DRAFT"; /** The keys this view describes with one `select`, which is what a row asks for. */ function* keysSelecting(view: ReplView, select: string): Operation { @@ -1970,14 +1967,10 @@ describe("U2 — the program performs a permission, end to end", () => { // drawer is up to answer it with. expect(shows(terminal, "[Allow once]")).toBe(false); - // A permission is answered on the Sessions surface, so that is where a - // person goes first — through the ordinary control, not a shortcut. - yield* pressUntil(terminal, "Sessions"); - terminal.feed("\r"); - yield* settled(40); - // Activate the request fact, then choose one offered option — Tab and Enter, - // through the ordinary normalized boundary. + // through the ordinary normalized boundary. The fact is reached where the + // screen draws it: this frame gives the Transcript a column of its own and + // the request stands in it, so there is no surface to open first. yield* pressUntil(terminal, "asks: Write"); terminal.feed("\r"); yield* settled(40); @@ -2039,11 +2032,8 @@ describe("U2 — the program performs a permission, end to end", () => { yield* settled(40); yield* showing(terminal, "asks: Write"); - // Open it the way a person does, from the surface a permission is answered - // on. - yield* pressUntil(terminal, "Sessions"); - terminal.feed("\r"); - yield* settled(40); + // Open it the way a person does: the request stands in the Transcript's + // own column at this size, and activating that row is what opens it. yield* pressUntil(terminal, "asks: Write"); terminal.feed("\r"); yield* settled(40); @@ -2446,71 +2436,125 @@ describe("U6 — the Sessions reading is windowed", () => { yield* until(session, "all three Prompts being observed", () => observed(session) === 3); yield* until(session, "one turn being recorded", () => recorded(session) === 1); - // Two drafts whose line counts have different numbers of digits, so the row - // that says how many lines are hidden gets shorter as the draft does. The - // draft is the row of this screen whose length changes, and these are two - // separate facts about it: what the terminal ends up showing, and what this - // application described for it to show. + // Two drafts whose newest visible text really shrinks. Two that differed + // only in how many identical lines they hide would leave the drawn row + // nearly unchanged and prove no erasure of anything a reader can see: the + // long tail here is thirty cells the short draft has to take back. const standing = onSessions(session); - const longer = withDraft(standing, "x\n".repeat(1200)); - const shorter = withDraft(standing, "x\n".repeat(999)); - + const LONGER = `${"x\n".repeat(1200)}longer text with removable tail`; + const SHORTER = `${"x\n".repeat(999)}y`; + const longer = withDraft(standing, LONGER); + const shorter = withDraft(standing, SHORTER); + + // One renderer and one mounted tree for both commits, with nothing + // clearing, resetting or resizing between them: the second frame is the + // diff that the first one is the ground for. const tree = yield* useReplTree(); const renderer = yield* useReplRenderer(NARROW); /** Everything written to this one terminal, in order. */ const written: Uint8Array[] = []; const first = reading(longer, session, NARROW); - written.push(yield* painted(renderer, tree, first)); - expect(draftRowIn(screenFrom(written))).toContain("[1200 lines]"); + const longFrame = yield* drawn(tree, first, renderer); + written.push(longFrame.bytes); + // Read now rather than later: this is the cell the tree is holding, and the + // next commit replaces it. + const before = longFrame.cell("footer:input") ?? ""; + expect(before).toContain("[1200 lines]"); + expect(before).toContain("longer text with removable tail"); + const longScreen = screenFrom(written); // The same terminal, drawn again, with nothing clearing it between the two. - // What comes back is the shorter count and nothing of the longer one. const second = reading(shorter, session, NARROW); - written.push(yield* painted(renderer, tree, second)); - - const after = draftRowIn(screenFrom(written)); + const shortFrame = yield* drawn(tree, second, renderer); + written.push(shortFrame.bytes); + const after = shortFrame.cell("footer:input") ?? ""; expect(after).toContain("[999 lines]"); expect(after).not.toContain("1200"); + expect(after).not.toContain("removable tail"); + + const shortScreen = screenFrom(written); + // The two screens really differ, which is what makes the comparison below + // an assertion about erasure rather than a tautology. + expect(shortScreen).not.toEqual(longScreen); // Cell for cell, what a renderer that had never drawn the longer reading // produces for the shorter one — blanks and all. This is the assertion that - // discriminates: a row that kept the tail of the count it used to carry - // reads the same as this one until the two screens are compared. + // discriminates: a row that kept the tail of what it used to carry reads + // the same as this one until the two screens are compared. const fresh = yield* scoped(function* (): Operation { const other = yield* useReplRenderer(NARROW); const otherTree = yield* useReplTree(); return screenFrom([yield* painted(other, otherTree, reading(shorter, session, NARROW))]); }); - expect(screenFrom(written)).toEqual(fresh); - - // And the row this application described is itself a complete row, as wide - // as the ones around it — the full-width contract the draft shares with - // every other row of its region, rather than one inherited from whatever - // draws it. - const drafted = (yield* describedBy(second)).find((one) => one.key === "footer:input"); - expect(drafted).toBeDefined(); - expect((drafted?.label ?? "").length).toBeGreaterThanOrEqual(NARROW.columns - 2); + expect(shortScreen).toEqual(fresh); + + // And the row this application described occupies the whole rectangle the + // frame gave it. Read from the committed manifest and the copied map rather + // than from the length of a padded label: what a label says about itself is + // not where it was placed, and the cells past the text are exactly where a + // shorter draft would leave old glyphs behind. + const placed = flatten(shortFrame.committed.manifest.root).filter( + (box) => box.key === "footer:input", + ); + // One box for this row in the whole committed tree, so the rectangle below + // is the row's own allocation and not one of two. + expect(placed).toHaveLength(1); + const bounds = shortFrame.boundsOf("footer:input"); + expect(bounds).toBeDefined(); + const box = bounds ?? { x: 0, y: 0, width: 0, height: 0 }; + // Whole cells, which is the only geometry this product draws at all. + for (const measure of [box.x, box.y, box.width, box.height]) { + expect(Number.isInteger(measure)).toBe(true); + } + expect(box.height).toBe(1); + const footer = shortFrame.region("footer"); + expect(footer).toBeDefined(); + const inside = footer ?? { x: 0, y: 0, width: 0, height: 0 }; + expect(box.x).toBeGreaterThanOrEqual(inside.x); + expect(box.y).toBeGreaterThanOrEqual(inside.y); + expect(box.x + box.width).toBeLessThanOrEqual(inside.x + inside.width); + expect(box.y + box.height).toBeLessThanOrEqual(inside.y + inside.height); + /** The cells of this row's own allocation, text and padding alike. */ + const allocated = (rows: readonly string[]): string => + (rows[box.y] ?? "").padEnd(box.x + box.width, " ").slice(box.x, box.x + box.width); + expect(allocated(shortScreen)).toBe(allocated(fresh)); + expect(allocated(shortScreen)).not.toContain("removable tail"); + expect(allocated(longScreen)).not.toBe(allocated(shortScreen)); + // One target for this row, and it is the field itself. + expect(shortFrame.targetable("footer:input")).toBe(true); + expect(shortFrame.keys.filter((key) => key === "footer:input")).toHaveLength(1); // The dedicated location is drawn nowhere, and reserves nothing: a draft // this long would otherwise be the longest thing on the screen. expect((yield* describedBy(second)).filter((one) => one.key.startsWith("location:"))).toEqual( [], ); - expect(screenFrom(written).some((row) => row.includes("xmd://"))).toBe(false); - // And the route itself is unchanged: hidden is not dropped. + expect(shortScreen.some((row) => row.includes("xmd://"))).toBe(false); + // And the route itself is unchanged: hidden is not dropped, and the whole + // multiline draft with its authored blank lines is still what it was. expect(second.location).toBe(encodeLocation(second.state.route)); + expect(second.state.draft).toBe(SHORTER); + expect(second.state.route.draft).toBe(SHORTER); // And the screen is still one a person can use: both ways off it, and a // control of the reading itself. - const usable = yield* drawn(tree, second); for (const key of ["sessions:heading", "entries:heading"]) { - expect(usable.targetable(key)).toBe(true); + expect(shortFrame.targetable(key)).toBe(true); } - const outlet = usable.keys.filter( - (key) => key.startsWith("sessions:turn:") && usable.targetable(key), + const outlet = shortFrame.keys.filter( + (key) => key.startsWith("sessions:turn:") && shortFrame.targetable(key), ); expect(outlet.length).toBeGreaterThan(0); + + // That control is the one it says it is. A pointer and the keyboard reach + // the same mounted node, so the two answer with the same action — the same + // turn, not merely the same kind of act. + const turn = outlet[0] ?? ""; + const pointer = yield* pointed(tree, shortFrame, turn); + yield* focusTo(tree, turn); + expect(keyed(tree)).toBe(turn); + expect(yield* activate(tree)).toEqual(pointer); }); it("U6: the first press after a resize moves the window, not the stored number", function* () { @@ -3219,12 +3263,15 @@ function usableFrame(terminal: Terminal): boolean { function focusedOn(terminal: Terminal, label: string): boolean { for (const line of screenOf(terminal)) { for (let at = line.indexOf(">"); at !== -1; at = line.indexOf(">", at + 1)) { - if ( - line - .slice(at + 1) - .trimStart() - .startsWith(label) - ) { + // Past the origin mark, where the row wears one. This list attributes + // every row it holds — an entry agent's work, a person's message, the + // Sidekick's reply, the work a reply started — and the mark stands + // between the focus marker and the row's own words. + const after = line + .slice(at + 1) + .trimStart() + .replace(/^\[[A-Z]\]\s*/, ""); + if (after.startsWith(label)) { return true; } } @@ -3237,6 +3284,27 @@ function shows(terminal: Terminal, expected: string): boolean { return screenOf(terminal).some((line) => line.includes(expected)); } +/** + * Whether the compact History rail holds a mark for this entry. + * + * The rail is where this screen states what each entry came to, at every size. + * Up to the rule, which is where the rail ends and the sentence beside it + * begins — a number past that is part of the sentence, not an entry. + */ +function railHas(terminal: Terminal, order: number, mark = "\u2713\u00d7\u25cf\u002d"): boolean { + for (const line of screenOf(terminal)) { + const at = line.indexOf("HISTORY"); + if (at === -1) { + continue; + } + const rail = line.slice(at + "HISTORY".length).split("\u2500\u2500")[0] ?? ""; + if (new RegExp(`(?:^|\\s)${order}\\s*[${mark}]`).test(rail)) { + return true; + } + } + return false; +} + /** * Wait until the first frame has been drawn. * @@ -3854,7 +3922,7 @@ function* focusTheDraft(terminal: Terminal, limit = 240): Operation { // destinations now — the draft and `Message Sidekick` (#883 section 5) — and // both wear the same focus marker, so a helper looking for the marker alone // stops at whichever it reaches first and types an entry into a chat box. - if (screenOf(terminal).some((line) => line.includes(`>> ${DRAFT_PROMPT}`))) { + if (screenOf(terminal).some((line) => line.includes(`>${DRAFT_PROMPT}`))) { return; } terminal.feed("\t"); @@ -3903,23 +3971,68 @@ function* admittedSources(hostRoot: string): Operation { } /** - * Exactly what the draft row is holding (#870 C3). + * Exactly what the draft is holding (#870 C3). * - * Read off the bottom-most row that carries the draft's own prompt, because the - * draft owns the last footer row at every size and shares it with nothing. Exact - * rather than a containment check: the requirement is that what somebody typed survived - * byte for byte, and `contains` would pass on a draft that had grown a character. + * Read from the rows beneath the row that names it, because the draft draws its + * name and its value apart now, and bounded by the column the name is drawn in + * so a neighbouring pane's rows are not read as the draft's. Exact rather than + * a containment check: the requirement is that what + * somebody typed survived byte for byte, and `contains` would pass on a draft + * that had grown a character. */ function draftOn(terminal: Terminal): string { const rows = screenOf(terminal); - for (let row = rows.length - 1; row >= 0; row -= 1) { - const line = rows[row] ?? ""; - const at = line.lastIndexOf(DRAFT_PROMPT); - if (at !== -1) { - return line.slice(at + DRAFT_PROMPT.length).trimEnd(); + const at = rows.findIndex((line) => line.includes(DRAFT_PROMPT)); + if (at === -1) { + return ""; + } + // Where this field's own content begins: the column its name starts at, one + // past the marker in front of it. And where its room ends: the Draft's name + // shares a row with the control that acts on the draft, and the column that + // control starts at is where the value stops. Without that bound the rows + // below run into whatever the pane beside this one drew on them. + const named = rows[at] ?? ""; + const from = Math.max(0, named.indexOf(DRAFT_PROMPT) - 1); + const borders = bordersBefore(named, from); + const beside = /\s\[[^\]]*\]/.exec(named.slice(from)); + const allocated = beside === null ? undefined : beside.index; + const value: string[] = []; + for (const line of rows.slice(at + 1)) { + // This column's own rows only. The rows below the pane run the whole width + // and are nobody's column, so a reader that walked into them would read a + // rail or a direction as the end of somebody's draft. + if (bordersBefore(line, from) < borders || footerRow(line)) { + break; + } + const within = line.slice(from); + const edge = within.indexOf("\u2502"); + const bounded = edge === -1 ? within : within.slice(0, edge); + const own = (allocated === undefined ? bounded : bounded.slice(0, allocated)).trimEnd(); + if (own.trim().startsWith("[")) { + break; } + value.push(own); } - return ""; + while (value.length > 0 && (value[value.length - 1] ?? "").trim() === "") { + value.pop(); + } + return value.join("\n").trim(); +} + +/** How many of this screen's column borders stand before this column. */ +function bordersBefore(line: string, column: number): number { + return line.slice(0, column).split("\u2502").length - 1; +} + +/** + * Whether this row belongs to the screen's footer rather than to any pane. + * + * The compact History band and the guidance below it run the whole width and + * are nobody's column. Recognised by what they say, because a count of rows + * from the bottom is the layout's business and not a reader's. + */ +function footerRow(line: string): boolean { + return line.includes("HISTORY") || line.includes("[exit]") || line.includes("Tab/Shift+Tab"); } /** @@ -3977,15 +4090,13 @@ describe("U2 — a draft outlives a permission and is still editable", () => { // That a request is waiting is read from the one row that says what the // execution is doing, because that row is on the screen at both sizes — // the chronology the request lives on is not: a narrow frame mounts one - // routed outlet, and this one is routed to Entries. - // Read from the one row that says what the execution is doing, because - // that row is on the screen at both sizes — the chronology the request - // lives on is not: a narrow frame mounts one routed outlet and this route - // selects Entries. Matched on the instruction rather than on the state - // phrase, because the narrow state is the compact `Entry 1 permission` - // and the wide one is `Entry 1 waiting for permission`. + // routed outlet and this route selects Entries. Matched on the word both + // spellings of that state share: the narrow one is `Entry 1 permission` + // and the wide one `Entry 1 waiting for permission`, and what each says + // to do differs too — a frame that draws the list says to activate the + // request and one that does not says where to open it. yield* untilScreen(terminal, "the request being announced", (one) => - shows(one, "open Sessions"), + shows(one, "permission"), ); // Arrival opened nothing, which is the row before this one's claim. Read // off the screen rather than the location: at 72 columns the location is @@ -4003,9 +4114,14 @@ describe("U2 — a draft outlives a permission and is still editable", () => { expect(draftOn(terminal)).toBe(TYPED); // 2. Answered once, through the ordinary controls and the one authority. - yield* pressUntil(terminal, "Sessions"); - terminal.feed("\r"); - yield* settled(40); + // Where one outlet is routed the chronology is behind its own tab and a + // person opens it first; where the Transcript has a column the request + // already stands in it, and there is no surface to cross to. + if (shows(terminal, "Sessions")) { + yield* pressUntil(terminal, "Sessions"); + terminal.feed("\r"); + yield* settled(40); + } yield* pressUntil(terminal, "asks: Write"); terminal.feed("\r"); yield* settled(40); @@ -4016,7 +4132,23 @@ describe("U2 — a draft outlives a permission and is still editable", () => { expect(stub.answers.get("call-1")).toBe(1); yield* settled(40); - // 3. Byte for byte what was typed, and on the screen. + // 3. Byte for byte what was typed, and on the screen. A narrow frame + // routes one outlet, and the chronology this request was answered on is + // not the outlet the draft is drawn on — so the way back is taken + // first, through the control that is on the screen at every size. + if (!shows(terminal, DRAFT_PROMPT)) { + yield* pressUntil(terminal, "Entries"); + terminal.feed("\r"); + yield* settled(40); + // That control both routes the outlet and opens the catalog over it. + // Put the catalog away again, as the row itself says to: a modal left + // up is a modal the rows below would be reading through. + if (shows(terminal, "[close]")) { + yield* pressUntil(terminal, "[close]"); + terminal.feed("\r"); + yield* settled(40); + } + } expect(draftOn(terminal)).toBe(TYPED); expect(shows(terminal, TYPED)).toBe(true); @@ -4030,11 +4162,6 @@ describe("U2 — a draft outlives a permission and is still editable", () => { // 5. And it is the thing that becomes the next entry, once the one that // asked for the permission has finished and been joined. - // Back to Entries to read its catalog, which a narrow frame only mounts - // when the route selects it. - yield* pressUntil(terminal, "Entries"); - terminal.feed("\r"); - yield* settled(40); // Not `[ok]`, which is the retained close: a close is recorded while the // task that wrote it is still coming down, and a submission taken in that // window has nowhere to go. The row that says the next entry may start is @@ -4042,10 +4169,17 @@ describe("U2 — a draft outlives a permission and is still editable", () => { yield* untilScreen(terminal, "the next entry becoming startable", (one) => shows(one, "Ready for Entry 2"), ); - expect(shows(terminal, "1. [ok] entry-1")).toBe(true); + // And what the first entry came to, read where this screen states it. + // `1. [ok] entry-1` is a row of the Entries catalog, and the catalog is + // a drawer over the reading at this size — opening one here would put a + // modal over the draft the rows below go on to read. + expect(railHas(terminal, 1, "\u2713")).toBe(true); yield* focusTheDraft(terminal); terminal.feed("\r"); - yield* untilScreen(terminal, "the second entry", (one) => shows(one, "2. ")); + // The History rail's own mark for the second entry, which every size + // draws: the catalog that spells `2.` is a drawer now, and one opened + // here would be a drawer over the draft this row goes on to read. + yield* untilScreen(terminal, "the second entry", (one) => railHas(one, 2)); // Submitted means consumed: the draft is the *next* entry's, and there is // no next one typed yet. yield* untilScreen(terminal, "the draft being spent", (one) => draftOn(one) === ""); diff --git a/packages/cli/tests/repl-agent-journey.test.ts b/packages/cli/tests/repl-agent-journey.test.ts index ae6364965..769dd986c 100644 --- a/packages/cli/tests/repl-agent-journey.test.ts +++ b/packages/cli/tests/repl-agent-journey.test.ts @@ -337,6 +337,10 @@ describe("J1 — the Story, from one entry to one README", () => { "agent_prompt", ]); expect(occurrences(terminal, "completed, recorded")).toBe(2); + // Read inside the catalog, which is where an entry's scopes are listed + // now. The rows above are read before it is opened, because the drawer is + // over the reading they are in. + yield* openCatalog(terminal); expect(shows(terminal, "component Plan")).toBe(true); expect(shows(terminal, "generated generated")).toBe(true); // And both Plan reviews are retained where they were asked, which is @@ -905,8 +909,6 @@ describe("J3 — durable truth, and a cold process over it", () => { // forms that were answered, the preview the program rendered and the // write it reported. for (const line of [ - "component Plan", - "generated generated", "# A first draft", "Proposed README.md:", '"project": "Ledger"', @@ -915,15 +917,24 @@ describe("J3 — durable truth, and a cold process over it", () => { ]) { yield* showing(second.terminal, line); } - // Both turns are recorded rows rather than a live process's readings, and - // both Plan reviews are retained where they were asked. + // Both turns are recorded rows rather than a live process's readings. + // Counted before the catalog is opened, because the drawer is over the + // reading these rows are in. expect(occurrences(second.terminal, "completed, recorded")).toBe(2); + // The packaged Plan's own scope and the program it produced, read where + // an entry's scopes are listed: inside its catalog. + yield* openCatalog(second.terminal); + for (const line of ["component Plan", "generated generated"]) { + yield* showing(second.terminal, line); + } + // And both Plan reviews are retained where they were asked. expect( yield* answeredUnder(second.terminal, "component Plan", "answered @executablemd/cli/Plan"), ).toBe(2); // The program's own two questions are retained with the answers they // took. Their positions carry no path — generated source is not a file — // so they are shown under the fragment that produced them. + yield* openCatalog(second.terminal); yield* click(second.terminal, "generated generated"); yield* settled(40); // The answers themselves are read from the reading above, which shows @@ -980,10 +991,10 @@ describe("J3 — durable truth, and a cold process over it", () => { yield* untilDrawn(first.terminal); yield* typed(first.terminal, promptEntry("ask-one")); - yield* showing(first.terminal, "1. [ok] entry-1"); + yield* settledOn(first.terminal, 1); yield* focusDraft(first.terminal); yield* typed(first.terminal, promptEntry("ask-two")); - yield* showing(first.terminal, "2. [ok] entry-2"); + yield* settledOn(first.terminal, 2); // Both turns are on the Sessions reading, in one conversation, before // anything is read back: this is the reading a cold process has to @@ -995,7 +1006,7 @@ describe("J3 — durable truth, and a cold process over it", () => { // surface is offering a conversation to filter by at all. Which key it // is, is a fact about the record and is asserted of the record below — // a provider session key is longer than this column and is drawn cut. - expect(shows(first.terminal, "All conversations")).toBe(true); + expect(shows(first.terminal, "All sessions")).toBe(true); turnRows = promptRows(first.terminal); expect(turnRows).toHaveLength(2); expect(turnRows.some((row) => shownPrompt(row, "ask-one"))).toBe(true); @@ -1061,16 +1072,21 @@ describe("J3 — durable truth, and a cold process over it", () => { yield* untilDrawn(second.terminal); // The catalog the file holds, in admission order with its outcomes. - yield* showing(second.terminal, "1. [ok] entry-1"); - yield* showing(second.terminal, "2. [ok] entry-2"); + yield* settledOn(second.terminal, 1); + yield* settledOn(second.terminal, 2); + // Which entry this location selected, read from the catalog that marks + // it. The catalog is a drawer over the reading now, so it is opened to be + // read and put away again before the rows below read what is underneath. + yield* openCatalog(second.terminal); expect(selectedEntryOn(second.terminal) ?? "").toContain(selected.key); + yield* closeCatalog(second.terminal); // The global Sessions chronology, row for row and conversation for // conversation, is the one the live process showed — both entries' turns // under the one conversation, with the selection narrowing none of it. yield* showing(second.terminal, "ONE-DONE"); yield* showing(second.terminal, "TWO-DONE"); - expect(shows(second.terminal, "All conversations")).toBe(true); + expect(shows(second.terminal, "All sessions")).toBe(true); expect(promptRows(second.terminal)).toEqual(turnRows); // The selected entry's own transcript, read off the transcript column @@ -1356,7 +1372,7 @@ describe("J2 — three conversations at once, through the terminal", () => { ] as const ).every(([asked, state]) => rows.some((line) => saying(line, asked, state))); }); - yield* showing(terminal, "All conversations"); + yield* showing(terminal, "All sessions"); // With the implementer's turn recorded too, a conversation control is // there to filter by while the planner has still not been taken. @@ -1365,6 +1381,10 @@ describe("J2 — three conversations at once, through the terminal", () => { // recognized as a conversation control on a sidebar too narrow for a key. const digest = (fake.turns[0]?.handle.sessionKey ?? "").split(":")[2] ?? ""; expect(digest).not.toBe(""); + // The conversations are behind the Transcript's own selector now: one + // list, and the choices that filter it are shown when the control saying + // so is activated. Opening it is how a person reaches them. + yield* click(terminal, "All sessions"); yield* awaiting("no conversation was ever offered", function* () { yield* settled(10); return conversationRows(terminal, digest).length > 0; @@ -1394,12 +1414,20 @@ describe("J2 — three conversations at once, through the terminal", () => { // standing on is the entry it is standing on. expect(unfiltered).toBeUndefined(); expect(sessionOf(terminal)).toBeDefined(); - // Its row, whatever it has settled to: what this asserts is that the - // catalog holds the entry, not what became of it. - expect(shows(terminal, "] entry-1")).toBe(true); const standing = sessionOf(terminal); // Where the person is standing now: on the control they just chose. const focused = focusedRow(terminal); + // Its row, whatever it has settled to: what this asserts is that the + // catalog holds the entry, not what became of it. The catalog is a drawer + // over the reading now, so it is opened for that fact and put away by its + // own control — and then the focus the person had is walked back to + // through the ordinary ring, because opening a drawer is a real move and + // the rows below compare where somebody is standing. + yield* openCatalog(terminal); + expect(shows(terminal, "] entry-1")).toBe(true); + yield* closeCatalog(terminal); + yield* focusBackTo(terminal, focused); + expect(focusedRow(terminal)).toBe(focused); // Background work in another conversation: the planner's turn is taken, // streams and is recorded, while the route, the history position and the @@ -1420,7 +1448,12 @@ describe("J2 — three conversations at once, through the terminal", () => { // Cleared back to All, the whole chronology is there again — all three // children, each with the state its own turn ended in, and one more // conversation to filter by than before. - yield* click(terminal, "All conversations"); + // Cleared through the selector, which is what the filter is chosen in: + // the control carries the name of whichever conversation was chosen, so + // it is opened by what it currently says and the unfiltered reading is a + // row inside it. + yield* openSelector(terminal); + yield* click(terminal, EVERY_CONVERSATION); yield* settled(40); expect(sessionOf(terminal)).toBeUndefined(); expect(childrenShown(terminal)).toEqual([PLAN_IT, REVIEW_IT, BUILD_IT]); @@ -1432,7 +1465,9 @@ describe("J2 — three conversations at once, through the terminal", () => { } // And one more conversation to filter by than there was, because the // third one has now been seen. + yield* openSelector(terminal); expect(conversationRows(terminal, digest).length).toBeGreaterThan(offered); + yield* closeSelector(terminal); // Three turns, three records, one per conversation. yield* awaiting("the three turns were never all retained", function* () { @@ -1518,6 +1553,10 @@ describe("J2 — a filter and a draft across a resize (#875 R1)", () => { // 1. A conversation filter, chosen by pointing at the row it is drawn in. const digest = (live.turns[0]?.handle.sessionKey ?? "").split(":")[2] ?? ""; expect(digest).not.toBe(""); + // The conversations are behind the Transcript's own selector now: one + // list, and the choices that filter it are shown when the control saying + // so is activated. Opening it is how a person reaches them. + yield* click(terminal, "All sessions"); yield* awaiting("no conversation was ever offered", function* () { yield* settled(10); return conversationRows(terminal, digest).length > 0; @@ -1544,7 +1583,7 @@ describe("J2 — a filter and a draft across a resize (#875 R1)", () => { // 2. A non-empty draft, typed into the field Tab reaches. yield* drafting(terminal, DRAFT); expect(sessionOf(terminal)).toBe(filtered); - expect(shows(terminal, `>> Draft: ${DRAFT}`)).toBe(true); + expect(draftTextOn(terminal)).toBe(DRAFT); expect(childrenShown(terminal)).toHaveLength(1); // 3. Narrow. What is read here is the draft row and the reading itself. @@ -1561,7 +1600,7 @@ describe("J2 — a filter and a draft across a resize (#875 R1)", () => { }); const narrow = replay(terminal.presented.slice(from)); expect(narrow.length).toBe(20); - expect(narrow.some((line) => line.includes(`>> Draft: ${DRAFT}`))).toBe(true); + expect(draftTextIn(narrow)).toBe(DRAFT); expect(narrow.filter((line) => line.trimEnd().length > 72)).toEqual([]); // A narrow frame draws one routed surface, and choosing a conversation // chooses a filter rather than a surface — so the Sessions reading is not @@ -1581,7 +1620,7 @@ describe("J2 — a filter and a draft across a resize (#875 R1)", () => { ); }); expect(sessionOf(terminal)).toBe(filtered); - expect(shows(terminal, `>> Draft: ${DRAFT}`)).toBe(true); + expect(draftTextOn(terminal)).toBe(DRAFT); expect(childrenShown(terminal)).toHaveLength(1); terminal.end(); @@ -1620,7 +1659,7 @@ describe("J2 — a filter and a draft across a resize (#875 R1)", () => { yield* settled(10); return sessionOf(second.terminal) === filtered; }); - expect(shows(second.terminal, `>> Draft: ${DRAFT}`)).toBe(true); + expect(draftTextOn(second.terminal)).toBe(DRAFT); expect(childrenShown(second.terminal)).toHaveLength(1); // Nobody was asked, nothing was started, created or ensured. @@ -1699,6 +1738,10 @@ describe("J2 — what filtering Sessions leaves on the screen (#875 R1)", () => const digest = (fake.turns[0]?.handle.sessionKey ?? "").split(":")[2] ?? ""; expect(digest).not.toBe(""); + // The conversations are behind the Transcript's own selector now: one + // list, and the choices that filter it are shown when the control saying + // so is activated. Opening it is how a person reaches them. + yield* click(terminal, "All sessions"); yield* awaiting("no conversation was ever offered", function* () { yield* settled(10); return conversationRows(terminal, digest).length > 0; @@ -2587,22 +2630,44 @@ const SIDEBAR = sidebarWidth({ columns: 160, rows: 36 }) ?? 0; /** The conversation this screen is filtered by, as the route states it. */ function sessionOf(terminal: Terminal): string | undefined { - // The conversation the reading is filtered to is the one its own row marks: - // two columns of focus cue, the list's indent, then the filter's own `>`. - // - // Read from inside the sidebar's body, past the accepted two-cell gutter: the - // gutter is layout that holds no text, so the row begins where the body does - // rather than where the column does. + // The conversation this reading is filtered to is what the Transcript's own + // selector says. There is one list now, and the control that opens its + // choices carries the name of whichever was chosen — so the filter is read + // where the screen states it rather than from a marked row of a column this + // composition does not draw. `All sessions` is the unfiltered reading, which + // is no conversation at all. + // Found by the mark that says it opens rather than by the row it is on: a + // name too long for the title's remaining room is given a row of its own + // beneath it, and the selector is the only thing in this column wearing that + // mark either way. for (const line of screenOf(terminal)) { - const column = (line.split("\u2502")[0] ?? "").slice(BODY_GUTTER); - const match = /^[>\s]\s {2}>\s(\S.*?)\s*$/.exec(column); - if (match !== null) { - return match[1]; + const column = (line.split("\u2502")[0] ?? "").trimEnd(); + if (!column.endsWith(SELECTOR_MARK)) { + continue; } + const label = column + .slice(0, column.length - SELECTOR_MARK.length) + .replace(new RegExp(`^\\s*${TRANSCRIPT_TITLE}`), "") + .trim(); + return label === "" || label === ALL_SESSIONS ? undefined : label; } return undefined; } +/** + * The row inside the selector that stands for no filter at all. + * + * Not the same words the control itself carries: the control says which + * conversation a reading is filtered to, and the list says which readings are + * on offer — the unfiltered one among them. + */ +const EVERY_CONVERSATION = "All conversations"; + +/** What the staged Transcript calls itself, its selector's mark, and no filter. */ +const TRANSCRIPT_TITLE = "TRANSCRIPT"; +const SELECTOR_MARK = "\u25be"; +const ALL_SESSIONS = "All sessions"; + /** * The conversation rows the Sessions surface offers to filter by, in order. * @@ -2670,6 +2735,40 @@ function occurrences(terminal: Terminal, text: string): number { * they were asked. Selection is the behavior being tested: a row counted under the wrong scope * would be an answer attributed to somewhere it never happened. */ +/** + * Open the Entries catalog, which is a drawer over the reading. + * + * An entry's own scopes are rows of that catalog, under the entry they belong + * to. The catalog is not a column any more, so reaching one of its rows begins + * with the control that opens it; a catalog already open is left alone. + */ +function* openCatalog(terminal: Terminal): Operation { + if (catalogShowing(terminal)) { + return; + } + yield* click(terminal, "Entries"); + yield* until(() => catalogShowing(terminal), "the Entries catalog never opened"); +} + +/** + * Put the Entries catalog away again, which chooses nothing. + * + * Its own control rather than a key, because this screen states the way out on + * the drawer itself and a key would be a second answer to the same question. + */ +function* closeCatalog(terminal: Terminal): Operation { + if (!catalogShowing(terminal)) { + return; + } + yield* click(terminal, "[close]"); + yield* until(() => !catalogShowing(terminal), "the Entries catalog never closed"); +} + +/** Whether the catalog's own rows are on the screen. */ +function catalogShowing(terminal: Terminal): boolean { + return screenOf(terminal).some((line) => /(?:^|\s)1\. \[/.test(line)); +} + function* answeredUnder(terminal: Terminal, scope: string, text: string): Operation { // Bindings first, where the command is showing the Sidekick. A scope's own // rows are the inspector's, and the right pane now defaults to the Sidekick @@ -2678,12 +2777,18 @@ function* answeredUnder(terminal: Terminal, scope: string, text: string): Operat // Which pane is showing, read from the body it draws: both tabs are named in // that column's header on every frame, so a label would answer yes whichever // reading was selected and this would stop asking for Bindings at all. + // The scope first, because it is a row of the entry's catalog and the catalog + // is a drawer over the reading: choosing a row closes it again, which is what + // leaves the reading below showing what that scope retains. Then the + // inspector, because selecting a scope is a reading and the right column + // defaults to the Sidekick. + yield* openCatalog(terminal); + yield* click(terminal, scope); + yield* settled(40); if (panePresented(terminal) === "sidekick") { - yield* click(terminal, "Bindings"); + yield* click(terminal, BINDINGS_TAB); yield* settled(40); } - yield* click(terminal, scope); - yield* settled(40); const found = occurrences(terminal, text); if (found === 0) { throw new Error( @@ -2724,6 +2829,50 @@ function focusedRow(terminal: Terminal): string | undefined { .find((line) => line.trimStart().startsWith(">")); } +/** + * Open the Transcript's conversation selector, whatever it currently says. + * + * The control carries the name of the conversation this reading is filtered to, + * and `All sessions` when it is filtered to none — so what opens it is read off + * the screen rather than spelled, and the choices it offers are its rows. + */ +function* openSelector(terminal: Terminal): Operation { + yield* click(terminal, sessionOf(terminal) ?? ALL_SESSIONS); + yield* settled(40); +} + +/** + * Put the selector's choices away again, which chooses nothing. + * + * The same control: activating it shows the conversations and activating it + * again puts them away, which is what this screen says it does. + */ +function* closeSelector(terminal: Terminal): Operation { + yield* click(terminal, sessionOf(terminal) ?? ALL_SESSIONS); + yield* settled(40); +} + +/** + * Walk the focus ring until this row holds focus again. + * + * Through the mounted tree's own traversal, which is how a person comes back to + * a control after a drawer took the focus: nothing here writes a focus store or + * names a node the screen is not offering. + */ +function* focusBackTo(terminal: Terminal, row: string | undefined): Operation { + if (row === undefined) { + return; + } + for (let press = 0; press <= 240; press += 1) { + if (focusedRow(terminal) === row) { + return; + } + terminal.feed("\t"); + yield* settled(12); + } + throw new Error(`focus never came back to ${JSON.stringify(row)}`); +} + /** Whether any row of the screen contains this text. */ function shows(terminal: Terminal, expected: string): boolean { return screenOf(terminal).some((line) => line.includes(expected)); @@ -2737,16 +2886,27 @@ function shows(terminal: Terminal, expected: string): boolean { * way. The Sidekick's body is the box its messages are typed into. */ function panePresented(terminal: Terminal): "sidekick" | "bindings" { - return shows(terminal, "Message Sidekick: ") ? "sidekick" : "bindings"; + return shows(terminal, MESSAGE_PROMPT) ? "sidekick" : "bindings"; } +/** The box the Sidekick's messages are typed into, as this screen names it. */ +const MESSAGE_PROMPT = "\u203a message the sidekick"; + +/** What the inspection column calls its two readings, as this screen names them. */ +const BINDINGS_TAB = "BINDINGS"; + /** Wait until the first frame has been drawn. */ function* untilDrawn(terminal: Terminal): Operation { yield* until(() => usable(terminal), "the screen never drew its first frame"); } -/** What the draft row is called, which is how this suite finds it. */ -const DRAFT_PROMPT = "Draft: "; +/** + * What the draft row is called, which is how this suite finds it. + * + * The draft is a block now: its name and focus marker are one row, and what + * somebody typed is fitted into rows of its own beneath them. + */ +const DRAFT_PROMPT = " DRAFT"; /** * Whether a frame a person could actually use has been drawn. @@ -2765,12 +2925,45 @@ function usable(terminal: Terminal): boolean { ); } -/** Exactly what the draft row is holding, after its marker and its prompt. */ +/** + * Exactly what the draft is holding, beneath the row that names it. + * + * Bounded by the column its name is drawn in and by the control beside that + * name: the draft is a block in a column now, and a row read to the end of the + * screen carries the pane alongside it as well. + */ function draftTextOn(terminal: Terminal): string | undefined { - const row = screenOf(terminal).find((line) => line.includes(DRAFT_PROMPT)); - return row === undefined - ? undefined - : row.slice(row.indexOf(DRAFT_PROMPT) + DRAFT_PROMPT.length).trimEnd(); + return draftTextIn(screenOf(terminal)); +} + +/** The same, out of rows a caller already has — a replay of one size's frames. */ +function draftTextIn(rows: readonly string[]): string | undefined { + const at = rows.findIndex((line) => line.includes(DRAFT_PROMPT)); + if (at === -1) { + return undefined; + } + const named = rows[at] ?? ""; + const from = Math.max(0, named.indexOf(DRAFT_PROMPT) - 1); + const beside = /\s\[[^\]]*\]/.exec(named.slice(from)); + const allocated = beside === null ? undefined : beside.index; + const value: string[] = []; + for (const line of rows.slice(at + 1)) { + if (line.includes("HISTORY") || line.includes("[exit]") || line.includes("Tab/Shift+Tab")) { + break; + } + const within = line.slice(from); + const edge = within.indexOf("\u2502"); + const bounded = edge === -1 ? within : within.slice(0, edge); + const own = (allocated === undefined ? bounded : bounded.slice(0, allocated)).trimEnd(); + if (own.trim().startsWith("[")) { + break; + } + value.push(own); + } + while (value.length > 0 && (value[value.length - 1] ?? "").trim() === "") { + value.pop(); + } + return value.join("\n").trim(); } /** @@ -2789,7 +2982,42 @@ function entriesOn(terminal: Terminal): string[] { found.push(`${match[1]}. ${match[3]}`); } } - return found; + return found.length > 0 ? found : railOn(terminal); +} + +/** Wait until the History rail marks this entry as settled well. */ +function* settledOn(terminal: Terminal, order: number): Operation { + yield* until( + () => railOn(terminal).includes(`${order}. \u2713`), + `the rail never marked entry ${order} as settled`, + ); +} + +/** + * The entries the compact History rail is marking, as `order. mark`. + * + * The catalog that spells `N. [ok] entry-N` is a drawer on this screen, so a + * reading of it is a reading of something nobody opened. The rail is what every + * frame draws, and it carries one mark per entry — which is the same two facts + * this suite asks for: how many entries there are and what each came to. + * + * Read up to the rule, which is where the rail ends and the sentence beside it + * begins: a number past that belongs to the sentence and not to an entry. + */ +function railOn(terminal: Terminal): string[] { + for (const line of screenOf(terminal)) { + const at = line.indexOf("HISTORY"); + if (at === -1) { + continue; + } + const rail = line.slice(at + "HISTORY".length).split("\u2500\u2500")[0] ?? ""; + const found: string[] = []; + for (const match of rail.matchAll(/(\d+)\s*([\u2713\u00d7\u25cf\u002d])/g)) { + found.push(`${match[1]}. ${match[2]}`); + } + return found; + } + return []; } /** The catalog row this reading has selected, if it is showing one. */ @@ -3058,7 +3286,12 @@ function promptEntry(text: string): string { */ function* focusDraft(terminal: Terminal): Operation { for (let press = 0; press <= 240; press += 1) { - if (screenOf(terminal).some((line) => line.includes(">> "))) { + // The draft's own row, by its marker and its name together. The draft is a + // block now: one marker, then the name it is known by, with what somebody + // typed on rows of its own beneath — and this screen has a second field + // wearing the same marker, so the marker alone would stop at whichever + // came first and type an entry into the Sidekick's box. + if (screenOf(terminal).some((line) => line.includes(`>${DRAFT_PROMPT}`))) { return; } terminal.feed("\t"); diff --git a/packages/cli/tests/repl-entries.test.ts b/packages/cli/tests/repl-entries.test.ts index 3201e911b..04794aba0 100644 --- a/packages/cli/tests/repl-entries.test.ts +++ b/packages/cli/tests/repl-entries.test.ts @@ -5006,3 +5006,338 @@ describe("REPL entries: what the Bindings reading shows at each position (#939)" expect(yield* bindingsAt(terminal, "entry-1")).toEqual(['token = "alpha"']); }); }); + +/** + * One entry that completes an effect, publishes a value, prints, and then waits. + * + * Everything an explicit Stop has to leave alone is in it before the wait: the + * first block is an effect whose record the journal acknowledges, `token` is an + * eval export the prefix retains, and the text it prints is output this process + * emitted and no record holds. Then it stops at a real question, which is where + * a person presses Stop. + */ +const STOPS = [ + "```js eval", + 'const token = "held";', + 'output("printed before the question");', + "const schema = {", + ' type: "object",', + ' properties: { decision: { type: "string", enum: ["go"] } },', + ' required: ["decision"],', + " additionalProperties: false,", + "};", + "```", + "", + 'Go?', + "", + "One: {answer.decision}", + "", +].join("\n"); + +/** + * Whether this failure is the given one, or carries it as a cause. + * + * By identity, through the cause chain: a failure that travelled through a + * reconciling boundary may be wrapped, and what must not happen is a different + * failure being reported in its place. + */ +function causedBy(failure: unknown, original: Error): boolean { + let seen: unknown = failure; + for (let depth = 0; depth < 16 && seen !== undefined && seen !== null; depth += 1) { + if (Object.is(seen, original)) { + return true; + } + if (seen instanceof AggregateError && seen.errors.some((one) => Object.is(one, original))) { + return true; + } + seen = seen instanceof Error ? seen.cause : undefined; + } + return false; +} + +describe("REPL entries: the explicit Stop, and what it is not (#971 B2)", () => { + beforeAll(() => useTempFileCompiler()); + + it("EL2: Stop retains a cancelled entry, and nothing follows until its cleanup joins", function* () { + const holder = replExecution(); + const entered = gate(); + const release = gate(); + try { + yield* scoped(function* () { + const session = opened( + yield* submitReplEntry({ + execution: holder, + source: STOPS, + installations: [holdingTeardown(entered, release)], + }), + ); + // A real question, reached by really running the entry. Everything + // before it is already in the file. + yield* asking(session); + const key = session.model.entries[0]?.key; + if (key === undefined) { + throw new Error("this session admitted no entry to stop"); + } + const before = yield* holder.stream.readAll(); + expect(before.some((event) => event.type === "close")).toBe(false); + expect(session.printing?.output).toContain("printed before the question"); + + // A key this session is not running stops nothing at all, and leaves + // the file exactly as it stands. Measured before the real Stop, so the + // stale press cannot be the one that did the work. + const stale = yield* session.stopEntry("entry-7"); + expect(stale.ok).toBe(false); + expect(yield* holder.stream.readAll()).toEqual(before); + + // The Stop itself, spawned, because its cleanup is held open below and + // this row has to stand inside that hold. + const answered = withResolvers>(); + yield* spawn(function* () { + answered.resolve(yield* session.stopEntry(key)); + }); + + // Cleanup is held. The entry's root has already written its ending by + // the time an execution's own finalizer is entered, so this is the + // state where "settled" and "joined" disagree — and a successor is + // refused in it. + yield* awaiting("the stopped entry reaching its teardown", entered.opened); + const held = yield* holder.stream.readAll(); + const closes = held.filter((event) => event.type === "close"); + expect(closes.length).toBe(1); + const only = closes[0]; + if (only === undefined || only.type !== "close") { + throw new Error("the stopped entry recorded no close"); + } + // Exactly the protocol's cancelled terminal: no output, no bindings and + // no invented answer travel in it. + expect(only.result.status).toBe("cancelled"); + expect(Object.keys(only.result)).toEqual(["status"]); + + const refused = refusedSubmission(yield* session.submit(PLAIN)); + expect(refused.name).toBe("ReplLifecycleError"); + expect(started(yield* holder.stream.readAll())).toBe(1); + + // Released, and only now does the Stop answer: it owns the slot across + // the whole of its own cleanup. + release.open(); + const stopped = yield* answered.operation; + expect(stopped.ok).toBe(true); + + // What the stopped entry left, read from the acknowledged history. + const settled = projected(yield* holder.stream.readAll()); + expect(settled.entries[0]?.terminal?.status).toBe("cancelled"); + // The question was never answered. A stopped entry fabricates no answer + // and no schema data: the entry holds no elicitation at all, and its + // records stop at the import that asked. + expect(settled.entries[0]?.scope.elicitations).toEqual([]); + expect(settled.entries[0]?.records.map((record) => record.type)).toEqual([ + "import_component", + "eval", + "import_component", + "close", + ]); + // Partial stays partial, in the channel it was already in. The text the + // entry really printed is retained beside the eval record that printed + // it; the cancelled terminal's own output is empty, because a stopped + // run rendered no document and nothing invents one for it. + expect( + settled.entries[0]?.transcript.some( + (row) => row.kind === "output" && row.text.includes("printed before the question"), + ), + ).toBe(true); + expect(settled.entries[0]?.terminal?.output).toBe(""); + // And what a successor inherits is exactly what the prefix really + // retained — the exports the eval block published before the wait, not + // a map synthesized for a cancelled close and not the answer that was + // never given. + const inheriting = entryInitialBindings(settled); + expect(Object.keys(inheriting).sort()).toEqual(["schema", "token"]); + expect(inheriting["token"]).toBe("held"); + + // A successor is admitted now, into the stopped entry's own history. + accepted(yield* session.submit(PLAIN)); + yield* session.join(); + const after = projected(yield* holder.stream.readAll()); + expect(after.entries.map((entry) => entry.key)).toEqual(["entry-1", "entry-2"]); + expect(after.entries[0]?.terminal?.status).toBe("cancelled"); + + // The successor settled on its own terms, and the stale Stop that + // follows is still stale: it names the entry that was stopped, which is + // no longer the entry running, so it reaches the successor not at all. + expect(after.entries[1]?.terminal?.status).toBe("ok"); + const late = yield* session.stopEntry(key); + expect(late.ok).toBe(false); + expect(projected(yield* holder.stream.readAll()).entries[1]?.terminal?.status).toBe("ok"); + }); + } finally { + release.open(); + } + + // Cold: the stopped segment is projected, never resumed, and neither it nor + // its completed effect runs again. + const cold = yield* holder.stream.readAll(); + const reopened = replExecution(cold); + yield* scoped(function* () { + const session = opened(yield* openReplSession({ execution: reopened })); + expect(session.live).toBe(false); + expect(session.model.entries[0]?.terminal?.status).toBe("cancelled"); + expect(entryInitialBindings(session.model)["token"]).toBe("held"); + }); + // Byte for byte what it opened: a cold read of a stopped history executes + // nothing and writes nothing. + expect(yield* reopened.stream.readAll()).toEqual(cold); + }); + + it("EL2: a refused cancelled close is the Stop's failure, and admits no successor", function* () { + // One deterministic gate: this journal takes everything except the ending a + // Stop writes. Nothing else about the entry changes. + const refusal = new Error("this journal refused the stopped ending"); + const stream = new InMemoryStream(); + const appended = stream.append.bind(stream); + stream.append = function* (event: DurableEvent): Operation { + if (event.type === "close" && event.result.status === "cancelled") { + throw refusal; + } + yield* appended(event); + }; + const holder: ReplExecution = { id: "entries", stream }; + const entered = gate(); + const release = gate(); + try { + yield* scoped(function* () { + const session = opened( + yield* submitReplEntry({ + execution: holder, + source: STOPS, + installations: [holdingTeardown(entered, release)], + }), + ); + yield* asking(session); + const key = session.model.entries[0]?.key; + if (key === undefined) { + throw new Error("this session admitted no entry to stop"); + } + + const answered = withResolvers>(); + yield* spawn(function* () { + answered.resolve(yield* session.stopEntry(key)); + }); + + // The remaining cleanup is still attempted: a refused append does not + // abandon what the entry was holding. + yield* awaiting("the stopped entry reaching its teardown", entered.opened); + release.open(); + + const stopped = yield* answered.operation; + expect(stopped.ok).toBe(false); + // The original failure, by identity, rather than a description of one. + if (stopped.ok) { + throw new Error("the refused append reported a successful stop"); + } + expect(causedBy(stopped.error, refusal)).toBe(true); + + // Nothing was written around it: no compensating ending of any kind, + // and the entry therefore has no outcome. + const events = yield* stream.readAll(); + expect(events.some((event) => event.type === "close")).toBe(false); + expect(partitioned(events).map((segment) => segment.settled)).toEqual([false]); + + // And this session admits nothing further. A stop whose append failed + // stopped nothing safely, so its authority to start work is withdrawn + // before any guard is released. + const refused = refusedSubmission(yield* session.submit(PLAIN)); + expect(refused.name).toBe("ReplLifecycleError"); + expect(started(yield* stream.readAll())).toBe(1); + }); + } finally { + release.open(); + } + }); + + it("EL2: a cleanup that fails during Stop is the Stop's failure, and admits no successor", function* () { + // The journal takes everything, so the stopped ending really is retained. + // What fails is the entry's own teardown, after it — which is the other + // half of "a stop succeeded": a terminal was written *and* everything the + // entry held came down. + const holder = replExecution(); + const entered = gate(); + const refusal = new Error("this installation's cleanup refused to finish"); + const refusingCleanup: ExecutionInstallation = { + *install(): Operation { + yield* ensure(() => { + entered.open(); + throw refusal; + }); + }, + }; + + yield* scoped(function* () { + const session = opened( + yield* submitReplEntry({ + execution: holder, + source: STOPS, + installations: [refusingCleanup], + }), + ); + yield* asking(session); + const key = session.model.entries[0]?.key; + if (key === undefined) { + throw new Error("this session admitted no entry to stop"); + } + + const stopped = yield* session.stopEntry(key); + // The cleanup was attempted and it failed, so this is not a stop that + // succeeded — whatever the journal managed to retain. + expect(stopped.ok).toBe(false); + if (stopped.ok) { + throw new Error("a refused cleanup reported a successful stop"); + } + expect(causedBy(stopped.error, refusal)).toBe(true); + yield* awaiting("the entry's own cleanup being attempted", entered.opened); + + // The ending was still written — a cleanup failure does not unwrite an + // acknowledged terminal, and nothing compensates around it. + const events = yield* holder.stream.readAll(); + const closes = events.filter((event) => event.type === "close"); + expect(closes.length).toBe(1); + expect(closes[0]?.type === "close" ? closes[0].result.status : undefined).toBe("cancelled"); + + // And this session admits nothing further, because its authority to start + // work was withdrawn before any guard was released. + expect(session.admits().ok).toBe(false); + const refused = refusedSubmission(yield* session.submit(PLAIN)); + expect(refused.name).toBe("ReplLifecycleError"); + expect(started(yield* holder.stream.readAll())).toBe(1); + }); + }); + + it("EL2: the same source left by teardown keeps its unfinished segment", function* () { + // The paired control. Identical source, identical wait — and the session is + // *torn down* rather than stopped, which is what Exit does. + const holder = replExecution(); + yield* scoped(function* () { + const session = opened(yield* submitReplEntry({ execution: holder, source: STOPS })); + yield* asking(session); + }); + + const events = yield* holder.stream.readAll(); + // No outcome of any kind. Teardown fabricates none, and the segment it + // leaves is unfinished. + expect(events.some((event) => event.type === "close")).toBe(false); + expect(partitioned(events).map((segment) => segment.settled)).toEqual([false]); + // So nothing may follow it, exactly as the row above this describe measures. + const blocked = refusedSession( + yield* submitReplEntry({ execution: replExecution(events), source: PLAIN }), + ); + expect(blocked.message).toContain("has not settled"); + + // And reopening resumes that one segment without repeating the effect it + // already acknowledged: one task over this execution, not two. + const resumed = replExecution(events); + yield* scoped(function* () { + const session = opened(yield* openReplSession({ execution: resumed })); + yield* asking(session); + expect(started(yield* resumed.stream.readAll())).toBe(1); + }); + }); +}); diff --git a/packages/cli/tests/repl-include-pty.test.ts b/packages/cli/tests/repl-include-pty.test.ts index 081ef6bb6..027f39472 100644 --- a/packages/cli/tests/repl-include-pty.test.ts +++ b/packages/cli/tests/repl-include-pty.test.ts @@ -24,6 +24,15 @@ import type { Pty } from "./support/pty.ts"; const ACT_MS = 30_000; const REACH_MS = 180_000; +/** + * The draft row's own name, which is how a pointer finds it. + * + * The draft is a block now: its name and focus marker are one row and what + * somebody typed is fitted into rows of its own beneath them. Clicking the name + * is what puts the cursor in it. + */ +const DRAFT_PROMPT = " DRAFT"; + function greeting(which: "First" | "Second"): string { return `---\ndescription: ${which} greeting contract.\nprops:\n type: object\n properties:\n from${which}: { type: string }\n required: [from${which}]\n additionalProperties: false\n---\n\n${which} greeting rendered.\n`; } @@ -41,9 +50,12 @@ function* click(pty: Pty, label: string): Operation { } function* submit(pty: Pty, source: string): Operation { - yield* click(pty, "Draft: "); + yield* click(pty, DRAFT_PROMPT); yield* pty.type(source); - yield* showing(pty, source.split("\n").at(-1) ?? source, ACT_MS); + // Across the rows the column wrapped it onto: the draft is a block in a + // column now, so a source longer than that column is on the screen without + // being on any one row of it. + yield* showingAcross(pty, source.split("\n").at(-1) ?? source, ACT_MS); yield* pty.type("\r"); } @@ -122,7 +134,7 @@ function* refused(pty: Pty, history: string, name: string): Operation { yield* submit(pty, `<${name} />`); yield* showing(pty, "this entry cannot run here", ACT_MS); expect(yield* readTextFile(history)).toBe(before); - yield* click(pty, "Draft: "); + yield* click(pty, DRAFT_PROMPT); for (let at = 0; at < `<${name} />`.length; at += 1) { yield* pty.type("\x7f"); } @@ -203,6 +215,10 @@ describe("REPL includes through the production source terminal", () => { yield* scoped(function* () { const pty = yield* allocate(line([location, ...includes]), home); yield* showing(pty, "Ready for Entry 3", REACH_MS); + // The catalog is a drawer on this screen: `Entries` is what opens it, and + // the row that selects the first entry is inside it. + yield* click(pty, "Entries"); + yield* showing(pty, "1. [ok]", ACT_MS); yield* click(pty, "1. [ok]"); yield* showing(pty, "First greeting rendered.", ACT_MS); yield* showing(pty, "External-only rendered.", ACT_MS); @@ -304,10 +320,11 @@ describe("REPL sequential source owners through a real terminal (#976)", () => { function* activateKey(pty: Pty, label: string, occurrence = 0): Operation { for (let step = 0; step < 160; step++) { const screen = yield* pty.shows(); - const matching = screen.filter((line) => { - const at = line.indexOf(label); - return at >= 0 && (!label.startsWith("component ") || at < 36); - }); + // Every row holding this label, in the order the screen draws them. + // The catalog is a drawer now rather than a column of its own, so the + // column a row lands in no longer tells its copy from another's: the + // occurrence does. + const matching = screen.filter((line) => line.includes(label)); const row = matching[occurrence]; const focused = label.startsWith("component ") ? row !== undefined && row.slice(0, row.indexOf(label)).includes(">") @@ -337,18 +354,27 @@ describe("REPL sequential source owners through a real terminal (#976)", () => { `focus never reached ${label} (${occurrence}): ${JSON.stringify(yield* pty.shows())}`, ); } + /** Open the Entries catalog, which is a drawer over the reading. */ + function* openCatalog(pty: Pty): Operation { + yield* activateKey(pty, "Entries"); + yield* showing(pty, "1. [ok]", ACT_MS); + } function* inspect(pty: Pty): Operation { for (const [index, expected] of ["first", "second"].entries()) { + // The scopes an entry holds are rows of its catalog, under the entry + // itself — and choosing one closes the drawer, so each pass opens it. + yield* openCatalog(pty); yield* activateKey(pty, "component OwnerLeaf", index); yield* showingAcross(pty, ' line.split("\u2502")[0] ?? ""); +function cueAt(terminal: Terminal): { readonly row: number; readonly column: number } | undefined { + for (const [row, line] of screenOf(terminal).entries()) { + const match = /(?:^|\s)>(?:>|\s*\S)/.exec(line); + if (match !== null) { + return { row, column: match.index + match[0].indexOf(">") }; + } + } + return undefined; } -/** - * What the draft row shows for one source: its last line under a count of the - * rest, which is the whole of what a one-row field can say about many lines. - */ -function draftPreview(source: string): string { - const lines = source.split("\n"); - const shown = - lines.length === 1 ? source : `[${lines.length - 1} lines] ${lines[lines.length - 1]}`; - return shown.trimEnd(); +/** Whether anything on this screen is wearing the focus cue. */ +function focusedSomewhere(terminal: Terminal): boolean { + return screenOf(terminal).some((line) => /(?:^|\s)>(?:>|\s*\S)/.test(line)); } -/** Whether the draft row says the next keystroke reaches it. */ -function draftFocused(terminal: Terminal): boolean { - return draftRow(terminal).trimStart().startsWith(">>"); +/** + * Whether the draft is showing this source. + * + * The draft is a block fitted into as many rows as its pane gives it, and the + * rows it shows are the end of the value — that is where somebody is typing. So + * what this asks is whether those rows are the last rows of the source, in + * order: a window on the end rather than a shortening of it. + */ +function draftShows(terminal: Terminal, source: string): boolean { + const seen = (text: string): string[] => + text + .split("\n") + .map((line) => line.trim()) + .filter((line) => line !== ""); + const shown = seen(draftText(terminal)); + const whole = seen(source); + if (shown.length === 0 || shown.length > whole.length) { + return false; + } + return whole.slice(whole.length - shown.length).join("\n") === shown.join("\n"); } /** - * Every entry the catalog is showing, by the identity it draws. + * How many entries this execution has admitted, as the screen says. * - * The outcome is part of the row this product promises, so a row without one is - * the empty placeholder rather than an entry. Markers are stripped: whether a row - * is selected or focused is a different question from whether it exists. + * From the History rail, which names every entry this file holds with the + * outcome it reached — and is drawn whether or not a catalog is open. The + * catalog is where the rows with names and keys are; this is the count, which + * is what a submission changes. */ -function entriesOn(terminal: Terminal): string[] { - const found: string[] = []; - for (const line of leftColumn(terminal)) { - const match = /^[\s>*]*(\d+)\.\s+\[([a-z]+)\]\s+(\S.*?)\s*$/.exec(line); - if (match !== null) { - found.push(`${match[1]}. ${match[3]}`); +function entryCountOn(terminal: Terminal): number { + for (const line of screenOf(terminal)) { + if (!line.includes("HISTORY")) { + continue; } + const band = line.slice(line.indexOf("HISTORY") + "HISTORY".length); + const rail = band.split("\u2500\u2500")[0] ?? ""; + return [...rail.matchAll(/\d+\s*[\u2713\u00d7\u25cf-]/g)].length; } - return found; + return 0; } /** The catalog row this reading has selected, if it is showing one. */ function selectedEntryOn(terminal: Terminal): string | undefined { - for (const line of leftColumn(terminal)) { - const match = /^[\s>]*\*\s*(\d+)\.\s+\[([a-z]+)\]\s+(\S.*?)\s*$/.exec(line); + // Read from the selected work's own breadcrumb, which is where this screen + // says which entry is being read. The catalog is an overlay somebody opens + // and closes; which entry they are in is a standing fact about the reading, + // and the breadcrumb is what states it whether or not a catalog is open. + // The breadcrumb row and no other: this screen also names entries in its chat + // work rows, and one of those is a fact about a message rather than about + // which entry is being read. The breadcrumb is the row that begins with the + // way to the catalog, and it says `No entry yet` where nothing is selected. + // The crumb's own form, which is the separator and then the entry it leads to: + // `Entries \u203a Entry 1` where there is room to name the catalog, and + // `\u203a Entry 1` on the narrow frame, whose bar above offers that way + // already. No row reached by this is the routing bar — that one offers the way + // and says nothing about where the reading is — and none is a chat row, which + // names an entry as a fact about a message rather than as this reading. + for (const line of screenOf(terminal)) { + const match = /\u203a\s*(Entry \d+)/.exec(line); if (match !== null) { - return `${match[1]}. ${match[3]}`; + return match[1]; } } return undefined; } -/** Which surface the frame is routed to, read from the marked heading. */ +/** + * The breadcrumb trail, as the one string its parts read as. + * + * Drawn as several nodes now — every name but the innermost is a control that + * goes to it — so the trail is the row they share, from the first separator to + * the state at its trailing edge. Read as a row rather than as one node's cell, + * because what a reader sees is the row. + */ +function crumbTrail(terminal: Terminal): string { + for (const line of screenOf(terminal)) { + const at = line.indexOf("\u203a "); + if (at === -1) { + continue; + } + const after = line.slice(at); + const edge = after.indexOf("\u2502"); + return (edge === -1 ? after : after.slice(0, edge)).trimEnd(); + } + return ""; +} + +/** + * Which surface the frame is routed to, read from the marked heading. + * + * Only a narrow frame has that bar: it routes one outlet, and the bar is how it + * says which. A shared width draws all three columns at once, so there is no + * routed surface to read there — {@link surfaceRouted} is what a row asks when + * it does not know which size it is at. + */ function routedSurfaceOn(terminal: Terminal): string | undefined { - for (const line of leftColumn(terminal)) { - const match = /^[\s>]*\*\s*(Sessions|Entries)\s*$/.exec(line); + for (const line of screenOf(terminal)) { + const match = /\*\s*(Sessions|Entries)(?:\s|$)/.exec(line); if (match !== null) { return match[1]; } @@ -390,6 +463,74 @@ function routedSurfaceOn(terminal: Terminal): string | undefined { return undefined; } +/** + * Whether this frame routes one outlet, and which — or none, where it draws + * every column at once. + * + * A shared width has no routed surface: the Transcript, the selected work and + * the right pane are all on the screen, so "being on Sessions" is a state only + * the narrow frame has. A row that asserts routing has to say which it means. + */ +function surfaceRouted(terminal: Terminal, expected: string): boolean { + const routed = routedSurfaceOn(terminal); + if (routed !== undefined) { + return routed === expected; + } + // A shared width draws every column at once and names no routed surface, so + // what it can answer is whether this frame *offers* that reading: the Entries + // surface is the one with a breadcrumb over it, and the Transcript of agent + // work is beside that reading rather than instead of it. Both are true there + // at once, which is the honest answer — a row that needs one of them to be + // exclusive is a row about the narrow frame, and asks it at that size. + return expected === "Entries" ? shows(terminal, "Entries") : shows(terminal, "TRANSCRIPT"); +} + +/** + * Reach the Transcript of agent work, whichever way this frame offers it. + * + * A narrow frame routes one outlet and names it in its own bar; a shared width + * draws the Transcript beside the selected work and offers its conversation + * selector instead. Either way what this leaves is focus inside that reading + * and nothing else moved. + */ +function* toTranscript(terminal: Terminal): Operation { + // A drawer covers the bar and the selector both, and a catalog left open is a + // reading nobody asked to keep. Put it away before looking for the way there. + yield* closeCatalog(terminal); + const routed = routedSurfaceOn(terminal); + if (routed === undefined) { + // A shared width already draws the Transcript beside the selected work, so + // reaching it is putting focus on that reading's own control. Nothing is + // activated: the selector is a destination, and opening it would be a + // choice this did not make. + yield* focusOn(terminal, "All sessions"); + return; + } + if (routed === "Sessions") { + return; + } + // A narrow frame routes one outlet, so reaching the Transcript means asking + // the bar for it. + yield* activate(terminal, "Sessions"); + yield* until_(terminal, "the Transcript routed", (one) => routedSurfaceOn(one) === "Sessions"); +} + +/** + * Reach the entries being read, whichever way this frame offers it. + * + * The control that routes this outlet is the same one that opens the catalog + * over it, so arriving here leaves a drawer up — and the reading this is for is + * underneath it. Put it away, so what follows is about the entries rather than + * about the list of them. + */ +function* toEntries(terminal: Terminal): Operation { + if (routedSurfaceOn(terminal) !== "Entries") { + yield* activate(terminal, "Entries"); + yield* until_(terminal, "the entries routed", (one) => routedSurfaceOn(one) !== "Sessions"); + } + yield* closeCatalog(terminal); +} + /** * Where the waiting-question control is, once this process is asking. * @@ -449,7 +590,11 @@ function* openQuestion(terminal: Terminal): Operation { * where the guidance has the row to itself rather than sharing it with a sidebar. */ function guidanceRow(terminal: Terminal): string { - return (screenOf(terminal)[0] ?? "").trimEnd(); + // The last row of the frame. This screen keeps its identity row at the top + // and its guidance at the bottom, so reading row zero would be reading the + // execution's name and calling it a direction. + const rows = screenOf(terminal); + return (rows[rows.length - 1] ?? "").trimEnd(); } function formShowing(terminal: Terminal): boolean { @@ -825,28 +970,41 @@ function* historyPath(root: string, execution: string): Operation { } /** - * The Transcript column's own rows, as the columns of the screen they occupy. + * The selected work's own rows, as the columns of the screen they occupy. + * + * The entry being read — its source, its output and its own reading window — is + * the centre column of this screen, between the Transcript on its left and + * Bindings/Sidekick on its right. So the slice is taken from the dividers the + * frame actually drew rather than from the names of the columns either side: + * the names are a heading's text and the dividers are where the pane is. + * + * Never trimmed: the padding between one row's text and the next is what a + * wrapped line's break became, and dropping it would join two words the reading + * kept apart. The divider and the reading's rail become spaces for the same + * reason — they are the frame saying where a row is, not part of what it says. * - * Sliced between the two headings that name the columns either side of it, so - * what comes back is this pane's text and not the sidebar's or the Bindings - * column's. Never trimmed: the padding between one row's text and the next is - * what a wrapped line's break became, and dropping it would join two words the - * reading kept apart. + * A narrow frame routes one outlet and draws no dividers, so its rows are the + * whole of what that outlet says. */ -function transcriptColumn(terminal: Terminal): string[] { +function workBodyColumn(terminal: Terminal): string[] { const rows = screenOf(terminal); - const header = rows.findIndex((line) => line.includes("Transcript")); - if (header < 0) { - throw new Error(`this screen has no Transcript column; it has ${JSON.stringify(rows)}`); + const heading = rows.findIndex((line) => line.includes("TRANSCRIPT")); + if (heading < 0) { + // One routed outlet. There is no column to cut out of the row. + return rows.map((line) => line.replace(/[\u2502\u2500]/g, " ")); } - const left = rows[header].indexOf("Transcript"); - const right = rows[header].indexOf("Bindings"); - // The pane's edge and the reading's rail become spaces: they are the frame - // saying where a row is, not part of what the row says, and a wrapped line's - // break is the whitespace either side of them. - return rows.map((line) => - line.slice(left, right < 0 ? undefined : right).replace(/[\u2502\u2500]/g, " "), - ); + const dividers: number[] = []; + for (let at = 0; at < rows[heading].length; at += 1) { + if (rows[heading][at] === "\u2502") { + dividers.push(at); + } + } + const left = dividers[0]; + const right = dividers[1]; + if (left === undefined) { + throw new Error(`this screen draws no column divider; it has ${JSON.stringify(rows)}`); + } + return rows.map((line) => line.slice(left + 1, right).replace(/[\u2502\u2500]/g, " ")); } /** One string with every run of whitespace as one space, for comparing two. */ @@ -912,8 +1070,19 @@ describe("REPL journey: one entry, from raw bytes", () => { // One burst is one frame, not one frame per character. expect(terminal.presented.length - before).toBeLessThan(4); - // The field is one line and says how many precede it. - expect(shows(terminal, `[${source.split("\n").length - 1} lines]`)).toBe(true); + // The field is a block now, and what it shows is the end of the value — + // that is where somebody is typing. So the rows on the screen are the + // source's own last rows, in order, with the authored blank between them + // kept as a row. The whole of it, byte for byte, is asserted against the + // decoded location below; this is what a reader can see of it. + expect(draftShows(terminal, source)).toBe(true); + const tail = source.split("\n").filter((line) => line.trim() !== ""); + expect( + draftText(terminal) + .trimEnd() + .endsWith(tail[tail.length - 1] ?? ""), + ).toBe(true); + expect(source.includes("\n\n")).toBe(true); // Nothing durable yet: a draft is this process's, and the file is empty. expect(yield* records(root, files[0])).toEqual([]); @@ -1044,19 +1213,18 @@ describe("REPL journey: one entry, from raw bytes", () => { expect(shows(terminal, "Decision: approve")).toBe(true); // Settled: the root closed, so the answer is in the history and the // entry's catalog row carries the outcome it closed with. - expect(shows(terminal, "[ok]")).toBe(true); + expect(shows(terminal, "\u2713 COMPLETED")).toBe(true); terminal.end(); yield* running; }); - // The Sessions surface is still there, and both of its sections are still - // explicitly empty. Two sections rather than one placeholder since #883 - // PR 2: this command has a Sidekick, so Sessions has two owners and each - // one says what it holds. - expect(shows(terminal, "Entry agents")).toBe(true); - expect(shows(terminal, "No entry turns.")).toBe(true); - expect(shows(terminal, "No Sidekick turns.")).toBe(true); + // The Transcript is still there and still explicitly empty. One list + // across both owners since the single-list correction, so an execution + // that has done no agent work says so once — the two owners keep their + // separate domains, and what changed is how the screen reads them. + expect(shows(terminal, "TRANSCRIPT")).toBe(true); + expect(shows(terminal, "(none retained)")).toBe(true); expect(location).toBeDefined(); expect(terminal.resets).toBe(1); }); @@ -1203,10 +1371,17 @@ describe("REPL journey: one entry, from raw bytes", () => { second.terminal.feed("\x1b"); yield* until_(second.terminal, "the drawer closing", (one) => !shows(one, "[close]")); - // The same topology: the entry, and the nested component occurrence beneath - // it, at the exact keys the first process recorded. + // The same topology: the entry, and the nested component occurrence + // beneath it, at the exact keys the first process recorded. Asked of the + // catalog, which is where this screen keeps them — opened explicitly, + // read, and put away again, because a drawer left open would cover the + // reading the rows below are about. + yield* openCatalog(second.terminal); expect(shows(second.terminal, entry.name)).toBe(true); expect(shows(second.terminal, nested.name)).toBe(true); + yield* closeCatalog(second.terminal); + // And closing chose nothing: the reading is the one the location named. + expect(selectedEntryOn(second.terminal)).toBeDefined(); // The binding value is asserted above, in the drawer this location named. // // Re-anchored for #883. It was asserted here as a `plan = ` row, which is @@ -1265,13 +1440,12 @@ describe("REPL journey: one entry, from raw bytes", () => { expect(yield* records(root, files[0])).toEqual([]); // The screen says where it is, what it holds and what it is waiting for. - // Both Sessions sections say they hold nothing, which is what a mounted - // command with a Sidekick draws since #883 PR 2. - expect(shows(terminal, "Sessions")).toBe(true); - expect(shows(terminal, "No entry turns.")).toBe(true); - expect(shows(terminal, "No Sidekick turns.")).toBe(true); + // The Transcript is one list across both owners, so an execution that + // has done nothing says so once rather than twice. + expect(shows(terminal, "TRANSCRIPT")).toBe(true); + expect(shows(terminal, "(none retained)")).toBe(true); expect(shows(terminal, "Entries")).toBe(true); - expect(shows(terminal, "(not submitted)")).toBe(true); + expect(shows(terminal, "No entry yet")).toBe(true); terminal.end(); yield* running; @@ -1508,9 +1682,23 @@ describe("REPL journey: leaving", () => { yield* settled(); // On the screen, not merely in the route: the entry the location named is - // the marked one, and what was being typed is in the draft row. - expect(routedSurfaceOn(second.terminal)).toBe("Entries"); + // the one being read, its own catalog row carries the selected marker, + // and what was being typed is in the draft. + // + // At a shared width there is no routed-surface bar — three columns are + // drawn at once, and that bar belongs to the narrow frame that routes one + // outlet. The breadcrumb is what states which entry is being read here, + // the catalog is where the marked row is, and the decoded route is the + // exact surface. The location equality below covers all three again. + const decoded = decodeLocation(location); + expect(decoded.ok).toBe(true); + if (decoded.ok) { + expect(decoded.value.surface).toBe("entries"); + } expect(selectedEntryOn(second.terminal)).toBeDefined(); + yield* openCatalog(second.terminal); + expect(screenOf(second.terminal).some((line) => /\*\s*\d+\.\s+\[/.test(line))).toBe(true); + yield* closeCatalog(second.terminal); expect(draftText(second.terminal)).toBe("# what comes next"); second.terminal.end(); @@ -1559,6 +1747,93 @@ describe("REPL journey: leaving", () => { }); }); +/** + * The breadcrumb's ancestors are controls, and each goes where it says (#971 B3). + * + * The trail says where the reading is, and every name in it but the last is a + * way back out to that name. So the entry's own name, with a nested scope + * selected under it, is a control: activating it leaves the reading at the + * entry and the trail with one name in it, and the location the command + * publishes on its way out says the same thing — which is what makes this a + * fact about the route rather than about a row. + */ +describe("REPL journey: the breadcrumb goes back the way it came", () => { + it("B3: an ancestor in the trail is a control, and it selects its own prefix", function* () { + const { terminal, install } = recordingTerminal(); + const source = yield* referenceSource(); + let standing: string | undefined; + yield* scoped(function* (): Operation { + yield* install(); + yield* immediateClock(); + yield* useTempFileCompiler(); + yield* useTemporaryHost(); + + const exited = exitRoute(); + const running = yield* spawn(function* (): Operation { + const ran = yield* runReplProgram({ profile: PROFILE }); + if (!ran.ok) { + throw ran.error; + } + exited.settle(ran.value); + }); + yield* untilDrawn(terminal); + + terminal.bytes(BYTES.encode(source)); + yield* settled(60); + terminal.feed("\r"); + yield* settled(400); + yield* dismissQuestion(terminal); + + // A nested scope, chosen in the catalog: that is what gives this trail an + // ancestor to go back to. With only an entry selected the trail is one + // name, and one name is where the reading already is. + yield* openCatalog(terminal); + yield* activate(terminal, "component Checklist"); + yield* until_( + terminal, + "the catalog closing behind the choice", + (one) => !catalogShowing(one), + ); + yield* until_(terminal, "the Checklist scope being read", (one) => shows(one, "Checklist")); + + // The trail now names both, in the order they were selected, and the + // outer one is a control a person can reach by Tab. + const trail = crumbTrail(terminal); + expect(trail).toContain("Entry 1"); + expect(trail).toContain("Checklist"); + expect(trail.indexOf("Entry 1")).toBeLessThan(trail.indexOf("Checklist")); + yield* focusOn(terminal, "\u203a Entry 1"); + expect(focusedOn(terminal, "\u203a Entry 1")).toBe(true); + + // Activated, it selects its own prefix of the path: the reading is the + // entry, and the scope beneath it is no longer selected. + terminal.feed("\r"); + yield* until_( + terminal, + "the reading coming back to the entry", + (one) => !crumbTrail(one).includes("Checklist"), + ); + expect(crumbTrail(terminal)).toContain("Entry 1"); + // And it is the entry that is being read, not nothing: the trail still + // names it and the catalog still marks its row. + yield* openCatalog(terminal); + expect(marked(terminal, "1. ")).toBe(true); + yield* closeCatalog(terminal); + + terminal.end(); + yield* running; + standing = exited.location(); + }); + + // The route the command published says the same: one scope, the entry's. + const stood = decodeLocation(standing ?? ""); + expect(stood.ok).toBe(true); + if (stood.ok) { + expect(stood.value.scopes).toHaveLength(1); + } + }); +}); + describe("REPL journey: what it refuses, and what it leaves alone", () => { it("J1: an invalid navigation leaves the standing route, tree, focus and targets", function* () { const { terminal, install } = recordingTerminal(); @@ -1588,13 +1863,21 @@ describe("REPL journey: what it refuses, and what it leaves alone", () => { yield* settled(400); yield* dismissQuestion(terminal); - // A standing, meaningful selection, reached through the product: the nested - // component occurrence, which exists only after the run admitted it. + // A standing, meaningful selection, reached through the product: the + // nested component occurrence, which exists only after the run admitted + // it. Its row is in the catalog, so the catalog is opened to reach it — + // and choosing it closes the catalog behind the choice, which is what + // leaves the reading on screen. + yield* openCatalog(terminal); yield* activate(terminal, "component Checklist"); - // Selected, and visibly so: the reading on screen is that scope's own. - yield* until_(terminal, "the Checklist scope being read", (one) => - shows(one, "component Checklist"), + yield* until_( + terminal, + "the catalog closing behind the choice", + (one) => !catalogShowing(one), ); + // Selected, and visibly so: the breadcrumb of the reading on screen names + // that scope. + yield* until_(terminal, "the Checklist scope being read", (one) => shows(one, "Checklist")); const before = screenOf(terminal); const admitted = yield* records(root, files[0]); @@ -1868,7 +2151,7 @@ describe("REPL journey: what it refuses, and what it leaves alone", () => { terminal.resized({ columns: 160, rows: 36 }); yield* settled(30); // Recovered without restarting anything. - expect(shows(terminal, "Sessions")).toBe(true); + expect(shows(terminal, "TRANSCRIPT")).toBe(true); terminal.end(); yield* running; @@ -1910,7 +2193,7 @@ describe("REPL journey: the whole of it, from raw bytes", () => { // and reading it off a clipped preview would prove less than it looks. terminal.bytes(BYTES.encode(source)); yield* settled(60); - expect(draftText(terminal)).toBe(draftPreview(source)); + expect(draftShows(terminal, source)).toBe(true); // 2. Admitted, and then held before it can reach the question. // @@ -1927,7 +2210,7 @@ describe("REPL journey: the whole of it, from raw bytes", () => { // mounted and Pause is the one control before the draft. All of it in one // burst, so nothing is read and nothing waits. expect(draftFocused(terminal)).toBe(true); - expect(draftText(terminal)).toBe(draftPreview(source)); + expect(draftShows(terminal, source)).toBe(true); terminal.feed("\r"); terminal.feed("\x1b[Z\r"); @@ -1985,10 +2268,12 @@ describe("REPL journey: the whole of it, from raw bytes", () => { // Waited for in the model first and then reached on screen: the gap above // each column heading costs the transcript a row, so this output is one // press down its own window rather than drawn with the rest. - yield* until_(terminal, "the entry settling", (t) => shows(t, "[ok]")); + // The screen says it in the selected work's own heading now: the + // outcome of the entry being read, beside its breadcrumb. + yield* until_(terminal, "the entry settling", (t) => shows(t, "\u2713 COMPLETED")); yield* scrollReadingTo(terminal, "Decision: approve"); expect(shows(terminal, "Decision: approve")).toBe(true); - expect(shows(terminal, "[ok]")).toBe(true); + expect(shows(terminal, "\u2713 COMPLETED")).toBe(true); // The generated fragment's source is on screen, inside the enclosure its // author wrote. Which text that is comes from the model rather than from a @@ -2010,11 +2295,20 @@ describe("REPL journey: the whole of it, from raw bytes", () => { // rather than clips: the whole of it is on the screen, across as many rows // as the pane needed, and concatenating the pane's own rows is what gets // it back. - const column = transcriptColumn(terminal).join(""); + const column = workBodyColumn(terminal).join(""); expect(flattened(column)).toContain(flattened(sourceRow.source)); // 7. The complete binding value, from the drawer that holds it. + // A catalog row: opened to reach it, and the choice closes it again. + yield* openCatalog(terminal); + // A catalog row: opened to reach it, and the choice closes it again. + yield* openCatalog(terminal); yield* activate(terminal, "1. [ok] entry-1"); + yield* until_( + terminal, + "the catalog closing behind the choice", + (one) => !catalogShowing(one), + ); // Through the tab, because the command's accepted default right pane is // the Sidekick: a person reading their bindings asks for them, and this // row is about what the binding's own drawer holds once they have. @@ -2055,7 +2349,7 @@ describe("REPL journey: the whole of it, from raw bytes", () => { // Closing keeps the position and the selection; only the layer moves. terminal.feed("\x1b"); yield* settled(30); - expect(selectedEntryOn(terminal)).toContain("entry-1"); + expect(selectedEntryOn(terminal)).toContain("Entry 1"); expect(historicalOn(terminal)).toBe(true); yield* activate(terminal, "answered"); expect(shows(terminal, "[close]")).toBe(true); @@ -2149,7 +2443,7 @@ describe("REPL journey: the whole of it, from raw bytes", () => { // else; the drawer is reopened below for its own content. second.terminal.feed("\x1b"); yield* settled(30); - expect(selectedEntryOn(second.terminal)).toContain("entry-1"); + expect(selectedEntryOn(second.terminal)).toContain("Entry 1"); expect(historicalOn(second.terminal)).toBe(true); yield* activate(second.terminal, "answered"); yield* until_(second.terminal, "the retained drawer again", (one) => shows(one, "[close]")); @@ -2169,13 +2463,26 @@ describe("REPL journey: the whole of it, from raw bytes", () => { // Then out from under the drawer, for the rest of what the file holds. second.terminal.feed("\x1b"); yield* settled(30); + // The catalog is where this screen keeps the entries and their scopes, so + // it is opened to read them and put away again before the reading below. + yield* openCatalog(second.terminal); + // Read in the catalog, which is where this screen keeps the rows. + yield* openCatalog(second.terminal); + expect(shows(second.terminal, "1. [ok] entry-1")).toBe(true); + yield* closeCatalog(second.terminal); + // Read in the catalog, which is where this screen keeps the rows. + yield* openCatalog(second.terminal); expect(shows(second.terminal, "component Checklist")).toBe(true); + yield* closeCatalog(second.terminal); + yield* closeCatalog(second.terminal); // Re-anchored for #881 PR 2: the transcript's `generated admitted:` // record row became the entry's reading, and the fragment this cold // process reconstructed is the catalog row that names it. The claim is // unchanged — a reopened process shows the generated region the first one // admitted. + yield* openCatalog(second.terminal); expect(shows(second.terminal, "generated generated")).toBe(true); + yield* closeCatalog(second.terminal); // The last of that reading, reached through its own window: the accepted // row of space above each column heading costs this column a row, so the // output the answer produced is a press down rather than drawn with the @@ -2323,7 +2630,7 @@ describe("REPL journey: resizing while a frame is being prepared", () => { const wide = screenOf(terminal); expect(wide.length).toBe(36); expect(shows(terminal, "resize me")).toBe(true); - expect(shows(terminal, "Sessions")).toBe(true); + expect(shows(terminal, "TRANSCRIPT")).toBe(true); /** The execution this run opened, named by the file it created for it. */ const execution = (yield* histories(host))[0]?.replace(/\.jsonl$/, ""); expect(execution).toBeDefined(); @@ -2366,7 +2673,8 @@ describe("REPL journey: resizing while a frame is being prepared", () => { // draft it was carrying, on the row a person types on. expect(after.some((line) => line.includes("at least 72x20"))).toBe(false); expect(after.some((line) => line.includes("* Entries"))).toBe(true); - expect(after.some((line) => line.includes(">> Draft: resize me!"))).toBe(true); + expect(draftFocused(terminal)).toBe(true); + expect(draftShows(terminal, "resize me!")).toBe(true); // And the same again, but dragged for longer than one frame is allowed to // rebuild for. Every size read answers with the size that was there and @@ -2412,11 +2720,12 @@ describe("REPL journey: resizing while a frame is being prepared", () => { // same draft — now carrying the second keystroke. expect(settledScreen.some((line) => line.includes("at least 72x20"))).toBe(false); expect(settledScreen.some((line) => line.includes("* Entries"))).toBe(true); - expect(settledScreen.some((line) => line.includes(">> Draft: resize me!?"))).toBe(true); + expect(draftFocused(terminal)).toBe(true); + expect(draftShows(terminal, "resize me!?")).toBe(true); // A whole frame again, not a fragment: the footer's own rows are all back, // which a narrow frame draws beneath the one outlet it routes. expect(settledScreen.some((line) => line.includes("[history]"))).toBe(true); - expect(settledScreen.some((line) => line.includes(HISTORY_TITLE))).toBe(true); + expect(settledScreen.some((line) => line.includes(COMPACT_HISTORY_TITLE))).toBe(true); expect((execution ?? "").length).toBeGreaterThan(0); terminal.end(); @@ -2453,7 +2762,7 @@ describe("REPL journey: the same product at every size", () => { // One row, and it says how many lines it is not showing. A draft this long // is read on the row it is typed on; its exact bytes are asserted at exit, // where the route still carries them, rather than off a clipped preview. - expect(draftText(terminal)).toBe(draftPreview(source)); + expect(draftShows(terminal, source)).toBe(true); // And no row of the reading reaches past the surface it was placed in: the // columns to its right belong to the inspection column, and the layout @@ -2587,7 +2896,7 @@ describe("REPL journey: the same product at every size", () => { // The routed surface, and the location above it — both, at the smallest // size this REPL draws at. expect(shows(terminal, "Entries")).toBe(true); - expect(routedSurfaceOn(terminal)).toBe("Entries"); + expect(surfaceRouted(terminal, "Entries")).toBe(true); expect(surfaceWidthOf(terminal.size)).toBe(NARROW.columns); // Narrow draws no dedicated location, at this size or any other. expect(screenOf(terminal).some((line) => line.includes("xmd://"))).toBe(false); @@ -3179,7 +3488,16 @@ describe("REPL journey: Pause reaches a submission before its entry exists", () /** The footer's one action row, at whatever size this terminal is. */ function actionRow(terminal: Terminal): string { - return screenOf(terminal)[terminal.size.rows - FOOTER_ROWS] ?? ""; + // The row this screen draws its footer controls on, found by the way out + // rather than counted back from the frame's height: how many rows a footer + // takes is the composition's, and this screen keeps its controls on one + // compact History row with the guidance below it. + return screenOf(terminal).find((line) => line.includes("[exit]")) ?? ""; + } + + /** The row that states the lifecycle phase, wherever this screen says it. */ + function phaseRow(terminal: Terminal, word: string): string { + return screenOf(terminal).find((line) => line.includes(word)) ?? ""; } /** @@ -3269,7 +3587,13 @@ describe("REPL journey: Pause reaches a submission before its entry exists", () // and the state is text. Backtab from the draft reaches Pause, because // the word beside it is not a focus stop and Continue is not mounted. const preparing = actionRow(terminal); - expect(preparing.indexOf("[pause]")).toBeLessThan(preparing.indexOf("preparing")); + // The control is on the footer's own row and the phase is stated as + // text. They are two rows of this screen rather than two words of one: + // the controls keep the compact History row and the phase is said in + // the band below it, which is what makes the phase a sentence a reader + // can read rather than whatever the control row had left. + expect(preparing).toContain("[pause]"); + expect(phaseRow(terminal, "preparing")).toContain("preparing"); expect(screenOf(terminal).length).toBe(size.rows); terminal.feed("\x1b[Z"); yield* settled(30); @@ -3534,7 +3858,9 @@ describe("REPL journey: Pause reaches a submission before its entry exists", () preflight.release(); // Failed as an entry, not refused as a submission: it was admitted, so it // has a position of its own to carry its failure. - yield* until_(terminal, "the failed entry", (one) => shows(one, "[err] entry-1")); + // The History rail marks it, with no catalog open: a wait that had to + // open a drawer would be changing the screen it was waiting on. + yield* settledEntry(terminal, 1, "err"); // Read from inside the refusal: by the time it fails the entry is armed, // and nothing its author wrote has run. The body was never reached, so // the hold it was asked for had nothing to be early for. @@ -3684,7 +4010,7 @@ describe("REPL journey: Pause reaches a submission before its entry exists", () terminal.bytes(BYTES.encode(first)); yield* settled(60); terminal.feed("\r"); - yield* until_(terminal, "the first entry settled", (one) => shows(one, "entry-1")); + yield* settledEntry(terminal, 1, "ok"); expect(preflight.holding).toBe(false); // The second, which does name it — so submitting suspends in the @@ -3776,7 +4102,7 @@ describe("REPL journey: Pause reaches a submission before its entry exists", () first.terminal.bytes(BYTES.encode(typed)); yield* settled(40); expect(preflight.holding).toBe(true); - expect(draftText(first.terminal)).toBe(draftPreview(held + typed)); + expect(draftShows(first.terminal, held + typed)).toBe(true); // And Exit reaches the command while it is still held. This cancels and // joins the work the submission owns; nothing is admitted, and no @@ -3828,7 +4154,7 @@ describe("REPL journey: Pause reaches a submission before its entry exists", () // The draft is restored and nothing else is: no preparation, no request, // and no Pause, because there is no submission and no live entry. - expect(draftText(second.terminal)).toBe(draftPreview(held + typed)); + expect(draftShows(second.terminal, held + typed)).toBe(true); expect(shows(second.terminal, "[pause]")).toBe(false); expect(shows(second.terminal, "preparing")).toBe(false); expect(shows(second.terminal, "pausing")).toBe(false); @@ -3998,7 +4324,16 @@ describe("REPL journey: a position earlier than the entry being read", () => { expect(head.entries.map((entry) => entry.key)).toEqual(["entry-1", "entry-2"]); // Standing on the second entry, with the next one already being typed. + // A catalog row: opened to reach it, and the choice closes it again. + yield* openCatalog(terminal); + // A catalog row: opened to reach it, and the choice closes it again. + yield* openCatalog(terminal); yield* activate(terminal, "2. [ok] entry-2"); + yield* until_( + terminal, + "the catalog closing behind the choice", + (one) => !catalogShowing(one), + ); yield* focusDraft(terminal); terminal.bytes(BYTES.encode("Three.")); yield* settled(40); @@ -4013,15 +4348,21 @@ describe("REPL journey: a position earlier than the entry being read", () => { terminal.feed("\x1b"); yield* settled(40); - // The selection that prefix cannot hold is gone from the catalog, and the - // position asked for stands. - expect(selectedEntryOn(terminal)).toBeUndefined(); + // The selection that prefix cannot hold is gone, and the position asked + // for stands. Read from the catalog, which is where this screen keeps the + // rows and their markers: the breadcrumb names whichever entry the + // reading fell back to, which is a different question. + yield* openCatalog(terminal); + expect(screenOf(terminal).some((line) => /\*\s*\d+\.\s+\[/.test(line))).toBe(false); + yield* closeCatalog(terminal); expect(historicalOn(terminal)).toBe(true); // And the catalog is the one that prefix holds, rather than the head's. // Which entries the prefix holds, whatever they had settled to by then — // at this position the first one has not closed. + yield* openCatalog(terminal); expect(shows(terminal, "] entry-1")).toBe(true); + yield* closeCatalog(terminal); expect(shows(terminal, "] entry-2")).toBe(false); // The draft stands through all of it, on the row it is typed on. expect(draftText(terminal)).toBe("Three."); @@ -4177,17 +4518,6 @@ function* reopening(root: string): Operation { * test is that the frame says it. The marker sits in its own two columns before * the label, beside the focus marker and independent of it. */ -function marked(terminal: Terminal, label: string): boolean { - for (const line of screenOf(terminal)) { - const at = line.indexOf(label); - // The two columns immediately before the label, so a focus marker beside - // them changes nothing: the two channels are independent. - if (at >= 2 && line.slice(at - 2, at) === "* ") { - return true; - } - } - return false; -} /** * Every rendered row, as the screen leaves it. @@ -4213,32 +4543,39 @@ function admittedDraft(terminal: Terminal, before: Submitting): Operation if (draftText(one) !== "") { return false; } - const now = entriesOn(one); - if (now.length > before.entries.length && now.some((e) => !before.entries.includes(e))) { + // The History rail has gained an entry, or — where no rail is drawn — the + // status row names one it did not name. Read without opening the catalog: + // the catalog is a drawer, and a wait that opened one would be changing the + // screen it is waiting on. + if (entryCountOn(one) > before.entries) { return true; } - // A narrow frame routed to Sessions draws no catalog at all, so what says an - // entry was admitted there is the status row naming one it did not name. - return now.length === 0 && statusOn(one) !== before.status; + return entryCountOn(one) === 0 && statusOn(one) !== before.status; }); } /** What the screen held before a submission, so the change can be read off it. */ interface Submitting { - readonly entries: readonly string[]; + readonly entries: number; readonly status: string | undefined; } function submitting(terminal: Terminal): Submitting { - return { entries: entriesOn(terminal), status: statusOn(terminal) }; + return { entries: entryCountOn(terminal), status: statusOn(terminal) }; } -/** Wait until this entry's catalog row carries this outcome. */ -function settledEntry(terminal: Terminal, order: number, outcome: string): Operation { - return awaiting(terminal, `entry ${order} reaching ${outcome}`, (one) => - shows(one, `${order}. [${outcome}]`), - ); -} +/** What the History rail marks each entry with, by its order. */ + +/** + * Wait until this entry has reached this outcome, as the screen says. + * + * Read from the History rail, which marks every entry this file holds with the + * outcome it reached — and says so whichever entry is being read, and whether + * or not a catalog is open. The catalog's own row spells the same fact `[ok]`; + * a row that wants that literal spelling opens the catalog and reads it there. + */ + +/** The mark the History rail gives this entry, or none where it names none. */ describe("REPL journey: three entries, one command", () => { it("EJ1: a draft survives a refusal, inherits, fails, and is followed", function* () { @@ -4285,7 +4622,7 @@ describe("REPL journey: three entries, one command", () => { expect(yield* records(root, files[0])).toEqual(held); // The draft is exactly where it was, on the row it is typed on: the last // of its lines under a count of the rest. - expect(draftText(terminal)).toBe(draftPreview(JOURNEY_TWO)); + expect(draftShows(terminal, JOURNEY_TWO)).toBe(true); // 3. Answer the question, and Entry 1 settles. yield* activate(terminal, "[answer]"); @@ -4322,7 +4659,16 @@ describe("REPL journey: three entries, one command", () => { // Read under its own entry. Answering Entry 1's question selected the entry // that was asking, and a selected entry is a transcript locus — so Entry 3's // output appears where Entry 3 is, which is what selecting it shows. + // A catalog row: opened to reach it, and the choice closes it again. + yield* openCatalog(terminal); + // A catalog row: opened to reach it, and the choice closes it again. + yield* openCatalog(terminal); yield* activate(terminal, "3. [ok] entry-3"); + yield* until_( + terminal, + "the catalog closing behind the choice", + (one) => !catalogShowing(one), + ); yield* awaiting(terminal, "entry 3's own transcript", (one) => shows(one, "Three: alpha-three"), ); @@ -4341,13 +4687,25 @@ describe("REPL journey: three entries, one command", () => { "err", "ok", ]); + // The catalog's own rows, in its own spelling. Opened to read them, + // because that is where this screen keeps the list. + yield* openCatalog(terminal); for (const [at, outcome] of ["ok", "err", "ok"].entries()) { expect([at, shows(terminal, `${at + 1}. [${outcome}]`)]).toEqual([at, true]); } // 6. The first entry is still selectable, and selecting it is a locus. + // A catalog row: opened to reach it, and the choice closes it again. + yield* openCatalog(terminal); + // A catalog row: opened to reach it, and the choice closes it again. + yield* openCatalog(terminal); yield* activate(terminal, "1. [ok] entry-1"); - expect(selectedEntryOn(terminal)).toContain("entry-1"); + yield* until_( + terminal, + "the catalog closing behind the choice", + (one) => !catalogShowing(one), + ); + expect(selectedEntryOn(terminal)).toContain("Entry 1"); yield* awaiting(terminal, "entry 1's own transcript", (one) => shows(one, "One: alpha/go")); expect(shows(terminal, "Three: alpha-three")).toBe(false); @@ -4503,26 +4861,34 @@ describe("REPL journey: what an open drawer covers, through the terminal", () => * accumulated screen is current at whatever size it is read at. */ function* covering(label: string): Operation { - const box = drawerBox(terminal); yield* settled(30); + // What a person is looking at before the drawer goes up, kept whole: + // which of its rows the drawer will actually cover is not a question + // the size answers on its own, so it is asked once the drawer is there + // and the same rectangle is then read on both screens. const body = screenOf(terminal); - // Pre-assert: the rectangle the drawer is about to be placed in really - // has text in it. Without this the assertion below would pass on an - // empty screen. - const behind = insideOf(body, box); - expect(behind.length).toBeGreaterThan(0); - const covered = textOf(body, box); - expect(covered.trim().length).toBeGreaterThan(0); yield* activate(terminal, "[history]"); // Waited for in the rectangle, because the drawer covers the row the // location is drawn on: there is no readable location to ask while it is // open, which is itself the coverage being tested. yield* until_(terminal, `the drawer at ${label}`, (one) => - textOf(screenOf(one), box).includes("History"), + textOf(screenOf(one), drawerBox(one)).includes("History"), ); yield* settled(30); const open = screenOf(terminal); + // The rectangle this frame actually placed it in. Read from the open + // drawer and not from the closed screen: the top is the rule the drawer + // draws, and on a closed screen there is no rule to find — the rows a + // guidance block of a different height leaves above it would be counted + // as covered while the drawer never reaches them. + const box = drawerBox(terminal); + // Pre-assert: the rectangle the drawer was placed in really had text in + // it. Without this the assertion below would pass on an empty screen. + const behind = insideOf(body, box); + expect(behind.length).toBeGreaterThan(0); + const covered = textOf(body, box); + expect(covered.trim().length).toBeGreaterThan(0); const inside = textOf(open, box); // Its own rows are in the rectangle instead — including its heading, so // this is the drawer and not an empty hole. @@ -4543,10 +4909,16 @@ describe("REPL journey: what an open drawer covers, through the terminal", () => } expect([label, line, inside.includes(line)]).toEqual([label, line, false]); } - // The footer is never covered: the draft owns the last row and shares it - // with nothing. + // The footer is never covered. The drawer stops above all of it: the + // compact History row, the directions below that, and the draft the + // body keeps at its own foot are each still there to read, outside the + // rectangle. The draft is not the frame's last row any more — that row + // is the guidance — so each is asked for where it actually is. expect(box.bottom).toBeLessThanOrEqual(terminal.size.rows - 7); - expect(open[terminal.size.rows - 1]?.includes(">")).toBe(true); + expect(guidanceRow(terminal).trim().length).toBeGreaterThan(0); + expect(open.some((line, row) => row >= box.bottom && line.includes(DRAFT_PROMPT))).toBe( + true, + ); terminal.feed("\x1b"); yield* until_(terminal, `the drawer closing at ${label}`, (one) => !shows(one, "[close]")); @@ -4605,11 +4977,23 @@ describe("REPL journey: a selection, a position and a draft across a resize", () // 1. An entry selected by pointing at the cell its row is drawn in, which // is the production input path for a selection. + // A catalog row: opened to point at it, and the choice closes it again. + yield* openCatalog(terminal); yield* click(terminal, "2. [ok] entry-2"); + yield* until_( + terminal, + "the catalog closing behind the choice", + (one) => !catalogShowing(one), + ); yield* until_(terminal, "entry-2 being the locus", (one) => - (selectedEntryOn(one) ?? "").includes("entry-2"), + (selectedEntryOn(one) ?? "").includes("Entry 2"), ); + yield* openCatalog(terminal); + // Read in the catalog, which is where this screen keeps the rows. + yield* openCatalog(terminal); expect(marked(terminal, "2. [ok] entry-2")).toBe(true); + yield* closeCatalog(terminal); + yield* closeCatalog(terminal); // 2. A History position, chosen through the drawer the footer control // opens. Entry 2's own admission, so the entry selected above still @@ -4636,13 +5020,16 @@ describe("REPL journey: a selection, a position and a draft across a resize", () // The whole semantic reading, as the screen shows it: the entry selected, // a recorded position being read, and the draft on its own row. - expect(selectedEntryOn(terminal)).toContain("entry-2"); + expect(selectedEntryOn(terminal)).toContain("Entry 2"); expect(historicalOn(terminal)).toBe(true); expect(draftFocused(terminal)).toBe(true); - expect(shows(terminal, `>> Draft: ${DRAFTED}`)).toBe(true); + expect(draftShows(terminal, DRAFTED)).toBe(true); // At entry 2's own admission the catalog says what that position says: the // entry is there and has settled nothing yet. + // The marked row is in the catalog, which is where the rows are. + yield* openCatalog(terminal); expect(marked(terminal, READING)).toBe(true); + yield* closeCatalog(terminal); // 4. Narrow. The routed surface is one column wide now, and what a reader // chose is still what the screen says: the draft is on its own row, the @@ -4663,9 +5050,22 @@ describe("REPL journey: a selection, a position and a draft across a resize", () const narrow = replay(terminal.presented.slice(from)); expect(narrow.length).toBe(20); expect(narrow.filter((line) => line.trimEnd().length > 72)).toEqual([]); - expect(narrow.some((line) => line.includes(`>> Draft: ${DRAFTED}`))).toBe(true); - expect(markedIn(narrow, READING)).toBe(true); - expect(narrow.some((line) => line.includes("1. [ok] entry-1"))).toBe(true); + // The draft is drawn in this frame, with what was typed into it. Drawn + // rather than focused: a resize rebuilds the tree, and which control the + // new one hands the keyboard to is its business — what this row is about + // is that the text a person put there is still on the screen they got + // back. Read from the narrow frames, like the rows above and below. + expect(narrow.some((line) => line.includes(DRAFT_PROMPT))).toBe(true); + expect(draftTextIn(narrow)).toBe(DRAFTED); + // And the catalog is still the catalog, read where this screen keeps it: + // a drawer over the reading, opened for its rows and put away again. The + // rows of the narrow terminal, so a wide frame's leftovers further down + // the buffer cannot answer for it. + yield* openCatalog(terminal); + const listed = screenOf(terminal); + expect(markedIn(listed, READING)).toBe(true); + expect(listed.some((line) => line.includes("1. [ok] entry-1"))).toBe(true); + yield* closeCatalog(terminal); // 5. Wide again, and the reading is the one that went in: the same selected // entry, the same frozen position, the same draft, on a screen that has @@ -4677,24 +5077,43 @@ describe("REPL journey: a selection, a position and a draft across a resize", () "the wide frame again", (one) => one.presented.length > back && replay(one.presented.slice(back)).length === 36, ); - expect(selectedEntryOn(terminal)).toContain("entry-2"); + expect(selectedEntryOn(terminal)).toContain("Entry 2"); expect(historicalOn(terminal)).toBe(true); expect(draftText(terminal)).toBe(DRAFTED); + // The marked row is in the catalog, which is where the rows are. + yield* openCatalog(terminal); expect(marked(terminal, READING)).toBe(true); + yield* closeCatalog(terminal); // 6. And the cells agree with the targets the frame published for them: a // pointer at the row entry 1 is drawn in selects entry 1, and one at // entry 2's row puts the reading back exactly as it was. + // A catalog row: opened to point at it, and the choice closes it again. + yield* openCatalog(terminal); yield* click(terminal, "1. [ok] entry-1"); + yield* until_( + terminal, + "the catalog closing behind the choice", + (one) => !catalogShowing(one), + ); yield* until_(terminal, "entry-1 being the locus", (one) => - (selectedEntryOn(one) ?? "").includes("entry-1"), + (selectedEntryOn(one) ?? "").includes("Entry 1"), ); + // Read in the catalog, which is where this screen keeps the rows. + yield* openCatalog(terminal); expect(marked(terminal, "1. [ok] entry-1")).toBe(true); + yield* closeCatalog(terminal); + // And back, by pointing at the other row — which means opening the + // catalog again, because the choice before it closed the catalog. + yield* openCatalog(terminal); yield* click(terminal, READING); yield* until_(terminal, "entry-2 again", (one) => - (selectedEntryOn(one) ?? "").includes("entry-2"), + (selectedEntryOn(one) ?? "").includes("Entry 2"), ); + // The marked row is in the catalog, which is where the rows are. + yield* openCatalog(terminal); expect(marked(terminal, READING)).toBe(true); + yield* closeCatalog(terminal); expect(draftText(terminal)).toBe(DRAFTED); terminal.end(); @@ -4740,13 +5159,24 @@ describe("REPL journey: a selection, a position and a draft across a resize", () }); yield* untilDrawn(second.terminal); - expect(shows(second.terminal, "1. [ok] entry-1")).toBe(true); + // Read in the catalog, which is where this screen keeps the rows. + // Where a cold process puts the keyboard, asked before anything here has + // opened a drawer over the reading: a drawer owns focus while it is up and + // hands it back to the control that opened it, so this is the one moment + // the question is about the process rather than about the catalog. + expect(draftFocused(second.terminal)).toBe(true); + expect(draftShows(second.terminal, DRAFTED)).toBe(true); + // The position this location names, not the head: entry 2 is there and has - // settled nothing at its own admission. + // settled nothing at its own admission. Both rows and the marker while + // the catalog is the drawer up, because that is where the rows are. + yield* openCatalog(second.terminal); + expect(shows(second.terminal, "1. [ok] entry-1")).toBe(true); expect(shows(second.terminal, READING)).toBe(true); expect(marked(second.terminal, READING)).toBe(true); - expect(shows(second.terminal, `>> Draft: ${DRAFTED}`)).toBe(true); - expect(selectedEntryOn(second.terminal)).toContain("entry-2"); + yield* closeCatalog(second.terminal); + expect(draftShows(second.terminal, DRAFTED)).toBe(true); + expect(selectedEntryOn(second.terminal)).toContain("Entry 2"); expect(historicalOn(second.terminal)).toBe(true); second.terminal.end(); @@ -4772,12 +5202,6 @@ describe("REPL journey: a selection, a position and a draft across a resize", () * what an assertion about one size needs, since the accumulated buffer still holds * the rows the other size drew. */ -function markedIn(rows: readonly string[], label: string): boolean { - return rows.some((line) => { - const at = line.indexOf(label); - return at >= 2 && line.slice(at - 2, at) === "* "; - }); -} /** The draft this reading carries across every size, with a space in it. */ const DRAFTED = "keep this draft"; @@ -4867,9 +5291,13 @@ function bandOf(terminal: Terminal): string[] { * accumulated buffer still holds the rows the size before it drew, and the * footer of a 36-row screen is not where a 20-row screen keeps its band. */ -function bandIn(rows: readonly string[], size: ReplTerminalSize): string[] { - const actions = size.rows - FOOTER_ROWS; - return rows.slice(actions + 1, actions + 1 + HISTORY_ROWS).map((row) => row.trimEnd()); +function bandIn(rows: readonly string[], _size: ReplTerminalSize): string[] { + // The rail shares the footer's own row with the controls now: it used to take + // five rows of its own beneath them, and it says the same things in one. Found + // by what that row carries rather than counted back from the frame's height, + // because how many rows a footer takes is the composition's business. + const at = rows.findIndex((row) => row.includes(COMPACT_HISTORY_TITLE)); + return at < 0 ? [] : [rows[at].trimEnd()]; } /** @@ -4893,7 +5321,8 @@ function* bandAt(terminal: Terminal, size: ReplTerminalSize): Operation row.includes(HISTORY_TITLE)) + shown.length === size.rows && + bandIn(shown, size).some((row) => row.includes(COMPACT_HISTORY_TITLE)) ); }); return bandIn(replay(terminal.presented.slice(from)), size); @@ -4974,9 +5403,16 @@ describe("REPL journey: every recorded position, and every admitted size", () => // 4. A whole reading to carry across the sizes: an entry selected, that // position being read, and a draft nobody has submitted. + // A catalog row: opened to point at it, and the choice closes it again. + yield* openCatalog(terminal); yield* click(terminal, "1. [ok] entry-1"); + yield* until_( + terminal, + "the catalog closing behind the choice", + (one) => !catalogShowing(one), + ); yield* until_(terminal, "entry-1 being the locus", (one) => - (selectedEntryOn(one) ?? "").includes("entry-1"), + (selectedEntryOn(one) ?? "").includes("Entry 1"), ); yield* focusDraft(terminal); terminal.bytes(BYTES.encode(DRAFTED)); @@ -4984,7 +5420,7 @@ describe("REPL journey: every recorded position, and every admitted size", () => expect(historicalOn(terminal)).toBe(true); expect(draftFocused(terminal)).toBe(true); listed = bandOf(terminal); - expect(listed.some((row) => row.includes(HISTORY_TITLE))).toBe(true); + expect(listed.some((row) => row.includes(COMPACT_HISTORY_TITLE))).toBe(true); // 5. The four sizes, in order. Each one is a profile: wide, medium, // narrow, and a window too small to draw in at all. @@ -5007,9 +5443,17 @@ describe("REPL journey: every recorded position, and every admitted size", () => expect(shown.some((line) => line.includes("Make the window larger"))).toBe(true); continue; } - // At a size it can draw at, the reading is the one that went in. - expect(markedIn(shown, "1. [ok] entry-1")).toBe(true); - expect(shown.some((line) => line.includes(`>> Draft: ${DRAFTED}`))).toBe(true); + // At a size it can draw at, the reading is the one that went in. The + // marked row is in the catalog, which is a drawer over the reading at + // every size this screen draws — so it is opened for the row and put + // away again, and the reader goes back to the draft they were typing + // in, which is where the rest of this row looks for them. + yield* openCatalog(terminal); + expect(marked(terminal, "1. [ok] entry-1")).toBe(true); + yield* closeCatalog(terminal); + yield* focusDraft(terminal); + expect(shown.some((line) => line.includes(DRAFT_PROMPT))).toBe(true); + expect(shown.some((line) => line.includes(DRAFTED))).toBe(true); } // 6. Wide again. The marker, the route, the draft and the focus are the @@ -5021,7 +5465,7 @@ describe("REPL journey: every recorded position, and every admitted size", () => "the wide frame again", (one) => one.presented.length > back && replay(one.presented.slice(back)).length === 36, ); - expect(selectedEntryOn(terminal)).toContain("entry-1"); + expect(selectedEntryOn(terminal)).toContain("Entry 1"); expect(historicalOn(terminal)).toBe(true); expect(draftText(terminal)).toBe(DRAFTED); expect(draftFocused(terminal)).toBe(true); @@ -5109,9 +5553,15 @@ describe("REPL journey: a cold process at an early position", () => { } // Rails with marks on them, so the comparison in the cold process below // is between two drawn rails rather than between two sets of blank rows. + // The compact rail marks an entry with the state the prefix being read + // holds it in, written as the first character of that state's own word — + // a glyph where the word has one and a letter where it does not. An entry + // the read prefix does not reach carries its number alone, which is why + // this asks for one marked entry rather than for all of them. The + // five-row rail's own position glyphs belong to the band this replaced. for (const band of hot) { - expect(band.some((row) => row.includes("\u2501"))).toBe(true); - expect(band.some((row) => /[\u25c6\u25bc\u2503]/.test(row))).toBe(true); + expect(band.some((row) => row.includes("\u2500"))).toBe(true); + expect(band.some((row) => /\s\d+ \S/.test(row))).toBe(true); } // Back to the size the cold process will open at, so the comparison below // is between two processes and not between two sizes. @@ -5180,8 +5630,12 @@ describe("REPL journey: a cold process at an early position", () => { // 2. The prefix this location names: entry 1 is there, and entry 2 — which // the whole of this position predates — is not. + // Read in the catalog, which is where this screen keeps those rows: it is + // a drawer over the reading, so it is opened for them and put away again. + yield* openCatalog(terminal); expect(shows(terminal, "1. [")).toBe(true); expect(shows(terminal, "2. [")).toBe(false); + yield* closeCatalog(terminal); expect(historicalOn(terminal)).toBe(true); expect(draftText(terminal)).toBe(DRAFTED); @@ -5321,9 +5775,15 @@ describe("REPL journey: a cold process over a multi-entry journal", () => { // Both entries, in admission order, with the outcomes the file holds — // and the one the location named is the locus. + // Read in the catalog, which is where this screen keeps the rows. + yield* openCatalog(second.terminal); expect(shows(second.terminal, "1. [ok] entry-1")).toBe(true); + yield* closeCatalog(second.terminal); + // Read in the catalog, which is where this screen keeps the rows. + yield* openCatalog(second.terminal); expect(shows(second.terminal, "2. [ok] entry-2")).toBe(true); - expect(selectedEntryOn(second.terminal)).toContain("entry-2"); + yield* closeCatalog(second.terminal); + expect(selectedEntryOn(second.terminal)).toContain("Entry 2"); expect(shows(second.terminal, "Two: alpha-again")).toBe(true); // Entry 2's locus, not the whole execution's: the first entry's output // belongs to the row above, and selecting one is selecting a transcript. @@ -5413,7 +5873,21 @@ describe("REPL responsiveness: an interaction beside a long reading", () => { yield* settledEntry(terminal, 1, "ok"); // The long reading is the selected one, so it is what a frame would // have to fit if anything made it fit again. + // A catalog row: opened to point at it, and the choice closes it again. + yield* openCatalog(terminal); + // A catalog row: opened to reach it, and the choice closes it again. + yield* openCatalog(terminal); yield* click(terminal, "1. [ok] entry-1"); + yield* until_( + terminal, + "the catalog closing behind the choice", + (one) => !catalogShowing(one), + ); + yield* until_( + terminal, + "the catalog closing behind the choice", + (one) => !catalogShowing(one), + ); yield* focusDraft(terminal); yield* settled(40); @@ -5470,15 +5944,15 @@ describe("REPL responsiveness: a focus move is one frame", () => { yield* settled(40); // Where focus is now, read off the screen rather than from the program. - const before = screenOf(terminal).findIndex((line) => line.includes(">> ")); - expect(before).toBeGreaterThanOrEqual(0); + const before = cueAt(terminal); + expect(before).toBeDefined(); const from = terminal.presented.length; terminal.feed("\t"); yield* settled(40); // The cue moved, so the traversal happened. - expect(screenOf(terminal).findIndex((line) => line.includes(">> "))).not.toBe(before); + expect(cueAt(terminal)).not.toEqual(before); // And it cost one frame. It used to cost two: the first drew the control // the reader had just left and the second corrected it, so the marker was // always one keystroke behind what the tree already held. @@ -5488,7 +5962,7 @@ describe("REPL responsiveness: a focus move is one frame", () => { const back = terminal.presented.length; terminal.feed("\x1b[Z"); yield* settled(40); - expect(screenOf(terminal).findIndex((line) => line.includes(">> "))).toBe(before); + expect(cueAt(terminal)).toEqual(before); expect(terminal.presented.length - back).toBe(1); terminal.end(); @@ -5621,7 +6095,7 @@ describe("REPL first use: UI1", () => { expect(shows(terminal, "Tab to draft")).toBe(true); } // The generic spelling, on a control with nothing particular to say. - yield* focusOn(terminal, "Sessions"); + yield* toTranscript(terminal); expect( shows( terminal, @@ -5666,22 +6140,30 @@ describe("REPL first use: UI1/UI5", () => { expect(model.checkpoints.length).toBeGreaterThan(0); const rows = screenOf(terminal); - const actions = NARROW.rows - 7; + // The row this screen draws its footer controls on, found by the way out + // rather than counted back from the frame's height: the footer is a + // compact History row and one guidance row now, not seven. + const actions = rows.findIndex((line) => line.includes("[exit]")); + expect(actions).toBeGreaterThanOrEqual(0); // One action row, and every control is on it. expect(rows[actions]).toContain("[history]"); expect(rows[actions]).toContain("[exit]"); - // Five band rows under it, each one a position this execution reached. - const band = rows.slice(actions + 1, actions + 1 + HISTORY_ROWS); - expect(band).toHaveLength(HISTORY_ROWS); + // The rail shares that row: its title, its rule and a mark per position. + // It used to take five rows of its own below the controls; the compact + // row says the same things in one, and what it says is `repl-history`'s. + const band = [rows[actions]]; // Re-anchored for #881 PR 3: the band listed checkpoint *labels* and now // draws a measured rail — its title, its rule and a mark per position. // What this case is about is the footer's order, and the band being // there is what it needs; what the band says is `repl-history`'s. - expect(band.some((row) => row.includes(HISTORY_TITLE))).toBe(true); + expect(band.some((row) => row.includes(COMPACT_HISTORY_TITLE))).toBe(true); expect(band.some((row) => row.includes("\u2500"))).toBe(true); - // The draft, on the last row, alone. - expect(rows[NARROW.rows - 1].trim().startsWith(">")).toBe(true); + // The draft is in the selected work's own body, which is where the thing + // it is the draft *of* is — not on the last row of the terminal, which + // this screen gives to its guidance. + expect(rows[rows.length - 1]).toContain("Tab/Shift+Tab"); + expect(draftFocused(terminal)).toBe(true); // And nothing of the band at the top of the screen, which is where it used // to be drawn: the first row belongs to whatever the body put there. @@ -5830,14 +6312,14 @@ describe("REPL first use: UI3", () => { yield* untilDrawn(terminal); // On Sessions, with something typed, before anything is asked. - yield* activate(terminal, "Sessions"); + yield* toTranscript(terminal); yield* focusDraft(terminal); terminal.bytes(BYTES.encode(JOURNEY_ONE)); yield* settled(60); const beforeOne = submitting(terminal); terminal.feed("\r"); yield* admittedDraft(terminal, beforeOne); - expect(routedSurfaceOn(terminal)).toBe("Sessions"); + expect(surfaceRouted(terminal, "Sessions")).toBe(true); yield* focusOn(terminal, "[exit]"); const standing = without(terminal); @@ -5845,7 +6327,7 @@ describe("REPL first use: UI3", () => { // surface, the focused control and the whole screen are where they were, // apart from the announcement itself. yield* until_(terminal, "the waiting question", (one) => askedRow(one) !== undefined); - expect(routedSurfaceOn(terminal)).toBe("Sessions"); + expect(surfaceRouted(terminal, "Sessions")).toBe(true); expect(focusedOn(terminal, "[exit]")).toBe(true); expect(shows(terminal, "decision: go")).toBe(false); expect(standing.length).toBeGreaterThan(0); @@ -5860,8 +6342,8 @@ describe("REPL first use: UI3", () => { yield* until_(terminal, "the question's form", (one) => shows(one, "decision: go")); // Activating it crossed to the surface the question belongs to, and // selected the entry that is asking. - expect(routedSurfaceOn(terminal)).toBe("Entries"); - expect(selectedEntryOn(terminal)).toContain("entry-1"); + expect(surfaceRouted(terminal, "Entries")).toBe(true); + expect(selectedEntryOn(terminal)).toContain("Entry 1"); // Escape dismisses without answering, and the question is still waiting — so // it announces itself again and opens again. @@ -5898,51 +6380,107 @@ describe("REPL first use: UI1 selection", () => { yield* submitted(terminal, COLD_TWO); yield* settledEntry(terminal, 2, "ok"); - // Entries is where the route starts, and the frame says so. - expect(marked(terminal, "Entries")).toBe(true); - expect(marked(terminal, "Sessions")).toBe(false); + // Entries is where the route starts, and the frame says so. At a shared + // width it says it in the selected work's own breadcrumb — the routed + // surface bar belongs to the narrow frame, which routes one outlet. + expect(surfaceRouted(terminal, "Entries")).toBe(true); + expect(selectedEntryOn(terminal)).toBeDefined(); // Switching surfaces changes cells that are not the location: the URL // alone cannot satisfy this, because the location rows are excluded. const onEntries = without(terminal); - yield* activate(terminal, "Sessions"); + yield* toTranscript(terminal); const onSessions = without(terminal); expect(onSessions).not.toEqual(onEntries); - expect(marked(terminal, "Sessions")).toBe(true); - expect(marked(terminal, "Entries")).toBe(false); + expect(surfaceRouted(terminal, "Sessions")).toBe(true); // By pointer too, and it means the same thing: the marker comes back to // Entries and the screen is no longer the Sessions one. Not byte-identical // to `onEntries`, because activating by pointer also moves focus there — // which is the other marker, and the point of their being two. yield* click(terminal, "Entries"); - expect(marked(terminal, "Entries")).toBe(true); - expect(marked(terminal, "Sessions")).toBe(false); + expect(surfaceRouted(terminal, "Entries")).toBe(true); expect(without(terminal)).not.toEqual(onSessions); // Selecting one entry versus the other moves one marker and no other. + // A catalog row: opened to reach it, and the choice closes it again. + yield* openCatalog(terminal); + // A catalog row: opened to reach it, and the choice closes it again. + yield* openCatalog(terminal); yield* activate(terminal, "1. [ok] entry-1"); + yield* until_( + terminal, + "the catalog closing behind the choice", + (one) => !catalogShowing(one), + ); + yield* until_( + terminal, + "the catalog closing behind the choice", + (one) => !catalogShowing(one), + ); + // The marker is in the catalog, which is where the rows are: reopened + // to read it, and put away again so the reading below is the reading. + yield* openCatalog(terminal); + // Read in the catalog, which is where this screen keeps the rows. + yield* openCatalog(terminal); expect(marked(terminal, "1. [ok] entry-1")).toBe(true); + yield* closeCatalog(terminal); + // Read in the catalog, which is where this screen keeps the rows. + yield* openCatalog(terminal); expect(marked(terminal, "2. [ok] entry-2")).toBe(false); + yield* closeCatalog(terminal); + yield* closeCatalog(terminal); const readingFirst = without(terminal); + // A catalog row: opened to reach it, and the choice closes it again. + yield* openCatalog(terminal); + // A catalog row: opened to reach it, and the choice closes it again. + yield* openCatalog(terminal); yield* activate(terminal, "2. [ok] entry-2"); + yield* until_( + terminal, + "the catalog closing behind the choice", + (one) => !catalogShowing(one), + ); + yield* until_( + terminal, + "the catalog closing behind the choice", + (one) => !catalogShowing(one), + ); + yield* openCatalog(terminal); + // Read in the catalog, which is where this screen keeps the rows. + yield* openCatalog(terminal); expect(marked(terminal, "2. [ok] entry-2")).toBe(true); + yield* closeCatalog(terminal); + // Read in the catalog, which is where this screen keeps the rows. + yield* openCatalog(terminal); expect(marked(terminal, "1. [ok] entry-1")).toBe(false); + yield* closeCatalog(terminal); + yield* closeCatalog(terminal); expect(without(terminal)).not.toEqual(readingFirst); // And the marker is the route's, not focus's: it stays where it is when // focus goes somewhere else entirely. yield* focusOn(terminal, "[exit]"); expect(focusedOn(terminal, "[exit]")).toBe(true); + expect(surfaceRouted(terminal, "Entries")).toBe(true); + // Still that entry's, with focus elsewhere entirely: the marker is the + // route's rather than focus's, and reopening the catalog shows it. + yield* openCatalog(terminal); + // Read in the catalog, which is where this screen keeps the rows. + yield* openCatalog(terminal); expect(marked(terminal, "2. [ok] entry-2")).toBe(true); - expect(marked(terminal, "Entries")).toBe(true); + yield* closeCatalog(terminal); + yield* closeCatalog(terminal); // Selecting an entry does not filter the agent work this execution has. // Both sections are still there and still say they hold none. - yield* activate(terminal, "Sessions"); - expect(shows(terminal, "No entry turns.")).toBe(true); - expect(shows(terminal, "No Sidekick turns.")).toBe(true); + yield* toTranscript(terminal); + // One list across both owners since the single-list correction, so an + // execution that has done no agent work says so once. The two owners + // keep their separate domains; what changed is how the screen reads + // them. + expect(shows(terminal, "(none retained)")).toBe(true); terminal.end(); yield* running; @@ -5979,7 +6517,7 @@ describe("REPL first use: UI10 narrow guidance", () => { // Focus starts in the draft. The state comes first, then what Enter does // there, then that typing goes to the draft, then movement. - expect(guidanceRow(terminal)).toBe( + expect(guidanceRow(terminal).trim()).toBe( "Ready for Entry 1 · Enter submits · Type here · Tab/Shift+Tab move", ); expect(guidanceRow(terminal).length).toBeLessThanOrEqual(NARROW.columns); @@ -5988,8 +6526,8 @@ describe("REPL first use: UI10 narrow guidance", () => { // row stops saying it submits and starts saying how to get back. `Sessions` // because it is a control with nothing particular to say about itself — // this is the generic spelling. - yield* focusOn(terminal, "Sessions"); - expect(guidanceRow(terminal)).toBe( + yield* toTranscript(terminal); + expect(guidanceRow(terminal).trim()).toBe( "Ready for Entry 1 · Enter activates · Tab/Shift+Tab move · Tab to draft", ); expect(guidanceRow(terminal).length).toBeLessThanOrEqual(NARROW.columns); @@ -5998,11 +6536,11 @@ describe("REPL first use: UI10 narrow guidance", () => { // because a row naming an action the focused node does not perform is worse // than one naming none: the person presses the key it promised. yield* focusOn(terminal, "[exit]"); - expect(guidanceRow(terminal)).toBe( + expect(guidanceRow(terminal).trim()).toBe( "Ready for Entry 1 · Enter exits · Tab/Shift+Tab move · Tab to draft", ); yield* focusOn(terminal, "[history]"); - expect(guidanceRow(terminal)).toBe( + expect(guidanceRow(terminal).trim()).toBe( "Ready for Entry 1 · Enter opens · Tab/Shift+Tab move · Tab to draft", ); expect(guidanceRow(terminal).length).toBeLessThanOrEqual(NARROW.columns); @@ -6046,20 +6584,20 @@ describe("REPL first use: UI10 narrow drawer guidance", () => { yield* until_(terminal, "the question's form", (one) => formShowing(one)); // A field holds focus: Enter answers, and the entry is still named. - const onField = guidanceRow(terminal); + const onField = guidanceRow(terminal).trim(); expect(onField).toBe("Entry 1 question · Enter answers · Esc closes · Tab/Shift+Tab move"); // The question's submit: the same promise, because activating it is how the // question gets answered. yield* focusOn(terminal, "[submit]"); - const onSubmit = guidanceRow(terminal); + const onSubmit = guidanceRow(terminal).trim(); expect(onSubmit).toBe("Entry 1 question · Enter answers · Esc closes · Tab/Shift+Tab move"); // And a control in the same drawer that does something else entirely. It // moves the window over a message too long to draw at once, so the row says // that instead of promising an answer to somebody who is still reading. yield* focusOn(terminal, "[↓ later]"); - const onScroll = guidanceRow(terminal); + const onScroll = guidanceRow(terminal).trim(); expect(onScroll).toBe("Entry 1 question · Enter scrolls · Esc closes · Tab/Shift+Tab move"); // Whichever node holds focus: the state, the action that node performs, the @@ -6138,11 +6676,12 @@ describe("REPL first use: UI15 drawer geometry", () => { yield* until_(terminal, "the History drawer again", (one) => shows(one, "[close]")); const saying = screenOf(terminal).findIndex((line) => line.includes("History ·")); expect(saying).toBeGreaterThanOrEqual(0); - // Above the drawer's own rows — which is exactly what made the old anchor - // read the wrong one. It shares columns with the box at this size, so the - // row is the distinction and the column is not. + // Outside the drawer's own rows. It shares columns with the box at this + // size, so the row is the distinction and the column is not — and this + // screen keeps its History band *below* the body the drawer floats over, + // so outside means either side of it rather than above it. const box = drawerBox(terminal); - expect(saying).toBeLessThan(box.top); + expect(saying < box.top || saying >= box.bottom).toBe(true); // And the helper still answers with the drawer's own rows: every one of // them is a position this history holds, and none is a piece of the @@ -6243,11 +6782,18 @@ describe("REPL first use: UI1 narrow question", () => { }); yield* untilDrawn(terminal); - // Asked from Sessions, so this row also proves the crossing, and asked with - // a message far wider than the terminal. - yield* activate(terminal, "Sessions"); + // Asked with a message far wider than the terminal, and read from + // Sessions, so this row also proves the crossing. + // + // Submitted where the draft is. This frame routes one outlet and the + // Sessions one has no draft in it, so a person asks from the entries they + // are reading and then goes to look at the Transcript — which is the + // crossing this row is about: the announcement arrives while they are + // somewhere else, and activating it takes them to the entry that asked. yield* submitted(terminal, LONG_QUESTION); + yield* toTranscript(terminal); yield* until_(terminal, "the waiting question", (one) => askedRow(one) !== undefined); + expect(surfaceRouted(terminal, "Sessions")).toBe(true); const at = askedRow(terminal); if (at === undefined) { @@ -6273,9 +6819,12 @@ describe("REPL first use: UI1 narrow question", () => { // survives it being closed. terminal.feed("\x1b"); yield* until_(terminal, "the drawer closing", (one) => !formShowing(one)); - expect(selectedEntryOn(terminal)).toContain("entry-1"); - expect(marked(terminal, "Entries")).toBe(true); + expect(selectedEntryOn(terminal)).toContain("Entry 1"); + expect(surfaceRouted(terminal, "Entries")).toBe(true); + // Read in the catalog, which is where this screen keeps the rows. + yield* openCatalog(terminal); expect(marked(terminal, "1. [unfinished] entry-1")).toBe(true); + yield* closeCatalog(terminal); // And reached again by Enter this time, because dismissing answered nothing. yield* activate(terminal, "[answer]"); @@ -6433,7 +6982,10 @@ describe("REPL first use: UI3", () => { yield* until_(terminal, "the drawer withdrawing", (one) => !shows(one, "decision: go")); expect(shows(terminal, "[close]")).toBe(false); // Focus is somewhere a person can use, not on a control that has gone. - expect(screenOf(terminal).some((line) => line.includes(">>"))).toBe(true); + // The cue is this screen's own marker beside the field or control holding + // it — a field wears one `>` before its name, a control wears one before + // its label — so what this asks is that something is wearing it. + expect(focusedSomewhere(terminal)).toBe(true); // The entry keeps its own legitimate failure, and nothing recorded an // elicitation: a withdrawal is not a decision. @@ -6546,26 +7098,27 @@ describe("REPL first use: UI6", () => { yield* openQuestion(terminal); - // Still drawn, on its own footer row, which the drawer sits above rather - // than across. The draft and the question are both readable at once, which - // is exactly why the draft has a row of its own below the drawer. - const row = size.rows - 1; - expect(screenOf(terminal)[row]).toContain("next entry"); + // Still drawn, in the draft's own rows in the work it belongs to, which + // the drawer sits above rather than across. The draft and the question + // are both readable at once, which is exactly why the drawer stops + // short of it. Read from the draft rather than from the frame's last + // row — that row is the guidance, and the draft is in the body. + const drawn = draftRowsIn(screenOf(terminal)); + expect(drawn.join("\n")).toContain("next entry"); expect(shows(terminal, "decision: go")).toBe(true); - const drawn = screenOf(terminal)[row]; // The modal owns the keystrokes: the draft is visible and not editable, so // text reaches the field the drawer focused and the draft keeps its bytes. terminal.bytes(BYTES.encode("go")); yield* settled(40); - expect(screenOf(terminal)[row]).toBe(drawn); + expect(draftRowsIn(screenOf(terminal))).toEqual(drawn); // Dismissed, and the draft is reachable again with exactly what it had. terminal.feed("\x1b"); yield* until_(terminal, "the drawer closing", (one) => !shows(one, "decision: go")); yield* focusDraft(terminal); expect(draftText(terminal)).toBe(kept); - expect(screenOf(terminal)[row]).toContain("next entry"); + expect(draftRowsIn(screenOf(terminal)).join("\n")).toContain("next entry"); terminal.end(); yield* running; @@ -6646,7 +7199,7 @@ describe("REPL first use: UI7", () => { } }); yield* untilDrawn(resumed.terminal); - expect(selectedEntryOn(resumed.terminal)).toContain("entry-1"); + expect(selectedEntryOn(resumed.terminal)).toContain("Entry 1"); resumed.terminal.end(); yield* running; }); @@ -6735,14 +7288,14 @@ describe("REPL first use: UI8", () => { // 2. Running, and then waiting — said on the entry's own row and in the // footer, without anything opening itself. - yield* until_(terminal, "the entry running", (one) => shows(one, "1. [unfinished]")); + yield* settledEntry(terminal, 1, "unfinished"); yield* until_(terminal, "the waiting question", (one) => askedRow(one) !== undefined); // 3. Between the surfaces and back, with the selected entry preserved. - yield* activate(terminal, "Sessions"); - expect(routedSurfaceOn(terminal)).toBe("Sessions"); - yield* activate(terminal, "Entries"); - expect(routedSurfaceOn(terminal)).toBe("Entries"); + yield* toTranscript(terminal); + expect(surfaceRouted(terminal, "Sessions")).toBe(true); + yield* toEntries(terminal); + expect(surfaceRouted(terminal, "Entries")).toBe(true); // 4. The question, answered in the drawer it opens. yield* openQuestion(terminal); @@ -6772,7 +7325,10 @@ describe("REPL first use: UI8", () => { // 6. The next entry, after the first has completely settled. yield* submitted(terminal, COLD_TWO); yield* settledEntry(terminal, 2, "ok"); + // Read in the catalog, which is where this screen keeps the rows. + yield* openCatalog(terminal); expect(shows(terminal, "2. [ok] entry-2")).toBe(true); + yield* closeCatalog(terminal); // 7. Out, from the control that said so all along. expect(yield* histories(root)).toHaveLength(1); @@ -6818,7 +7374,7 @@ describe("REPL first use: C1 narrow", () => { yield* untilDrawn(terminal); // 1. What the screen says it is for, before anything has been typed. - expect(guidanceRow(terminal)).toBe( + expect(guidanceRow(terminal).trim()).toBe( "Ready for Entry 1 · Enter submits · Type here · Tab/Shift+Tab move", ); @@ -6827,7 +7383,7 @@ describe("REPL first use: C1 narrow", () => { yield* until_(terminal, "the waiting question", (one) => askedRow(one) !== undefined); // 3. The screen now says what the execution is doing, and what to activate. - expect(guidanceRow(terminal)).toBe( + expect(guidanceRow(terminal).trim()).toBe( "Entry 1 question · activate answer · Type here · Tab/Shift+Tab move", ); @@ -6859,12 +7415,15 @@ describe("REPL first use: C1 narrow", () => { yield* settled(30); yield* activate(terminal, "[submit]"); yield* until_(terminal, "the entry settling", (one) => shows(one, "Ready for Entry 2")); + // Read in the catalog, which is where this screen keeps the rows. + yield* openCatalog(terminal); expect(shows(terminal, "1. [ok] entry-1")).toBe(true); + yield* closeCatalog(terminal); // 8. Sessions and back, with the entry still the one being read. - yield* activate(terminal, "Sessions"); + yield* toTranscript(terminal); yield* until_(terminal, "the Sessions surface", (one) => marked(one, "Sessions")); - yield* activate(terminal, "Entries"); + yield* toEntries(terminal); yield* until_(terminal, "the Entries surface", (one) => marked(one, "Entries")); // 9. A draft that has to survive a position, and a position to survive. @@ -6884,7 +7443,7 @@ describe("REPL first use: C1 narrow", () => { // The position is taken while the drawer is still up, and the row already // names the state it put the screen in rather than waiting to be dismissed. yield* until_(terminal, "the frozen state inside the drawer", (one) => - guidanceRow(one).startsWith("History · "), + guidanceRow(one).trim().startsWith("History · "), ); expect(guidanceRow(terminal)).toContain("Esc closes"); @@ -6894,7 +7453,7 @@ describe("REPL first use: C1 narrow", () => { yield* until_(terminal, "the drawer closing", (one) => !shows(one, "[close]")); yield* focusDraft(terminal); yield* until_(terminal, "the frozen guidance", (one) => - guidanceRow(one).startsWith("History · Enter unavailable"), + guidanceRow(one).trim().startsWith("History · Enter unavailable"), ); expect(guidanceRow(terminal)).toContain("activate live"); expect(shows(terminal, "entry two")).toBe(true); @@ -6902,11 +7461,13 @@ describe("REPL first use: C1 narrow", () => { // 11. Back to the head, and the draft submits from there. yield* activate(terminal, "[live]"); yield* until_(terminal, "the head being ready again", (one) => - guidanceRow(one).startsWith("Ready for Entry 2"), + guidanceRow(one).trim().startsWith("Ready for Entry 2"), ); yield* focusDraft(terminal); terminal.feed("\r"); - yield* until_(terminal, "the second entry", (one) => shows(one, "2. ")); + // In the rail, which is where the head's own record of it is. `2. ` is a + // catalog row, and the catalog is a drawer nobody opened here. + yield* settledEntry(terminal, 2, "ok"); // 12. And out, from a control on the screen. yield* activate(terminal, "[exit]"); diff --git a/packages/cli/tests/repl-layout-admission.test.ts b/packages/cli/tests/repl-layout-admission.test.ts index f31c3842c..1b0e8feaf 100644 --- a/packages/cli/tests/repl-layout-admission.test.ts +++ b/packages/cli/tests/repl-layout-admission.test.ts @@ -18,12 +18,52 @@ import { expect } from "@executablemd/test-support/expect"; import { scoped } from "effection"; import type { Operation } from "effection"; -import { NARROW, profileFor } from "../src/repl/layout.ts"; +import { NARROW, profileFor, skeletonOps } from "../src/repl/layout.ts"; +import { projectRepl } from "../src/repl/model.ts"; +import { navigationOf } from "../src/repl/navigation.ts"; +import { NO_LIFECYCLE } from "../src/repl/lifecycle.ts"; import type { ReplBounds } from "../src/repl/layout.ts"; +import type { ReplLive } from "../src/repl/application.ts"; + +/** + * The two cells in front of a control's label that its focus marker draws in. + * + * Every control is measured with them, because a control measured without its + * marker comes out two columns narrower than the one drawn in its place. + */ +const CONTROL_ROOM = " "; import { useReplRenderer } from "../src/repl/renderer.ts"; +import type { ReplRenderer } from "../src/repl/renderer.ts"; +import { prepareFrame } from "../src/repl/program.ts"; +import { + ACTION_ROW, + DRAWER_WINDOW, + initialState, + NO_AGENT, + QUEUE_WINDOW, + reduceRepl, + viewFor, + withWorkspace, + workspaceOf, +} from "../src/repl/application.ts"; import { useReplTree } from "../src/repl/reconcile.ts"; import type { ReplTerminalSize } from "../src/repl/terminal.ts"; -import { createGrid, useCommitter, windowOf } from "./fixtures/repl/presentation.ts"; +import { createGrid, useCommitter, usePresenter, windowOf } from "./fixtures/repl/presentation.ts"; +import { useTempFileCompiler } from "@executablemd/core"; +import { + CHECKPOINT_ASKING, + CHECKPOINT_CURRENT_WORKSPACE, + CHECKPOINT_LIVE, + CHECKPOINT_MEDIUM, + CHECKPOINT_NARROW, + CHECKPOINT_SIZE, + checkpointCatalogModel, + checkpointModel, + checkpointState, + checkpointStateModel, + checkpointViewOf, + commitCheckpoint, +} from "./fixtures/repl/workspace-checkpoint.ts"; import type { Committer, Drawn, TerminalGrid } from "./fixtures/repl/presentation.ts"; import { EMPTY, @@ -491,3 +531,452 @@ describe("REPL layout: the profiles this product supports", () => { } }); }); + +/** + * #971 A2: what the staged composition admits at each supported size. + * + * The same invariant the rest of this file is about, asked of the screen the + * workspace draws: every window the frame measured holds a whole number of rows + * it has room for, every control it published was drawn, and nothing it left + * out is reachable. The geometry those numbers come from is a proposal under + * image review; that each of them is measured rather than computed here is not. + */ +describe("REPL layout: what the staged workspace admits at each size", () => { + it("A2: every measured window is whole, bounded and reachable", function* () { + yield* useTempFileCompiler(); + const model = yield* checkpointModel(); + for (const size of [CHECKPOINT_SIZE, CHECKPOINT_MEDIUM, CHECKPOINT_NARROW]) { + const where = `${size.columns}x${size.rows}`; + const presenter = yield* usePresenter(size); + const committed = yield* commitCheckpoint(presenter, { + model, + size, + workspace: CHECKPOINT_CURRENT_WORKSPACE, + }); + const { admission, manifest } = committed.observed.committed; + + // A window holds whole rows, never more than its capacity, and never a + // capacity it did not measure. + expect({ where, windows: admission.windows.size > 0 }).toEqual({ where, windows: true }); + for (const slot of manifest.viewports) { + const window = windowOf(admission, slot.window); + const bounds = committed.observed.regionOf(slot.id); + expect({ where, window: slot.window, measured: bounds !== undefined }).toEqual({ + where, + window: slot.window, + measured: true, + }); + expect({ + where, + window: slot.window, + whole: Number.isInteger(window.capacity) && Number.isInteger(window.count), + }).toEqual({ where, window: slot.window, whole: true }); + expect({ where, window: slot.window, room: window.capacity >= 0 }).toEqual({ + where, + window: slot.window, + room: true, + }); + expect({ + where, + window: slot.window, + within: window.count <= window.capacity && window.from + window.count <= window.total, + }).toEqual({ where, window: slot.window, within: true }); + } + + // Every admitted action was drawn, and nothing the row left out was. + const drawn = new Set( + committed.observed.targets.flatMap((one) => { + const key = presenter.tree.keyOf(one.node); + return key === undefined ? [] : [key]; + }), + ); + for (const candidate of manifest.actions?.controls ?? []) { + const admitted = admission.actions.has(candidate.key); + expect({ where, key: candidate.key, admitted, drawn: drawn.has(candidate.key) }).toEqual({ + where, + key: candidate.key, + admitted, + drawn: admitted, + }); + } + } + }); + + // The queue-specific form of this measurement — an empty moving tray with + // its own controls around it — is DQ2 in + // `fixtures/repl/deferred-presentation.json`. The obligation it carried is + // the case below, over the catalog this build actually offers. + + /** + * The same question, about the catalog this build actually offers. + * + * A window is admitted against the room the frame will draw, and the only way + * to know the room was the right one is to watch the preparation measure it: + * a viewport still holding its own rows reports the space its contents wanted, + * and one measured before the controls around it reports a rectangle that is + * about to move. Nothing here changes an operation or a returned rectangle — + * the production `prepareFrame` runs against the same real engine, and this + * reads what went past. + */ + it("A2: an overflowing Entries catalog is admitted from its last empty coherent measurement", function* () { + yield* useTempFileCompiler(); + const model = yield* checkpointCatalogModel(9); + const size = CHECKPOINT_NARROW; + const presenter = yield* usePresenter(size); + // The closed screen first, so the catalog is opened from a frame whose + // focus the mounted tree actually answered for — the drawer is a modal, and + // only what it contains may hold focus while it is open. + const shipping = { model, size, workspace: workspaceOf(model) } as const; + const before = yield* commitCheckpoint(presenter, { + ...shipping, + state: checkpointState(model, { size, surface: "entries" }), + }); + const opened = reduceRepl( + before.view.state, + { kind: "open-catalog" }, + model, + before.view.live, + ).state; + const view = checkpointViewOf(model, before.focused, { ...shipping, state: opened }); + const viewport = `box:${DRAWER_WINDOW}:viewport`; + const row = /^box:drawer:.*:row:\d+:row$/; + const seen: { + bounds: ReplBounds | undefined; + rows: number; + earlier: boolean; + later: boolean; + chrome: boolean; + }[] = []; + const delegate = presenter.renderer; + const watching: ReplRenderer = { + *measure(ops, measured) { + const result = yield* delegate.measure(ops, measured); + const ids = ops.flatMap((op) => ("id" in op && typeof op.id === "string" ? [op.id] : [])); + if (result.ok && ids.includes(viewport)) { + seen.push({ + bounds: result.value.boundsOf(viewport), + rows: ids.filter((id) => row.test(id)).length, + earlier: ids.includes("box:drawer:drawer:scroll:up"), + later: ids.includes("box:drawer:drawer:scroll:down"), + chrome: + ids.includes("box:drawer:drawer:open") && ids.includes("box:drawer:drawer:close"), + }); + } + return result; + }, + draw: (input) => delegate.draw(input), + resize: (next) => delegate.resize(next), + last: () => delegate.last(), + engines: () => delegate.engines(), + }; + const prepared = yield* prepareFrame(watching, view); + if (!prepared.ok) { + throw prepared.error; + } + // More rows than the drawer was given, which is the case a moving window + // exists for at all. + const window = windowOf(prepared.value.admission, DRAWER_WINDOW); + expect({ beyond: window.total > window.capacity }).toEqual({ beyond: true }); + + // The last measurement that held the viewport is the one that admission + // came from: no rows of its own inside it, both of its own controls and + // the drawer's fixed chrome around it. + const last = seen[seen.length - 1]; + if (last === undefined || last.bounds === undefined) { + throw new Error("this preparation measured no catalog viewport"); + } + expect({ + rows: last.rows, + earlier: last.earlier, + later: last.later, + chrome: last.chrome, + }).toEqual({ rows: 0, earlier: true, later: true, chrome: true }); + // And it is a region with room in it. A window admitted against nothing + // would satisfy every rule below and show no reading at all. + expect({ + width: last.bounds.width > 0, + height: last.bounds.height > 0, + whole: Number.isInteger(last.bounds.width) && Number.isInteger(last.bounds.height), + }).toEqual({ width: true, height: true, whole: true }); + + // The same rectangle the finished frame places that viewport at, measured + // by the same engine from the manifest it published. + const final = yield* delegate.measure( + skeletonOps(prepared.value.presentation.manifest.root), + size, + ); + if (!final.ok) { + throw final.error; + } + expect(last.bounds).toEqual(final.value.boundsOf(viewport)); + const above = final.value.boundsOf("box:drawer:drawer:scroll:up"); + const below = final.value.boundsOf("box:drawer:drawer:scroll:down"); + if (above === undefined || below === undefined) { + throw new Error("this frame drew the catalog without its own controls"); + } + expect({ + above: above.y + above.height <= last.bounds.y, + below: below.y >= last.bounds.y + last.bounds.height, + }).toEqual({ above: true, below: true }); + + // And a row the first frame does not show is reached by the control that + // moves the window, with a whole target of its own inside that viewport. + const first = yield* presenter.commit(view); + const shown = (observed: typeof first) => + observed.keys.filter((key) => key.startsWith("entry:")).sort(); + const moved = reduceRepl( + opened, + { kind: "scroll-entries", delta: 1 }, + model, + before.view.live, + prepared.value.admission, + ).state; + const after = yield* presenter.commit( + checkpointViewOf(model, before.focused, { ...shipping, state: moved }), + ); + const reached = shown(after).filter((key) => !shown(first).includes(key)); + expect({ hidden: shown(first).length < window.total, reached: reached.length > 0 }).toEqual({ + hidden: true, + reached: true, + }); + const node = after.nodeOf(reached[0]); + const target = node === undefined ? undefined : after.targets.find((one) => one.node === node); + const bounds = after.boundsOf(reached[0]); + if (target === undefined || bounds === undefined) { + throw new Error(`${reached[0]} was drawn with no target of its own`); + } + expect({ target: target.bounds }).toEqual({ target: bounds }); + expect({ + whole: + Number.isInteger(bounds.x) && + Number.isInteger(bounds.width) && + Number.isInteger(bounds.y) && + Number.isInteger(bounds.height), + inside: + bounds.y >= last.bounds.y && bounds.y + bounds.height <= last.bounds.y + last.bounds.height, + }).toEqual({ whole: true, inside: true }); + }); + + // The accepted wide row is a fitted map, a gap and the controls. The gap and + // the controls both grew, so the engine divided what the map left between + // them — in halves, and half of an odd number of cells is not a cell. The + // accepted fixture's map happens to leave an even remainder, so nothing said + // the arrangement only worked for even ones. An ordinary empty root leaves an + // odd one, and every control after the gap landed on half a cell. + /** + * #971: the paused narrow History row gives its controls whole cells. + * + * The row holds the compact map and the footer's controls side by side. A + * paused entry offers four of them — History, Exit, Pause and Continue — with + * the phase stated between them, and that is the widest this row ever gets. + * + * What this holds the row to is that none of them is compressed. Measured + * before the correction, a 72-cell row gave its map the 29 cells its own text + * wanted and left the controls 43 where they need 51, and the engine answered + * every one of them with part of a cell — which the renderer then refused. + * The map yields first now, and the split is stated from a measurement of the + * two parts taken apart rather than of each under the other's pressure. + */ + it("A3: a paused narrow History row admits every control on whole cells", function* () { + const empty = projectRepl([]); + if (!empty.ok) { + throw empty.error; + } + const model = empty.value; + // Paused, and stably so: `pausable` with expansion `paused` is the one + // state that offers Pause, the phase and Continue at once, and it is held + // fixed so every preparation pass sees the same four controls. + const paused: ReplLive = { + output: "", + question: undefined, + expansion: "paused" as const, + pausable: true, + running: true, + agent: NO_AGENT, + lifecycle: NO_LIFECYCLE, + }; + for (const size of [CHECKPOINT_MEDIUM, CHECKPOINT_NARROW]) { + const where = `${size.columns}x${size.rows}`; + yield* scoped(function* (): Operation { + const view = viewFor( + initialState("paused-root"), + model, + paused, + size, + undefined, + navigationOf(model.checkpoints, "live"), + ); + if (!view.ok) { + throw view.error; + } + const presenter = yield* usePresenter(size); + const staged = withWorkspace(view.value, workspaceOf()); + // Committing is itself part of the assertion: the renderer refuses a + // control on part of a cell rather than rounding it, so a frame that + // drew at all drew on whole ones. Said again below over every + // published rectangle, because "it did not throw" is not the claim. + const first = yield* presenter.commit(staged); + const node = presenter.tree.focused(); + const focused = node === undefined ? undefined : presenter.tree.keyOf(node); + const committed = yield* presenter.commit({ ...staged, focused }); + + // Every control this state offers is admitted — Continue included, by + // being measured rather than appended after the fact — and each is + // drawn whole, inside the row, with its own complete label. + const labels: Readonly> = { + "footer:history": "[history]", + "footer:exit": "[exit]", + "footer:pause": "[pause]", + "footer:continue": "[continue]", + }; + const row = committed.regionOf("box:history"); + const map = committed.regionOf("box:history:map"); + const actions = committed.regionOf(ACTION_ROW); + if (row === undefined || map === undefined || actions === undefined) { + throw new Error(`${where} drew no History row to divide`); + } + for (const [key, label] of Object.entries(labels)) { + const bounds = committed.boundsOf(key); + if (bounds === undefined) { + throw new Error( + `${where} drew no ${key}; its controls are ${JSON.stringify( + committed.keys.filter((one) => one.startsWith("footer:")), + )}`, + ); + } + expect([ + where, + key, + Number.isInteger(bounds.x) && Number.isInteger(bounds.width), + ]).toEqual([where, key, true]); + // The whole label, inside the target that owns it: a control shown + // short is a control a reader cannot read, and a label wider than its + // own box is one drawn over its neighbour. Every control is measured + // with the two cells its focus marker draws in, which is why its box + // is its label and that room — a control measured without it comes + // out two columns narrower than the one drawn in its place. + expect([where, key, bounds.width]).toEqual([ + where, + key, + label.length + CONTROL_ROOM.length, + ]); + expect([where, key, (committed.cells.get(key) ?? "").includes(label)]).toEqual([ + where, + key, + true, + ]); + expect([where, key, bounds.x >= actions.x]).toEqual([where, key, true]); + expect([where, key, bounds.x + bounds.width <= actions.x + actions.width]).toEqual([ + where, + key, + true, + ]); + } + // The phase is stated beside them, as text rather than as part of a + // control's name. + expect([where, committed.keys.includes("footer:pause-state")]).toEqual([where, true]); + + // The split itself: whole, exhaustive, and the map is what yielded. + expect([where, Number.isInteger(map.width) && Number.isInteger(actions.width)]).toEqual([ + where, + true, + ]); + expect([where, map.width + actions.width]).toEqual([where, row.width]); + expect([where, actions.width >= 0]).toEqual([where, true]); + + // And the control a keystroke reaches is one the frame actually drew. + const holding = presenter.tree.focused(); + const held = holding === undefined ? undefined : presenter.tree.keyOf(holding); + expect([where, held === undefined || committed.boundsOf(held) !== undefined]).toEqual([ + where, + true, + ]); + // The first frame drew the same row whole, before focus was carried in. + expect([where, Number.isInteger(first.regionOf(ACTION_ROW)?.width ?? 0.5)]).toEqual([ + where, + true, + ]); + }); + } + }); + + it("A2: the wide History row divides an odd remainder into whole cells", function* () { + const empty = projectRepl([]); + if (!empty.ok) { + throw empty.error; + } + const size = CHECKPOINT_SIZE; + const model = empty.value; + const view = viewFor( + initialState("ordinary-root"), + model, + { + output: "", + question: undefined, + expansion: "playing" as const, + pausable: false, + running: false, + agent: NO_AGENT, + lifecycle: NO_LIFECYCLE, + }, + size, + undefined, + navigationOf(model.checkpoints, "empty"), + ); + if (!view.ok) { + throw view.error; + } + const presenter = yield* usePresenter(size); + const staged = withWorkspace(view.value, workspaceOf()); + const first = yield* presenter.commit(staged); + const node = presenter.tree.focused(); + const focused = node === undefined ? undefined : presenter.tree.keyOf(node); + const committed = yield* presenter.commit({ ...staged, focused }); + + // The remainder this reading leaves really is odd, which is the whole + // reason the row needed a rule. Measured, not assumed. + const row = first.regionOf("box:history"); + const map = first.regionOf("box:history:map"); + if (row === undefined || map === undefined) { + throw new Error("this frame drew no History row to divide"); + } + expect({ odd: (row.width - map.width) % 2 === 1 }).toEqual({ odd: true }); + + // Committing is the assertion the renderer makes: it refuses a control on + // half a cell rather than rounding it, so a frame that drew at all drew on + // whole cells. Said again here over every published rectangle. + for (const key of ["footer:history", "footer:exit"]) { + const bounds = committed.boundsOf(key); + if (bounds === undefined) { + throw new Error(`${key} was not drawn on the ordinary root's own screen`); + } + expect({ + key, + whole: Number.isInteger(bounds.x) && Number.isInteger(bounds.width), + }).toEqual({ key, whole: true }); + expect({ key, room: bounds.width > 0 }).toEqual({ key, room: true }); + } + const gap = committed.regionOf("box:history:space"); + const actions = committed.regionOf(ACTION_ROW); + if (gap === undefined || actions === undefined) { + throw new Error("this row drew no gap and controls to divide"); + } + // The odd cell goes to the controls, which are what somebody is aiming at. + expect({ whole: Number.isInteger(gap.width) && Number.isInteger(actions.width) }).toEqual({ + whole: true, + }); + expect({ gap: gap.width, actions: actions.width }).toEqual({ + gap: Math.floor((row.width - map.width) / 2), + actions: row.width - map.width - Math.floor((row.width - map.width) / 2), + }); + + // The controls say what they are, where the frame put them. + const history = committed.boundsOf("footer:history"); + if (history === undefined) { + throw new Error("the History control was not drawn"); + } + expect(presenter.grid.textIn(history).join(" ")).toContain("[history]"); + // And focus is the mounted tree's own answer, read back rather than set. + expect({ focused: focused !== undefined }).toEqual({ focused: true }); + }); +}); diff --git a/packages/cli/tests/repl-presentation-style.test.ts b/packages/cli/tests/repl-presentation-style.test.ts index 793be0370..34e401796 100644 --- a/packages/cli/tests/repl-presentation-style.test.ts +++ b/packages/cli/tests/repl-presentation-style.test.ts @@ -21,6 +21,7 @@ import { beforeAll, describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; +import { close, fit, open, text } from "@bomb.sh/tty"; import { collect, Elicitation, @@ -31,6 +32,7 @@ import { import { executeInstalled } from "@executablemd/core/host"; import { InMemoryStream } from "@executablemd/durable-streams"; import { fileURLToPath } from "node:url"; +import { readTextFile } from "@effectionx/fs"; import type { DurableEvent, DurableStream, Json } from "@executablemd/durable-streams"; import { scoped, sleep, spawn } from "effection"; import type { Operation } from "effection"; @@ -44,6 +46,7 @@ import { initialState, NO_AGENT, reduceRepl, + workspaceOf, viewFor, } from "../src/repl/application.ts"; import { NO_LIFECYCLE } from "../src/repl/lifecycle.ts"; @@ -106,7 +109,39 @@ import { windowOf, } from "./fixtures/repl/presentation.ts"; import { referenceEvents } from "./fixtures/repl/reference.ts"; +import { + CHECKPOINT_DRAFT, + CHECKPOINT_LIVE, + CHECKPOINT_MEDIUM, + CHECKPOINT_MESSAGE, + CHECKPOINT_NARROW, + CHECKPOINT_CURRENT_WORKSPACE, + CHECKPOINT_SIDEKICK, + CHECKPOINT_SIZE, + CHECKPOINT_SIZES, + CHECKPOINT_ASKING, + chatAt, + checkpointModel, + checkpointState, + checkpointViewOf, + checkpointScopes, + checkpointStateModel, + commitCheckpoint, + RUNNING_SOURCE, + WRAPPING_LINE, +} from "./fixtures/repl/workspace-checkpoint.ts"; +import { chatLinesFor, QUEUE_WINDOW } from "../src/repl/application.ts"; +import { readDescription } from "../src/repl/description.ts"; import { useCountingRenderer } from "./fixtures/repl/presentation.ts"; +import { + appearanceJournal, + appearanceModel, + appearanceRecord, + readAppearanceRecord, + differences, + screenOf, + sizeOf, +} from "./fixtures/repl/appearance.ts"; import { useReplTree } from "../src/repl/reconcile.ts"; import { createFitStore, createPlainFitStore, fitPlain } from "../src/repl/fitting.ts"; import type { ReplFittedLine } from "../src/repl/fitting.ts"; @@ -556,6 +591,171 @@ function* focusOn(presenter: Presenter, key: string, limit = 400): Operation { + for (const profile of ["160x36", "120x30", "72x20"] as const) { + it(`${profile}: every row and style run is the reviewed record's`, function* () { + const record = yield* appearanceRecord(profile); + const journal = yield* appearanceJournal(); + // The record says which input it describes, and this is that input. A + // record left behind by a re-recording fails here, on the digest, rather + // than further down as a row that stopped matching. + expect([profile, record.input.journalSha256]).toEqual([profile, journal.sha256]); + const model = yield* appearanceModel(); + const size = sizeOf(record); + yield* scoped(function* (): Operation { + const presenter = yield* usePresenter(size); + const frame = yield* commitCheckpoint(presenter, { + model, + size, + workspace: workspaceOf(model, CHECKPOINT_SIDEKICK), + }); + const screen = screenOf(presenter.grid, size); + const found = differences(record, screen.rows, screen.runs); + if (found.length > 0) { + throw new Error( + [ + `${profile}: ${found.length} difference(s) from the reviewed record`, + `expected data ${record.expectedSha256}, input ${record.input.journalSha256}`, + ...found.slice(0, 12), + ].join("\n"), + ); + } + // The record describes a screen whose Draft took focus on its own. + expect([profile, frame.focused]).toEqual([profile, record.input.focused]); + expect([profile, screen.rows.length]).toEqual([profile, record.size.rows]); + }); + }); + } +}); + +/** + * What the recorded input actually is, before anything is drawn from it. + * + * The first recording of this journal was taken without the compiler + * installed: every eval closed `err` with "compiler not installed", the first + * entry settled nothing, and the nested child was never imported. The records + * drawn from it were a consistent description of a broken reading. This says + * what the reading has to be — a first entry that completed, a second still + * running, and the nested scope the second one admits — so a journal that + * loses it fails here, where the message is about the input. + */ +describe("REPL appearance - the recorded input", () => { + it("the journal parses, settles its first entry and admits the nested Checklist", function* () { + const journal = yield* appearanceJournal(); + expect(journal.events.length).toBeGreaterThan(0); + const model = yield* appearanceModel(); + + // Two entries: the first completed, the second has not closed. + expect(model.entries.map((one) => one.terminal?.status ?? "unfinished")).toEqual([ + "ok", + "unfinished", + ]); + // The first one's own output is there, which is what it closed `ok` with. + const settled = model.entries[0]; + expect({ key: settled.key, outcome: settled.terminal?.status }).toEqual({ + key: settled.key, + outcome: "ok", + }); + // And the running entry admitted its nested child: one scope checkpoint, + // belonging to the second entry, from a component this journal really + // imported. Recorded without the compiler, that import never happened and + // there was no scope to select. + const scopes = model.checkpoints.filter((one) => one.kind === "scope"); + expect({ + scopes: scopes.length, + of: scopes.every((one) => one.marker.startsWith(`${model.entries[1].key}:`)), + imported: journal.events.some( + (event) => + event.type === "yield" && + event.description.type === "import_component" && + event.description.name === "Checklist", + ), + }).toEqual({ scopes: 1, of: true, imported: true }); + + // Every record describes this journal and no other. + for (const profile of ["160x36", "120x30", "72x20"] as const) { + const record = yield* appearanceRecord(profile); + expect([profile, record.input.journalSha256]).toEqual([profile, journal.sha256]); + } + }); + + /** + * A record is parsed, so the shapes it could have lost are refused. + * + * The members a reader reaches for are the ones worth naming: a null where a + * member should be, a number where a string should be, a run missing its + * width. Each of these is a record that would otherwise be handed on with a + * type written over it and fail later as a row that did not match. + */ + it("a malformed record is refused by name rather than read as one", function* () { + // Read through the reader itself, so the seed every counterexample is built + // from is a record rather than whatever JSON.parse returned — and so the + // callbacks below know what a style line and a run are without being told. + const sound = readAppearanceRecord( + "72x20", + yield* readTextFile( + fileURLToPath(new URL("./fixtures/repl/appearance/72x20.json", import.meta.url)), + ), + ); + const broken: readonly { readonly what: string; readonly make: () => unknown }[] = [ + { what: "a null input", make: () => ({ ...sound, input: null }) }, + { + what: "a null member of the input", + make: () => ({ ...sound, input: { ...sound.input, journalSha256: null } }), + }, + { + what: "an authority value that is not a string", + make: () => ({ ...sound, authority: { ...sound.authority, reviewedCapture: 7 } }), + }, + { what: "a null size", make: () => ({ ...sound, size: null }) }, + { + what: "a size whose columns went missing", + make: () => ({ ...sound, size: { rows: sound.size.rows } }), + }, + { + what: "a style line that is not an object", + make: () => ({ ...sound, styleRuns: [...sound.styleRuns.slice(1), "line"] }), + }, + { + what: "a run missing its width", + make: () => ({ + ...sound, + styleRuns: sound.styleRuns.map((line, at) => + at === 0 + ? { y: line.y, runs: line.runs.map((run) => ({ ...run, width: undefined })) } + : line, + ), + }), + }, + { what: "rows that are not strings", make: () => ({ ...sound, rows: [1, 2, 3] }) }, + { what: "a row count the size does not agree with", make: () => ({ ...sound, rows: [] }) }, + ]; + for (const one of broken) { + let refused: unknown; + try { + readAppearanceRecord("72x20", JSON.stringify(one.make())); + } catch (error) { + refused = error; + } + expect({ what: one.what, refused: refused instanceof Error }).toEqual({ + what: one.what, + refused: true, + }); + } + // And the record on disk is read without complaint. + expect(readAppearanceRecord("72x20", JSON.stringify(sound)).profile).toBe("72x20"); + }); +}); + describe("REPL presentation: what one row's cells say it is", () => { beforeAll(() => useTempFileCompiler()); @@ -1148,7 +1348,7 @@ describe("REPL presentation: the panes a reading is laid out in", () => { } }); - it("P2: the footer keeps seven rows, and the draft says what it is", function* () { + it("P2 kernel: the footer keeps seven rows, and the draft says what it is", function* () { const model = yield* settledAndFailed(); for (const size of [WIDE, MEDIUM, NARROW]) { const presenter = yield* usePresenter(size); @@ -1188,7 +1388,7 @@ describe("REPL presentation: the panes a reading is laid out in", () => { } }); - it("P2: the draft is marked unfocused when focus is elsewhere", function* () { + it("P2 kernel: the draft is marked unfocused when focus is elsewhere", function* () { const model = yield* settledAndFailed(); const presenter = yield* usePresenter(WIDE); const state = selecting(stateWith({ draft: "typed" }), "entry-1"); @@ -2647,7 +2847,7 @@ describe("REPL responsiveness: a complete frame after a keystroke", () => { }); /** - * Tier TE — the accepted columns, headers and edges, in production cells (#883). + * Tier TE — the accepted roles, in ordinary kernel cells (#883). * * Every expected value here comes from `issue-883/reference-style.ts`, which * transcribes the refinement the Product Owner accepted. Nothing is read back @@ -2658,8 +2858,15 @@ describe("REPL responsiveness: a complete frame after a keystroke", () => { * purpose. A vertical column divider and the rule between the body and the * footer are different readings of the screen, so a product drawing both from * one value cannot satisfy both of these rows at once. + * + * **Ordinary kernel.** These frames are `reading()`, which is `viewFor` with no + * workspace: the production renderer draws them, but the composition is the + * kernel's rather than the staged screen a person is offered. That is why the + * three shipping appearance records do not stand in for any of it — they hold + * one input with one tab selected, and this asks about roles under both tabs + * and about outlets they never route. */ -describe("Tier TE — the accepted columns, headers and edges in production cells", () => { +describe("Tier TE kernel — pane roles, edges and routed outlets", () => { /** The two sizes that draw three columns. */ const COLUMNED: readonly { readonly name: string; readonly size: ReplTerminalSize }[] = Object.freeze([ @@ -2682,7 +2889,7 @@ describe("Tier TE — the accepted columns, headers and edges in production cell return grid.styleAt(bounds.x + bounds.width - 1, bounds.y).background; } - it("TE4: each pane, header, divider and edge is the accepted value in its own cells", function* () { + it("TE4 kernel: pane roles survive both tab selections", function* () { const model = yield* settledAndFailed(); for (const { name, size } of COLUMNED) { for (const tab of TABS) { @@ -2846,7 +3053,7 @@ describe("Tier TE — the accepted columns, headers and edges in production cell } }); - it("TE4: a narrow frame routes one body, keeps seven rows and draws no wide chrome", function* () { + it("TE4 kernel: each routed outlet omits wide chrome and targets", function* () { const model = yield* settledAndFailed(); // Each outlet reached by the action that reaches it, rather than by a route // written here: what a narrow frame draws is decided by the surface a @@ -3360,8 +3567,15 @@ describe("REPL responsiveness: a chat the keystroke did not change", () => { * Every expected number here is the accepted table's. Nothing asks * `inspectionWidth` what it thinks: a test that did would agree with the * product however it drifted. + * + * **Ordinary kernel.** This is `reading()` again — `viewFor` with no workspace — + * so what it pins is the column primitive, not the screen a person is offered. + * The shipping appearance of the three accepted profiles is the appearance + * records' subject; this one also asks about an odd 161 column, which is not a + * profile and not the odd History remainder, and about the Bindings body, which + * those records never select. */ -describe("Tier PW — the accepted column shares, in the frame that was committed", () => { +describe("REPL kernel — ordinary column shares and pane bodies", () => { // The fixture entry publishes its binding from an eval block, and the // Bindings reading below measures the row that binding draws. Without the // compiler that block refuses and the pane has nothing in it to measure. @@ -3418,7 +3632,7 @@ describe("Tier PW — the accepted column shares, in the frame that was committe }, ]); - it("PW1: each column is the accepted share, and the three of them are disjoint", function* () { + it("PW1 kernel: ordinary shares and pane bodies remain whole under both tabs", function* () { const model = yield* settledAndFailed(); for (const share of SHARES) { for (const tab of [ @@ -4367,3 +4581,1092 @@ describe("Tier BP5 — header space, the inset title and pane navigation", () => } }); }); + +/** + * #971 checkpoint C1/C2: the staged workspace screen, drawn by the production + * pipeline at 160x36 from one controlled reading. + * + * The geometry asserted here is the plan's proposal — columns of 36, 66 and 58 + * cells, one identity row, 33 body rows, a four-row Draft, one History row and + * one guidance row — not a reviewed image. Colours are asserted only as the + * distinctions the screen has to keep; the palette itself waits for the + * Product Owner's image review. + */ +describe("Checkpoint #971 — the staged workspace screen at 160x36", () => { + beforeAll(() => useTempFileCompiler()); + + it("C1/C2: every surface is placed whole, wraps inside its region and keeps its styles", function* () { + expect(RUNNING_SOURCE.includes(WRAPPING_LINE)).toBe(true); + const presenter = yield* usePresenter(CHECKPOINT_SIZE); + const committed = yield* commitCheckpoint(presenter); + const { model, view, observed } = committed; + const { grid } = presenter; + const region = (id: string): ReplBounds => { + const found = observed.regionOf(id); + if (found === undefined) { + throw new Error(`this frame placed no ${id}`); + } + return found; + }; + + // C1: the rows above, inside and below the body. + expect(region("box:identity")).toEqual({ x: 0, y: 0, width: 160, height: 1 }); + expect(region("box:body")).toEqual({ x: 0, y: 1, width: 160, height: 33 }); + expect(region("box:history")).toEqual({ x: 0, y: 34, width: 160, height: 1 }); + expect(region("box:guidance")).toEqual({ x: 0, y: 35, width: 160, height: 1 }); + // The three columns, dividers included, side by side and whole. + expect(regionBounds(observed, "sidebar")).toEqual({ x: 0, y: 1, width: 36, height: 33 }); + expect(regionBounds(observed, "transcript")).toEqual({ x: 36, y: 1, width: 66, height: 33 }); + expect(regionBounds(observed, "inspection")).toEqual({ x: 102, y: 1, width: 58, height: 33 }); + // The Draft is four rows at the foot of the selected work. + const draft = region("box:draft"); + expect(draft.height).toBe(4); + expect(draft.y + draft.height).toBe(34); + expect(draft.x).toBeGreaterThanOrEqual(36); + expect(draft.x + draft.width).toBeLessThanOrEqual(102); + + // C1: a real nested scope is selected, and the breadcrumb beside Entries + // says the ancestry it actually resolved to. + const entry = model.entries[model.entries.length - 1]; + const scopes = checkpointScopes(model); + expect(scopes.length).toBe(2); + expect(scopes[0]).toBe(entry.key); + expect(view.state.route.scopes).toEqual(scopes); + const ancestry = view.selection.ancestry; + expect(ancestry.map((one) => one.key)).toEqual(scopes); + expect(view.selection.scope?.key).toBe(scopes[1]); + expect(view.selection.scope?.key).not.toBe(entry.scope.key); + expect(view.selection.scope?.kind).toBe("component"); + // The breadcrumb, as the parts it is drawn in. Every name but the innermost + // is a control that goes to it — the reading is already at the last one — + // so the trail is one node per ancestor and one row for where the reading + // is, each on the same row as the Entries control and in the order the + // scopes were selected. + const ancestors = observed.keys + .filter((key) => key.startsWith("work:crumb:")) + .sort((left, right) => placed(observed, left).x - placed(observed, right).x); + expect(ancestors).toHaveLength(ancestry.length - 1); + const crumbRow = placed(observed, "work:crumbs"); + const entries = placed(observed, "entries:heading"); + const crumbText = (key: string): string => observed.cells.get(key) ?? ""; + const targeted = (key: string): boolean => { + const node = observed.nodeOf(key); + return node !== undefined && observed.targets.some((one) => one.node === node); + }; + // The entry is the one ancestor here, and it is where this trail starts. + expect(crumbText(ancestors[0])).toContain(`Entry ${entry.order}`); + // The innermost is the reading itself, and it is not a way to anywhere. + expect(crumbText("work:crumbs")).toContain(ancestry[1].name); + expect(targeted("work:crumbs")).toBe(false); + // And each ancestor is reachable: it publishes a target of its own, inside + // the row, in front of the reading it leads to. + for (const key of ancestors) { + const at = placed(observed, key); + expect([key, at.y]).toEqual([key, entries.y]); + expect([key, at.x > entries.x && at.x < crumbRow.x]).toEqual([key, true]); + expect([key, targeted(key)]).toEqual([key, true]); + } + expect(crumbRow.y).toBe(entries.y); + expect(entries.x).toBeLessThan(crumbRow.x); + + // The old composition's surfaces are not on this screen at all. + for (const key of observed.keys) { + expect(key.startsWith("entry:")).toBe(false); + expect(key === "sessions:heading" || key === "transcript:heading").toBe(false); + } + expect(observed.regionOf("box:band:0")).toBe(undefined); + + // The Draft's own Stop shares its heading row, at the right of the field. + const input = placed(observed, "footer:input"); + const draftStop = placed(observed, "draft:stop"); + expect(input.y).toBe(draft.y); + expect(draftStop.y).toBe(draft.y); + expect(draftStop.x).toBeGreaterThan(input.x); + expect(draftStop.x + draftStop.width).toBeLessThanOrEqual(draft.x + draft.width); + + // The inactive TODO tray on the left and the Sidekick's own Stop on the + // right, on one row above the composer. QUEUE and the Follow checkbox that + // used to sit between them are DQ0 and DF1 in + // `fixtures/repl/deferred-presentation.json`: this build offers neither, so + // neither is drawn, measured or mounted. + const todo = placed(observed, "tray:todo"); + const sidekickStop = placed(observed, "sidekick:stop"); + expect(sidekickStop.y).toBe(todo.y); + expect(todo.x).toBeLessThan(sidekickStop.x); + expect(sidekickStop.x + sidekickStop.width).toBeGreaterThan(155); + for (const key of ["tray:queue", "follow"]) { + expect({ key, mounted: observed.nodeOf(key) !== undefined }).toEqual({ + key, + mounted: false, + }); + } + expect(placed(observed, "sidekick:message").y).toBeGreaterThan(todo.y); + + // The pending-message rows this screen used to draw beside the composer — + // their order, their origin marks, each row's own withdrawal control and + // the way a long one wrapped between its origin and its X — are DQ1 in + // `fixtures/repl/deferred-presentation.json`. This build offers no shared + // queue, so there is nothing here to place them against. + + // Mounted controls are whole, inside the screen, and never overlap. + const targets = observed.targets.map((one) => one.bounds); + for (const one of targets) { + expect(Number.isInteger(one.x) && Number.isInteger(one.y)).toBe(true); + expect(one.x).toBeGreaterThanOrEqual(0); + expect(one.x + one.width).toBeLessThanOrEqual(160); + expect(one.y + one.height).toBeLessThanOrEqual(36); + } + for (const [at, one] of targets.entries()) { + for (const other of targets.slice(at + 1)) { + const apart = + one.x + one.width <= other.x || + other.x + other.width <= one.x || + one.y + one.height <= other.y || + other.y + other.height <= one.y; + expect(apart).toBe(true); + } + } + + // C2: the exact source wraps at measured word boundaries inside the + // selected work, and the run of spaces in it survives. + const body = region(TRANSCRIPT_BODY); + for (const key of observed.keys.filter((one) => one.startsWith("reading:"))) { + const row = placed(observed, key); + expect(row.x).toBeGreaterThanOrEqual(body.x); + expect(row.x + row.width).toBeLessThanOrEqual(body.x + body.width); + } + const centre = grid.textIn(body); + const start = centre.findIndex((one) => one.includes("Compute the total once, after")); + expect(start).toBeGreaterThanOrEqual(0); + const column = centre[start].indexOf("Compute"); + const pieces: string[] = []; + for ( + let at = start; + at < centre.length && pieces.join(" ").length < WRAPPING_LINE.length; + at += 1 + ) { + pieces.push(centre[at].slice(column).trimEnd()); + } + expect(pieces.length).toBeGreaterThan(1); + expect(pieces.join(" ")).toBe(WRAPPING_LINE); + + // Surfaces: the three columns, a header row and the Draft are each their + // own ground, blank cells included. + const left = grid.styleAt(30, 30).background; + const middle = grid.styleAt(80, 26).background; + const right = grid.styleAt(150, 22).background; + const heading = grid.styleAt(30, 1).background; + const drafted = grid.styleAt(draft.x + draft.width - 2, draft.y + 2).background; + expect(new Set([left, middle, right, heading, drafted]).size).toBe(5); + for (const key of ["draft:value:0", "draft:value:1", "draft:value:2"]) { + const row = placed(observed, key); + expect(grid.styleAt(row.x + row.width - 1, row.y).background).toBe(drafted); + } + // C2: the Draft is the control the mounted tree holds focus on, and the + // cue is drawn on that same control. A cue for a control that does not hold + // focus would say the next keystroke lands somewhere it does not. + expect(committed.focused).toBe("footer:input"); + expect(focusKeyOf(presenter)).toBe("footer:input"); + expect(view.focused).toBe("footer:input"); + const marker = grid.styleAt(input.x, input.y); + expect(grid.at(input.x, input.y)).toBe(">"); + expect(marker.attrs.includes(BOLD)).toBe(true); + const value = placed(observed, "draft:value:0"); + expect(grid.styleAt(value.x, value.y).foreground).not.toBe( + grid.styleAt(value.x + 1, value.y).foreground, + ); + // What a person asked, what the agent replied, what this execution is doing + // and what is still being typed all read differently from one another. The + // queued rows and their origin marks this used to compare are DQ1 in + // `fixtures/repl/deferred-presentation.json`. + const asked = placed(observed, "sidekick:chat:message:1"); + const replied = placed(observed, "sidekick:chat:response:1"); + const askedInk = grid.styleAt(asked.x, asked.y).foreground; + const repliedInk = grid.styleAt(replied.x, replied.y).foreground; + expect(askedInk).not.toBe(repliedInk); + const composer = placed(observed, "sidekick:message:value:0"); + expect(grid.styleAt(composer.x, composer.y).foreground).not.toBe(askedInk); + const working = placed(observed, "sidekick:work:entry-1"); + expect(grid.styleAt(working.x, working.y).foreground).not.toBe(askedInk); + const state = placed(observed, "work:state"); + expect(grid.styleAt(state.x, state.y).foreground).not.toBe( + grid.styleAt(placed(observed, "work:crumbs").x, state.y).foreground, + ); + }); + + /** + * #971: the Transcript is one list, and the conversations are its filter. + * + * The person who accepted this screen asked for exactly that — one list + * rather than two headed sections, with All sessions filtering it + * (`human-transcript-single-list-20261027-1706.json`). Every fact here is the + * reading's own: two owners that really did work under two different provider + * session keys, and the keys themselves read from those records rather than + * named here. + * + * One displayed list is not one lifetime. The owners keep their own authority + * and their own recorded order — this asserts what a reader sees and what the + * selector does to it, and nothing about merging the two histories. + */ + /** + * #971 B1: the Entries catalog, opened over the reading it navigates. + * + * The staged screen keeps no permanent catalog column — its three columns are + * the Transcript, the selected work and Bindings/Sidekick — so Entries opens + * the catalog as a drawer over the body, the way every other whole-execution + * reading is opened. What this holds it to is that the overlay is a + * *presentation*: it selects nothing by existing, it changes nothing by + * closing, and the rows in it are the same rows, under the same keys, that + * the permanent column draws. + */ + it("A6: Entries opens a catalog over the reading, and only a row in it navigates", function* () { + const model = yield* checkpointModel(); + for (const size of CHECKPOINT_SIZES) { + const where = `${size.columns}x${size.rows}`; + yield* scoped(function* (): Operation { + const presenter = yield* usePresenter(size); + // The shipping root's own presentation, not the staged fixture's. + const shipping = { model, size, workspace: workspaceOf() }; + const base = yield* commitCheckpoint(presenter, shipping); + + // Closed, there is no catalog on the screen and no drawer layer. + expect([where, base.observed.keys.includes("drawer:open")]).toEqual([where, false]); + expect([where, base.observed.keys.some((key) => key.startsWith("entry:"))]).toEqual([ + where, + false, + ]); + + // The control a person actually presses asks for the catalog. Reached + // through the mounted tree at the cell it was drawn in, so what this + // asserts is the control on the screen rather than a description. + const opener = placed(base.observed, "entries:heading"); + const pressed = yield* presenter.tree.dispatch({ + kind: "pointer", + target: base.observed.committed.rendered.map.at(opener.x, opener.y) ?? "", + frame: base.observed.committed.rendered.tree, + }); + if (!pressed.ok) { + throw new Error(`${where}: the Entries control refused its own activation`); + } + expect([where, asked(pressed.value)]).toEqual([where, { kind: "open-catalog" }]); + + const next = reduceRepl(base.view.state, { kind: "open-catalog" }, model, base.view.live); + // Opening is navigation and nothing else: no provider work, no append, + // no submission. + expect([where, next.intent]).toEqual([where, { kind: "none" }]); + const open = yield* presenter.commit( + checkpointViewOf(model, base.focused, { ...shipping, state: next.state }), + ); + + // A floating layer over the body, whole-celled, inside the frame and + // clear of the footer the identity and History rows keep. + const layer = open.regionOf("box:drawer:layer"); + if (layer === undefined) { + throw new Error(`${where}: the catalog drew no drawer layer`); + } + for (const edge of [layer.x, layer.y, layer.width, layer.height]) { + expect([where, Number.isInteger(edge)]).toEqual([where, true]); + } + expect([where, layer.x + layer.width <= size.columns]).toEqual([where, true]); + expect([where, layer.y + layer.height < size.rows - 1]).toEqual([where, true]); + + // The same rows the permanent column builds, under the same keys: an + // entry is one identity however a reader reached it. Every entry this + // prefix retained, in admission order, with its own nested scope. + const rows = open.keys.filter( + (key) => key.startsWith("entry:") || key.startsWith("scope:"), + ); + expect([where, rows]).toEqual([ + where, + ["entry:entry-1", "entry:entry-2", "scope:entry-2/Checklist-1"], + ]); + // And they are inside the drawer, not mounted somewhere nothing drew + // them: each one has a box, and each box is inside that layer. + for (const key of rows) { + const at = open.boundsOf(key); + if (at === undefined) { + throw new Error(`${where}: the catalog mounted ${key} and drew it nowhere`); + } + expect([where, key, at.x >= layer.x && at.x + at.width <= layer.x + layer.width]).toEqual( + [where, key, true], + ); + expect([ + where, + key, + at.y >= layer.y && at.y + at.height <= layer.y + layer.height, + ]).toEqual([where, key, true]); + } + // Its own window controls and its way out, outside the moving rows. + for (const key of ["drawer:scroll:up", "drawer:scroll:down", "drawer:close"]) { + expect([where, key, open.boundsOf(key) !== undefined]).toEqual([where, key, true]); + } + + // A nested scope, by its exact path. The reading moves to that exact + // entry and child, and the catalog closes behind the choice it was + // opened to make. + const chose = reduceRepl( + next.state, + { kind: "select-scope", scopes: ["entry-2", "Checklist-1"] }, + model, + base.view.live, + ); + expect([where, chose.intent]).toEqual([where, { kind: "none" }]); + expect([where, chose.state.route.scopes]).toEqual([where, ["entry-2", "Checklist-1"]]); + expect([where, chose.state.catalog]).toEqual([where, false]); + + // An older entry, by its own key, from the same open catalog. + const older = reduceRepl( + next.state, + { kind: "select-scope", scopes: ["entry-1"] }, + model, + base.view.live, + ); + expect([where, older.state.route.scopes]).toEqual([where, ["entry-1"]]); + expect([where, older.state.catalog]).toEqual([where, false]); + + // Nothing a person was holding moved, by either route: both texts, the + // conversation filter, the history marker and the saved chat stand. + for (const after of [chose.state, older.state, next.state]) { + expect([where, after.draft]).toEqual([where, base.view.state.draft]); + expect([where, after.message]).toEqual([where, base.view.state.message]); + expect([where, after.route.session]).toEqual([where, base.view.state.route.session]); + expect([where, after.route.at]).toEqual([where, base.view.state.route.at]); + expect([where, after.route.sidekick]).toEqual([where, base.view.state.route.sidekick]); + } + + // And closing chooses nothing. The scope being read is the one that was + // being read before the catalog opened. + const shut = reduceRepl(next.state, { kind: "close-catalog" }, model, base.view.live); + expect([where, shut.state.catalog]).toEqual([where, false]); + expect([where, shut.state.route.scopes]).toEqual([where, base.view.state.route.scopes]); + expect([where, shut.state.draft]).toEqual([where, base.view.state.draft]); + + // The catalog survives a resize: it is a reading somebody opened, not a + // property of the frame that drew it. + const grown = yield* presenter.commit( + checkpointViewOf(model, base.focused, { + ...shipping, + size: CHECKPOINT_SIZE, + state: next.state, + }), + ); + expect([where, grown.keys.includes("drawer:open")]).toEqual([where, true]); + }); + } + }); + + it("A5: one Transcript list, with the conversations as its filter", function* () { + const entryTurn = CHECKPOINT_LIVE.agent.turns[0]; + const chatTurn = CHECKPOINT_SIDEKICK.turns[0]; + if (entryTurn === undefined || chatTurn === undefined) { + throw new Error("this reading holds no turn for one of its two owners"); + } + // Two owners, two provider conversations: a filter that kept both rows + // under either key would prove nothing about filtering. + const entryKey = entryTurn.sessionKey; + const chatKey = chatTurn.sessionKey; + if (entryKey === undefined || chatKey === undefined || entryKey === chatKey) { + throw new Error("this reading does not hold two separate provider conversations"); + } + const entryRow = `sessions:turn:${entryTurn.key}`; + const chatRow = `sessions:${chatTurn.key}`; + + const presenter = yield* usePresenter(CHECKPOINT_SIZE); + const opened = yield* commitCheckpoint(presenter); + const { model } = opened; + + /** What activating one drawn row asks for, as the pointer reaches it. */ + function* activated(observed: Observed, key: string): Operation { + const bounds = placed(observed, key); + const pointed = yield* presenter.tree.dispatch({ + kind: "pointer", + target: observed.committed.rendered.map.at(bounds.x, bounds.y) ?? "", + frame: observed.committed.rendered.tree, + }); + if (!pointed.ok) { + throw new Error(`activating ${key} was refused`); + } + return asked(pointed.value); + } + + /** The next frame of this same screen, after one action was reduced. */ + function* acted( + state: ReplState, + action: ReplAction, + ): Operation<{ readonly state: ReplState; readonly observed: Observed }> { + const next = reduceRepl(state, action, model, CHECKPOINT_LIVE); + // Filtering is a reading, not work: no provider is started and no record + // is written by any of these. + expect(next.intent).toEqual({ kind: "none" }); + const observed = yield* presenter.commit( + checkpointViewOf(model, presenter.tree.keyOf(presenter.tree.focused() ?? "") ?? undefined, { + state: next.state, + }), + ); + return { state: next.state, observed }; + } + + // One list. A row from each owner is in it, and there is no heading over + // either of them and no per-owner placeholder beneath either. + expect(opened.observed.keys).toContain(entryRow); + expect(opened.observed.keys).toContain(chatRow); + for (const gone of [ + "sessions:section:entries", + "sessions:section:sidekick", + "sessions:entries:none", + "sessions:sidekick:none", + ]) { + expect({ key: gone, drawn: opened.observed.keys.includes(gone) }).toEqual({ + key: gone, + drawn: false, + }); + } + // Each row still says whose work it is, which is where owner authority is + // read now that no heading says it. + expect(opened.observed.cells.get(`${entryRow}:whose`) ?? "").toContain(entryKey); + expect(opened.observed.cells.get(`${chatRow}:whose`) ?? "").toContain(chatTurn.conversation); + // And each goes where it always did. + const toEntry = yield* activated(opened.observed, entryRow); + const toChat = yield* activated(opened.observed, chatRow); + expect(toChat).toEqual({ + kind: "read-sidekick-turn", + conversation: chatTurn.conversation, + turn: chatTurn.turn, + }); + + // The selector is a control a keystroke reaches, not a caption — and it + // draws no choices while it is closed. + yield* focusOn(presenter, "transcript:selector"); + const pressed = yield* presenter.tree.dispatch({ kind: "key", key: "Enter" }); + if (!pressed.ok) { + throw new Error("the Transcript selector refused its own activation"); + } + expect(asked(pressed.value)).toEqual({ kind: "toggle-session-filter" }); + expect(opened.observed.keys.some((key) => key.startsWith("sessions:conversation:"))).toBe( + false, + ); + + // Opened, it offers the conversations this reading actually holds, once + // each, with the way back to all of them. + const showing = yield* acted(opened.view.state, { kind: "toggle-session-filter" }); + expect(showing.observed.keys).toContain("sessions:all"); + expect(showing.observed.keys).toContain(`sessions:conversation:${entryKey}`); + expect(showing.observed.keys).toContain(`sessions:conversation:${chatKey}`); + // Both owners' rows are still in the list behind the open chooser. + expect(showing.observed.keys).toContain(entryRow); + expect(showing.observed.keys).toContain(chatRow); + + // Choosing one is the key that row names, and it filters the whole list: + // only that conversation's rows remain, across both owners. + const chose = yield* activated(showing.observed, `sessions:conversation:${entryKey}`); + expect(chose).toEqual({ kind: "select-session", session: entryKey }); + const filtered = yield* acted(showing.state, chose); + expect(filtered.state.route.session).toBe(entryKey); + expect(filtered.observed.keys).toContain(entryRow); + expect(filtered.observed.keys).not.toContain(chatRow); + // The choice closes the chooser behind it, and the selector says what is + // chosen. + expect(filtered.observed.keys.some((key) => key.startsWith("sessions:conversation:"))).toBe( + false, + ); + expect(filtered.observed.cells.get("transcript:selector") ?? "").toContain(entryKey); + // The entry row goes exactly where it went unfiltered. + expect(yield* activated(filtered.observed, entryRow)).toEqual(toEntry); + // Nothing anybody was typing moved. + expect(filtered.state.draft).toBe(CHECKPOINT_DRAFT); + expect(filtered.state.message).toBe(CHECKPOINT_MESSAGE); + + // All sessions restores both owners' rows to the one list. + const restored = yield* acted(filtered.state, { kind: "all-sessions" }); + expect(restored.state.route.session).toBe(undefined); + expect(restored.observed.keys).toContain(entryRow); + expect(restored.observed.keys).toContain(chatRow); + + // The other conversation filters to its own owner's rows, so the filter is + // the key rather than a section this list no longer has. + const theirs = yield* acted(restored.state, { kind: "select-session", session: chatKey }); + expect(theirs.observed.keys).toContain(chatRow); + expect(theirs.observed.keys).not.toContain(entryRow); + + // And closing the chooser without choosing changes no selection: the + // conversation stands, the list stands, and both texts stand. + const reopened = yield* acted(theirs.state, { kind: "toggle-session-filter" }); + const closed = yield* acted(reopened.state, { kind: "toggle-session-filter" }); + expect(closed.state.route).toEqual(theirs.state.route); + expect(closed.observed.keys.some((key) => key.startsWith("sessions:conversation:"))).toBe( + false, + ); + expect(closed.observed.keys).toContain(chatRow); + expect(closed.observed.keys).not.toContain(entryRow); + expect(closed.state.draft).toBe(CHECKPOINT_DRAFT); + expect(closed.state.message).toBe(CHECKPOINT_MESSAGE); + }); + + /** + * #971: the Transcript's selector says a name in cells, not in code units. + * + * What that control says once a conversation has been chosen is the + * conversation's name, and a provider's session key is a name nothing in this + * product bounds. Counting its code units gets its width wrong in both + * directions — a wide character occupies two cells and counts as one unit, a + * base character with combining marks occupies one and counts as several — + * and slicing by them cuts an emoji sequence in half. All three were + * measured: twenty-five `\u754c` declared twenty-nine cells and needed + * fifty-four, so the engine drew none of it; combining accents declared more + * cells than they used and published a target reaching past the column's own + * divider; and a joined sequence came back as two glyphs where its author + * wrote one. + * + * The ASCII key is here beside them because it passed throughout: a row that + * only held ASCII is what let the other three through. + */ + it("A5 selector: the name is fitted in cells, whole graphemes, inside its column", function* () { + const named: readonly { readonly what: string; readonly key: string }[] = [ + { what: "ascii", key: "x".repeat(50) }, + { what: "wide", key: "\u754c".repeat(25) }, + { what: "combining", key: "e\u0301".repeat(25) }, + { what: "joined", key: "\u{1f469}\u200d\u{1f4bb}".repeat(12) }, + // One grapheme, and longer in code units than any cut this product used + // to make before the fitter saw it: an `e` under a hundred combining + // accents is 101 units and one cell. Measured, the old cut at + // fifty-nine units handed the fitter a name missing forty-two of those + // accents, so what was drawn was an alteration of somebody's name that + // the fitter then reported an honest width for. + { what: "one long grapheme", key: `e${"\u0301".repeat(100)}` }, + ]; + const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + const graphemes = (text: string): readonly string[] => + [...segmenter.segment(text)].map((one) => one.segment); + + for (const size of [CHECKPOINT_SIZE, { columns: 120, rows: 30 }]) { + for (const one of named) { + const where = `${one.what} at ${size.columns}`; + const presenter = yield* usePresenter(size); + const model = yield* checkpointModel(); + // A key this reading can actually be filtered by: the live turns name + // it, which is what makes it a conversation this route may select. + const live: ReplLive = Object.freeze({ + ...CHECKPOINT_LIVE, + agent: Object.freeze({ + ...CHECKPOINT_LIVE.agent, + turns: Object.freeze( + CHECKPOINT_LIVE.agent.turns.map((turn) => + Object.freeze({ ...turn, sessionKey: one.key }), + ), + ), + }), + }); + const base = checkpointState(model, { size }); + const committed = yield* commitCheckpoint(presenter, { + size, + model, + live, + state: Object.freeze({ + ...base, + route: Object.freeze({ ...base.route, session: one.key }), + }), + }); + const observed = committed.observed; + const label = observed.cells.get("transcript:selector") ?? ""; + const target = observed.boundsOf("transcript:selector"); + if (target === undefined) { + throw new Error(`${where}: the selector published no target`); + } + + // Whole cells, which is what this product draws in: a fractional + // rectangle is a frame it refuses rather than rounds. + expect([where, Number.isInteger(target.x) && Number.isInteger(target.width)]).toEqual([ + where, + true, + ]); + + // The engine's own answer for the label it is given, against the target + // that label was published in. Equal, not merely "no exception": a + // label wider than its box is drawn over the column beside it, and a + // box wider than its label publishes cells nothing wrote. + const natural = yield* presenter.renderer.measure( + [ + open("selector:probe", { layout: { width: fit(), height: fit() } }), + text(label), + close(), + ], + size, + ); + if (!natural.ok) { + throw natural.error; + } + expect([where, natural.value.boundsOf("selector:probe")?.width]).toEqual([ + where, + target.width, + ]); + + // Inside the Transcript's own column, whole. A target reaching past + // that divider is a pointer target over somebody else's reading. + const column = observed.regionOf("box:sidebar"); + if (column === undefined) { + throw new Error(`${where}: this frame drew no Transcript column`); + } + expect([ + where, + target.x >= column.x && target.x + target.width <= column.x + column.width, + ]).toEqual([where, true]); + + // The mark that says it opens survives whatever the name costs. + expect([where, label.endsWith(" \u25be")]).toEqual([where, true]); + + // And the name itself is whole graphemes of the key, in order: a cut + // inside a sequence is a glyph its author never wrote. The marker room + // in front and the mark behind are the row's, not the name's. + const said = label.slice(2, label.length - 2); + const shown = said.endsWith("\u2026") ? said.slice(0, -1) : said; + expect([where, graphemes(shown)]).toEqual([ + where, + graphemes(one.key).slice(0, graphemes(shown).length), + ]); + // A key longer than its column gives columns up; one that fits keeps + // every grapheme it had. Either way what is drawn is a whole prefix of + // the *original* name — nothing shortened it on the way to the fitter. + expect([where, shown.length > 0]).toEqual([where, true]); + } + } + }); +}); + +/** + * #971 A1 is now read in the places each of its fields belongs. + * + * What the screen looks like — every row of text, every style run under it and + * the blank backgrounds a surface paints — is the three appearance records and + * the cases above them. Where the frame put its regions, pane bodies and whole + * contained targets is A2 and the admission boundary. Which reading it is of, + * which scope is selected and which control a keystroke reaches are A6 and the + * mounted-focus cases. The accepted capture itself stays in the history at + * `accepted-160.ts`, blob 83dd9453 at f0232b3e, recoverable with `git show`. + * + * It was one case asserting six different kinds of thing about a composition + * this build no longer draws — the staged screen it pinned still had the shared + * queue in it. + */ +describe("Checkpoint #971 A2 — the staged screen at the two smaller sizes", () => { + beforeAll(() => useTempFileCompiler()); + + /** Every mounted control's box, by the key that owns it. */ + function targetsOf(presenter: Presenter, observed: Observed): ReadonlyMap { + return new Map( + observed.targets.flatMap((one) => { + const key = presenter.tree.keyOf(one.node); + return key === undefined ? [] : [[key, one.bounds] as const]; + }), + ); + } + + it("A2: every size places whole controls inside the frame and keeps its routes", function* () { + for (const size of CHECKPOINT_SIZES) { + const where = `${size.columns}x${size.rows}`; + const presenter = yield* usePresenter(size); + const observed = (yield* commitCheckpoint(presenter, { size })).observed; + + // Three rows of chrome at every size: the identity row above the body, + // then the compact History row and the guidance row below it. + expect({ where, bounds: observed.regionOf("box:identity") }).toEqual({ + where, + bounds: { x: 0, y: 0, width: size.columns, height: 1 }, + }); + expect({ where, bounds: observed.regionOf("box:body") }).toEqual({ + where, + bounds: { x: 0, y: 1, width: size.columns, height: size.rows - 3 }, + }); + expect({ where, bounds: observed.regionOf("box:history") }).toEqual({ + where, + bounds: { x: 0, y: size.rows - 2, width: size.columns, height: 1 }, + }); + expect({ where, bounds: observed.regionOf("box:guidance") }).toEqual({ + where, + bounds: { x: 0, y: size.rows - 1, width: size.columns, height: 1 }, + }); + + // Whole cells, inside the frame, and no two controls over one another. + const targets = [...targetsOf(presenter, observed).values()]; + expect({ where, count: targets.length > 0 }).toEqual({ where, count: true }); + for (const one of targets) { + expect({ where, whole: Number.isInteger(one.x) && Number.isInteger(one.y) }).toEqual({ + where, + whole: true, + }); + expect({ + where, + inside: + one.x >= 0 && + one.y >= 0 && + one.x + one.width <= size.columns && + one.y + one.height <= size.rows, + }).toEqual({ where, inside: true }); + } + for (const [at, one] of targets.entries()) { + for (const other of targets.slice(at + 1)) { + const apart = + one.x + one.width <= other.x || + other.x + other.width <= one.x || + one.y + one.height <= other.y || + other.y + other.height <= one.y; + expect({ where, apart }).toEqual({ where, apart: true }); + } + } + } + }); + + it("A2: medium gives the selected work room its reading can use", function* () { + const presenter = yield* usePresenter(CHECKPOINT_MEDIUM); + const committed = yield* commitCheckpoint(presenter, { size: CHECKPOINT_MEDIUM }); + const { observed } = committed; + // The corrected medium proportions, dividers included: the selected work + // takes the remainder because it is the column whose room is spent on a + // reserved rail and badge column before a reader sees any source. + expect(regionBounds(observed, "sidebar")).toEqual({ x: 0, y: 1, width: 36, height: 27 }); + expect(regionBounds(observed, "transcript")).toEqual({ x: 36, y: 1, width: 52, height: 27 }); + expect(regionBounds(observed, "inspection")).toEqual({ x: 88, y: 1, width: 32, height: 27 }); + + // What that buys is source room, measured through the reading's own + // reservation rather than counted from the column's width. + const prepared = yield* prepareFrame(presenter.renderer, committed.view); + if (!prepared.ok) { + throw prepared.error; + } + const reservation = prepared.value.context.reading?.reservation; + expect(reservation?.content).toBeGreaterThanOrEqual(24); + // The reservation itself is unchanged: the rail, its separators and a + // column as wide as the widest badge this reading could ever hold. + expect(reservation?.rail).toBeGreaterThan(0); + expect(reservation?.status).toBeGreaterThan(0); + + // A header whose groups do not fit one row still takes a second row of its + // own column rather than a shortened label or a dropped control. + for (const id of ["box:inspection:tabs", "box:tray:controls"]) { + const row = observed.regionOf(id); + const second = observed.regionOf(`${id}:second`); + expect({ id, wrapped: second === undefined || second.y === (row?.y ?? 0) + 1 }).toEqual({ + id, + wrapped: true, + }); + } + + // The Draft keeps its four rows at the foot of that column. + const draft = observed.regionOf("box:draft"); + expect(draft?.height).toBe(4); + expect((draft?.y ?? 0) + (draft?.height ?? 0)).toBe(CHECKPOINT_MEDIUM.rows - 2); + }); + + it("A2: the selected work's source wraps at word boundaries and stays exact", function* () { + const presenter = yield* usePresenter(CHECKPOINT_MEDIUM); + const committed = yield* commitCheckpoint(presenter, { size: CHECKPOINT_MEDIUM }); + const prepared = yield* prepareFrame(presenter.renderer, committed.view); + if (!prepared.ok) { + throw prepared.error; + } + const rows = prepared.value.context.reading?.rows ?? []; + const texts = rows.map((one) => one.text); + + // The output line breaks between whole words, and neither filename is cut. + // The boundary space stays on the row that ends with it: whitespace is + // part of the line, and the next row does not begin with somebody else's. + const opened = texts.indexOf("Reading checkout.md and "); + expect(opened).toBeGreaterThanOrEqual(0); + expect(texts[opened + 1]).toBe("architecture.md"); + + // The long source line reconstructs exactly, four spaces included, and the + // remainder that fits a row stays whole on it. + const started = texts.findIndex((one) => WRAPPING_LINE.startsWith(one.trimEnd()) && one !== ""); + expect(started).toBeGreaterThanOrEqual(0); + let said = ""; + let at = started; + while (at < texts.length && said.length < WRAPPING_LINE.length) { + said += texts[at]; + at += 1; + } + expect(said).toBe(WRAPPING_LINE); + expect(said).toContain("once, after"); + // The last row of that line is its whole remainder: what fits a row stays + // on it, which is the behaviour D10's own regressions hold the fitter to. + expect(texts[at - 1]).toBe("customer pays."); + // Each drawn row fits the room the reading reserved for its content. + const content = prepared.value.context.reading?.reservation.content ?? 0; + for (const row of rows) { + expect(row.width + row.indent).toBeLessThanOrEqual(content); + } + }); + + it("A2: narrow routes one outlet, keeps both surface controls and the Draft", function* () { + const model = yield* checkpointModel(); + // The entry surface: the selected work, its reading and its Draft. + const work = yield* usePresenter(CHECKPOINT_NARROW); + const entries = (yield* commitCheckpoint(work, { size: CHECKPOINT_NARROW, model })).observed; + expect(regionBounds(entries, "content")).toEqual({ x: 0, y: 1, width: 72, height: 17 }); + // Neither of the other two columns exists at this size. + expect(regionBounds(entries, "sidebar")).toBe(undefined); + expect(regionBounds(entries, "inspection")).toBe(undefined); + for (const key of ["sessions:heading", "entries:heading", "footer:input", "draft:stop"]) { + expect({ key, drawn: entries.boundsOf(key) !== undefined }).toEqual({ key, drawn: true }); + } + // The Sidekick's own rows are on no other surface's screen. + for (const key of ["tray:queue", "follow", "sidekick:message"]) { + expect({ key, drawn: entries.keys.includes(key) }).toEqual({ key, drawn: false }); + } + + // The Sessions surface: the Transcript's own reading, and no Draft. + const sessions = yield* usePresenter(CHECKPOINT_NARROW); + const read = (yield* commitCheckpoint(sessions, { + size: CHECKPOINT_NARROW, + model, + surface: "sessions", + })).observed; + expect(read.keys.includes("transcript:title")).toBe(true); + expect(read.keys.includes("footer:input")).toBe(false); + expect(read.boundsOf("sessions:heading")).not.toBe(undefined); + + // The Sidekick surface: its chat, its tray and its composer, reached by the + // route rather than by a tab this size does not draw. + const chat = yield* usePresenter(CHECKPOINT_NARROW); + const talking = (yield* commitCheckpoint(chat, { + size: CHECKPOINT_NARROW, + model, + sidekick: true, + })).observed; + for (const key of ["tray:todo", "sidekick:stop", "sidekick:message"]) { + expect({ key, drawn: talking.boundsOf(key) !== undefined }).toEqual({ key, drawn: true }); + } + // Neither offer this build does not have reaches a narrow outlet either. + for (const key of ["tray:queue", "follow"]) { + expect({ key, drawn: talking.boundsOf(key) !== undefined }).toEqual({ key, drawn: false }); + } + expect(talking.keys.includes("tab:bindings")).toBe(false); + expect(talking.keys.includes("footer:input")).toBe(false); + }); +}); + +/** + * #971 A4: every outcome a reading can hold, on one screen, at each size. + * + * One controlled reading rather than one per state: four entries that really + * ran — settled, failed, stopped and still running — a Sidekick question + * waiting, and a queue longer than the tray can show. Asking for a screen per + * state would be asking for the same frame fifteen times. + * + * What this proves is fitting and state: that each outcome is on the screen, + * that they read differently, and that nothing the window holds is unreachable. + * Whether it looks right is the image review's answer, not a test's. + */ +describe("Checkpoint #971 A4 — current outcomes, work and the waiting question", () => { + beforeAll(() => useTempFileCompiler()); + + /** The four outcomes this reading holds, as the words the screen says. */ + const OUTCOMES = ["✓ COMPLETED", "× FAILED", "- INTERRUPTED", "● ACTIVE"] as const; + + it("A4: every outcome, work identity and the waiting question read from the current screen", function* () { + const model = yield* checkpointStateModel(); + expect(model.entries.map((one) => one.terminal?.status ?? "unfinished")).toEqual([ + "ok", + "err", + "cancelled", + "unfinished", + ]); + + for (const size of CHECKPOINT_SIZES) { + const where = `${size.columns}x${size.rows}`; + const narrow = size.columns === CHECKPOINT_NARROW.columns; + const presenter = yield* usePresenter(size); + // The composition this build offers: the same staged work rows and + // conversation, with no shared queue tray, no queued rows and no Follow. + const workspace = CHECKPOINT_CURRENT_WORKSPACE; + const committed = yield* commitCheckpoint(presenter, { + model, + size, + live: CHECKPOINT_ASKING, + workspace, + // A narrow frame routes one outlet, and the conversation is the outlet + // these readings are in. + ...(narrow ? { sidekick: true } : {}), + }); + const { observed } = committed; + const { admission } = observed.committed; + + // Every outcome is in the reading this screen is showing, whatever its + // window has room for: a smaller screen scrolls to them rather than + // losing them. The rows come from the production builder, so this is the + // reading the frame draws from rather than a second account of it. + const reading = chatLinesFor(committed.view) + .map((one) => one.text) + .join("\n"); + for (const word of OUTCOMES) { + expect({ where, word, held: reading.includes(word) }).toEqual({ + where, + word, + held: true, + }); + } + // And what this window is not showing is reachable rather than gone. + const whole = windowOf(admission, SIDEKICK_WINDOW); + expect({ + where, + reachable: whole.total >= whole.count && whole.from + whole.count <= whole.total, + }).toEqual({ where, reachable: true }); + + // The outcomes this frame is actually showing read differently from one + // another: no two of them in the same ink. + const said = presenter.grid + .textIn({ x: 0, y: 0, width: size.columns, height: size.rows }) + .join("\n"); + const shown = OUTCOMES.filter((word) => said.includes(word)); + const inks = new Set(); + for (const word of shown) { + const at = said.indexOf(word); + const before = said.slice(0, at).split("\n"); + const y = before.length - 1; + const x = before[before.length - 1].length; + inks.add(presenter.grid.styleAt(x, y).foreground); + } + expect({ where, distinct: inks.size }).toEqual({ where, distinct: shown.length }); + + // The question announces itself where it can be answered, and answering + // it is a control rather than a sentence. + if (!narrow || committed.view.state.route.view === "sidekick") { + expect({ where, asked: observed.boundsOf("sidekick:answer") !== undefined }).toEqual({ + where, + asked: true, + }); + } + + // Each work row this screen carries is an identity this history already + // holds: a message this conversation acknowledged, paired with the entry + // that message admitted. Nothing is shown for an entry this reading does + // not hold. + expect({ where, work: workspace.work.length > 0 }).toEqual({ where, work: true }); + for (const one of workspace.work) { + expect({ + where, + message: CHECKPOINT_SIDEKICK.chat.some((row) => row.key === one.message), + entry: model.entries.some((entry) => entry.key === one.entry), + }).toEqual({ where, message: true, entry: true }); + } + // And the tray this build offers is the inactive one, with no shared + // queue behind it. + expect({ + where, + todo: observed.nodeOf("tray:todo") !== undefined || narrow, + queue: observed.nodeOf("tray:queue") !== undefined, + }).toEqual({ where, todo: true, queue: false }); + } + }); +}); + +/** + * #971 R2: the chat window this screen holds, traversed rather than described. + * + * The chat holds more than it can show, and the question is the same one a + * window always asks: is what it is not showing reachable, and is what it *is* + * showing something a person can act on? So this walks it — down to the + * outcomes its first window cuts off — and reads the answers off the committed + * frame rather than off the description that produced it. + * + * One presenter, one tree and one grid throughout: the screen under test is + * what the frames have left in the grid together, which is also the only way a + * row that stopped being drawn can be told from a row that is still there. + * + * The shared message queue this case used to traverse beside the chat is not + * offered by this build. Its rows, its withdrawal controls and its own window + * are recorded in `fixtures/repl/deferred-presentation.json` under DQ1, with + * the case they came from and what has to exist before they come back. + */ +describe("Checkpoint #971 R2 — current chat windows and outcomes", () => { + beforeAll(() => useTempFileCompiler()); + + /** The four outcomes this reading holds, as the words the screen says. */ + const OUTCOMES = ["✓ COMPLETED", "× FAILED", "- INTERRUPTED", "● ACTIVE"] as const; + + it("R2: current chat traversal reads every outcome and keeps its composer", function* () { + const model = yield* checkpointStateModel(); + // The size whose conversation still holds more than it shows. Without the + // shared queue's tray the widest screen has room for the whole chat, and a + // window with nothing outside it is not a traversal. + const size = CHECKPOINT_MEDIUM; + const presenter = yield* usePresenter(size); + const asking = { + model, + size, + live: CHECKPOINT_ASKING, + workspace: CHECKPOINT_CURRENT_WORKSPACE, + } as const; + + const opening = yield* commitCheckpoint(presenter, asking); + + // No shared queue is offered here, so nothing of it is measured, mounted or + // drawn: a window over rows this build has none of would be chrome over a + // reading that does not exist. + expect(opening.observed.committed.admission.windows.get(QUEUE_WINDOW)).toBe(undefined); + for (const key of ["queue:earlier", "queue:later", "tray:queue"]) { + expect({ key, mounted: opening.observed.nodeOf(key) !== undefined }).toEqual({ + key, + mounted: false, + }); + } + + // What the column does offer is all there: the inactive tray, the + // conversation's own composer, and the controls that move the chat. + for (const key of ["tray:todo", "sidekick:message", "sidekick:earlier", "sidekick:later"]) { + expect({ key, drawn: opening.observed.boundsOf(key) !== undefined }).toEqual({ + key, + drawn: true, + }); + } + const chat = windowOf(opening.observed.committed.admission, SIDEKICK_WINDOW); + expect({ rows: chat.count >= 1 }).toEqual({ rows: true }); + + // One traversal of the chat's own window, which holds more rows than it + // shows: each outcome's ink is read the first time that outcome is on the + // screen, and the walk stops once all four have been. + const inks = new Map(); + // Where the window actually sat at each step. A following chat sits at its + // own end, so this walk moves it down from there rather than up. + const visited = new Set([chat.from]); + for (let offset = 0; offset <= chat.total && inks.size < OUTCOMES.length; offset += 1) { + const at = yield* commitCheckpoint(presenter, { + ...asking, + state: chatAt(model, offset), + focused: opening.focused, + }); + visited.add(windowOf(at.observed.committed.admission, SIDEKICK_WINDOW).from); + const said = presenter.grid + .textIn({ x: 0, y: 0, width: size.columns, height: size.rows }) + .join("\n"); + for (const word of OUTCOMES) { + const found = said.indexOf(word); + if (found < 0 || inks.has(word)) { + continue; + } + const before = said.slice(0, found).split("\n"); + const y = before.length - 1; + const x = before[before.length - 1]?.length ?? 0; + inks.set(word, presenter.grid.styleAt(x, y).foreground); + } + } + // The walk moved the window rather than redrawing the same rows. + expect({ moved: visited.size > 1 }).toEqual({ moved: true }); + // All four were rendered, and no two of them in the same ink. + expect({ read: [...inks.keys()].sort() }).toEqual({ + read: [...OUTCOMES].sort(), + }); + expect({ distinct: new Set(inks.values()).size }).toEqual({ + distinct: OUTCOMES.length, + }); + + // The composer the conversation is answered in is still there afterwards, + // and the chat's own offset moved without the source or the saved reading + // following it. + expect({ + composer: opening.observed.boundsOf("sidekick:message") !== undefined, + source: opening.observed.committed.admission.windows.get(READING_WINDOW)?.from, + }).toEqual({ + composer: true, + source: windowOf(opening.observed.committed.admission, READING_WINDOW).from, + }); + }); +}); diff --git a/packages/cli/tests/repl-sidekick-configuration.test.ts b/packages/cli/tests/repl-sidekick-configuration.test.ts index e90609658..e3f27c1a2 100644 --- a/packages/cli/tests/repl-sidekick-configuration.test.ts +++ b/packages/cli/tests/repl-sidekick-configuration.test.ts @@ -64,6 +64,8 @@ import { screenOf, sendMessage, settled, + saidBySidekick, + saysAcross, shows, typing, useMountedRepl, @@ -1497,7 +1499,10 @@ describe("Tier CFG — Configure in the Sidekick's pane", () => { yield* awaiting(repl.terminal, "the drawer over a working Sidekick", (one) => shows(one, "[apply]"), ); - expect(shows(repl.terminal, "already working")).toBe(true); + // Read across the column's rows: the drawer is the Sidekick's own pane, 30 + // cells of interior at this size, and a sentence that long is wrapped prose + // there rather than one row. + expect(saysAcross(repl.terminal, "already working")).toBe(true); yield* closing(); // The drawer is gone, the turn is not: nothing stopped it, and it finishes // on its own when the provider is released. @@ -2364,6 +2369,237 @@ describe("Tier TE — the two configured agents each do their own work", () => { const read = yield* mountedReading(repl); expect(read.model.entries).toHaveLength(1); expect(read.model.entries[0]?.terminal?.output).toContain(ANSWERED); - expect(shows(repl.terminal, `${OTHER_MOUNTED_AGENT}: Sidekick here.`)).toBe(true); + // Read by the attribution mark this pane spells its origins with: the + // staged chat is one list of utterances, so who said a row is a mark in + // front of it rather than a name prefix inside the words. + expect(saidBySidekick(repl.terminal, "Sidekick here.")).toBe(true); + }); +}); + +/** + * Configure's notices, fitted to the drawer that has to hold them. + * + * A drawer row is one row, and a notice is a sentence somebody has to be able + * to read. What decides how many rows it takes is the engine, at the interior + * this frame measured and on the original graphemes — not a count of code + * units in a builder that has never seen a cell. Measured before this root + * existed: a seventy-character ASCII token inside a thirty-cell drawer was + * drawn as nothing at all, and twelve spaced CJK words came back as one + * forty-eight cell row in the same thirty. + * + * The notice a person can actually produce here is the one about replacing an + * agent, because the name in it is theirs: it is typed into the drawer's own + * field, so these rows drive the width of a notice the way a person does. + */ +describe("Configure notice fitting", () => { + /** What the agent in these rows advertises, so the drawer has choices to draw. */ + const ADVERTISED = { + model: { + selected: "balanced", + choices: [ + { id: "balanced", name: "Balanced", description: "Even-handed." }, + { id: "fast", name: "Fast", group: { id: "speed", name: "Speed" } }, + ], + }, + effort: { + balanced: { selected: "medium", choices: [{ id: "medium", name: "Medium" }] }, + fast: { + selected: "low", + choices: [ + { id: "low", name: "Low" }, + { id: "high", name: "High" }, + ], + }, + }, + } as const; + + /** Open Configure the way a person does, and wait for the drawer. */ + function* configure(repl: MountedRepl): Operation { + yield* click(repl.terminal, `[configure ${MOUNTED_AGENT}]`); + yield* settled(60); + expect(shows(repl.terminal, "[refresh]")).toBe(true); + } + + /** Exactly what the drawer's agent field is holding, as its own row shows it. */ + function agentField(repl: MountedRepl): string { + const row = screenOf(repl.terminal).find((line) => line.includes("Agent: ")); + if (row === undefined) { + throw new Error("the drawer is drawing no agent field"); + } + return row.slice(row.indexOf("Agent: ") + "Agent: ".length).trimEnd(); + } + + /** Replace the agent the drawer names, a character at a time. */ + function* named(repl: MountedRepl, agent: string): Operation { + yield* focusOn(repl.terminal, `> Agent: `); + // Cleared a keystroke at a time, which is how a person replaces a name, and + // waited for rather than counted: a grapheme is not a key, and a name made + // of wide and combining ones takes more presses than it has characters. + for (let press = 0; press < 200; press += 1) { + if (agentField(repl) === "") { + break; + } + repl.terminal.feed("\x7f"); + yield* settled(8); + } + if (agentField(repl) !== "") { + throw new Error(`the agent field would not clear: ${JSON.stringify(agentField(repl))}`); + } + yield* typing(repl.terminal, agent); + yield* settled(60); + } + + /** + * The drawer's own rows, and where its rectangle begins and ends. + * + * Read from the rule it draws across its own top, which is the one row whose + * whole width is this drawer's. Everything the drawer draws is inside that + * rectangle, so a row of it reaching past the rule is a row written over the + * pane behind. + */ + function drawerRows(repl: MountedRepl): { from: number; to: number; rows: string[] } { + const screen = screenOf(repl.terminal); + const at = screen.findIndex((line) => /\u2500{10,}/.test(line)); + if (at === -1) { + throw new Error(`the drawer is not on this screen: ${JSON.stringify(screen)}`); + } + const rule = screen[at] ?? ""; + const from = rule.indexOf("\u2500"); + const to = rule.lastIndexOf("\u2500") + 1; + return { + from, + to, + rows: screen.slice(at + 1).map((line) => line.slice(from, to).trimEnd()), + }; + } + + /** Whether this notice's whole text is recoverable from the drawer's rows. */ + function saysWhole(repl: MountedRepl, text: string): boolean { + const bare = (one: string): string => one.replace(/\s+/g, ""); + const { rows } = drawerRows(repl); + const wanted = bare(text); + return rows.some((_, at) => bare(rows.slice(at, at + 8).join("")).includes(wanted)); + } + + /** Wait until the replacement notice this name produces has been drawn. */ + function* noticed(repl: MountedRepl, text: string): Operation { + yield* awaiting(repl.terminal, "the replacement notice", () => saysWhole(repl, text)); + } + + it("the whole of a long unbroken token is drawn, inside the drawer's own rows", function* () { + const repl = yield* useMountedRepl({ size: { columns: 120, rows: 30 } }); + repl.fake.configuration(ADVERTISED); + yield* sendMessage(repl, "hello"); + yield* awaiting(repl.terminal, "the first reply", (one) => shows(one, "[send]")); + yield* configure(repl); + // The drawer as it stands before any of this: its own way out, the way to + // apply a choice and the way to read the agent again. + expect(shows(repl.terminal, "[close]")).toBe(true); + expect(shows(repl.terminal, "[apply]")).toBe(true); + expect(shows(repl.terminal, "[refresh]")).toBe(true); + + // Seventy characters with nothing to break on, inside an interior of + // thirty: the notice has to be paid out over rows, and every one of its + // characters has to be on one of them. + const LONG = "x".repeat(70); + yield* named(repl, LONG); + yield* noticed(repl, `${LONG} has its own settings.`); + + // And none of it is drawn outside the drawer. A row reaching past the + // drawer's own width is a row written over the pane behind it. + const { from, to, rows } = drawerRows(repl); + expect(to).toBeGreaterThan(from); + for (const row of rows) { + expect(row.length).toBeLessThanOrEqual(to - from); + } + + // The way out and the way to read again are where they were, and a name + // this long has not moved them: fitting a sentence moves no control. Apply + // is deliberately not among them while a replacement is what it would do — + // that is this drawer's established semantics, not an effect of fitting. + expect(shows(repl.terminal, "[close]")).toBe(true); + expect(shows(repl.terminal, "[refresh]")).toBe(true); + expect(shows(repl.terminal, "[start new conversation]")).toBe(true); + }); + + it("wide, combining and joined graphemes survive, and a shorter notice erases the longer", function* () { + const repl = yield* useMountedRepl({ size: { columns: 120, rows: 30 } }); + repl.fake.configuration(ADVERTISED); + // One message first, so this conversation has an agent of its own: the + // notice these rows are about is the one that says a *replacement* would + // start another conversation, and there is nothing to replace until then. + yield* sendMessage(repl, "hello"); + yield* awaiting(repl.terminal, "the first reply", (one) => shows(one, "[send]")); + yield* configure(repl); + + // Twelve spaced wide words, a doubly combined letter and a joined emoji. + // Each is one grapheme that is not one cell, and none of them may be cut + // apart, dropped, or left behind when the notice that carries them goes. + const WIDE = `${"\u754c\u754c ".repeat(12).trim()} e\u0301\u0301 \u{1F469}\u200D\u{1F4BB}`; + yield* named(repl, WIDE); + // Recovered as the grid can hold it: a cell carries a base character, and + // the zero-width joiner and the combining marks are not cells of their own. + // Every wide word is there, in order, and so is the whole of the sentence + // the name is in — which is what the builder this replaced drew none of. + const DRAWN = `${"\u754c\u754c ".repeat(12).trim()} e \u{1F469}\u{1F4BB} has its own settings.`; + yield* noticed(repl, DRAWN); + + // Paid out over the drawer's own rows rather than clipped to one: the + // builder this replaced gave a notice this long a single row. + const wide = drawerRows(repl); + expect(wide.rows.filter((row) => row.includes("\u754c")).length).toBeGreaterThan(1); + for (const row of wide.rows) { + expect(row.length).toBeLessThanOrEqual(wide.to - wide.from); + } + // Each cluster is whole where it is drawn, in the form the grid holds: the + // base character occupies its cells and the zero-width joiner and the + // combining marks occupy none of their own. `DRAWN` above is that form, + // and recovering the whole of it is this assertion. + + // Replaced by a short name: every cell the long one occupied is blank or + // the new notice's, rather than a tail of the old one left underneath. + yield* named(repl, "ag"); + yield* noticed(repl, "ag has its own settings."); + const after = drawerRows(repl); + expect(after.rows.filter((row) => row.includes("\u754c"))).toEqual([]); + expect(after.rows.filter((row) => row.includes("\u{1F469}"))).toEqual([]); + + // Closing still chooses nothing: no preference was written by any of this. + yield* click(repl.terminal, "[close]", paneEnvelope(repl.terminal.size.columns)); + yield* settled(60); + expect(shows(repl.terminal, "[apply]")).toBe(false); + expect(yield* savedPreference(repl)).toBe(undefined); + }); + + it("Backspace removes one grapheme, not one code point", function* () { + const repl = yield* useMountedRepl({ size: { columns: 120, rows: 30 } }); + repl.fake.configuration(ADVERTISED); + yield* configure(repl); + yield* named(repl, "a"); + + // A letter under two combining marks and a joined emoji are each one thing + // on this screen and three code points underneath. One press takes one of + // them away whole. A reduction counting code points took the laptop off + // the woman and left her standing, and removed one accent from the letter + // and left the letter — so the row below each press is what discriminates. + // + // Read as the recorded grid holds them: a cell carries the base character, + // and the zero-width joiner and the combining marks are not cells of their + // own. What is compared is therefore the cluster being there and then + // being gone in one press, not the code points it is spelled with. + yield* focusOn(repl.terminal, `> Agent: `); + yield* typing(repl.terminal, "\u{1F469}\u200D\u{1F4BB}"); + yield* settled(40); + expect(agentField(repl)).toBe("a\u{1F469}\u{1F4BB}"); + repl.terminal.feed("\x7f"); + yield* settled(40); + expect(agentField(repl)).toBe("a"); + + yield* typing(repl.terminal, "e\u0301\u0301"); + yield* settled(40); + expect(agentField(repl)).toBe("ae"); + repl.terminal.feed("\x7f"); + yield* settled(40); + expect(agentField(repl)).toBe("a"); }); }); diff --git a/packages/cli/tests/repl-sidekick-journey.test.ts b/packages/cli/tests/repl-sidekick-journey.test.ts index ce17c08c4..97c7d6c74 100644 --- a/packages/cli/tests/repl-sidekick-journey.test.ts +++ b/packages/cli/tests/repl-sidekick-journey.test.ts @@ -30,7 +30,7 @@ import { initialState } from "../src/repl/application.ts"; import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { Ok, spawn, suspend, until, useScope, withResolvers } from "effection"; +import { ensure, Ok, spawn, suspend, until, useScope, withResolvers } from "effection"; import type { Operation, Result } from "effection"; import { InMemoryStream } from "@executablemd/durable-streams"; @@ -59,6 +59,7 @@ import { } from "./fixtures/repl/issue-932/history.ts"; import { appendGate, + awaitedFact, awaiting, awaited, draftText, @@ -67,8 +68,13 @@ import { MESSAGE_PROMPT, messageText, MOUNTED_AGENT, + blockSays, mountedReading, + saidByPerson, + saidBySidekick, screenOf, + rowsShow, + sidekickColumn, sendMessage, settled, shows, @@ -76,6 +82,7 @@ import { useMountedRepl, useMountedHost, mountedExecution, + sidekickPaneIn, } from "./fixtures/repl/issue-883/mounted-program.ts"; import type { MountedRepl } from "./fixtures/repl/issue-883/mounted-program.ts"; @@ -1212,13 +1219,20 @@ describe("Tier PV — a response in flight, shown as it arrives", () => { expect(during.chat.filter((record) => record.kind === "control")).toEqual([]); expect(during.chat.filter((record) => record.kind === "response")).toEqual([]); expect(during.model.entries).toEqual([]); - expect(shows(repl.terminal, "(not submitted)")).toBe(true); + // The screen says the same thing by what it is showing: the catalog holds + // no entry, and the draft is still a draft somebody is holding rather than + // work that has been admitted. + expect(shows(repl.terminal, "No entry yet")).toBe(true); // Both composers still hold what was typed into them, and the Sidekick is // working — so Stop is the control, not Send. expect(draftText(repl.terminal)).toBe("mine"); expect(messageText(repl.terminal)).toBe("later"); expect(shows(repl.terminal, "[stop]")).toBe(true); - expect(shows(repl.terminal, "[send]")).toBe(false); + // The Sidekick's own control is Stop while it works. The entry draft keeps + // its own Send beside it — they are two destinations with two controls — + // so this asks the pane rather than the whole screen. + const pane = screenOf(repl.terminal).map((line) => line.slice(sidekickColumn(repl.terminal))); + expect(rowsShow(pane, "[send]")).toBe(false); // The rest of the text arrives, and the entry the reply submits is held // open: generation is over and the response is running its controls. @@ -1243,7 +1257,7 @@ describe("Tier PV — a response in flight, shown as it arrives", () => { expect(complete.chat.filter((record) => record.kind === "response")).toEqual([]); expect(complete.model.entries).toEqual([]); expect(draftText(repl.terminal)).toBe("mine"); - expect(shows(repl.terminal, "(not submitted)")).toBe(true); + expect(shows(repl.terminal, "No entry yet")).toBe(true); expect(repl.terminal.fed).toBe(fed); repl.fake.releaseDeltas(); @@ -1258,7 +1272,7 @@ describe("Tier PV — a response in flight, shown as it arrives", () => { // becomes the reply — the preview is gone, not joined by an answer. closing.release(); yield* awaiting(repl.terminal, "the evaluated reply", (one) => - shows(one, `${MOUNTED_AGENT}: Half a thought.`), + saidBySidekick(one, "Half a thought."), ); yield* settled(40); expect(shows(repl.terminal, "(preview)")).toBe(false); @@ -1373,8 +1387,17 @@ describe("Tier PV — a response in flight, shown as it arrives", () => { repl.fake.releaseDeltas(); yield* awaiting(repl.terminal, "the evaluated reply", (one) => - shows(one, `${MOUNTED_AGENT}: Half a thought.`), + saidBySidekick(one, "Half a thought."), ); + // The screen says the Sidekick's words as soon as it has said them, and + // this reply carries an entry: the response is published once that entry + // has been evaluated, which is after the row is drawn. Caught on a loaded + // runner as a published chat whose last record was still the control the + // evaluation had just taken. So the fact this row goes on to read is the + // one it waits for. + yield* awaitedFact("the reply reaching the published chat", function* () { + return (yield* mountedReading(repl)).chat.some((record) => record.kind === "response"); + }); const after = (yield* mountedReading(repl)).chat; const answered = after.flatMap((record) => record.kind === "response" @@ -1488,7 +1511,7 @@ describe("Tier PV — a response in flight, shown as it arrives", () => { // ER2 — and the follow-up that *did* say something says it, in its own row, // exactly as it always has: removing the silent reply's notice removes a // sentence nobody wrote and nothing else. - expect(shows(repl.terminal, `${MOUNTED_AGENT}: Read it.`)).toBe(true); + expect(saidBySidekick(repl.terminal, "Read it.")).toBe(true); expect(shows(repl.terminal, "(preview)")).toBe(false); // And the reply that selected no chat contributed no utterance at all. The // superseded form of this row asserted a sentence about the absence; what @@ -1525,7 +1548,7 @@ describe("Tier PV — a response in flight, shown as it arrives", () => { yield* settled(40); // What a person asked for is still there, and nothing of the two replies is. - expect(shows(repl.terminal, "you: look")).toBe(true); + expect(saidByPerson(repl.terminal, "look")).toBe(true); expect(shows(repl.terminal, "answered without saying")).toBe(false); expect(shows(repl.terminal, "(preview)")).toBe(false); expect(shows(repl.terminal, "REPL.Bindings")).toBe(false); @@ -1604,7 +1627,7 @@ describe("Tier PV — a response in flight, shown as it arrives", () => { // And the correction it was given, which answered: the failure stands in // the chat beside the reply that followed it, in that order. yield* awaiting(repl.terminal, "the corrected reply", (one) => - shows(one, `${MOUNTED_AGENT}: Fixed it.`), + saidBySidekick(one, "Fixed it."), ); expect(yield* composedChat(repl)).toEqual([ " you: remove it", @@ -1614,6 +1637,24 @@ describe("Tier PV — a response in flight, shown as it arrives", () => { }); }); +/** + * A reply that prepares an entry whose preflight suspends, and submits it. + * + * Its one element is a component read from a file the row below gates, so the + * preflight has somewhere to wait — between the slot being reserved and the + * entry starting, which is the window the start check exists for. + */ +const GATED_ENTRY_REPLY = [ + '', + "```md", + "", + "```", + "", + "{entry}", + "", + "", +].join("\n"); + describe("Tier R5 — a mounted message prepares and submits a real entry", () => { it("M3: the message a person sends runs its entry, and is answered about it", function* () { const repl = yield* useMountedRepl(); @@ -1653,9 +1694,9 @@ describe("Tier R5 — a mounted message prepares and submits a real entry", () = // And the chat is the person's message and the two replies' own Output — // not a response body, not the entry's source, not the feedback envelope. - expect(shows(repl.terminal, "you: greet")).toBe(true); - expect(shows(repl.terminal, `${MOUNTED_AGENT}: Submitted it.`)).toBe(true); - expect(shows(repl.terminal, `${MOUNTED_AGENT}: Thanks for the receipt.`)).toBe(true); + expect(saidByPerson(repl.terminal, "greet")).toBe(true); + expect(saidBySidekick(repl.terminal, "Submitted it.")).toBe(true); + expect(saidBySidekick(repl.terminal, "Thanks for the receipt.")).toBe(true); expect(shows(repl.terminal, "REPL.EntryInput")).toBe(false); // A second message, in the same conversation: one conversation per @@ -1693,6 +1734,253 @@ describe("Tier R5 — a mounted message prepares and submits a real entry", () = expect(second.model.entries[1]?.terminal?.output).toContain("| a | b |"); expect(repl.fake.prompts[4] ?? "").toContain("Entry entry-2 completed."); }); + + /** + * #971 B1: the Sidekick's own rows carry the entry each reply admitted. + * + * The association is read out of the Submit records this history already + * holds — which chat and which response performed the control, the slot it + * reserved, the entry key it named and the exact source it reserved — so a + * reply that really admitted an entry is followed by that entry's own state + * and output, and a reply that admitted nothing is followed by nothing. + * + * What the row *says* is read from the model where it is drawn and nowhere + * else: a reply's wording is not an account of what its entry did, and the + * two are deliberately different strings here so that one cannot pass for the + * other. + */ + it("M3: each reply's rows carry the entry it admitted, and nothing it did not", function* () { + // A component the entry below reads, so its preflight has somewhere to + // wait: between the slot being reserved and the entry actually starting, + // which is the window the start check exists for. + const directory = yield* useTemporary("xmd-971-refused-reservation-"); + yield* until(writeFile(join(directory, "GateThing.md"), "The manual entry ran.\n")); + const reached = withResolvers(); + const allow = withResolvers(); + // Released at the end whatever happened, so a failing row leaves no latch. + yield* ensure(() => allow.resolve()); + let holding = false; + yield* API.Fs.around({ + *readTextFile([path], next): Operation { + if (holding && path.endsWith("GateThing.md")) { + holding = false; + reached.resolve(); + yield* allow.operation; + } + return yield* next(path); + }, + }); + const host = yield* useMountedHost({ includes: [directory] }); + let execution = ""; + /** A position holding the first entry and not the second. */ + let earlier = ""; + let entries = ""; + let chat = ""; + + yield* scoped(function* (): Operation { + const repl = yield* useMountedRepl({ host }); + repl.fake.script({ reply: entrySource("first", "the first entry ran", "{first}") }); + repl.fake.script({ reply: "That is one.\n" }); + yield* sendMessage(repl, "run one"); + yield* awaiting(repl.terminal, "the first receipt", (one) => shows(one, "That is one.")); + + const after = yield* mountedReading(repl); + execution = after.execution; + expect(after.model.entries.map((entry) => entry.key)).toEqual(["entry-1"]); + // The position as it stands with one entry admitted, for the prefix arm + // below. Taken now, because what makes it a prefix is what came after it. + earlier = after.model.checkpoints[after.model.checkpoints.length - 1]?.marker ?? ""; + expect(earlier).not.toBe(""); + + repl.fake.script({ reply: entrySource("second", "the second entry ran", "{second}") }); + repl.fake.script({ reply: "That is two.\n" }); + yield* sendMessage(repl, "run two"); + yield* awaiting(repl.terminal, "the second receipt", (one) => shows(one, "That is two.")); + + // Each reply's work, after the reply that admitted it: the entry the + // record names, the state the model holds it in, and what it actually + // printed. The entry's own words and the reply's are different strings, + // so a row carrying the reply's wording could not pass for either. + const live = yield* mountedReading(repl); + expect(live.model.entries.map((entry) => entry.key)).toEqual(["entry-1", "entry-2"]); + const pane = sidekickPaneIn(screenOf(repl.terminal)); + const at = (said: string): number => pane.findIndex((row) => row.includes(said)); + expect(at("Entry 1 \u00b7 \u2713 COMPLETED")).toBeGreaterThan(at("Submitted it.")); + expect(at("the first entry ran")).toBe(at("Entry 1 \u00b7 \u2713 COMPLETED") + 1); + expect(at("That is one.")).toBeGreaterThan(at("the first entry ran")); + expect(at("Entry 2 \u00b7 \u2713 COMPLETED")).toBeGreaterThan(at("That is one.")); + expect(at("the second entry ran")).toBe(at("Entry 2 \u00b7 \u2713 COMPLETED") + 1); + // One row each: a reply is followed by the entry it submitted, not by + // every entry this execution has admitted. + for (const said of ["Entry 1 \u00b7", "Entry 2 \u00b7"]) { + expect([said, pane.filter((row) => row.includes(said)).length]).toEqual([said, 1]); + } + // The state is the model's own, not a word from the reply. + expect(live.model.entries[0]?.terminal?.status).toBe("ok"); + expect(live.model.entries[0]?.terminal?.output).toContain("the first entry ran"); + + // A reply that admits nothing is followed by nothing. The draft an + // admission finished is empty, so this Submit is refused before any slot + // is taken — no control record, and so no association to read. + repl.fake.script({ reply: "\n" }); + repl.fake.script({ reply: "I will prepare source first.\n" }); + yield* sendMessage(repl, "submit it"); + yield* awaiting(repl.terminal, "the refusal going back", (one) => + shows(one, "I will prepare source first."), + ); + const refused = yield* mountedReading(repl); + expect(refused.model.entries.map((entry) => entry.key)).toEqual(["entry-1", "entry-2"]); + const after3 = sidekickPaneIn(screenOf(repl.terminal)); + const said = after3.findIndex((row) => row.includes("I will prepare source first.")); + expect(said).toBeGreaterThan(0); + // Nothing after that reply says an entry: the rows below it are the + // composer and the pane's own controls. + expect(after3.slice(said + 1).some((row) => /Entry \d+ \u00b7/.test(row))).toBe(false); + + // And a reply whose reservation *was* retained and then refused, whose + // exact reserved source somebody then runs themselves at the same slot. + // This is the one the record's slot, key and source cannot tell apart on + // their own: all three match, and the entry is still not that reply's. + holding = true; + repl.fake.script({ reply: GATED_ENTRY_REPLY }); + repl.fake.script({ reply: "I did not admit an entry.\n" }); + yield* sendMessage(repl, "run the gated one"); + yield* reached.operation; + // The draft moves while the submission is being prepared, which is what + // the start check refuses on: the prepared text is not the text anybody + // is asking for any more. + yield* focusDraft(repl.terminal); + yield* typing(repl.terminal, "!"); + allow.resolve(); + yield* awaiting(repl.terminal, "the refused submission going back", (one) => + shows(one, "I did not admit an entry."), + ); + + // Retained, and retained as refused: an intent with a reservation, and + // an outcome saying it ended without one. Nothing was admitted. + const held = yield* mountedReading(repl); + expect(held.model.entries.map((entry) => entry.key)).toEqual(["entry-1", "entry-2"]); + // The reservation this message took, which is the last one written: the + // two before it are the entries that really ran. + const intent = held.chat + .filter( + (record) => + record.kind === "control" && record.component === "REPL.Submit" && record.submit, + ) + .at(-1); + if (intent?.kind !== "control" || intent.submit === undefined) { + throw new Error("this history retained no Submit reservation to refuse"); + } + const answered = held.chat.find( + (record) => + record.kind === "control-outcome" && + record.conversation === intent.conversation && + record.response === intent.response && + record.order === intent.order, + ); + if (answered?.kind !== "control-outcome" || answered.outcome.status !== "err") { + throw new Error("that reservation was not retained as refused"); + } + + // Exactly the source it reserved, typed back in and run by hand. The + // backspace takes off the character that moved the draft, which leaves + // the reserved text itself. + yield* focusDraft(repl.terminal); + repl.terminal.feed("\x7f"); + yield* awaiting(repl.terminal, "the reserved source restored", (one) => + blockSays(draftText(one), intent.submit?.source ?? ""), + ); + repl.terminal.feed("\r"); + yield* awaiting(repl.terminal, "the manual entry running", (one) => + shows(one, "The manual entry ran."), + ); + const manual = yield* mountedReading(repl); + // The same slot, the same key and the same source — every coordinate the + // reservation named. + const took = manual.model.entries[intent.submit.ordinal]; + expect([took?.key, took?.source]).toEqual([intent.submit.entry, intent.submit.source]); + // And the refused reply is credited with none of it: no work row, no + // state and no output of that entry anywhere after it. + const paneNow = sidekickPaneIn(screenOf(repl.terminal)); + const refusedAt = paneNow.findIndex((row) => row.includes("I did not admit an entry.")); + expect(refusedAt).toBeGreaterThan(0); + expect(paneNow.slice(refusedAt + 1).some((row) => /Entry \d+ \u00b7/.test(row))).toBe(false); + expect(paneNow.some((row) => row.includes("The manual entry ran."))).toBe(false); + expect(paneNow.filter((row) => /Entry \d+ \u00b7/.test(row))).toHaveLength(2); + + entries = yield* until(readFile(pairedLocation(host.root, execution).entries, "utf8")); + chat = yield* until(readFile(pairedLocation(host.root, execution).sidekick, "utf8")); + repl.terminal.end(); + yield* awaiting(repl.terminal, "the command leaving", () => repl.outcome() !== undefined); + }); + + // Cold, at a position that holds the first entry and not the second. The + // association is read again from the same records, and the second reply's + // work is not shown — an entry this prefix never admitted is not work this + // reading can show. + const calls = host.fake.prompts.length; + yield* scoped(function* (): Operation { + const base = initialState(execution); + const repl = yield* useMountedRepl({ + host, + location: encodeLocation({ ...base.route, at: earlier }), + }); + yield* awaiting(repl.terminal, "the cold chat", (one) => shows(one, "That is one.")); + const pane = sidekickPaneIn(screenOf(repl.terminal)); + const counted = (said: string): number => pane.filter((row) => row.includes(said)).length; + // Once, after the one reply that admitted it. Twice would be an entry + // handed to a reply that did not submit it — which is what following the + // latest entry rather than the record's own coordinates would do here, + // because this prefix holds exactly one. + expect(counted("Entry 1 \u00b7 \u2713 COMPLETED")).toBe(1); + expect(counted("the first entry ran")).toBe(1); + expect(counted("Entry 2 \u00b7")).toBe(0); + expect(counted("the second entry ran")).toBe(0); + repl.terminal.end(); + yield* awaiting( + repl.terminal, + "the cold command leaving", + () => repl.outcome() !== undefined, + ); + }); + + // And cold at the head, where every entry this execution admitted is in + // the reading. The refusal is still a refusal: reopening reconciles what a + // dead process left and retains what it finds, and what it finds here is + // an answer already written — so nothing resurrects an association out of + // a slot that was later filled by hand. + yield* scoped(function* (): Operation { + const repl = yield* useMountedRepl({ + host, + location: encodeLocation(initialState(execution).route), + }); + yield* awaiting(repl.terminal, "the cold chat at the head", (one) => + shows(one, "I did not admit an entry."), + ); + const pane = sidekickPaneIn(screenOf(repl.terminal)); + const refusedAt = pane.findIndex((row) => row.includes("I did not admit an entry.")); + expect(refusedAt).toBeGreaterThan(0); + expect(pane.slice(refusedAt + 1).some((row) => /Entry \d+ \u00b7/.test(row))).toBe(false); + expect(pane.some((row) => row.includes("The manual entry ran."))).toBe(false); + // The two replies that really did admit still carry theirs, which is + // what makes the absence above an answer rather than an empty pane. + expect(pane.filter((row) => /Entry \d+ \u00b7/.test(row))).toHaveLength(2); + repl.terminal.end(); + yield* awaiting( + repl.terminal, + "the second cold command leaving", + () => repl.outcome() !== undefined, + ); + }); + // And reading it asked nobody anything and wrote nothing. + expect(host.fake.prompts).toHaveLength(calls); + expect(yield* until(readFile(pairedLocation(host.root, execution).entries, "utf8"))).toBe( + entries, + ); + expect(yield* until(readFile(pairedLocation(host.root, execution).sidekick, "utf8"))).toBe( + chat, + ); + }); }); /** @@ -1884,7 +2172,7 @@ describe("Tier TE — a published binding becomes real greetings, and then a rea // Held at the transport with the draft filled: the program is there, byte // for byte, and the seed is still the only entry that has run. - expect(ran.heldDraft).toBe(GREETINGS); + expect(blockSays(ran.heldDraft, GREETINGS)).toBe(true); expect(ran.heldEntries).toEqual(["entry-1"]); // The entry that ran is the draft that was held, and its output is the @@ -1923,7 +2211,7 @@ describe("Tier TE — a published binding becomes real greetings, and then a rea const ran = yield* arm(SECOND); expect(ran.published).toEqual({ names: SECOND }); - expect(ran.heldDraft).toBe(GREETINGS); + expect(blockSays(ran.heldDraft, GREETINGS)).toBe(true); // The program is the same program. What changed is the binding it reads, // and that is what the output has to show. @@ -3028,10 +3316,16 @@ describe("975 — the shipped session component journey", () => { yield* focusDraft(repl.terminal); yield* typing(repl.terminal, 'project manual'); repl.terminal.feed("\r"); - yield* awaiting(repl.terminal, "the manual successor", () => { - const output = screenOf(repl.terminal).join("\n"); - return output.includes("entry-4") && output.includes("[ok]"); - }); + yield* awaiting(repl.terminal, "the manual successor", () => + screenOf(repl.terminal).some((line) => { + // The same fact the catalog used to spell as `entry-4 [ok]`: the fourth + // entry is the one being read, and it completed. The staged screen says + // it in the selected work's own heading — both on that one row, because + // a chat row naming an entry and a breadcrumb naming an outcome are two + // different rows saying two different things. + return line.includes("Entry 4") && line.includes("\u2713 COMPLETED"); + }), + ); expect(yield* until(readFile(join(project, "manual.md"), "utf8"))).toBe("project manual"); beforeEntries = yield* until(readFile(location.entries, "utf8")); beforeChat = yield* until(readFile(location.sidekick, "utf8")); diff --git a/packages/cli/tests/repl-sidekick-lifecycle.test.ts b/packages/cli/tests/repl-sidekick-lifecycle.test.ts index 6df945bb2..fd23ebb5f 100644 --- a/packages/cli/tests/repl-sidekick-lifecycle.test.ts +++ b/packages/cli/tests/repl-sidekick-lifecycle.test.ts @@ -55,15 +55,67 @@ import { MOUNTED_AGENT, mountedExecution, mountedReading, + saidByPerson, + saidBySidekick, sendMessage, settled, + settledEntry, panePresented, + screenOf, + rowsShow, shows, typing, useMountedHost, useMountedRepl, } from "./fixtures/repl/issue-883/mounted-program.ts"; import type { MountedRepl, Terminal } from "./fixtures/repl/issue-883/mounted-program.ts"; +import { workspaceConversationWidth } from "../src/repl/layout.ts"; + +/** + * Where the Sidekick's own column begins, whatever is drawn over it. + * + * Asked of the size rather than found by the composer. A composer is one thing + * that column holds and it is not always drawn in it: the Sidekick's question + * opens a drawer over that pane, and its own Stop is then a control of the + * drawer rather than a control beside the box. A narrow frame routes one + * outlet, so where the Sidekick is being read the column is the frame. + */ +function sidekickEdge(terminal: Terminal): number { + const width = workspaceConversationWidth(terminal.size); + return width === undefined ? 0 : Math.max(0, terminal.size.columns - width); +} + +/** + * The Sidekick's own Stop, pressed where it is. + * + * This screen has two. The entry draft wears one while its entry runs, in the + * centre column, and it is the leftmost — so a press that went looking for + * `[stop]` on the screen stops the document's work and never reaches the + * generation it meant to. Measured: with both on screen the draft's was at + * column 94 and the Sidekick's at 105. + */ +function* stopSidekick(terminal: Terminal): Operation { + yield* click(terminal, "[stop]", sidekickEdge(terminal)); +} + +/** + * Whether the Sidekick is at rest: its own Send offered, its own Stop gone. + * + * Both asked of its column. The entry draft has a Send beside its field and it + * is there whatever the Sidekick is doing, so a Send found anywhere on the + * screen says nothing about whether this generation has finished. + */ +function sidekickIdle(terminal: Terminal): boolean { + const pane = sidekickRows(terminal); + return rowsShow(pane, "[send]") && !rowsShow(pane, "[stop]"); +} + +/** That column's own rows, which is where its controls are read. */ +function sidekickRows(terminal: Terminal): readonly string[] { + const from = sidekickEdge(terminal); + return screenOf(terminal).map((line) => line.slice(from)); +} + import { ENTRIES_FILE, PAIRED_DIRECTORY, @@ -888,12 +940,12 @@ describe("Tier R6 — Stop, pressed on the screen", () => { yield* awaiting(repl.terminal, "the entry's output arriving", (one) => shows(one, "the entry kept running"), ); - expect(shows(repl.terminal, "[stop]")).toBe(true); + expect(rowsShow(sidekickRows(repl.terminal), "[stop]")).toBe(true); - // Stop, through its own control. - yield* click(repl.terminal, "[stop]"); + // Stop, through its own control — the Sidekick's, in the pane that owns it. + yield* stopSidekick(repl.terminal); yield* awaiting(repl.terminal, "Stop joining and the Sidekick coming to rest", (one) => - shows(one, "[send]"), + sidekickIdle(one), ); // No turn followed. One message, one turn: whatever the entry does later, @@ -922,21 +974,24 @@ describe("Tier R6 — Stop, pressed on the screen", () => { ).toEqual(["REPL.Submit"]); // The chat a person was reading is still there, and so is the draft the // reply filled. - expect(shows(repl.terminal, "you: run it")).toBe(true); + expect(saidByPerson(repl.terminal, "run it")).toBe(true); expect(draftText(repl.terminal)).toBe(""); // The entry was never among what came down: it is still running, and it // settles when its own record lands rather than when Stop said so. expect(stopped.model.entries.map((entry) => entry.terminal)).toEqual([undefined]); closing.release(); - yield* awaiting(repl.terminal, "the entry settling on its own", (one) => shows(one, "[ok]")); + // Settled, read from the History rail, which marks every entry this file + // holds with the state it reached. `[ok]` is a catalog row, and the catalog + // is a drawer nobody opened here. + yield* settledEntry(repl.terminal, 1, "ok"); // And a later explicit message is possible: Stop ended the asking, not the // conversation. repl.fake.script({ reply: "Still here.\n" }); yield* sendMessage(repl, "again"); yield* awaiting(repl.terminal, "the next message being answered", (one) => - shows(one, `${MOUNTED_AGENT}: Still here.`), + saidBySidekick(one, "Still here."), ); expect(repl.fake.prompts).toHaveLength(2); }); @@ -951,15 +1006,17 @@ describe("Tier R6 — Stop, pressed on the screen", () => { yield* sendMessage(repl, "stop me"); yield* repl.fake.holdingTurns(1); - yield* click(repl.terminal, "[stop]"); + yield* stopSidekick(repl.terminal); yield* cancelling.reached; // Stop is inside the teardown it asked for. The row still says the Sidekick // is working, and there is no Send to start a successor with — a screen // that offered one here would be claiming the Sidekick had stopped while it - // was still unwinding. - expect(shows(repl.terminal, "[stop]")).toBe(true); - expect(shows(repl.terminal, "[send]")).toBe(false); + // was still unwinding. Asked of that pane: the entry draft has a Send of + // its own beside its field, and it is there whatever the Sidekick is doing. + const paneNow = sidekickRows(repl.terminal); + expect(rowsShow(paneNow, "[stop]")).toBe(true); + expect(rowsShow(paneNow, "[send]")).toBe(false); expect(repl.fake.prompts).toHaveLength(1); // And ordinary typing still works, because the join is on the screen's own @@ -970,7 +1027,7 @@ describe("Tier R6 — Stop, pressed on the screen", () => { cancelling.release(); yield* awaiting(repl.terminal, "Stop returning once its cleanup joined", (one) => - shows(one, "[send]"), + sidekickIdle(one), ); expect(draftText(repl.terminal)).toBe("meanwhile"); expect(repl.fake.prompts).toHaveLength(1); @@ -1089,7 +1146,11 @@ describe("Tier PV — generation that ended without a reply", () => { // Stopped, with the provider's own cancellation held: this is what says // Stop joined what it asked for rather than returning over the top of it. const cancelling = repl.fake.holdCancellation(); - yield* click(repl.terminal, "[stop]"); + // The Sidekick's own Stop. This person's entry is running, so the draft is + // wearing a Stop too — and it is the leftmost, so a press that went looking + // for `[stop]` on the screen would stop the document's work and never reach + // the generation this row is about. + yield* stopSidekick(repl.terminal); yield* cancelling.reached; expect(repl.outcome()).toBe(undefined); cancelling.release(); @@ -1123,7 +1184,7 @@ describe("Tier PV — generation that ended without a reply", () => { host, location: `xmd://repl/${execution}/entries`, }); - yield* awaiting(again.terminal, "the reopened chat", (one) => shows(one, "you: try it")); + yield* awaiting(again.terminal, "the reopened chat", (one) => saidByPerson(one, "try it")); expect(shows(again.terminal, "(unfinished)")).toBe(false); expect(shows(again.terminal, "As far as I")).toBe(false); }); @@ -1147,8 +1208,8 @@ describe("Tier PV — generation that ended without a reply", () => { repl.fake.script({ held: true, reply: "Unrecorded.\n" }); yield* sendMessage(repl, "unrecorded"); yield* repl.fake.holdingTurns(1); - yield* click(repl.terminal, "[stop]"); - yield* awaiting(repl.terminal, "Stop finishing", (one) => shows(one, "[send]")); + yield* stopSidekick(repl.terminal); + yield* awaiting(repl.terminal, "Stop finishing", (one) => sidekickIdle(one)); const execution = yield* mountedExecution(repl); yield* click(repl.terminal, "[exit]"); yield* awaited("the first command closing", () => repl.outcome() !== undefined); @@ -1158,9 +1219,9 @@ describe("Tier PV — generation that ended without a reply", () => { repl.fake.script({ reply: "First reply.\n" }); yield* sendMessage(repl, "saved"); yield* awaiting(repl.terminal, "the successful predecessor", (one) => - shows(one, `${MOUNTED_AGENT}: First reply.`), + saidBySidekick(one, "First reply."), ); - yield* awaiting(repl.terminal, "the box being ready again", (one) => shows(one, "[send]")); + yield* awaiting(repl.terminal, "the box being ready again", (one) => sidekickIdle(one)); // Which response that reply was recorded as, and which turn it actually // ran as — read out of the history the command wrote. const preceding = (yield* mountedReading(repl)).chat.flatMap((record) => @@ -1365,7 +1426,10 @@ describe("Tier MQ — a Sidekick question, filled in and answered on the screen" yield* typing(repl.terminal, "later"); yield* awaiting(repl.terminal, "the question announcing itself", (one) => - shows(one, `${MOUNTED_AGENT} has a question.`), + // The notice this screen gives: the control that opens the question, in + // the pane that owns it. The prose naming the agent belongs to the + // screen this one replaced. + shows(one, "[answer sidekick]"), ); yield* click(repl.terminal, "[answer sidekick]"); yield* awaiting(repl.terminal, "the form opening", (one) => shows(one, "[submit]")); @@ -1399,7 +1463,7 @@ describe("Tier MQ — a Sidekick question, filled in and answered on the screen" ); yield* activate(repl.terminal, "Sidekick"); yield* awaiting(repl.terminal, "the notice still in the pane", (one) => - shows(one, `${MOUNTED_AGENT} has a question.`), + shows(one, "[answer sidekick]"), ); yield* click(repl.terminal, "[answer sidekick]"); yield* awaiting(repl.terminal, "the form reopening", (one) => shows(one, "[submit]")); @@ -1416,7 +1480,7 @@ describe("Tier MQ — a Sidekick question, filled in and answered on the screen" yield* click(repl.terminal, "[submit]"); yield* awaiting(repl.terminal, "the reply answering once the question was", (one) => - shows(one, `${MOUNTED_AGENT}: Thanks for deciding.`), + saidBySidekick(one, "Thanks for deciding."), ); // The form and its way in are both gone, because the question is. expect(shows(repl.terminal, "[submit]")).toBe(false); @@ -1531,7 +1595,10 @@ describe("Tier MQ — two questions, two answers, and Stop", () => { yield* sendMessage(repl, "go on?"); yield* awaiting(repl.terminal, "the Sidekick's question announcing itself", (one) => - shows(one, `${MOUNTED_AGENT} has a question.`), + // The notice this screen gives: the control that opens the question, in + // the pane that owns it. The prose naming the agent belongs to the + // screen this one replaced. + shows(one, "[answer sidekick]"), ); // The Sidekick's own, opened from its own pane and answered through its own @@ -1544,7 +1611,7 @@ describe("Tier MQ — two questions, two answers, and Stop", () => { repl.terminal.feed("\r"); yield* awaiting(repl.terminal, "the reply hearing it", (one) => - shows(one, `${MOUNTED_AGENT}: Sidekick heard yes.`), + saidBySidekick(one, "Sidekick heard yes."), ); // The entry's question is untouched: still waiting, still its own. Read @@ -1586,7 +1653,10 @@ describe("Tier MQ — two questions, two answers, and Stop", () => { yield* sendMessage(repl, "go on?"); yield* awaiting(repl.terminal, "the Sidekick's question announcing itself", (one) => - shows(one, `${MOUNTED_AGENT} has a question.`), + // The notice this screen gives: the control that opens the question, in + // the pane that owns it. The prose naming the agent belongs to the + // screen this one replaced. + shows(one, "[answer sidekick]"), ); // Open, and half filled in, so Stop is reaching a form somebody is using. yield* click(repl.terminal, "[answer sidekick]"); @@ -1595,12 +1665,12 @@ describe("Tier MQ — two questions, two answers, and Stop", () => { // Stop, through its own control. The form was open; it goes without an // answer. - yield* click(repl.terminal, "[stop]"); + yield* stopSidekick(repl.terminal); yield* awaiting(repl.terminal, "Stop joining and the Sidekick coming to rest", (one) => - shows(one, "[send]"), + sidekickIdle(one), ); expect(shows(repl.terminal, "Shall I go on?")).toBe(false); - expect(shows(repl.terminal, `${MOUNTED_AGENT} has a question.`)).toBe(false); + expect(shows(repl.terminal, "[answer sidekick]")).toBe(false); // Nothing was answered and nothing followed: the intent stands with no // outcome beside it, which is what an interrupted question is. @@ -1634,7 +1704,10 @@ describe("Tier MQ — two questions, two answers, and Stop", () => { repl.fake.script({ reply: asks(SIDEKICK_ASKS, "Asking again.") }); yield* sendMessage(repl, "ask again"); yield* awaiting(repl.terminal, "the next question announcing itself", (one) => - shows(one, `${MOUNTED_AGENT} has a question.`), + // The notice this screen gives: the control that opens the question, in + // the pane that owns it. The prose naming the agent belongs to the + // screen this one replaced. + shows(one, "[answer sidekick]"), ); yield* click(repl.terminal, "[answer sidekick]"); yield* awaiting(repl.terminal, "the new form", (one) => shows(one, "Asking again.")); @@ -1692,7 +1765,10 @@ describe("Tier MQ — leaving with questions pending, and the cold reopen after" // And the Sidekick's, opened and half filled in. yield* sendMessage(repl, "shall you?"); yield* awaiting(repl.terminal, "the Sidekick's question announcing itself", (one) => - shows(one, `${MOUNTED_AGENT} has a question.`), + // The notice this screen gives: the control that opens the question, in + // the pane that owns it. The prose naming the agent belongs to the + // screen this one replaced. + shows(one, "[answer sidekick]"), ); yield* click(repl.terminal, "[answer sidekick]"); yield* awaiting(repl.terminal, "the form opening", (one) => shows(one, "Shall I?")); @@ -1739,12 +1815,11 @@ describe("Tier MQ — leaving with questions pending, and the cold reopen after" host, location: `xmd://repl/${execution}/entries`, }); - expect(shows(again.terminal, `${MOUNTED_AGENT} has a question.`)).toBe(false); expect(shows(again.terminal, "[answer sidekick]")).toBe(false); expect(shows(again.terminal, "Shall I?")).toBe(false); expect(shows(again.terminal, "[close]")).toBe(false); // And the chat it does project holds what the stopped process wrote. - expect(shows(again.terminal, "you: shall you?")).toBe(true); + expect(saidByPerson(again.terminal, "shall you?")).toBe(true); expect(host.fake.ensured).toHaveLength(before.ensured); expect(host.fake.prompts).toHaveLength(before.prompts); } @@ -1861,7 +1936,7 @@ describe("Tier CFG — a preference being written when the command leaves", () = yield* configure(repl); yield* awaiting(repl.terminal, "the advertised models", (one) => shows(one, "Fast")); yield* click(repl.terminal, "[close]", 96); - yield* awaiting(repl.terminal, "the pane again", (one) => shows(one, "[send]")); + yield* awaiting(repl.terminal, "the pane again", (one) => sidekickIdle(one)); // Somebody else's record held inside the host, so the writer is occupied // by an append that is not this save's. diff --git a/packages/cli/tests/repl-sidekick-presentation.test.ts b/packages/cli/tests/repl-sidekick-presentation.test.ts index 5d5d71aa5..b262e8c7b 100644 --- a/packages/cli/tests/repl-sidekick-presentation.test.ts +++ b/packages/cli/tests/repl-sidekick-presentation.test.ts @@ -15,6 +15,7 @@ */ import { describe, it } from "@executablemd/test-support/bdd"; +import { useTempFileCompiler } from "@executablemd/core"; import { expect } from "@executablemd/test-support/expect"; import type { Operation, Result } from "effection"; import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; @@ -25,6 +26,11 @@ import { until } from "effection"; import { activate, + catalogShowing, + closeCatalog, + marked, + openCatalog, + settledEntry, appendGate, awaited, awaiting, @@ -39,7 +45,13 @@ import { focusFormValue, focusMessage, focusOn, + blockSays, + draftTextIn, MESSAGE_PROMPT, + saidByPerson, + saidBySidekick, + sidekickColumn, + sidekickPaneIn, messageRows, messageText, messageTextIn, @@ -76,6 +88,23 @@ import { useTemporary } from "./fixtures/repl/issue-883/sidekick-harness.ts"; import { agentReferenceEvents } from "./fixtures/repl/reference.ts"; import type { DurableEvent } from "@executablemd/durable-streams"; import { usePresenter } from "./fixtures/repl/presentation.ts"; +import type { CheckpointFrame } from "./fixtures/repl/workspace-checkpoint.ts"; +import { + CHECKPOINT_ASKING, + CHECKPOINT_BELOW, + CHECKPOINT_MEDIUM, + CHECKPOINT_NARROW, + CHECKPOINT_QUESTION, + CHECKPOINT_QUESTION_FORM, + CHECKPOINT_SIZE, + checkpointModel, + checkpointSelected, + checkpointState, + commitCheckpoint, + SEEDED_DRAFT, + SEEDED_MESSAGE, + seededState, +} from "./fixtures/repl/workspace-checkpoint.ts"; import { runReplProgram, sidekickChatRows, sidekickTurnRows } from "../src/repl/program.ts"; import type { ReplOutcome } from "../src/repl/program.ts"; import { projectSidekick, SAVED_WITHOUT_REPLY } from "../src/repl/sidekick/model.ts"; @@ -85,6 +114,8 @@ import { initialState, LATER, NO_AGENT, + presentedDrawer, + reduceRepl, SCROLL_DOWN, SCROLL_UP, viewFor, @@ -93,9 +124,15 @@ import type { ReplLive, ReplSidekickChatRow, ReplSidekickReading, + ReplState, } from "../src/repl/application.ts"; + import { NO_LIFECYCLE } from "../src/repl/lifecycle.ts"; -import { FOOTER_ROWS, inspectionWidth, NARROW, sidebarWidth } from "../src/repl/layout.ts"; +import { + NARROW, + workspaceConversationWidth, + workspaceTranscriptWidth, +} from "../src/repl/layout.ts"; import { ENTRIES_FILE, PAIRED_DIRECTORY, @@ -137,6 +174,90 @@ function* held(root: string): Operation { return found.sort(); } +/** + * The conversation pane's own rows at one size. + * + * Sliced to that column, because a row of a shared width crosses all three and + * a control found somewhere on one is usually a different control in a + * different column. A narrow frame routes one outlet, so where this reading is + * the routed one the pane *is* the frame — and the column slicer that reads a + * conversation beside its composer cannot help a read-only chat, which has no + * composer to find the column by. + */ +function conversationPane(rows: readonly string[], size: ReplTerminalSize): readonly string[] { + const width = workspaceConversationWidth(size); + return width === undefined ? rows : rows.map((line) => line.slice(size.columns - width)); +} + +/** + * How the staged panes spell their own two tabs. + * + * In capitals, which is what a reader sees. A row reaching for one by the name + * a person says it by is case-folded by {@link focusOn}; a row *reading* the + * screen is not, and found neither. + */ +const PANE_TABS = Object.freeze({ bindings: "BINDINGS", sidekick: "SIDEKICK" }); + +/** + * Whether the Transcript is narrowed to one conversation, and to which. + * + * One list with the conversations as its filter says which one it is narrowed to + * on the selector itself — that is the control a person opened to choose it, and + * it names the choice afterwards. `All conversations (filtered)` is a row *of* + * the chooser, and choosing closes the chooser behind the choice, so it is not + * on the screen once a choice has been made. + * + * Matched against a prefix of the key, because the selector gives up columns + * for a name nothing in this product bounds: a provider's session key is longer + * than this column, so what it draws is as much of it as fits and an ellipsis. + */ +function filteredTo(terminal: Terminal, key: string): boolean { + const selector = sessionsColumn(terminal).find((line) => line.includes("\u25be")); + if (selector === undefined) { + return false; + } + for (let kept = key.length; kept >= 8; kept -= 1) { + const shown = kept === key.length ? key : `${key.slice(0, kept)}\u2026`; + if (selector.includes(shown)) { + return true; + } + } + return false; +} + +/** + * Open the Transcript's conversation filter, the way a person does. + * + * One list with every conversation as its filter is the reading this screen was + * asked for, so the choices live behind the selector and are not on the screen + * while it is closed. Nothing is chosen by opening it. + */ +function* openFilter(terminal: Terminal): Operation { + if (columnShows(sessionsColumn(terminal), "All conversations")) { + return; + } + yield* activate(terminal, "All sessions"); + yield* awaiting(terminal, "the conversations being offered", (one) => + columnShows(sessionsColumn(one), "All conversations"), + ); +} + +/** + * Whether the catalog marks this row as the one being read. + * + * Asked in the catalog, which is where that marker is. The selection is a + * standing fact about the reading and the catalog is a drawer over it, so this + * opens the drawer for the marker and puts it away again — and the drawer hands + * focus back to the control that opened it, which is why a row asking this asks + * it after whatever it has to say about focus. + */ +function* markedEntry(terminal: Terminal, label: string): Operation { + yield* openCatalog(terminal); + const found = marked(terminal, label); + yield* closeCatalog(terminal); + return found; +} + describe("Tier MP — one Sidekick per command, acquired without working", () => { it("M1: a fresh command shows its Sidekick, asks the agent nothing, and still runs entries", function* () { const repl = yield* useMountedRepl(); @@ -145,12 +266,12 @@ describe("Tier MP — one Sidekick per command, acquired without working", () => // drawn. Both tabs are named in that column's header either way, so the // reading is read from the pane rather than from the label. expect(panePresented(repl.terminal)).toBe("sidekick"); - expect(shows(repl.terminal, "Bindings")).toBe(true); + expect(shows(repl.terminal, "BINDINGS")).toBe(true); // An execution nobody has said anything in. expect(shows(repl.terminal, "No messages yet.")).toBe(true); // And the box its messages go in, named so the row says which destination // it is. - expect(shows(repl.terminal, "Message Sidekick:")).toBe(true); + expect(shows(repl.terminal, MESSAGE_PROMPT.trim())).toBe(true); expect(shows(repl.terminal, "[send]")).toBe(true); // Acquiring it did no work: no runtime created, no session established, no @@ -313,7 +434,10 @@ describe("Tier MP — the first explicit message is what upgrades the history", expect(focusedField(repl.terminal, MESSAGE_PROMPT)).toBe(true); yield* typing(repl.terminal, "explain"); expect(messageText(repl.terminal).includes("explain")).toBe(true); - yield* click(repl.terminal, "[send]"); + // This screen has two Sends — the entry draft's and this composer's — so + // the column the Sidekick's own pane begins at is what names which one. + // Clicking the leftmost would submit a draft instead of sending a message. + yield* click(repl.terminal, "[send]", sidekickColumn(repl.terminal)); yield* settled(120); // Nothing has been asked of the provider, because the history it would be @@ -373,7 +497,7 @@ describe("Tier MP — the first explicit message is what upgrades the history", expect(repl.fake.prompts[0]).not.toContain("explain!"); // Saved once in the history too, and shown as the person's own message. expect(chatMessages(yield* readSidekickRecords(pair.sidekick))).toEqual(["explain"]); - expect(shows(repl.terminal, "you: explain")).toBe(true); + expect(saidByPerson(repl.terminal, "explain")).toBe(true); // And that same entry's later record reached the new file and no other: the // writer moved, and the object the running entry holds moved with it. @@ -506,11 +630,15 @@ describe("Tier MP — a working Sidekick, and what reaches the screen while it w expect(repl.fake.prompts).toHaveLength(1); expect(chatMessages((yield* mountedReading(repl)).chat)).toEqual(["ask"]); - // Working, and the row beside the box says so. Send is gone — there is no - // admitted activation target for a second cycle — and Stop is there. - expect(shows(repl.terminal, `${MOUNTED_AGENT} is working...`)).toBe(true); + // Working, and the pane says so in its own status row. Send is gone — + // there is no admitted activation target for a second cycle — and Stop is + // there. + expect(saidBySidekick(repl.terminal, "working")).toBe(true); expect(shows(repl.terminal, "[stop]")).toBe(true); - expect(shows(repl.terminal, "[send]")).toBe(false); + // Asked of this pane rather than of the whole screen: the entry draft keeps + // its own Send beside it, and the two are two destinations with two + // controls. What must be gone is *this* pane's. + expect(rowsShow(paneColumn(repl.terminal), "[send]")).toBe(false); // And the box is still a box: somebody composing their next question while // a reply comes back is the ordinary case. @@ -549,7 +677,7 @@ describe("Tier MP — a working Sidekick, and what reaches the screen while it w yield* awaiting( repl.terminal, "the acknowledged first reply arriving with no keystroke", - (one) => shows(one, `${MOUNTED_AGENT}: First answer.`), + (one) => saidBySidekick(one, `First answer.`), ); expect(repl.terminal.fed).toBe(fed); // Still working, because the turn its read owes has not come back. @@ -560,7 +688,7 @@ describe("Tier MP — a working Sidekick, and what reaches the screen while it w repl.fake.releaseTurn(); yield* awaiting(repl.terminal, "the follow-up answering", (one) => - shows(one, `${MOUNTED_AGENT}: Second answer.`), + saidBySidekick(one, `Second answer.`), ); expect(repl.fake.prompts).toHaveLength(2); expect(shows(repl.terminal, "[send]")).toBe(true); @@ -617,21 +745,49 @@ describe("Tier MP — the Sidekick at every size it is drawn at", () => { for (const size of PROFILES) { const rows = yield* readingAt(repl.terminal, size); - const at = size.rows - FOOTER_ROWS; const drawn = rows(); - // The footer is seven rows of this screen's own: the action row where - // this size puts it, and the draft on the last row of the frame. - expect(drawn[at] ?? "").toContain("[exit]"); - expect(drawn[size.rows - 1] ?? "").toContain("Draft: "); + // The footer is this screen's own rows at the bottom of the frame: the + // way out is in that band wherever this size puts it, and the frame is + // exactly as tall as the size asked for. + expect(drawn.slice(footerRow(drawn)).some((line) => line.includes("[exit]"))).toBe(true); expect(drawn.length).toBe(size.rows); - // Both texts survived the resize, on both of their own rows. - expect(rowsShow(drawn, "Draft: note")).toBe(true); + // The Sidekick's own text is in the pane this route is reading, at every + // size — one body and one message box, not one per column. expect(messageTextIn(drawn).includes("ask")).toBe(true); - // One Sidekick body and one message box, not one per column. - expect(drawn.filter((line) => line.includes("Message Sidekick: ")).length).toBe(1); - expect(drawn.filter((line) => line.includes("[send]")).length).toBe(1); + expect(drawn.filter((line) => line.includes(MESSAGE_PROMPT)).length).toBe(1); + // And one Send in that pane. The entry draft keeps its own beside it — + // two destinations, two controls — so this counts the pane's rather than + // the screen's. + expect(sidekickPaneIn(drawn).filter((line) => line.includes("[send]")).length).toBe(1); + + if (size.columns > NARROW.columns) { + // A shared width draws the selected work beside the chat, so the + // entry's own draft is there to read as well. + expect(blockSays(draftTextIn(drawn), "note")).toBe(true); + continue; + } + // A narrow frame routes one outlet, and this route is reading the + // Sidekick — so the draft is in no cell of it, which is not the same as + // lost. Going back to the entries is how a person reaches it, and it is + // still holding what they typed; then back here, where the chat's text is + // still holding too. Both are facts about the reading rather than about + // the frame, which is the whole of this row's claim. + expect(drawn.some((line) => line.includes(DRAFT_PROMPT))).toBe(false); + yield* openCatalog(repl.terminal); + yield* closeCatalog(repl.terminal); + yield* awaiting(repl.terminal, "the draft back on the narrow outlet", (one) => + blockSays(draftTextIn(screenOf(one)), "note"), + ); + // And the chat's own text is holding too, read where there is a column + // for it. Reaching the entries is what cleared the Sidekick route, and a + // narrow frame heads no shared column — so there is no tab on it to ask + // for that reading back with, which is the routing this size does rather + // than anything happening to the text. + const recovered = yield* readingAt(repl.terminal, PROFILES[0]); + expect(messageTextIn(recovered()).includes("ask")).toBe(true); + expect(blockSays(draftTextIn(recovered()), "note")).toBe(true); } }); @@ -650,10 +806,12 @@ describe("Tier MP — the Sidekick at every size it is drawn at", () => { yield* activate(repl.terminal, "Bindings"); const onBindings = yield* readingAt(repl.terminal, repl.terminal.size); expect(paneShown(onBindings())).toBe("bindings"); - expect(rowsShow(onBindings(), "Message Sidekick: ")).toBe(false); - expect(rowsShow(onBindings(), "[send]")).toBe(false); + expect(rowsShow(onBindings(), MESSAGE_PROMPT)).toBe(false); + // The pane's own Send, not the screen's: the entry draft is in a different + // column and keeps the control that belongs to it. + expect(rowsShow(paneColumn(repl.terminal), "[send]")).toBe(false); // The draft is still the draft, because it is not in that pane. - expect(rowsShow(onBindings(), "Draft: my entry")).toBe(true); + expect(blockSays(draftTextIn(onBindings()), "my entry")).toBe(true); // And going back brings the message with it: the text is process-local and // is not thrown away by looking at something else. @@ -672,8 +830,11 @@ describe("Tier MP — the Sidekick at every size it is drawn at", () => { // Narrow routes one outlet, and with no explicit view it is Entries. const narrow = yield* resizeTo(repl.terminal, { columns: 72, rows: 20 }); expect(rowsShow(narrow(), "Entries")).toBe(true); - expect(rowsShow(narrow(), "Message Sidekick: ")).toBe(false); - expect(rowsShow(narrow(), "[send]")).toBe(false); + expect(rowsShow(narrow(), MESSAGE_PROMPT)).toBe(false); + // The Sidekick's own Send is gone with its pane. The entry draft keeps the + // one that belongs to it — this outlet is the Draft's — so this asks about + // the pane that was routed away rather than about the screen. + expect(paneShown(narrow())).toBe("bindings"); // Back out, and ask for the Sidekick explicitly. That is a route, so it // survives the shrink this time. @@ -701,15 +862,21 @@ describe("Tier MP — the Sidekick at every size it is drawn at", () => { repl.terminal.bytes(new TextEncoder().encode(TWO_STEP_ENTRY)); yield* settled(60); repl.terminal.feed("\r"); - yield* awaiting( - repl.terminal, - "the first entry settling", - (one) => shows(one, "the released step") && shows(one, "[ok]"), - ); + // Settled, read from the rail. The catalog's `[ok]` row is in a drawer + // nobody has opened, and what this needs is that the entry is done. + yield* awaiting(repl.terminal, "the released step", (one) => shows(one, "the released step")); + yield* settledEntry(repl.terminal, 1, "ok"); // Selected, so the reading beside the catalog is that entry's own. + // Chosen in the catalog, which is where this screen keeps those rows: a + // drawer over the reading, opened to point at one, and the choice closes it. + yield* openCatalog(repl.terminal); yield* click(repl.terminal, "1."); - yield* awaiting(repl.terminal, "the first entry being selected", (one) => shows(one, "* 1.")); + yield* awaiting( + repl.terminal, + "the catalog closing behind the choice", + (one) => !catalogShowing(one), + ); // A reply that runs a second entry. The chat and the catalog both move; // the position somebody is reading does not. @@ -733,11 +900,11 @@ describe("Tier MP — the Sidekick at every size it is drawn at", () => { yield* sendMessage(repl, "run two"); yield* awaiting(repl.terminal, "the second entry being reported", (one) => - shows(one, `${MOUNTED_AGENT}: Both ran.`), + saidBySidekick(one, `Both ran.`), ); // The catalog grew and the chat updated, and the selection is where it was. - expect(shows(repl.terminal, "* 1.")).toBe(true); + expect(yield* markedEntry(repl.terminal, "1.")).toBe(true); expect((yield* mountedReading(repl)).model.entries).toHaveLength(2); // The reading is still the first entry's: a current message does not move // somebody off the position they were reading. @@ -750,16 +917,16 @@ describe("Tier MP — the Sidekick at every size it is drawn at", () => { "the inspector coming back", (one) => panePresented(one) === "bindings", ); - expect(shows(repl.terminal, "* 1.")).toBe(true); + expect(yield* markedEntry(repl.terminal, "1.")).toBe(true); expect(shows(repl.terminal, "the released step")).toBe(true); - expect(shows(repl.terminal, "Message Sidekick: ")).toBe(false); + expect(shows(repl.terminal, MESSAGE_PROMPT)).toBe(false); // And back to the Sidekick, with the chat it had. yield* activate(repl.terminal, "Sidekick"); yield* awaiting(repl.terminal, "the chat coming back", (one) => - shows(one, `${MOUNTED_AGENT}: Both ran.`), + saidBySidekick(one, `Both ran.`), ); - expect(shows(repl.terminal, "you: run two")).toBe(true); + expect(saidByPerson(repl.terminal, "run two")).toBe(true); }); it("M7: a terminal below the minimum refuses, and recovers with both texts", function* () { @@ -779,12 +946,14 @@ describe("Tier MP — the Sidekick at every size it is drawn at", () => { rowsShow(replay(repl.terminal.presented.slice(from)), `${NARROW.columns}x`), ); const refused = replay(repl.terminal.presented.slice(from)); - expect(rowsShow(refused, "Message Sidekick: ")).toBe(false); + expect(rowsShow(refused, MESSAGE_PROMPT)).toBe(false); + // A refusal is the whole of its own screen: no control of any pane's is on + // it, the draft's own Send included. expect(rowsShow(refused, "[send]")).toBe(false); // And back: nothing about either text was a property of the frame. const recovered = yield* resizeTo(repl.terminal, { columns: 160, rows: 36 }); - expect(rowsShow(recovered(), "Draft: my entry")).toBe(true); + expect(blockSays(draftTextIn(recovered()), "my entry")).toBe(true); expect(messageTextIn(recovered()).includes("my question")).toBe(true); }); }); @@ -955,9 +1124,16 @@ describe("Tier MQ — a Sidekick question announces itself and takes nothing ove repl.terminal.bytes(new TextEncoder().encode(TWO_STEP_ENTRY)); yield* settled(60); repl.terminal.feed("\r"); - yield* awaiting(repl.terminal, "the entry settling", (one) => shows(one, "[ok]")); + yield* settledEntry(repl.terminal, 1, "ok"); + // Chosen in the catalog, which is where this screen keeps those rows: a + // drawer over the reading, opened to point at one, and the choice closes it. + yield* openCatalog(repl.terminal); yield* click(repl.terminal, "1."); - yield* awaiting(repl.terminal, "the entry being selected", (one) => shows(one, "* 1.")); + yield* awaiting( + repl.terminal, + "the catalog closing behind the choice", + (one) => !catalogShowing(one), + ); // Then the message that will ask, sent from the Sidekick's own box. yield* sendMessage(repl, "review it"); @@ -979,7 +1155,7 @@ describe("Tier MQ — a Sidekick question announces itself and takes nothing ove const fed = repl.terminal.fed; filing.release(); yield* awaiting(repl.terminal, "the question announcing itself with no keystroke", (one) => - shows(one, "Sidekick (question)"), + shows(one, `${PANE_TABS.sidekick} (question)`), ); expect(repl.terminal.fed).toBe(fed); @@ -989,7 +1165,7 @@ describe("Tier MQ — a Sidekick question announces itself and takes nothing ove expect(panePresented(repl.terminal)).toBe("bindings"); expect(focusedField(repl.terminal, DRAFT_PROMPT)).toBe(true); expect(draftText(repl.terminal)).toBe("mine"); - expect(shows(repl.terminal, "* 1.")).toBe(true); + expect(yield* markedEntry(repl.terminal, "1.")).toBe(true); // And no form is open: arrival opens no drawer. expect(shows(repl.terminal, "[submit]")).toBe(false); @@ -997,7 +1173,7 @@ describe("Tier MQ — a Sidekick question announces itself and takes nothing ove // nothing — opening is a separate deliberate act. yield* activate(repl.terminal, "Sidekick"); yield* awaiting(repl.terminal, "the notice in the pane", (one) => - shows(one, `${MOUNTED_AGENT} has a question.`), + shows(one, "[answer sidekick]"), ); expect(shows(repl.terminal, "[answer sidekick]")).toBe(true); expect(shows(repl.terminal, "[submit]")).toBe(false); @@ -1073,13 +1249,14 @@ const EARLIER_CONTROL = "[\u2191 earlier]"; /** The outer envelope of the pane that owns the Sidekick's form, by width. */ function paneEnvelope(columns: number): number { - // From the accepted table rather than asked of the product: the space after - // the sidebar is shared equally and the spare cell of an odd remainder goes - // to the Transcript, so the right column begins at 96 of 160 and 74 of 120. + // From the accepted table rather than asked of the product: the staged screen + // gives the Transcript 36 and the conversation 58 of 160 and 32 of 120, so the + // right column begins at 102 and at 88. A narrow frame routes one outlet and + // has no column of its own to begin. if (columns >= 160) { - return 96; + return 102; } - return columns >= 120 ? 74 : 0; + return columns >= 120 ? 88 : 0; } /** Where on the screen this text starts, or a failure saying it is not there. */ @@ -1104,8 +1281,11 @@ describe("Tier MQ — the question form lives in the pane that owns it", () => { yield* focusMessage(repl.terminal); yield* typing(repl.terminal, "later"); + // The notice this screen gives: the control that opens the question, in the + // pane that owns it. The prose that named the agent belongs to the screen + // this one replaced. yield* awaiting(repl.terminal, "the question announcing itself", (one) => - shows(one, `${MOUNTED_AGENT} has a question.`), + shows(one, "[answer sidekick]"), ); yield* click(repl.terminal, "[answer sidekick]"); yield* awaiting(repl.terminal, "the form opening", (one) => shows(one, "Fill this in.")); @@ -1152,16 +1332,27 @@ describe("Tier MQ — the question form lives in the pane that owns it", () => { const at = columnOf(repl.terminal, label, envelope); expect(at.column).toBeGreaterThanOrEqual(envelope); expect(at.column).toBeLessThan(size.columns); - // Above the footer's seven rows, which the form never covers. - expect(at.row).toBeLessThanOrEqual(size.rows - FOOTER_ROWS - 1); + // Above this screen's own footer, which the form never covers. Read + // from the row the way out is actually drawn on rather than from a + // count: how many rows a footer takes is the layout's, and what this + // asserts is that the drawer stays off it. + expect(at.row).toBeLessThan(footerRow(drawn)); } - // The footer is still the footer, and the draft still the draft. + // The footer is still the footer. expect(rowsShow(drawn, "[exit]")).toBe(true); - expect(rowsShow(drawn, "Draft: mine")).toBe(true); // No entry form is drawn beside it: one question drawer at a time. expect(drawn.filter((line) => line.includes("[close]")).length).toBe(1); // What was typed is still in it. expect(rowsShow(drawn, "one: first")).toBe(true); + // And the draft is still the draft, read where it is drawn: a narrow + // frame routes one outlet, and this form belongs to the reading that has + // it — so the draft is in no cell there, which the resize below proves is + // not the same as lost. + if (size.columns > NARROW.columns) { + expect(blockSays(draftTextIn(drawn), "mine")).toBe(true); + } else { + expect(drawn.some((line) => line.includes(DRAFT_PROMPT))).toBe(false); + } } // Too small to draw: a refusal, and the form is not in it. @@ -1184,7 +1375,7 @@ describe("Tier MQ — the question form lives in the pane that owns it", () => { shows(one, "Fill this in."), ); expect(rowsShow(wide(), "one: first")).toBe(true); - expect(rowsShow(wide(), "Draft: mine")).toBe(true); + expect(blockSays(draftTextIn(wide()), "mine")).toBe(true); // The form holds focus while it is open, so the way out of this reading is // its own close — which is the same precedence the entry's drawer has. @@ -1207,7 +1398,7 @@ describe("Tier MQ — the question form lives in the pane that owns it", () => { yield* activate(repl.terminal, "Sidekick"); yield* awaiting(repl.terminal, "the notice still in the pane", (one) => - shows(one, `${MOUNTED_AGENT} has a question.`), + shows(one, "[answer sidekick]"), ); // Coming back selects the reading and nothing more: the form is not put // back on the screen by returning to the pane it was open in. @@ -1247,10 +1438,13 @@ function* askedAndOpen(explicit: boolean): Operation { } // Traversal rather than activation in the default arm: activating the tab is // what would make this a route, and this arm is about the default reading. - yield* focusOn(repl.terminal, `> ${MESSAGE_PROMPT}`); + // The field's own helper, because a field is focused by its marker and its + // prompt together: this prompt begins with a space of its own, so composing + // one here would be spelling the row the screen does not draw. + yield* focusMessage(repl.terminal); expect(focusedField(repl.terminal, MESSAGE_PROMPT)).toBe(true); yield* typing(repl.terminal, "ask"); - yield* click(repl.terminal, "[send]"); + yield* click(repl.terminal, "[send]", sidekickColumn(repl.terminal)); yield* awaiting(repl.terminal, "the question opener", (one) => shows(one, "[answer sidekick]")); yield* click(repl.terminal, "[answer sidekick]"); yield* awaiting(repl.terminal, "the form opening", (one) => shows(one, "[submit]")); @@ -1416,14 +1610,22 @@ describe("Tier CFG — Configure in the pane that owns it, at every size", () => const at = columnOf(repl.terminal, label, envelope); expect(at.column).toBeGreaterThanOrEqual(envelope); expect(at.column).toBeLessThan(size.columns); - expect(at.row).toBeLessThanOrEqual(size.rows - FOOTER_ROWS - 1); + expect(at.row).toBeLessThan(footerRow(drawn)); } // One presented modal, and the footer still the footer. expect(drawn.filter((line) => line.includes("[close]")).length).toBe(1); expect(rowsShow(drawn, "[exit]")).toBe(true); - expect(rowsShow(drawn, "Draft: note")).toBe(true); // The chosen model survived the resize, and nothing was applied by it. expect(rowsShow(drawn, "(x) Fast")).toBe(true); + // And the draft is read where it is drawn. A narrow frame routes one + // outlet and Configure belongs to the pane this route is reading, so the + // draft is in no cell there — which the resize back below proves is not + // the same as lost. + if (size.columns > NARROW.columns) { + expect(blockSays(draftTextIn(drawn), "note")).toBe(true); + } else { + expect(drawn.some((line) => line.includes(DRAFT_PROMPT))).toBe(false); + } } // Too small to draw: a refusal, and Configure is not in it. @@ -1438,7 +1640,7 @@ describe("Tier CFG — Configure in the pane that owns it, at every size", () => const wide = yield* resizeTo(repl.terminal, { columns: 160, rows: 36 }); yield* awaiting(repl.terminal, "Configure coming back", (one) => shows(one, "[refresh]")); expect(rowsShow(wide(), "(x) Fast")).toBe(true); - expect(rowsShow(wide(), "Draft: note")).toBe(true); + expect(blockSays(draftTextIn(wide()), "note")).toBe(true); }); it("CFG6: a choice below the drawer's window is reached by scrolling to it", function* () { @@ -1446,7 +1648,11 @@ describe("Tier CFG — Configure in the pane that owns it, at every size", () => // More models than this drawer can draw at once. A real agent's list is // whatever it advertises, so the window has to be something a person can // move rather than a limit on what they may choose. - const many = Array.from({ length: 24 }, (_, at) => ({ + // Sixty-four of them. A real agent's list is whatever it advertises, and + // the staged pane is taller than the one this row was written against — + // twenty-four of these now fit, and a window nothing overflows proves + // nothing about reaching past one. + const many = Array.from({ length: 64 }, (_, at) => ({ id: `model-${at + 1}`, name: `Model ${at + 1}`, })); @@ -1522,8 +1728,11 @@ describe("Tier CFG — Configure in the pane that owns it, at every size", () => yield* awaiting(repl.terminal, "Configure closing", (one) => !shows(one, "[refresh]")); yield* sendMessage(repl, "review it"); + // The notice this screen gives: the control that opens the question, in the + // pane that owns it. The prose that named the agent belongs to the screen + // this one replaced. yield* awaiting(repl.terminal, "the question announcing itself", (one) => - shows(one, `${MOUNTED_AGENT} has a question.`), + shows(one, "[answer sidekick]"), ); yield* click(repl.terminal, "[answer sidekick]"); yield* awaiting(repl.terminal, "the form opening", (one) => shows(one, "[submit]")); @@ -1602,7 +1811,20 @@ describe("Tier CFG — Configure in the pane that owns it, at every size", () => yield* awaiting(repl.terminal, "the name being edited at this size", (one) => shows(one, `Agent: ${MOUNTED_AGENT}-two`), ); - expect(draftText(repl.terminal)).toBe("note"); + // And the draft kept what is in it. Read where it is drawn, which takes two + // steps at this size: Configure owns the keyboard while it is open, so it + // is closed first, and then this frame's one outlet is routed back to the + // entries, which is the reading the draft belongs to. + expect(shows(repl.terminal, DRAFT_PROMPT)).toBe(false); + yield* click(repl.terminal, "[close]"); + yield* awaiting(repl.terminal, "Configure closing", (one) => !shows(one, "[refresh]")); + yield* openCatalog(repl.terminal); + yield* closeCatalog(repl.terminal); + yield* awaiting( + repl.terminal, + "the draft back on the narrow outlet", + (one) => draftText(one) === "note", + ); }); it("CFG6: History over Configure closes once, and a hidden Configure takes no keys", function* () { @@ -1701,7 +1923,11 @@ describe("Tier CFG — Configure in the pane that owns it, at every size", () => it("CX4: Configure says scroll, the readings around it still say earlier", function* () { // More models than the drawer draws at either size, so its window is real // and the controls below are the way through it. - const many = Array.from({ length: 24 }, (_, at) => ({ + // Sixty-four of them. A real agent's list is whatever it advertises, and + // the staged pane is taller than the one this row was written against — + // twenty-four of these now fit, and a window nothing overflows proves + // nothing about reaching past one. + const many = Array.from({ length: 64 }, (_, at) => ({ id: `model-${at + 1}`, name: `Model ${at + 1}`, })); @@ -1774,8 +2000,8 @@ describe("Tier CFG — Configure in the pane that owns it, at every size", () => /** The wide size every row in this file drives the mounted program at. */ const MOUNTED_SIZE: ReplTerminalSize = { columns: 160, rows: 36 }; -/** How wide the sidebar is at that size, which is where Sessions is. */ -const SIDEBAR_COLUMNS = sidebarWidth(MOUNTED_SIZE) ?? 0; +/** How wide the Transcript column is at that size, which is where the list is. */ +const SIDEBAR_COLUMNS = workspaceTranscriptWidth(MOUNTED_SIZE) ?? 0; /** * The column the right pane begins at, at the wide size these rows drive. @@ -1786,12 +2012,12 @@ const SIDEBAR_COLUMNS = sidebarWidth(MOUNTED_SIZE) ?? 0; * a wrong width belongs — a slicer that disagreed with the product would quietly * read the wrong pane instead of failing. */ -const INSPECTION_FROM = MOUNTED_SIZE.columns - (inspectionWidth(MOUNTED_SIZE) ?? 0); +const INSPECTION_FROM = MOUNTED_SIZE.columns - (workspaceConversationWidth(MOUNTED_SIZE) ?? 0); /** - * The Sessions reading, as the sidebar draws it. + * The Transcript of agent work, as its own column draws it. * - * Sliced to the sidebar's own columns, because three columns write to the same + * Sliced to that column's own cells, because three columns write to the same * rows: a label found *somewhere* on a line is usually a different control in * a different pane. What a reader sees in this column is the prefix of each row * that fits in it. @@ -1809,6 +2035,18 @@ function sessionsColumn(terminal: Terminal): string[] { * whether the message box is drawn at all — and its prompt ends in a space, so * a trimmed row would answer "no composer" for a pane that has one. */ +/** + * The row this screen draws its way out on, which is where its footer begins. + * + * Read from the screen rather than counted from a layout constant: how many + * rows a footer takes is the composition's business, and what a drawer has to + * respect is the band it actually drew. + */ +function footerRow(rows: readonly string[]): number { + const at = rows.findIndex((line) => line.includes("[exit]")); + return at === -1 ? rows.length : at; +} + function paneColumn(terminal: Terminal): string[] { return screenOf(terminal) .map((line) => line.slice(INSPECTION_FROM)) @@ -2023,24 +2261,21 @@ describe("Tier SS — Sessions shows two owners, each in its own order", () => { yield* sendMessage(repl, "ask side"); yield* repl.fake.streamingTurns(1); - // Two labelled sections, in the one fixed order, each holding its own - // owner's work. The entry turn is in Entry agents and the Sidekick turn is - // in Sidekick, and neither list holds the other's row. - yield* awaiting(repl.terminal, "both sections holding their own turn", (one) => { + // One list, which is the reading the person who accepted this screen asked + // for, holding both owners' work. The headings over blocks of rows are + // gone, so whose work a row is is said on the row. + yield* awaiting(repl.terminal, "both owners' turns in the one list", (one) => { const rows = sessionsColumn(one); - return columnShows(rows, "Entry agents") && columnShows(rows, "ask side"); + return columnShows(rows, "ask-entry") && columnShows(rows, "ask side"); }); const held = sessionsColumn(repl.terminal); - const entries = columnRow(held, "Entry agents"); - const sidekick = columnRow(held, "Sidekick"); - expect(entries).toBeGreaterThanOrEqual(0); - expect(sidekick).toBeGreaterThan(entries); - expect(columnRow(held, "ask-entry")).toBeGreaterThan(entries); - expect(columnRow(held, "ask-entry")).toBeLessThan(sidekick); - expect(columnRow(held, "ask side")).toBeGreaterThan(sidekick); - // The Sidekick row says whose work it is, which chat it is in and which - // agent answered there — never whichever agent happens to be selected. - expect(columnShows(held, `Sidekick · ${MOUNTED_AGENT}`)).toBe(true); + expect(columnShows(held, "Entry agents")).toBe(false); + expect(columnRow(held, "ask-entry")).toBeGreaterThanOrEqual(0); + expect(columnRow(held, "ask side")).toBeGreaterThanOrEqual(0); + // The Sidekick's row says which agent answered and in which conversation — + // never whichever agent happens to be selected. Read from the row, because + // one list of two owners' work has no other place to say it. + expect(columnShows(held, MOUNTED_AGENT)).toBe(true); // The later-started turn released first. Its evaluated reply is recorded // while the entry's turn is still generating, so the two owners settle in @@ -2100,7 +2335,7 @@ describe("Tier SS — Sessions shows two owners, each in its own order", () => { // has nothing queued: from here the only thing that can put the follow-up // on the screen is the canonical boundary announcing that it began. yield* awaiting(repl.terminal, "the first reply arriving", (one) => - shows(one, `${MOUNTED_AGENT}: First answer.`), + saidBySidekick(one, `First answer.`), ); yield* repl.fake.holdingTurns(1); const fed = repl.terminal.fed; @@ -2253,7 +2488,7 @@ describe("Tier SS — an older chat is read, and only read", () => { rowsShow(paneColumn(one), "Older answer."), ); expect(rowsShow(paneColumn(repl.terminal), "Newer answer.")).toBe(false); - expect(rowsShow(paneColumn(repl.terminal), `${MOUNTED_AGENT}: Older answer.`)).toBe(true); + expect(rowsShow(paneColumn(repl.terminal), `[S] Older answer.`)).toBe(true); // Read-only, and absent rather than refused: no composer, no Send, no // Stop, no Configure and no Retry addressing it. @@ -2291,7 +2526,10 @@ describe("Tier SS — an older chat is read, and only read", () => { repl.fake.script({ held: true, reply: "Arrived later.\n" }); yield* focusMessage(repl.terminal); yield* typing(repl.terminal, "one more"); - yield* click(repl.terminal, "[send]"); + // This screen has two Sends — the entry draft's and this composer's — so + // the column the Sidekick's own pane begins at is what names which one. + // Clicking the leftmost would submit a draft instead of sending a message. + yield* click(repl.terminal, "[send]", sidekickColumn(repl.terminal)); yield* repl.fake.holdingTurns(1); // Now read the older chat. @@ -2331,8 +2569,11 @@ describe("Tier SS — an older chat is read, and only read", () => { repl.fake.script({ reply: askingReply("Approve the plan?") }); repl.fake.script({ reply: "Noted.\n" }); yield* sendMessage(repl, "review it"); + // The notice this screen gives: the control that opens the question, in the + // pane that owns it. The prose that named the agent belongs to the screen + // this one replaced. yield* awaiting(repl.terminal, "the question announcing itself", (one) => - shows(one, `${OTHER_MOUNTED_AGENT} has a question.`), + shows(one, "[answer sidekick]"), ); yield* click(repl.terminal, "[answer sidekick]"); yield* awaiting(repl.terminal, "the form opening", (one) => shows(one, "Approve the plan?")); @@ -2368,13 +2609,19 @@ describe("Tier SS — an older chat is read, and only read", () => { "decision: appr", "[submit]", "[answer sidekick]", - `${OTHER_MOUNTED_AGENT} has a question.`, - "[send]", MESSAGE_PROMPT, - "[stop]", ]) { expect([absent, shows(repl.terminal, absent)]).toEqual([absent, false]); } + // The controls that would act on the chat are absent from the pane that + // reads it. Asked of that pane rather than of the screen: the entry draft + // has a Send and a Stop of its own in a different column, and a question + // about this chat's controls that found those would be about the wrong + // destination. + for (const absent of ["[send]", "[stop]"]) { + const inside = conversationPane(screenOf(repl.terminal), repl.terminal.size); + expect([absent, rowsShow(inside, absent)]).toEqual([absent, false]); + } // And nothing is a target at the cell that control occupied: a press there // reaches no node, so no form opens and the reading does not move. @@ -2425,7 +2672,10 @@ describe("Tier SS — an older chat is read, and only read", () => { }); yield* focusMessage(repl.terminal); yield* typing(repl.terminal, "goes wrong"); - yield* click(repl.terminal, "[send]"); + // This screen has two Sends — the entry draft's and this composer's — so + // the column the Sidekick's own pane begins at is what names which one. + // Clicking the leftmost would submit a draft instead of sending a message. + yield* click(repl.terminal, "[send]", sidekickColumn(repl.terminal)); yield* repl.fake.streamingTurns(1); // Read the older chat while that failure is in flight. @@ -2455,9 +2705,28 @@ describe("Tier SS — an older chat is read, and only read", () => { // The current chat's live failure is not in this reading at all: what it // is saying belongs to the conversation it is in. + // Named by the message that started it, because the half it streamed was + // never evaluated and so is in no row of either reading. + expect(rowsShow(paneColumn(repl.terminal), "goes wrong")).toBe(false); expect(rowsShow(paneColumn(repl.terminal), "Current half.")).toBe(false); expect(rowsShow(paneColumn(repl.terminal), "Older answer.")).toBe(true); - expect(rowsShow(paneColumn(repl.terminal), "Older attempt.")).toBe(true); + // And this chat's own unfinished attempt is in it too. What the screen says + // about an attempt the provider stopped is that it is unfinished and why: + // the reply it had begun was never evaluated, so there is no output of it to + // show, and the row gives the stop reason rather than inventing one. The + // item itself is in `before` above, with an identity of its own. + expect(rowsShow(paneColumn(repl.terminal), "(unfinished)")).toBe(true); + expect(saysAcross(repl.terminal, "max_tokens")).toBe(true); + // What that window is showing now, kept: the failure below is compared + // against the reading somebody was actually looking at. The Sidekick's own + // working state is the pane's rather than this reading's — it legitimately + // stops saying so when the current turn ends — so it is not one of the rows + // being compared, and the exact items are compared as records above. + const reading = (rows: readonly string[]): readonly string[] => + rows + .map((line) => line.replace(/^\s*\u2502/, "").trim()) + .filter((line) => line !== "" && line !== "[S] working"); + const looking = reading(paneColumn(repl.terminal)); // Let it fail for real, still reading the older chat. repl.fake.releaseDeltas(); @@ -2468,14 +2737,21 @@ describe("Tier SS — an older chat is read, and only read", () => { // The older chat's items are exactly the items they were: the current // failure took none of their keys, replaced none of them and dropped none. expect(yield* olderRows()).toEqual(before); - expect(rowsShow(paneColumn(repl.terminal), "Older answer.")).toBe(true); + // And the window is still the window: row for row, the reading somebody was + // looking at is the reading they are still looking at. + expect(reading(paneColumn(repl.terminal))).toEqual(looking); + expect(rowsShow(paneColumn(repl.terminal), "goes wrong")).toBe(false); expect(rowsShow(paneColumn(repl.terminal), "Current half.")).toBe(false); // And the current chat's own account of it is there when somebody goes back. yield* click(repl.terminal, "[Back to current chat]"); yield* awaiting(repl.terminal, "the current chat coming back", (one) => - rowsShow(paneColumn(one), "Current half."), + rowsShow(paneColumn(one), "goes wrong"), ); + // Its own account of what became of it: the provider stopped this turn, so + // the row says that and why. The half it had begun streaming was never + // evaluated, and this screen does not show an output nothing produced. + expect(saysAcross(repl.terminal, "refusal")).toBe(true); }); }); @@ -2538,7 +2814,7 @@ describe("Tier SS — an older chat, from a frame that measures no chat", () => // A real entry, selected, and a partial message left in the composer. Both // belong to this screen and neither belongs to the chat about to be read. yield* submitEntry(repl, promptEntry("kept-entry")); - yield* awaiting(repl.terminal, "the entry settling", (one) => shows(one, "[ok]")); + yield* settledEntry(repl.terminal, 1, "ok"); yield* focusDraft(repl.terminal); yield* typing(repl.terminal, "my entry"); yield* focusMessage(repl.terminal); @@ -2659,7 +2935,7 @@ describe("Tier ER — a turn that said nothing, selected from Sessions", () => { // Both texts this screen owns, left partly typed, and a real entry selected. yield* submitEntry(repl, promptEntry("kept-entry")); - yield* awaiting(repl.terminal, "the entry settling", (one) => shows(one, "[ok]")); + yield* settledEntry(repl.terminal, 1, "ok"); yield* focusDraft(repl.terminal); yield* typing(repl.terminal, "my entry"); yield* focusMessage(repl.terminal); @@ -2684,7 +2960,7 @@ describe("Tier ER — a turn that said nothing, selected from Sessions", () => { expect(columnShows(sessionsColumn(repl.terminal), "run it \u00b7 completed")).toBe(true); yield* clickAt(repl.terminal, sessionsCoordinate(repl.terminal, "run it ")); yield* awaiting(repl.terminal, "the request that turn answered", (one) => - columnShows(paneColumn(one), "you: run it"), + columnShows(paneColumn(one), "[U] run it"), ); // The window moved to it. An older chat left to itself follows its newest // rows, so the newest reply being absent is what says this one was placed. @@ -2693,7 +2969,11 @@ describe("Tier ER — a turn that said nothing, selected from Sessions", () => { // no empty row, no bare agent name and no sentence about the absence stands // between the two turns whose replies said nothing. const pane = paneColumn(repl.terminal); - expect(columnRow(pane, "you: say it")).toBe(columnRow(pane, "you: run it") + 1); + // Asked of the rows that say something. A blank row between items is this + // chat's own separator and stands between every pair of them, so counting + // it would be counting the chat's spacing as composed content. + const said = pane.filter((line) => line.replace(/^\s*\u2502/, "").trim() !== ""); + expect(columnRow(said, "[U] say it")).toBe(columnRow(said, "[U] run it") + 1); expect(rowsShow(pane, "answered without saying")).toBe(false); expect(rowsShow(pane, "Nothing to add.")).toBe(false); // The chat being read is the one that holds that turn, and not the chat @@ -2712,7 +2992,7 @@ describe("Tier ER — a turn that said nothing, selected from Sessions", () => { yield* reach("Automatic feedback")(); yield* clickAt(repl.terminal, sessionsCoordinate(repl.terminal, "Automatic fee")); yield* awaiting(repl.terminal, "the request that chain began with", (one) => - columnShows(paneColumn(one), "you: run it"), + columnShows(paneColumn(one), "[U] run it"), ); expect(columnLacks(paneColumn(repl.terminal), "ANSWER-10")).toBe(true); expect(rowsShow(paneColumn(repl.terminal), "answered without saying")).toBe(false); @@ -2826,13 +3106,13 @@ describe("Tier ER — a turn that said nothing, selected from Sessions", () => { yield* reach("run it \u00b7 completed")(); yield* clickAt(again.terminal, sessionsCoordinate(again.terminal, "run it ")); yield* awaiting(again.terminal, "the request that turn answered", (one) => - columnShows(paneColumn(one), "you: run it"), + columnShows(paneColumn(one), "[U] run it"), ); expect(columnLacks(paneColumn(again.terminal), "ANSWER-8")).toBe(true); yield* reach("Automatic feedback")(); yield* clickAt(again.terminal, sessionsCoordinate(again.terminal, "Automatic fee")); yield* awaiting(again.terminal, "the request that chain began with", (one) => - columnShows(paneColumn(one), "you: run it"), + columnShows(paneColumn(one), "[U] run it"), ); expect(columnLacks(paneColumn(again.terminal), "ANSWER-8")).toBe(true); expect(rowsShow(paneColumn(again.terminal), "answered without saying")).toBe(false); @@ -2868,9 +3148,7 @@ describe("Tier SS — a real gap, and a turn nothing answered", () => { repl = yield* useMountedRepl({ host, location: `xmd://repl/${execution}/entries` }); repl.fake.script({ reply: "Saved reply.\n" }); yield* sendMessage(repl, "saved ask"); - yield* awaiting(repl.terminal, "the saved reply", (one) => - shows(one, `${MOUNTED_AGENT}: Saved reply.`), - ); + yield* awaiting(repl.terminal, "the saved reply", (one) => saidBySidekick(one, `Saved reply.`)); repl.fake.script({ reply: "Half said.\n", stopReason: "max_tokens" }); yield* sendMessage(repl, "failed ask"); yield* awaiting(repl.terminal, "the failed turn being accounted for", (one) => @@ -2970,11 +3248,16 @@ describe("Tier SS — a real gap, and a turn nothing answered", () => { // text, rejoined, is exactly the status. // The pane's rows without the column divider they begin at, so rejoining // them recovers the sentence rather than the rule beside it. + // And without the mark that says who said it. That mark is this pane's own + // attribution rather than a word of the sentence — asserted on its own + // below, because an anchor nobody is said to have written is a different + // claim from this one. const anchor = paneColumn(reopened.terminal).map((row) => - (row.startsWith("\u2502") ? row.slice(1) : row).trim(), + (row.startsWith("\u2502") ? row.slice(1) : row).replace(/^\s*\[[US]\]\s/, "").trim(), ); const opens = anchor.findIndex((row) => row.includes("Automatic feedback")); expect(opens).toBeGreaterThanOrEqual(0); + expect(saidBySidekick(reopened.terminal, "Automatic feedback")).toBe(true); expect( anchor .slice(opens, opens + 3) @@ -3123,9 +3406,12 @@ describe("Tier SS — a copied location reopens the chat it named", () => { ); expect(rowsShow(paneColumn(repl.terminal), "[Back to current chat]")).toBe(true); - // And the Entries surface, and back. - yield* activate(repl.terminal, "Entries"); - yield* settled(40); + // And the Entries catalog, and back. Entries opens a drawer over the + // reading rather than routing to a column of its own, so it is put away + // before the pane beneath it is reached for — a modal owns the keyboard + // while it is up, and Tab through one would be Tab through a modal. + yield* openCatalog(repl.terminal); + yield* closeCatalog(repl.terminal); yield* activate(repl.terminal, "Sidekick"); yield* awaiting(repl.terminal, "the chat coming back again", (one) => rowsShow(paneColumn(one), "Only answer."), @@ -3324,7 +3610,7 @@ describe("Tier SS — a copied location reopens the chat it named", () => { yield* sendMessage(repl, "only ask"); yield* awaiting(repl.terminal, "the reply", (one) => shows(one, "Only answer.")); yield* submitEntry(repl, TWO_STEP_ENTRY); - yield* awaiting(repl.terminal, "the entry settling", (one) => shows(one, "[ok]")); + yield* settledEntry(repl.terminal, 1, "ok"); execution = yield* mountedExecution(repl); before = yield* pairBytes(host.root, execution); yield* click(repl.terminal, "[exit]"); @@ -3441,14 +3727,13 @@ function* stoppedBeforeAppend(stopped: boolean): Operation { expect(appending.holding).toBe(true); // The key is offered and chosen while the turn is still unsaved. + yield* openFilter(repl.terminal); yield* awaiting(repl.terminal, "the live turn's conversation being offered", (one) => sessionsColumn(one).some((line) => line.includes("xmd:v1:")), ); const offered = sessionsColumn(repl.terminal).find((line) => line.includes("xmd:v1:")); yield* click(repl.terminal, (offered ?? "").trim()); - yield* awaiting(repl.terminal, "the filter being applied", (one) => - columnShows(sessionsColumn(one), "All conversations (filtered)"), - ); + yield* awaiting(repl.terminal, "the filter being applied", (one) => filteredTo(one, observed)); if (stopped) { // Stop, with that append's own operation still suspended. This is the @@ -3500,7 +3785,7 @@ function* stoppedBeforeAppend(stopped: boolean): Operation { // The filter the person selected is still a filter either way: the key was // genuinely observed by this process, so it does not become guessed // emptiness because nothing was saved under it. - expect(columnShows(sessionsColumn(repl.terminal), "All conversations (filtered)")).toBe(true); + expect(filteredTo(repl.terminal, observed)).toBe(true); expect(sessionsColumn(repl.terminal).some((line) => line.includes("xmd:v1:"))).toBe(true); execution = yield* mountedExecution(repl); @@ -3520,7 +3805,7 @@ function* stoppedBeforeAppend(stopped: boolean): Operation { } else { const reopened = yield* useMountedRepl({ host: cold, location }); yield* awaiting(reopened.terminal, "the saved conversation filtering cold", (one) => - columnShows(sessionsColumn(one), "All conversations (filtered)"), + filteredTo(one, observed), ); } expect(cold.fake.created).toEqual([]); @@ -3604,14 +3889,15 @@ describe("Tier SS — the filter both sections share", () => { focusedField(one, MESSAGE_PROMPT), ); yield* typing(repl.terminal, "say it"); - yield* click(repl.terminal, "[send]"); + // This screen has two Sends — the entry draft's and this composer's — so + // the column the Sidekick's own pane begins at is what names which one. + // Clicking the leftmost would submit a draft instead of sending a message. + yield* click(repl.terminal, "[send]", sidekickColumn(repl.terminal)); yield* appended.started(); // The key its own publication named is offered as a filter, and the live // row it belongs to is shown under it — while that row is still unsaved. - yield* awaiting(repl.terminal, "the live turn's own conversation being offered", (one) => - columnShows(sessionsColumn(one), "All conversations"), - ); + yield* openFilter(repl.terminal); const offered = sessionsColumn(repl.terminal).find((line) => line.includes("xmd:v1:")); if (offered === undefined) { throw new Error( @@ -3619,10 +3905,12 @@ describe("Tier SS — the filter both sections share", () => { ); } const key = offered.trim(); + // The row's own text is the key's headline, shortened to the column it is + // drawn in; the key itself is what the selector names afterwards. + const named = /xmd:v1:[0-9a-f:]+/.exec(offered)?.[0] ?? ""; + expect(named).not.toBe(""); yield* click(repl.terminal, key); - yield* awaiting(repl.terminal, "the filter being applied", (one) => - columnShows(sessionsColumn(one), "All conversations (filtered)"), - ); + yield* awaiting(repl.terminal, "the filter being applied", (one) => filteredTo(one, named)); // The turn is still there under it: a key an exact publication established // is membership for its own live row, acknowledged or not. expect(columnShows(sessionsColumn(repl.terminal), "say it")).toBe(true); @@ -3640,7 +3928,7 @@ describe("Tier SS — the filter both sections share", () => { // The filter the person selected is still a filter — the key was genuinely // observed, so it does not become guessed emptiness — and the conversation // is still offered. - expect(columnShows(sessionsColumn(repl.terminal), "All conversations (filtered)")).toBe(true); + expect(filteredTo(repl.terminal, named)).toBe(true); expect(sessionsColumn(repl.terminal).some((line) => line.includes("xmd:v1:"))).toBe(true); }); @@ -3668,7 +3956,7 @@ describe("Tier SS — the filter both sections share", () => { // One entry with no Agent work of its own, which settles and leaves a // recorded checkpoint behind it. yield* submitEntry(repl, TWO_STEP_ENTRY); - yield* awaiting(repl.terminal, "the first entry settling", (one) => shows(one, "[ok]")); + yield* settledEntry(repl.terminal, 1, "ok"); const first = yield* mountedReading(repl); marker = first.model.checkpoints[first.model.checkpoints.length - 1]?.marker ?? ""; expect(marker).not.toBe(""); @@ -3713,9 +4001,7 @@ describe("Tier SS — the filter both sections share", () => { `xmd://repl/${execution}/sessions?at=${encodeURIComponent(marker)}` + `&inspect&session=${encodeURIComponent(chatKey)}`, }); - expect(columnShows(sessionsColumn(reopened.terminal), "All conversations (filtered)")).toBe( - true, - ); + expect(filteredTo(reopened.terminal, chatKey)).toBe(true); expect(columnShows(sessionsColumn(reopened.terminal), "chat ask")).toBe(true); // The entry prefix really is frozen there: the entry whose turn carries // the other key was admitted after this position, so it is not in the @@ -3749,7 +4035,10 @@ describe("Tier SS — what reaches the screen, and at every size", () => { focusedField(one, MESSAGE_PROMPT), ); yield* typing(repl.terminal, "hold it"); - yield* click(repl.terminal, "[send]"); + // This screen has two Sends — the entry draft's and this composer's — so + // the column the Sidekick's own pane begins at is what names which one. + // Clicking the leftmost would submit a draft instead of sending a message. + yield* click(repl.terminal, "[send]", sidekickColumn(repl.terminal)); yield* repl.fake.streamingTurns(1); // From here nothing is typed, clicked or submitted, and this is what says @@ -3773,7 +4062,7 @@ describe("Tier SS — what reaches the screen, and at every size", () => { columnShows(sessionsColumn(one), "hold it · completed"), ); expect(repl.terminal.fed).toBe(fed); - expect(shows(repl.terminal, `${MOUNTED_AGENT}: Half said.`)).toBe(true); + expect(saidBySidekick(repl.terminal, `Half said.`)).toBe(true); }); it("SS7: a saved Prompt and a reply still being evaluated are two facts, unpressed", function* () { @@ -3839,9 +4128,14 @@ describe("Tier SS — what reaches the screen, and at every size", () => { columnShows(sessionsColumn(one), "control-ask · completed, recorded"), ); expect(repl.terminal.fed).toBe(fed); - // And it is in its own section, with nothing of the Sidekick's in it. - expect(columnShows(sessionsColumn(repl.terminal), "Entry agents")).toBe(true); - expect(columnShows(sessionsColumn(repl.terminal), "No Sidekick turns.")).toBe(true); + // One list, and this turn is the entry's own: the row says whose work it is + // and which conversation it was in, which is where that fact lives now — + // the headings over blocks of rows were taken away deliberately. Nothing of + // the Sidekick's is in it, because the Sidekick has done none. + const listed = sessionsColumn(repl.terminal); + expect(columnShows(listed, "Entry agents")).toBe(false); + expect(columnShows(listed, MOUNTED_AGENT)).toBe(true); + expect(columnCount(listed, "control-ask")).toBe(1); }); it("SS7: three profiles keep a populated older reading, its window and the way home", function* () { @@ -3900,25 +4194,40 @@ describe("Tier SS — what reaches the screen, and at every size", () => { for (const size of PROFILES) { const rows = yield* readingAt(repl.terminal, size); - // The footer keeps its own seven rows at every size. - expect((rows()[Math.max(0, size.rows - FOOTER_ROWS)] ?? "").includes("[exit]")).toBe(true); - // The entry draft is this screen's and survives every size. - expect(rowsShow(rows(), "Draft: my entry")).toBe(true); - // The chat being read is the older one, at the turn that was chosen, and - // the way home is reachable at every size that draws it. + // The way out is in the footer at every size, found by the row it is + // actually on. How many rows a footer takes is the composition's + // business, and this one says what it has to say in one compact History + // row and one of directions below it. + expect((rows()[footerRow(rows())] ?? "").includes("[exit]")).toBe(true); + // The chat being read is the older one, opened at the turn that was + // chosen — the window is placed on it rather than left at whichever end + // it happened to be — and the way home is reachable at every size. expect(rowsShow(rows(), `OLD-${TURNS}`)).toBe(true); expect(rowsShow(rows(), "[Back to current chat]")).toBe(true); // Read-only: no composer, no Send and no Configure for a chat nobody may - // continue — and so the partial message is not drawn either. - expect(rowsShow(rows(), "[send]")).toBe(false); + // continue — and so the partial message is not drawn either. Asked of + // that pane rather than of the screen: the entry draft has a Send of its + // own in a different column, and a question about this chat's controls + // that found that one would answer about the wrong destination. + expect(rowsShow(conversationPane(rows(), size), "[send]")).toBe(false); expect(rowsShow(rows(), `[configure `)).toBe(false); if (size.columns >= 120) { - // Both sections, and both window controls, where the frame has columns. - expect(rowsShow(rows(), "Entry agents")).toBe(true); - expect(rowsShow(rows(), "Sidekick")).toBe(true); + // The one Transcript list, the pane's own tab, and both window + // controls, where the frame has columns for them. Whose work a row is + // is said on the row — the headings over blocks of them are gone. + expect(rowsShow(rows(), "Entry agents")).toBe(false); + expect(rowsShow(rows(), "TRANSCRIPT")).toBe(true); + expect(rowsShow(rows(), PANE_TABS.sidekick)).toBe(true); expect(rowsShow(rows(), EARLIER)).toBe(true); expect(rowsShow(rows(), LATER)).toBe(true); + // The entry draft is this screen's and is drawn beside this reading. + expect(blockSays(draftTextIn(rows()), "my entry")).toBe(true); + continue; } + // A narrow frame routes one outlet and this route is reading the chat, so + // the draft is in no cell of it. It is read where it is drawn, which is + // what the widest size above has already done. + expect(rows().some((line) => line.includes(DRAFT_PROMPT))).toBe(false); } // Home again, at the widest size: the current chat, its controls and the @@ -3944,19 +4253,38 @@ describe("Tier SS — what reaches the screen, and at every size", () => { for (const size of PROFILES) { const rows = yield* readingAt(repl.terminal, size); - // The footer keeps its own seven rows at every size. - expect((rows()[Math.max(0, size.rows - FOOTER_ROWS)] ?? "").includes("[exit]")).toBe(true); - // Both texts survive every size: neither is a property of the frame. - expect(rowsShow(rows(), "Draft: my entry")).toBe(true); + // The way out is in the footer at every size, found by the row it is + // actually on. How many rows a footer takes is the composition's + // business, and this one says what it has to say in one compact History + // row and one of directions below it. + expect((rows()[footerRow(rows())] ?? "").includes("[exit]")).toBe(true); if (size.columns >= 120) { - // Both sections and the Sessions window controls are where the frame - // places them, and the chat has its own window controls too. - expect(rowsShow(rows(), "Entry agents")).toBe(true); - expect(rowsShow(rows(), "Sidekick")).toBe(true); + // Both texts survive every size: neither is a property of the frame. + // Both are drawn here, each in its own destination's block, with the + // one Transcript list, the pane's own tab and the window controls where + // the frame places them — and no headings over blocks of rows, because + // whose work a row is is said on the row. + expect(blockSays(draftTextIn(rows()), "my entry")).toBe(true); expect(messageTextIn(rows()).includes("my q")).toBe(true); + expect(rowsShow(rows(), "Entry agents")).toBe(false); + expect(rowsShow(rows(), "TRANSCRIPT")).toBe(true); + expect(rowsShow(rows(), PANE_TABS.sidekick)).toBe(true); expect(rowsShow(rows(), EARLIER)).toBe(true); expect(rowsShow(rows(), LATER)).toBe(true); + continue; } + // A narrow frame routes one outlet, and the Sidekick is the one this + // reading asked for — so the chat's text is drawn and the draft is in no + // cell. Neither is lost: the draft is read by going back to the entries, + // which is the outlet that has it, and the chat is still holding its own + // when the frame is wide enough to draw both again. + expect(messageTextIn(rows()).includes("my q")).toBe(true); + expect(rows().some((line) => line.includes(DRAFT_PROMPT))).toBe(false); + yield* openCatalog(repl.terminal); + yield* closeCatalog(repl.terminal); + yield* awaiting(repl.terminal, "the draft back on the narrow outlet", (one) => + blockSays(draftTextIn(screenOf(one)), "my entry"), + ); } // Back at the widest size, read the older chat and prove the read-only @@ -3974,7 +4302,7 @@ describe("Tier SS — what reaches the screen, and at every size", () => { // controls — and Back is reachable beside them, outside the window. expect(rowsShow(rows(), "[Back to current chat]")).toBe(true); } - expect((rows()[Math.max(0, size.rows - FOOTER_ROWS)] ?? "").includes("[exit]")).toBe(true); + expect((rows()[footerRow(rows())] ?? "").includes("[exit]")).toBe(true); } }); }); @@ -4110,17 +4438,17 @@ describe("Tier CF — complete chat messages, and the newest ones in view", () = // The newest reply, with nothing pressed to bring it into view. const fed = repl.terminal.fed; yield* awaiting(repl.terminal, "the newest reply at 160x36", (one) => - shows(one, `${MOUNTED_AGENT}: ANSWER-12`), + saidBySidekick(one, `ANSWER-12`), ); expect(repl.terminal.fed).toBe(fed); // The first exchange has moved up out of the window, and that is following // rather than loss: it is still in the history that was just read. - expect(chatRows(repl.terminal)).not.toContain(`${MOUNTED_AGENT}: ANSWER-1`); + expect(chatRows(repl.terminal)).not.toContain(`[S] ANSWER-1`); expect(JSON.stringify(read.chat)).toContain("ANSWER-1"); // The same at the medium size. const medium = yield* resizeTo(repl.terminal, { columns: 120, rows: 30 }); - expect(rowsShow(medium(), `${MOUNTED_AGENT}: ANSWER-12`)).toBe(true); + expect(rowsShow(medium(), `[S] ANSWER-12`)).toBe(true); // And in the narrow outlet, asked for explicitly: a narrow frame has no // tab to activate, so the route is taken while there is one and survives @@ -4130,20 +4458,20 @@ describe("Tier CF — complete chat messages, and the newest ones in view", () = focusedField(one, MESSAGE_PROMPT), ); const narrow = yield* resizeTo(repl.terminal, { columns: 72, rows: 20 }); - expect(rowsShow(narrow(), `${MOUNTED_AGENT}: ANSWER-12`)).toBe(true); + expect(rowsShow(narrow(), `[S] ANSWER-12`)).toBe(true); // Back at the wide size, the earlier messages are reachable by the chat's // own control, which is what "they moved up" has to mean: they went up, // they did not go away. yield* resizeTo(repl.terminal, { columns: 160, rows: 36 }); yield* awaiting(repl.terminal, "the wide chat again", (one) => - rowsShow(paneColumn(one), `${MOUNTED_AGENT}: ANSWER-12`), + rowsShow(paneColumn(one), `[S] ANSWER-12`), ); let reached = false; for (let press = 0; press < 40 && !reached; press += 1) { yield* clickAt(repl.terminal, paneCoordinate(repl.terminal, EARLIER_CONTROL)); yield* settled(12); - reached = chatRows(repl.terminal).includes(`${MOUNTED_AGENT}: ANSWER-1`); + reached = chatRows(repl.terminal).includes(`[S] ANSWER-1`); } expect(reached).toBe(true); }); @@ -4168,7 +4496,7 @@ describe("Tier CF — complete chat messages, and the newest ones in view", () = yield* sendMessage(repl, `old-${number}`); } yield* awaiting(repl.terminal, "the older chat's last reply", (one) => - shows(one, `${MOUNTED_AGENT}: OLD-${each}`), + saidBySidekick(one, `OLD-${each}`), ); yield* click(repl.terminal, `[configure ${MOUNTED_AGENT}]`); @@ -4187,7 +4515,7 @@ describe("Tier CF — complete chat messages, and the newest ones in view", () = yield* sendMessage(repl, `new-${number}`); } yield* awaiting(repl.terminal, "the current chat's last reply", (one) => - shows(one, `${OTHER_MOUNTED_AGENT}: NEW-${each}`), + saidBySidekick(one, `NEW-${each}`), ); } @@ -4209,19 +4537,19 @@ describe("Tier CF — complete chat messages, and the newest ones in view", () = yield* twoLongChats(repl, 10); // The current chat is at its tail, which is where following put it. - expect(chatRows(repl.terminal)).toContain(`${OTHER_MOUNTED_AGENT}: NEW-10`); + expect(chatRows(repl.terminal)).toContain(`[S] NEW-10`); // An older chat, opened read-only and then scrolled: a position taken in a // chat somebody is only reading. yield* readOlder(repl, "old-1 "); yield* awaiting(repl.terminal, "the older chat opening", (one) => - rowsShow(paneColumn(one), `${MOUNTED_AGENT}: OLD-`), + rowsShow(paneColumn(one), `[S] OLD-`), ); let moved = false; for (let press = 0; press < 40 && !moved; press += 1) { yield* clickAt(repl.terminal, paneCoordinate(repl.terminal, EARLIER_CONTROL)); yield* settled(12); - moved = !chatRows(repl.terminal).includes(`${MOUNTED_AGENT}: OLD-10`); + moved = !chatRows(repl.terminal).includes(`[S] OLD-10`); } expect(moved).toBe(true); @@ -4230,9 +4558,9 @@ describe("Tier CF — complete chat messages, and the newest ones in view", () = // somebody else's. yield* click(repl.terminal, "[Back to current chat]", 96); yield* awaiting(repl.terminal, "the current chat returning", (one) => - rowsShow(paneColumn(one), `${OTHER_MOUNTED_AGENT}: NEW-`), + rowsShow(paneColumn(one), `[S] NEW-`), ); - expect(chatRows(repl.terminal)).toContain(`${OTHER_MOUNTED_AGENT}: NEW-10`); + expect(chatRows(repl.terminal)).toContain(`[S] NEW-10`); }); it("CF-P1: control — a deliberate current reading also survives the visit", function* () { @@ -4244,7 +4572,7 @@ describe("Tier CF — complete chat messages, and the newest ones in view", () = for (let press = 0; press < 40 && !scrolled; press += 1) { yield* clickAt(repl.terminal, paneCoordinate(repl.terminal, EARLIER_CONTROL)); yield* settled(12); - scrolled = !chatRows(repl.terminal).includes(`${OTHER_MOUNTED_AGENT}: NEW-10`); + scrolled = !chatRows(repl.terminal).includes(`[S] NEW-10`); } expect(scrolled).toBe(true); const held = chatBody(repl.terminal); @@ -4253,11 +4581,11 @@ describe("Tier CF — complete chat messages, and the newest ones in view", () = // reading they return to, neither re-followed nor moved by the visit. yield* readOlder(repl, "old-1 "); yield* awaiting(repl.terminal, "the older chat opening", (one) => - rowsShow(paneColumn(one), `${MOUNTED_AGENT}: OLD-`), + rowsShow(paneColumn(one), `[S] OLD-`), ); yield* click(repl.terminal, "[Back to current chat]", 96); yield* awaiting(repl.terminal, "the current chat returning", (one) => - rowsShow(paneColumn(one), `${OTHER_MOUNTED_AGENT}: NEW-`), + rowsShow(paneColumn(one), `[S] NEW-`), ); expect(chatBody(repl.terminal)).toEqual(held); }); @@ -4308,7 +4636,21 @@ describe("Tier CF — complete chat messages, and the newest ones in view", () = // Released, it settles as the same item: one response, still showing its // own final row, with both composers exactly as they were left. repl.fake.releaseDeltas(); - yield* awaiting(repl.terminal, "the evaluated reply settling", (one) => shows(one, "[send]")); + // The Sidekick's own control, in the Sidekick's own pane. This screen has + // two Sends — the entry draft's is beside its field and is there whatever + // the Sidekick is doing — so a `[send]` found anywhere says nothing about + // whether this turn has finished, and a row that waited on one went on to + // count a reply the provider had not acknowledged yet. + yield* awaiting( + repl.terminal, + "the Sidekick's own control going back to Send", + (one) => + rowsShow(sidekickPaneIn(screenOf(one)), "[send]") && + !rowsShow(sidekickPaneIn(screenOf(one)), "[stop]"), + ); + // And the reply itself, acknowledged: the control coming back is the pane + // saying it is idle, and the record is what says the turn was kept. + yield* repliesSaved(repl, 1); const read = yield* mountedReading(repl); const responses = read.chat.filter((one) => one.kind === "response"); expect(responses).toHaveLength(1); @@ -4325,9 +4667,7 @@ describe("Tier CF — complete chat messages, and the newest ones in view", () = repl.fake.script({ reply: `ANSWER-${number}\n` }); yield* sendMessage(repl, `ask-${number}`); } - yield* awaiting(repl.terminal, "the newest reply", (one) => - shows(one, `${MOUNTED_AGENT}: ANSWER-12`), - ); + yield* awaiting(repl.terminal, "the newest reply", (one) => saidBySidekick(one, `ANSWER-12`)); // Deliberately reading further back, until the newest reply is no longer // the one in view. Counted by what the pane holds rather than by presses: @@ -4336,7 +4676,7 @@ describe("Tier CF — complete chat messages, and the newest ones in view", () = for (let press = 0; press < 40 && !scrolled; press += 1) { yield* clickAt(repl.terminal, paneCoordinate(repl.terminal, EARLIER_CONTROL)); yield* settled(12); - scrolled = !chatRows(repl.terminal).includes(`${MOUNTED_AGENT}: ANSWER-12`); + scrolled = !chatRows(repl.terminal).includes(`[S] ANSWER-12`); } expect(scrolled).toBe(true); const held = chatRows(repl.terminal); @@ -4352,7 +4692,7 @@ describe("Tier CF — complete chat messages, and the newest ones in view", () = // Saved, and this reading did not jump to it. const read = yield* mountedReading(repl); expect(JSON.stringify(read.chat)).toContain("ANSWER-13"); - expect(chatRows(repl.terminal)).not.toContain(`${MOUNTED_AGENT}: ANSWER-13`); + expect(chatRows(repl.terminal)).not.toContain(`[S] ANSWER-13`); expect(chatRows(repl.terminal)).toEqual(held); // Reaching the end again resumes following, so the arrival after it is @@ -4361,14 +4701,14 @@ describe("Tier CF — complete chat messages, and the newest ones in view", () = for (let press = 0; press < 60 && !back; press += 1) { yield* clickAt(repl.terminal, paneCoordinate(repl.terminal, LATER_CONTROL)); yield* settled(12); - back = chatRows(repl.terminal).includes(`${MOUNTED_AGENT}: ANSWER-13`); + back = chatRows(repl.terminal).includes(`[S] ANSWER-13`); } expect(back).toBe(true); repl.fake.script({ reply: "ANSWER-14\n" }); yield* sendMessage(repl, "ask-14"); yield* awaiting(repl.terminal, "following having resumed", (one) => - shows(one, `${MOUNTED_AGENT}: ANSWER-14`), + saidBySidekick(one, `ANSWER-14`), ); }); }); @@ -4393,7 +4733,11 @@ describe("Tier MC — the message composer wraps and grows", () => { /** How many rows of chat this frame is drawing, between its two controls. */ function chatCapacity(terminal: Terminal): number { - const rows = paneColumn(terminal).map((line) => + // The pane this size draws the chat in, which at a narrow frame is the + // routed outlet and so the whole of it: a slicer that always cut at the + // right column of a shared width read nothing at all there, and answered + // that the chat had no rows when it had the screen. + const rows = conversationPane(screenOf(terminal), terminal.size).map((line) => (line.startsWith("│") ? line.slice(1) : line).trim(), ); const from = rows.indexOf(EARLIER_CONTROL); @@ -4408,7 +4752,7 @@ describe("Tier MC — the message composer wraps and grows", () => { yield* sendMessage(repl, `ask-${number}`); } yield* awaiting(repl.terminal, "the newest reply", (one) => - shows(one, `${MOUNTED_AGENT}: SAID-${exchanges}`), + saidBySidekick(one, `SAID-${exchanges}`), ); } @@ -4454,7 +4798,7 @@ describe("Tier MC — the message composer wraps and grows", () => { const after = chatCapacity(repl.terminal); expect(after).toBeLessThan(before); expect(after).toBeGreaterThanOrEqual(1); - expect(shows(repl.terminal, `${MOUNTED_AGENT}: SAID-10`)).toBe(true); + expect(saidBySidekick(repl.terminal, `SAID-10`)).toBe(true); // The name, the controls and the footer are all where they were. expect(shows(repl.terminal, MESSAGE_PROMPT)).toBe(true); @@ -4554,7 +4898,10 @@ describe("Tier MC — the message composer wraps and grows", () => { expect(atMedium).toBeLessThanOrEqual(6); // Narrow, asked for explicitly: the cap is what this screen can leave the - // chat, which at twenty rows is fewer than six. + // chat. How many rows that is belongs to the composition — this outlet + // keeps three rows of chrome where the one this row was written against + // kept seven, so twenty rows now allow the whole cap — and what the cap is + // *for* is that the chat keeps a row of its own, which is asserted below. yield* resizeTo(repl.terminal, { columns: 160, rows: 36 }); yield* activate(repl.terminal, "Sidekick"); yield* awaiting(repl.terminal, "the message box taking focus", (one) => @@ -4564,7 +4911,7 @@ describe("Tier MC — the message composer wraps and grows", () => { expect(messageTextIn(narrow())).toContain("ROW-8"); const atNarrow = composer(repl.terminal).length; expect(atNarrow).toBeGreaterThanOrEqual(1); - expect(atNarrow).toBeLessThan(6); + expect(atNarrow).toBeLessThanOrEqual(6); // Send and at least one chat row are still there to reach. expect(rowsShow(narrow(), "[send]")).toBe(true); expect(chatCapacity(repl.terminal)).toBeGreaterThanOrEqual(1); @@ -4575,8 +4922,16 @@ describe("Tier MC — the message composer wraps and grows", () => { if (label === undefined) { throw new Error("the narrow outlet drew no message box"); } - expect(label.indexOf(MESSAGE_PROMPT) - 3).toBe(2); - expect(label.slice(0, 2)).toBe(" "); + // Measured from the prompt rather than from a marker's room. The field + // wears one `>` now, drawn in the first of its own cells, where the row + // this was written against reserved three cells in front of the prompt for + // `>> ` — so the body begins where the inset ends whether or not it has + // focus, and the gutter beside it holds nothing either way. + const begins = label.indexOf(MESSAGE_PROMPT); + expect(begins).toBe(2); + // And nothing of any other control is in front of it: what is there is the + // field's own marker, which it wears only while it has focus. + expect([">", ""]).toContain(label.slice(0, begins).trim()); // Below the minimum, and back. Resized rather than waited on a whole // frame: a refused size draws one sentence and no frame to wait for. @@ -4862,7 +5217,7 @@ describe("Tier HI — a submission reserves from the acknowledged history", () = repl.fake.script({ reply: submitting(FAILING) }); yield* sendMessage(repl, "run one that fails"); - yield* awaiting(repl.terminal, "the third entry failing", (one) => shows(one, "3. [err]")); + yield* settledEntry(repl.terminal, 3, "err"); const read = yield* mountedReading(repl); const intent = lastSubmit(read.chat); @@ -5061,9 +5416,10 @@ describe("Tier UH — both pane tabs still select, by pointer and by keyboard", yield* typing(repl.terminal, "my question"); // The Sidekick is the default reading, and both names are on the screen - // whichever one that is. + // whichever one that is. Spelled the way the staged panes spell their own + // tabs — in capitals — rather than the way a person says them. expect(panePresented(repl.terminal)).toBe("sidekick"); - for (const label of ["Bindings", "Sidekick"]) { + for (const label of [PANE_TABS.bindings, PANE_TABS.sidekick]) { expect([label, shows(repl.terminal, label)]).toEqual([label, true]); } expect(shows(repl.terminal, "[selected]")).toBe(false); @@ -5076,26 +5432,26 @@ describe("Tier UH — both pane tabs still select, by pointer and by keyboard", "Bindings being selected", (one) => panePresented(one) === "bindings", ); - expect(focusedOn(repl.terminal, "Bindings")).toBe(true); + expect(focusedOn(repl.terminal, PANE_TABS.bindings)).toBe(true); // Focus on a tab is not selection of it: the other tab can hold focus // without the reading changing under it. - yield* focusOn(repl.terminal, "Sidekick"); - expect(focusedOn(repl.terminal, "Sidekick")).toBe(true); + yield* focusOn(repl.terminal, PANE_TABS.sidekick); + expect(focusedOn(repl.terminal, PANE_TABS.sidekick)).toBe(true); expect(panePresented(repl.terminal)).toBe("bindings"); // By pointer: the same control, reached at its own cell. Its box is its own // label now, so a click on the name is a click on the tab bearing it. - yield* click(repl.terminal, "Sidekick"); + yield* click(repl.terminal, PANE_TABS.sidekick); yield* awaiting( repl.terminal, "the chat coming back", (one) => panePresented(one) === "sidekick", ); - yield* click(repl.terminal, "Bindings"); + yield* click(repl.terminal, PANE_TABS.bindings); yield* awaiting(repl.terminal, "Bindings again", (one) => panePresented(one) === "bindings"); // And neither composer was edited by any of it. - yield* click(repl.terminal, "Sidekick"); + yield* click(repl.terminal, PANE_TABS.sidekick); yield* awaiting( repl.terminal, "the chat once more", @@ -5142,9 +5498,16 @@ describe("Tier UH — both pane tabs still select, by pointer and by keyboard", // A real entry first, so the Transcript has a reading with controls around // it — submitting consumes the draft, so the texts below are typed after. yield* submitEntry(repl, promptEntry("read-entry")); - yield* awaiting(repl.terminal, "the entry settling", (one) => shows(one, "[ok]")); + yield* settledEntry(repl.terminal, 1, "ok"); + // Chosen in the catalog, which is where this screen keeps those rows: a + // drawer over the reading, opened to point at one, and the choice closes it. + yield* openCatalog(repl.terminal); yield* click(repl.terminal, "1."); - yield* awaiting(repl.terminal, "the entry being selected", (one) => shows(one, "* 1.")); + yield* awaiting( + repl.terminal, + "the catalog closing behind the choice", + (one) => !catalogShowing(one), + ); // Then text in both destinations, so the round trip below has something of // each to preserve. @@ -5156,21 +5519,40 @@ describe("Tier UH — both pane tabs still select, by pointer and by keyboard", // The Sessions reading is reached by its own control, in the column the // inset narrowed: a row built to the pane instead of the body would have // published a target reaching into the gutter. - yield* click(repl.terminal, "All conversations"); - yield* settled(30); - expect(shows(repl.terminal, "All conversations")).toBe(true); + // Behind the selector, which is where one list with the conversations as its + // filter keeps them: opening it is what puts the choices on the screen. + yield* openFilter(repl.terminal); + const choice = coordinateOf(repl.terminal, "All conversations"); + if (choice === undefined) { + throw new Error("the filter offered no way back to every conversation"); + } + // Inside the Transcript's own column, which is this row's claim: a row + // built to the pane instead of the body would have published a target + // reaching into the gutter. + expect(choice.column).toBeLessThan(workspaceTranscriptWidth(repl.terminal.size) ?? 0); + yield* clickAt(repl.terminal, choice); + // And the press landed. Choosing is the whole of what the chooser is for, + // so it closes behind the choice rather than staying open over the reading + // it just changed. + yield* awaiting( + repl.terminal, + "the chooser closing behind the choice", + (one) => !columnShows(sessionsColumn(one), "All conversations"), + ); // And the Transcript scrolls by its *own* control at both shared sizes. The // reading is narrower than it was, so its later rows are reached rather than // drawn — which is what these two controls are for. Found by the column it - // is in, because the sidebar's two readings each draw one of their own. + // is in: each of the three readings on this screen draws a pair of its own. for (const size of [ { columns: 120, rows: 30 }, { columns: 160, rows: 36 }, ]) { yield* resizeTo(repl.terminal, size); - const left = sidebarWidth(size) ?? 0; - const right = size.columns - (inspectionWidth(size) ?? 0); + // The Transcript is the leftmost column of the staged screen, so its own + // controls are the ones inside its width. + const left = 0; + const right = workspaceTranscriptWidth(size) ?? 0; const at = screenOf(repl.terminal).flatMap((line, row) => { const column = line.indexOf(LATER); return column >= left && column < right ? [{ column, row }] : []; @@ -5189,7 +5571,7 @@ describe("Tier UH — both pane tabs still select, by pointer and by keyboard", size.columns, true, ]); - expect([size.columns, shows(repl.terminal, "* 1.")]).toEqual([size.columns, true]); + expect([size.columns, yield* markedEntry(repl.terminal, "1.")]).toEqual([size.columns, true]); } // Narrow routes one outlet and is given no inset of its own, so its own @@ -5201,7 +5583,7 @@ describe("Tier UH — both pane tabs still select, by pointer and by keyboard", const wide = yield* resizeTo(repl.terminal, { columns: 160, rows: 36 }); expect(paneShown(wide())).toBe("sidekick"); expect(messageTextIn(wide())).toContain("kept question"); - expect(rowsShow(wide(), "Draft: kept entry")).toBe(true); + expect(blockSays(draftTextIn(wide()), "kept entry")).toBe(true); }); it("UH4: resizing through narrow and back keeps both texts, and narrow grows no tabs", function* () { @@ -5213,7 +5595,7 @@ describe("Tier UH — both pane tabs still select, by pointer and by keyboard", // Medium keeps the three columns and the one header row. const medium = yield* resizeTo(repl.terminal, { columns: 120, rows: 30 }); - for (const label of ["Bindings", "Sidekick"]) { + for (const label of [PANE_TABS.bindings, PANE_TABS.sidekick]) { expect([label, rowsShow(medium(), label)]).toEqual([label, true]); } expect(rowsShow(medium(), "[selected]")).toBe(false); @@ -5226,8 +5608,277 @@ describe("Tier UH — both pane tabs still select, by pointer and by keyboard", // And back, with both texts where they were left. const wide = yield* resizeTo(repl.terminal, { columns: 160, rows: 36 }); - expect(rowsShow(wide(), "Bindings")).toBe(true); + expect(rowsShow(wide(), PANE_TABS.bindings)).toBe(true); expect(messageTextIn(wide())).toContain("kept question"); - expect(rowsShow(wide(), "Draft: kept entry")).toBe(true); + expect(blockSays(draftTextIn(wide()), "kept entry")).toBe(true); + }); +}); + +/** + * #971 A3: one mounted screen carried through every supported size and back. + * + * One presenter, one tree and one grid throughout, because what is under test + * is what survives a resize rather than what a fresh frame can draw: the + * reading, the selection, both texts, and the control a keystroke reaches. + * + * The sizes below the minimum are part of the journey. That frame draws one + * sentence and places no control at all, and the one after it has to bring the + * reading back — a screen that recovered into something else would be a resize + * that lost somebody's work. + */ +describe("Tier A3 — the staged screen resized, and recovered", () => { + it("A3: 160 to 120 to 72, below the minimum, and back", function* () { + // The fixture's entries compile their own eval blocks, which is what this + // file's other rows get from the program they run. + yield* useTempFileCompiler(); + // The journal read at a position somebody moved to, rather than at its + // head. Both halves come from one call, because a route may only name the + // position its own model was projected at: a marker set on the model of + // the present is a route resolution refuses. + const { model, at: selected } = yield* checkpointSelected(); + // Seeded once, and the only reading in this journey: that retained + // position, the entry and nested scope it holds, and two texts no default + // supplies. A frame that rebuilt the fixture's own state instead of + // carrying this one would be visibly a different screen. + const state = seededState(model); + const presenter = yield* usePresenter(CHECKPOINT_SIZE); + const first = yield* commitCheckpoint(presenter, { model, state }); + expect(first.focused).toBe("footer:input"); + const scopes = state.route.scopes; + const at = state.route.at; + expect(scopes.length).toBe(2); + + // The position is a real one: a marker this journal retained, named by the + // route, and the one its model was projected at. All three, because any + // two of them agreeing on `undefined` would say only that this reading is + // of the present. + expect(typeof at).toBe("string"); + expect(at).toBe(selected); + expect(at).toBe(model.selection); + expect(model.checkpoints.map((one) => one.marker)).toContain(selected); + expect(selected.length).toBeGreaterThan(0); + + // And the screen says which reading it is showing: a position behind HEAD + // marks the History band, which a reading of the present does not. + const banded = (frame: CheckpointFrame): string => { + const bounds = frame.observed.regionOf("box:history"); + if (bounds === undefined) { + throw new Error("this frame published no History band"); + } + return presenter.grid.textIn(bounds).join(" "); + }; + expect(banded(first)).toContain("reading earlier"); + + // The key the tree answered with, carried from size to size. Passing it is + // what tells the fixture not to let the Draft take focus again: + // re-offering it at every size would be restoring focus rather than + // keeping whatever a resize left focused. + let held = first.focused; + + /** The same reading at one more size, committed into the same tree. */ + const resized = function* (size: ReplTerminalSize) { + presenter.resize(size); + const committed = yield* commitCheckpoint(presenter, { + model, + state, + size, + ...(held === undefined ? {} : { focused: held }), + }); + held = committed.focused; + return committed; + }; + + for (const size of [CHECKPOINT_MEDIUM, CHECKPOINT_NARROW]) { + const where = `${size.columns}x${size.rows}`; + const committed = yield* resized(size); + // The reading is the same reading: same entry, same nested scope, same + // recorded position, and both texts exactly as they were typed. + expect({ where, scopes: committed.view.state.route.scopes }).toEqual({ where, scopes }); + expect({ where, at: committed.view.state.route.at }).toEqual({ where, at }); + expect({ where, draft: committed.view.state.draft }).toEqual({ + where, + draft: SEEDED_DRAFT, + }); + expect({ where, message: committed.view.state.message }).toEqual({ + where, + message: SEEDED_MESSAGE, + }); + // And it is still showing the retained reading rather than the present, + // wherever the row has the cells to say so. The band is content, so it + // is cut at the room its own measured controls leave it; at 72 columns + // this reading's band is longer than that room, and what falls off is + // the phrase below. That the row cannot hold it is an appearance + // question for the image review these sizes are already waiting on — + // what this journey holds is that the reading itself does not change. + const band = banded(committed); + expect({ where, drawn: band.length > 0 }).toEqual({ where, drawn: true }); + if (size.columns >= CHECKPOINT_MEDIUM.columns) { + expect({ where, banded: band.includes("reading earlier") }).toEqual({ + where, + banded: true, + }); + } + // The Draft is still on this screen, so focus is still on it. + expect({ where, focused: committed.focused }).toEqual({ where, focused: "footer:input" }); + expect({ + where, + drawn: committed.observed.boundsOf("footer:input") !== undefined, + }).toEqual({ where, drawn: true }); + } + + // A control narrowing removed is not merely hidden: it is in no cell, no + // frame map and no mounted node, so nothing can activate it. + const narrow = presenter.last(); + if (narrow === undefined) { + throw new Error("this journey committed no narrow frame"); + } + for (const key of ["tray:queue", "follow", "sidekick:stop"]) { + expect({ key, mounted: narrow.keys.includes(key) }).toEqual({ key, mounted: false }); + expect({ key, drawn: narrow.boundsOf(key) !== undefined }).toEqual({ key, drawn: false }); + } + + // Below the minimum the frame is its own refusal: one sentence, and not one + // control anywhere on it. + const refused = yield* resized(CHECKPOINT_BELOW); + expect(refused.observed.committed.manifest.profile).toBe("too-small"); + expect(refused.observed.targets.length).toBe(0); + expect(refused.observed.committed.manifest.actions).toBe(undefined); + const said = presenter.grid + .textIn({ x: 0, y: 0, width: CHECKPOINT_BELOW.columns, height: CHECKPOINT_BELOW.rows }) + .join(" "); + expect(said).toContain(`${NARROW.columns}x${NARROW.rows}`); + + // And growing the window brings the same reading back. + const recovered = yield* resized(CHECKPOINT_SIZE); + expect(recovered.view.state.route.scopes).toEqual(scopes); + expect(recovered.view.state.route.at).toEqual(at); + expect(recovered.view.state.draft).toBe(SEEDED_DRAFT); + expect(recovered.view.state.message).toBe(SEEDED_MESSAGE); + expect(recovered.focused).toBe("footer:input"); + expect(recovered.observed.boundsOf("footer:input")).not.toBe(undefined); + expect(banded(recovered)).toContain("reading earlier"); + for (const key of ["tray:todo", "sidekick:stop"]) { + expect({ key, drawn: recovered.observed.boundsOf(key) !== undefined }).toEqual({ + key, + drawn: true, + }); + } + // The shared queue's tray and the Follow control a recovered frame used to + // bring back with it are DQ0 and DF1 in + // `fixtures/repl/deferred-presentation.json`: this build offers neither, at + // any size, before or after a refusal. + for (const key of ["tray:queue", "follow"]) { + expect({ key, drawn: recovered.observed.boundsOf(key) !== undefined }).toEqual({ + key, + drawn: false, + }); + } + }); +}); + +/** + * #971 R3: the Sidekick's question, opened and typed into at a new size. + * + * The question is compiled from a closed object schema by the product's own + * parser, so the form this screen draws is one the REPL's form language + * actually supports — a form asserted into shape would draw fields the parser + * would have refused. + * + * And it is opened the way a person opens it: the action the control carries, + * through the reducer, against what the frame measured. A test that set + * `asking` itself would be asserting the drawer rather than reaching it, and + * would not notice a control that stopped being able to. + */ +describe("Tier R3 — the Sidekick's parsed question at a constrained size", () => { + it("R3: the drawer opens through its own action and its field takes text", function* () { + yield* useTempFileCompiler(); + const model = yield* checkpointModel(); + // The narrowest size this screen supports, with the conversation routed to + // the one outlet it has: the drawer has least to work with here. + const size = CHECKPOINT_NARROW; + const presenter = yield* usePresenter(size); + const routed = { + model, + size, + live: CHECKPOINT_ASKING, + sidekick: true, + } as const; + + // One ordinary string field, as the parser read it. + expect(CHECKPOINT_QUESTION_FORM.fields.map((one) => one.name)).toEqual(["rule"]); + const field = "rule"; + + // The question is announced where it can be answered. + const asked = yield* commitCheckpoint(presenter, routed); + expect(asked.observed.boundsOf("sidekick:answer")).not.toBe(undefined); + expect(asked.view.live.sidekick?.question?.key).toBe(CHECKPOINT_QUESTION); + + /** + * The same reducer the keystroke reaches, against what this frame measured + * and which of the Sidekick's drawers it presented. Both are the frame's + * own answers: a form held open in state while the screen shows something + * else owns no keystroke, so a field action reduced without the frame that + * drew the field is refused. + */ + const act = ( + state: ReplState, + action: Parameters[1], + frame: CheckpointFrame, + ) => + reduceRepl( + state, + action, + model, + CHECKPOINT_ASKING, + frame.observed.committed.admission, + presentedDrawer(frame.view), + ).state; + + // Opened by the action its own control carries, not by a fixture. + const opened = act( + checkpointState(model, routed), + { kind: "open-sidekick-question", question: CHECKPOINT_QUESTION }, + asked, + ); + expect({ key: opened.asking?.key, open: opened.asking?.open }).toEqual({ + key: CHECKPOINT_QUESTION, + open: true, + }); + + // Drawn, the drawer carries that one field. + const drawn = yield* commitCheckpoint(presenter, { + ...routed, + state: opened, + focused: asked.focused, + }); + expect(drawn.observed.boundsOf("drawer:open")).not.toBe(undefined); + expect(drawn.observed.boundsOf(`drawer:field:${field}`)).not.toBe(undefined); + + // The field takes the focus text acts on, and then the text. + const answered = "half up, to the cent"; + const selected = act( + opened, + { kind: "select-sidekick-field", question: CHECKPOINT_QUESTION, field }, + drawn, + ); + expect(selected.asking?.form.field).toBe(field); + const typed = act(selected, { kind: "type", text: answered, field }, drawn); + // The question this is an answer to is still the question that was asked. + expect({ + key: typed.asking?.key, + value: typed.asking?.form.values[field], + }).toEqual({ key: CHECKPOINT_QUESTION, value: answered }); + + // And the screen says so, in the row the value belongs to. + const held = yield* commitCheckpoint(presenter, { + ...routed, + state: typed, + focused: drawn.focused, + }); + const row = held.observed.boundsOf(`drawer:field:${field}`); + if (row === undefined) { + throw new Error("the field that took the text is no longer drawn"); + } + expect(presenter.grid.textIn(row).join(" ")).toContain("half up"); }); }); diff --git a/packages/cli/tests/repl-sidekick-recovery.test.ts b/packages/cli/tests/repl-sidekick-recovery.test.ts index 167051c29..8e49ee5f7 100644 --- a/packages/cli/tests/repl-sidekick-recovery.test.ts +++ b/packages/cli/tests/repl-sidekick-recovery.test.ts @@ -78,6 +78,7 @@ import { click, focusDraft, operationGate, + settledEntry, shows, typing, useMountedRepl, @@ -2188,12 +2189,12 @@ describe("Tier HI — a reopening reconciles against the acknowledged history", yield* awaiting(repl.terminal, "the ordinary entry printing", (one) => shows(one, "an ordinary entry"), ); - // Settled, not merely printed: the catalog says so once the entry's - // outcome is in the journal, which is the boundary the reads below need. - const settledRow = `${row.entriesWhenFound.length + 1}. [ok]`; - yield* awaiting(repl.terminal, `the catalog showing ${settledRow}`, (one) => - shows(one, settledRow), - ); + // Settled, not merely printed: the boundary the reads below need is the + // entry's outcome being in the journal. Read from the History rail, + // which marks every entry this file holds with the state it reached — + // the catalog spells the same fact `[ok]`, and the catalog is a drawer + // nobody opened here. + yield* settledEntry(repl.terminal, row.entriesWhenFound.length + 1, "ok"); // The successor took the next slot with its own source, the finding was // not written twice, and the reserved work was neither resubmitted nor @@ -2605,9 +2606,10 @@ describe("975 — placement survives cold Submit reconciliation", () => { yield* scoped(function* () { const repl = yield* useMountedRepl({ host, location: coldLocation }); yield* awaited("the second cold continuation", () => observed.length === 3); - yield* awaiting(repl.terminal, "the continued suffix", (one) => - shows(one, "1. [ok] entry-1"), - ); + // The reserved entry ran and settled here, read from the History rail. + // `1. [ok] entry-1` is a catalog row, and the catalog is a drawer + // nobody opened in this reading. + yield* settledEntry(repl.terminal, 1, "ok"); expect(observed.at(-1)).toBe(where); expect(yield* until(readFile(join(where, "suffix.md"), "utf8"))).toBe("continued"); expect(yield* cwd()).toBe(project); diff --git a/packages/cli/tests/repl-sidekick-testing.test.ts b/packages/cli/tests/repl-sidekick-testing.test.ts index 2bd3e4e9d..05bd3caab 100644 --- a/packages/cli/tests/repl-sidekick-testing.test.ts +++ b/packages/cli/tests/repl-sidekick-testing.test.ts @@ -35,7 +35,7 @@ import { focusDraft, typing, click, - screenOf, + settledEntry, } from "./fixtures/repl/issue-883/mounted-program.ts"; import type { MountedRepl } from "./fixtures/repl/issue-883/mounted-program.ts"; @@ -166,13 +166,10 @@ describe("REPL.Test accepted journeys", () => { yield* focusDraft(repl.terminal); yield* typing(repl.terminal, ""); repl.terminal.feed("\r"); - yield* awaiting( - repl.terminal, - "manual reuse", - () => - screenOf(repl.terminal).join("\n").includes("entry-5") && - screenOf(repl.terminal).join("\n").includes("[ok]"), - ); + // The rail's own mark for the fifth entry. `entry-5` and `[ok]` are rows of + // the Entries catalog, and the catalog is a drawer on this screen: a reader + // that waited for them would be waiting for a drawer nobody opened. + yield* settledEntry(repl.terminal, 5, "ok"); const read = yield* mountedReading(repl); expect(read.model.entries.slice(-2).map((entry) => entry.source)).toEqual([ "", diff --git a/packages/cli/tests/repl-source-reading.test.ts b/packages/cli/tests/repl-source-reading.test.ts index a8045ab61..e87a59562 100644 --- a/packages/cli/tests/repl-source-reading.test.ts +++ b/packages/cli/tests/repl-source-reading.test.ts @@ -29,7 +29,13 @@ import type { Operation } from "effection"; import { entryReading, NO_READING, READING_STATUSES } from "../src/repl/source-reading.ts"; import type { ReplReadingLine } from "../src/repl/source-reading.ts"; -import { createFitStore, prepareReading, readingRuns, reserveFor } from "../src/repl/fitting.ts"; +import { + createFitStore, + fitPlain, + prepareReading, + readingRuns, + reserveFor, +} from "../src/repl/fitting.ts"; import type { ReplPreparedReading } from "../src/repl/fitting.ts"; import { useCountingRenderer } from "./fixtures/repl/presentation.ts"; import type { ReplCountingRenderer } from "./fixtures/repl/presentation.ts"; @@ -492,6 +498,99 @@ describe("the reading one entry is shown as", () => { expect(prepared.value.rows.map((one) => one.text)).toEqual(["界é", "🙂"]); })); + /** + * The tail of a wrapped line, which the engine measures as fitting whole. + * + * Four leading spaces, so the hanging indent its continuations are set in + * to is four as well, and a tail that has a space in it — which is what + * makes a whitespace backoff applicable where it must not apply. + */ + const FITTING_TAIL = " I started the Architect review and will say what it asks for."; + /** The room that line is fitted to, and what it is cut into there. */ + const TAIL_ROOM = 55; + const TAIL_ROWS: readonly { readonly text: string; readonly indent: number }[] = [ + { text: " I started the Architect review and will say what ", indent: 0 }, + { text: "it asks for.", indent: 4 }, + ]; + + /** One logical line of plain text, as `fitPlain` is handed them. */ + const plain = (text: string) => ({ key: "one", text }); + + it("D10: a remainder the engine measures as fitting stays on one row", () => + withRenderer(function* (renderer) { + // The tail fits the room it is left with, so it is one row. Backing off + // to its last space would cut a row the engine had already accepted, + // and the reader would find `for.` alone on a row of its own. + const fitted = yield* fitPlain(renderer, WIDE, [plain(FITTING_TAIL)], TAIL_ROOM); + expect(fitted.ok).toBe(true); + if (!fitted.ok) { + return; + } + expect(fitted.value.map((one) => ({ text: one.text, indent: one.indent }))).toEqual( + TAIL_ROWS, + ); + // Still exactly what was written: the cuts put nothing in and take + // nothing out, and each row's runs spell the row. + expect(fitted.value.map((one) => one.text).join("")).toBe(FITTING_TAIL); + for (const row of fitted.value) { + expect(runText(row.runs)).toBe(row.text); + expect(row.text.length + row.indent).toBeLessThanOrEqual(TAIL_ROOM); + } + + // And a remainder exactly as wide as the room it has: the bound is + // inclusive, so it is one row rather than two. + const exact = yield* fitPlain(renderer, WIDE, [plain("1234567 abc defg")], 8); + expect(exact.ok).toBe(true); + if (!exact.ok) { + return; + } + expect(exact.value.map((one) => ({ text: one.text, indent: one.indent }))).toEqual([ + { text: "1234567 ", indent: 0 }, + { text: "abc defg", indent: 0 }, + ]); + expect(exact.value.map((one) => one.text).join("")).toBe("1234567 abc defg"); + })); + + it("D10: a reserved reading keeps a fitting remainder whole", () => + withRenderer(function* (renderer) { + const line: ReplReadingLine = Object.freeze({ + key: "one", + text: FITTING_TAIL, + runs: Object.freeze([Object.freeze({ text: FITTING_TAIL, token: "source" as const })]), + rail: "rail-pending" as const, + depth: 0, + badge: undefined, + style: Object.freeze({ role: "source" as const, selected: false, inspected: false }), + }); + const reservation = yield* reserveFor(renderer, WIDE, 80); + expect(reservation.ok).toBe(true); + if (!reservation.ok) { + return; + } + // The same content room the plain case has, once the rail, its two + // separators and the status column this reading reserves are taken. + const room = + reservation.value.rail + + reservation.value.separator * 2 + + reservation.value.status + + TAIL_ROOM; + const prepared = yield* prepareReading(renderer, WIDE, [line], room); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + return; + } + expect(prepared.value.reservation.content).toBe(TAIL_ROOM); + expect(prepared.value.rows.map((one) => ({ text: one.text, indent: one.indent }))).toEqual( + TAIL_ROWS, + ); + expect(prepared.value.rows.map((one) => one.text).join("")).toBe(FITTING_TAIL); + for (const row of prepared.value.rows) { + expect(runText(row.runs)).toBe(row.text); + // Measured, not counted: every row fits the content it was reserved. + expect(row.width + row.indent).toBeLessThanOrEqual(prepared.value.reservation.content); + } + })); + it("refuses rather than clipping when not one grapheme fits", () => withRenderer(function* (renderer) { const line: ReplReadingLine = Object.freeze({ diff --git a/packages/cli/tests/support/pty.ts b/packages/cli/tests/support/pty.ts index 93d90bdeb..88c4ab11b 100644 --- a/packages/cli/tests/support/pty.ts +++ b/packages/cli/tests/support/pty.ts @@ -487,27 +487,34 @@ function* showing(pty: Pty, what: string, within: number): Operation { * right one for a label, a control or a status, and it is the wrong one here. * * What it proves: every non-space character of the expected text, in order, - * within four adjacent rows. What it gives up: the spacing between words, since - * a wrap consumes the space it happened at and the rails between columns are - * removed to rejoin a cut token. It does not give up completeness — an elided + * within four adjacent rows of one column. What it gives up: the spacing + * between words, since a wrap consumes the space it happened at. The rows are + * rejoined inside their own column — a divider is where one reading ends and + * the next begins, and joining across one would splice the pane alongside into + * the middle of the text. It does not give up completeness — an elided * row keeps its `…`, so a truncated token still fails here, which is the whole * point of asking about a long one. */ function* showingAcross(pty: Pty, what: string, within: number): Operation { - /** The same text with every space and column rail taken out of it. */ - const bare = (line: string): string => line.replaceAll("\u2502", "").replace(/\s+/g, ""); + /** The same text with every space taken out of it. */ + const bare = (line: string): string => line.replace(/\s+/g, ""); const wanted = bare(what); const deadline = Date.now() + within; while (true) { const screen = yield* pty.shows(); - // Four, because a token this long is cut at most once in a column this - // wide and the label before it may sit on its own row above. - const joined = screen.map((_, at) => - screen - .slice(at, at + 4) - .map(bare) - .join(""), - ); + // Column by column, because a divider is where one reading ends and the + // next begins: rejoining a block's rows across the whole width would + // splice the pane beside it into the middle of the text being looked for. + const columns = Math.max(...screen.map((line) => line.split("\u2502").length), 1); + const joined: string[] = []; + for (let column = 0; column < columns; column += 1) { + const own = screen.map((line) => bare(line.split("\u2502")[column] ?? "")); + // Four, because a token this long is cut at most once in a column this + // wide and the label before it may sit on its own row above. + for (let at = 0; at < own.length; at += 1) { + joined.push(own.slice(at, at + 4).join("")); + } + } if (joined.some((rows) => rows.includes(wanted))) { return; } @@ -522,5 +529,52 @@ function* showingAcross(pty: Pty, what: string, within: number): Operation } } +/** How this suite names an outcome, and the mark the History rail gives it. */ +const RAIL_MARKS: Readonly> = Object.freeze({ + ok: "\u2713", + err: "\u00d7", + cancelled: "\u002d", + unfinished: "\u25cf", +}); + +/** + * Wait until the History rail says this entry reached this outcome. + * + * The rail rather than the Entries catalog. `N. [ok] entry-N` is a row of that + * catalog, and the catalog is a drawer on this screen — so a journey waiting + * for one of its rows is waiting for a drawer nobody opened. What every frame + * draws instead is the compact History band, which carries one mark per entry. + * + * Read up to the rule, which is where the rail ends and the sentence beside it + * begins: a number past that belongs to the sentence and not to an entry. + */ +function* settledEntry( + pty: Pty, + order: number, + outcome: keyof typeof RAIL_MARKS, + within: number, +): Operation { + const mark = RAIL_MARKS[outcome]; + const found = new RegExp(`(?:^|\\s)${order}\\s*${mark}`); + const deadline = Date.now() + within; + while (true) { + const screen = yield* pty.shows(); + const railed = screen.some((line) => { + const at = line.indexOf("HISTORY"); + return at === -1 ? false : found.test(line.slice(at + 7).split("\u2500\u2500")[0] ?? ""); + }); + if (railed) { + return; + } + if (Date.now() > deadline) { + throw new Error( + `the History rail never marked entry ${order} as ${outcome} within ${within}ms. screen=` + + JSON.stringify(screen.map((line) => line.trimEnd()).filter((line) => line.length > 0)), + ); + } + yield* sleep(150); + } +} + export type { Pty }; -export { allocate, showing, showingAcross }; +export { allocate, settledEntry, showing, showingAcross }; diff --git a/packages/cli/tests/support/repl-files-journey.ts b/packages/cli/tests/support/repl-files-journey.ts index e83b6f9e6..a714bf94b 100644 --- a/packages/cli/tests/support/repl-files-journey.ts +++ b/packages/cli/tests/support/repl-files-journey.ts @@ -26,7 +26,7 @@ import { execFile } from "node:child_process"; import { chmod, mkdir, readdir } from "node:fs/promises"; import { join } from "node:path"; import process from "node:process"; -import { allocate, showing, showingAcross } from "./pty.ts"; +import { allocate, settledEntry, showing, showingAcross } from "./pty.ts"; import type { Pty } from "./pty.ts"; /** @@ -262,7 +262,9 @@ export function replFilesJourney(repl: ReplCommand): Operation { yield* showing(second, "Recorded value: first", ACT_MS); yield* showing(second, "Continue this experiment?", ACT_MS); yield* click(second, "( ) continue"); - yield* showing(second, "[ok] entry-1", ACT_MS); + // The rail's own mark for the first entry. `[ok] entry-1` is a row of the + // Entries catalog, which this screen keeps in a drawer. + yield* settledEntry(second, 1, "ok", ACT_MS); // The suffix write executed, for the first time, on the answer. yield* holding(join(checkout, "after.md"), "continued"); @@ -503,7 +505,9 @@ export function replRepositoryJourney(repl: ReplCommand, through: ManagedJourney yield* showing(second, "Recorded value: first", ACT_MS); yield* showing(second, "Continue this experiment?", ACT_MS); yield* click(second, "( ) continue"); - yield* showing(second, "[ok] entry-1", ACT_MS); + // The rail's own mark for the first entry. `[ok] entry-1` is a row of the + // Entries catalog, which this screen keeps in a drawer. + yield* settledEntry(second, 1, "ok", ACT_MS); // The suffix write executed, for the first time, on the answer — in the // checkout the restored placement named. @@ -867,7 +871,9 @@ export function replGitJourney(repl: ReplCommand, through: GitJourney): Operatio // today's HEAD. yield* showing(second, `Recorded commit: ${recorded}`, ACT_MS); yield* click(second, "( ) continue"); - yield* showing(second, "[ok] entry-1", ACT_MS); + // The rail's own mark for the first entry. `[ok] entry-1` is a row of the + // Entries catalog, which this screen keeps in a drawer. + yield* settledEntry(second, 1, "ok", ACT_MS); // The suffix write executed, for the first time, on the answer. yield* holding(join(working, "after.md"), "continued"); diff --git a/packages/cli/tests/support/repl-issues-journey.ts b/packages/cli/tests/support/repl-issues-journey.ts index 6f3da161f..2d2549e3a 100644 --- a/packages/cli/tests/support/repl-issues-journey.ts +++ b/packages/cli/tests/support/repl-issues-journey.ts @@ -29,7 +29,7 @@ import type { Operation } from "effection"; import { useIssueTrackerServer } from "../../../git/tests/support/issue-tracker-server.ts"; import type { IssueTrackerServer } from "../../../git/tests/support/issue-tracker-server.ts"; import { credential } from "../../../git/tests/support/issue-tracker-server.ts"; -import { allocate, showing, showingAcross } from "./pty.ts"; +import { allocate, settledEntry, showing, showingAcross } from "./pty.ts"; import type { Pty } from "./pty.ts"; /** @@ -221,7 +221,9 @@ export function replIssuesJourney(repl: ReplCommand): Operation { yield* second.type("\r"); yield* showing(second, `Recorded issue: ${TRACKER}/issues/1`, ACT_MS); yield* click(second, "( ) continue"); - yield* showing(second, "[ok] entry-1", ACT_MS); + // The rail's own mark for the first entry. `[ok] entry-1` is a row of the + // Entries catalog, which this screen keeps in a drawer. + yield* settledEntry(second, 1, "ok", ACT_MS); // One new suffix effect, executed on the answer and nothing before it: the // issue is the one issue, with the mark it was filed under untouched. diff --git a/packages/cli/tests/support/repl-mounts-journey.ts b/packages/cli/tests/support/repl-mounts-journey.ts index d35aaba0b..205763fbb 100644 --- a/packages/cli/tests/support/repl-mounts-journey.ts +++ b/packages/cli/tests/support/repl-mounts-journey.ts @@ -88,6 +88,10 @@ export function replMountsJourney(repl: ReplCommand): Operation { yield* third.type("\r"); yield* showing(third, "Ready for Entry 3", 30_000); yield* holding(join(selected, "fresh.md"), selected === old ? "changed" : "current"); + // The catalog is a drawer on this screen: `Entries` opens it, and the + // row that selects the second entry is inside it. + yield* click(third, "Entries"); + yield* showing(third, "2. [ok]", 30_000); yield* click(third, "2. [ok]"); yield* showing(third, selected === old ? "fresh is changed" : "fresh is current", 30_000); yield* reach(third, "[exit]"); diff --git a/packages/cli/tests/support/repl-pull-request-journey.ts b/packages/cli/tests/support/repl-pull-request-journey.ts index 2e5bb2dc9..b8724579d 100644 --- a/packages/cli/tests/support/repl-pull-request-journey.ts +++ b/packages/cli/tests/support/repl-pull-request-journey.ts @@ -58,7 +58,7 @@ import { } from "@executablemd/git"; import type { GitHostReconciliationRecord } from "@executablemd/git"; import type { DurableEvent } from "@executablemd/durable-streams"; -import { allocate, showing, showingAcross, terminalDataHome } from "./pty.ts"; +import { allocate, settledEntry, showing, showingAcross, terminalDataHome } from "./pty.ts"; import type { Pty } from "./pty.ts"; import { ACT_MS, @@ -413,7 +413,9 @@ export function replPullRequestJourney(repl: ReplCommand): Operation { // opened.md and evidence.md, written from the same bindings. yield* showingAcross(second, ASKED, ACT_MS); yield* click(second, "( ) continue"); - yield* showing(second, "[ok] entry-1", ACT_MS); + // The rail's own mark for the first entry. `[ok] entry-1` is a row of the + // Entries catalog, which this screen keeps in a drawer. + yield* settledEntry(second, 1, "ok", ACT_MS); // The suffix executed for the first time on the answer: the write landed, // and the read written after the question is a new occurrence, so it diff --git a/packages/cli/tests/support/repl-push-journey.ts b/packages/cli/tests/support/repl-push-journey.ts index 1f1ff417b..c60ae819e 100644 --- a/packages/cli/tests/support/repl-push-journey.ts +++ b/packages/cli/tests/support/repl-push-journey.ts @@ -30,7 +30,7 @@ import { execFile } from "node:child_process"; import { chmod, mkdir, readdir } from "node:fs/promises"; import { join } from "node:path"; import process from "node:process"; -import { allocate, showing, showingAcross } from "./pty.ts"; +import { allocate, settledEntry, showing, showingAcross } from "./pty.ts"; import type { Pty } from "./pty.ts"; /** @@ -169,7 +169,9 @@ export function replPushJourney(repl: ReplCommand): Operation { // the branch is now. yield* showing(second, `Published commit: ${published}`, ACT_MS); yield* click(second, "( ) continue"); - yield* showing(second, "[ok] entry-1", ACT_MS); + // The rail's own mark for the first entry. `[ok] entry-1` is a row of the + // Entries catalog, which this screen keeps in a drawer. + yield* settledEntry(second, 1, "ok", ACT_MS); // The suffix write executed, for the first time, on the answer — and // answering published nothing further. diff --git a/packages/test-support/launch.ts b/packages/test-support/launch.ts index 14580bf2b..41e71ab24 100644 --- a/packages/test-support/launch.ts +++ b/packages/test-support/launch.ts @@ -33,7 +33,15 @@ export function cliRuntime(): "deno" | "bun" | "node" { export function cliBase(): string[] { if (Reflect.has(globalThis, "Deno")) { - return [process.execPath, "run", "--allow-all", entry("deno")]; + // Quiet, because this runtime writes its own progress to the same terminal + // the command owns. Measured on a pty: `\x1b[0G\x1b[2K\x1b[J` arrived in + // the middle of a full-screen frame, erasing the cursor's row and every row + // below it — the History rail and the directions — and the command's diff + // renderer, which had already written them and was never told, did not + // write them again. The rows came back blank in tmux's own emulator and in + // an independent replay of the same bytes alike. It suppresses nothing the + // command itself says. + return [process.execPath, "run", "--quiet", "--allow-all", entry("deno")]; } if (Reflect.has(globalThis, "Bun")) { return [process.execPath, entry("bun")]; diff --git a/specs/repl-spec.md b/specs/repl-spec.md index 74e205e22..f77d30d32 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -336,8 +336,8 @@ preparation and starts no work. Pause asks and cancels nothing — neither the preparation nor the entry — and asking twice asks for the same thing. Preparation is not execution. A reserved entry is not running, paused, admitted -or settled; it adds no history position and no head state, and the footer keeps -its seven rows throughout. +or settled; it adds no history position and no head state, and the rows around +the body keep their count throughout. When the root settles, its recorded output is what the transcript shows, and the screen stops asking for frames. @@ -461,10 +461,27 @@ entry holds, a value an entry that never settled computed, a value a discarded result had. The reserved `props` namespace belongs to each entry's own validated root and is never inherited. -An entry interrupted before its root closed is a different thing. The file holds -no close for it, so it is unfinished, and an unfinished entry permits no -successor — reopen that execution and it resumes where its records stop, and -another entry can be submitted once it has settled. +An entry interrupted before its root closed is a different thing, and there are +two kinds of it. + +**Left unfinished.** Exit, EOF, a terminal failure and the command's own +teardown end an entry without appending any outcome for it. The file holds no +close, so the entry is unfinished, and an unfinished entry permits no successor +— reopen that execution and it resumes where its records stop, without +repeating the effects it already recorded, and another entry can be submitted +once it has settled. + +**Stopped.** An explicit Entry Stop is the other kind, and it is final. The +entry's own root retains a cancelled close before the entry is released, so the +entry has an outcome and is settled: what it had already completed stands, what +it had already printed stays where it was recorded, the retained exports it +published are inherited exactly as a settled entry's are, and no answer, +permission or result it was waiting on is invented for it. It is projected and +never resumed — reopening that execution reads it and runs nothing — and a +successor may be submitted once that terminal is retained **and** every +resource the stopped entry held has been released. A stop whose append or +cleanup failed is not a stop that succeeded: the session admits no successor +after one. ### A history written before captures were retained @@ -556,15 +573,25 @@ instead of being replaced. ## Sessions: two owners, and which conversation you are reading -The Sessions surface shows the agent work this execution has, in two labelled -sections: **Entry agents**, then **Sidekick**. Two, because the two histories -are two files with two owners and no shared scheduling number — one interleaved -list would have to claim an order neither file establishes. Each section keeps -its own order, and an empty one says it holds no turns. A Sidekick history this -version cannot read says why instead, because an empty section for a file that -holds somebody's conversations is the one answer that cannot be corrected later. -An execution with no Sidekick installation has one list and says so when it is -empty. +The **Sessions surface** shows the agent work this execution has, in two +labelled sections: **Entry agents**, then **Sidekick**. Two, because the two +histories are two files with two owners and no shared scheduling number — one +*interleaved* list would have to claim an order neither file establishes. Each +section keeps its own order, and an empty one says it holds no turns. A Sidekick +history this version cannot read says why instead, because an empty section for a +file that holds somebody's conversations is the one answer that cannot be +corrected later. An execution with no Sidekick installation has one list and says +so when it is empty. + +The **Transcript column** of the staged workspace — and its narrow outlet — +shows the same work as **one displayed list**, with no section heading over +either owner. That is what the person who accepted this screen asked for. It is a +presentation and nothing more: the two owners keep their separate domains, each +keeps its own recorded order, each row keeps its own destination, and every row +says whose work it is and which conversation it was in. The two orders are shown +one after the other and never interleaved, so this list still claims no order +across the two files. Everything the sections say below about what each owner +holds, what a row means and how a conversation filters is true of this list too. **Entry agents** shows every Agent turn the entries did, in one order: the turns the history retains, then the turns this process is watching, by the order they @@ -598,19 +625,21 @@ never merged, whatever they have in common: they have different owners, so an equal provider conversation or an equal turn number leaves two rows. Conversations are the provider's own session keys, offered in Entry agents order -and then Sidekick order, each section earliest-observed first and a duplicate -key offered once. A queued turn belongs to none of them yet, and the name the -document gave its `` is not an answer to which conversation a provider -opened, so a turn without one appears under **All conversations** and nowhere -else. A Sidekick turn's key is the one its own saved record names — a failed or +and then Sidekick order, each owner earliest-observed first and a duplicate +key offered once: above the Sessions surface's own list, and behind the **All +sessions** selector at the head of the Transcript. A queued turn belongs to none of them yet, and the name the document +gave its `` is not an answer to which conversation a provider opened, so +a turn without one appears under **All sessions** and nowhere else. A Sidekick turn's key is the one its own saved record names — a failed or cancelled record included — and an earlier turn's key is never substituted for a later one's. The conversation a successful publication names belongs to its own live turn before anything is acknowledged, and it still does not make that turn saved. -Selecting a conversation filters both sections and changes nothing else: the +Selecting a conversation filters both owners' rows — both sections on the +Sessions surface, the one list in the Transcript — and changes nothing else: the route gains a session and keeps its surface, its scopes and its history -position. Clearing it back to All restores both whole. The Sidekick's facts are +position, and the selected work, the Draft, the composer and focus stay where +they were. Clearing it back to All restores both whole. The Sidekick's facts are applicable beside any entry position, because freezing a prefix freezes *entry* facts and a chat has no position in that history — so a Sidekick conversation can be selected where the frozen prefix contains no such conversation, while an @@ -661,8 +690,8 @@ an absence of messages. Omitting the chat follows the current one. An explicit identifier pins the reading to that chat, and it has the usual controls only while it *is* the active association. Every other saved chat is read-only: no composer, no -`[send]`, no `[retry]`, no Configure, no pending question and no `[stop]` -addressing it, absent from the screen, its focus order and its target map +`[send]`, no `[retry]`, no Configure, no pending question, no Follow, TODO or +QUEUE and no `[stop]` addressing it, absent from the screen, its focus order and its target map rather than drawn and refused. A current-chat question or Configure drawer is hidden while an older chat is read and keeps every value already typed into it. Root actions still check the actual current conversation, so the selector can @@ -734,8 +763,8 @@ the accent this screen uses for waiting, so the drawer you have to act on says s before you have read a word of it. The title, both window controls and `[close]` stay outside the thing they move, -and the footer keeps its own seven rows — `[history]` and `[exit]` stay reachable -from inside an open drawer, where they belong to that drawer. +and the rows below the body stay where they are — `[history]` and `[exit]` stay +reachable from inside an open drawer, where they belong to that drawer. A route drawer begins below the whole contextual guidance block, so the screen goes on saying how to close the drawer in front of it. The block is as many rows @@ -1000,11 +1029,68 @@ selected entry, the conversation filter and the draft. | terminal | what it shows | | --- | --- | -| `160x36` and larger | a Sessions/Entries sidebar, a Transcript pane and a Bindings pane — each with its own surface and the edge that starts it — the drawer layer, and a fixed full-width footer holding the contextual action row, five History rows and the draft | -| `120x30` and larger | the same, with a narrower sidebar and inspection column | -| `72x20` and larger | one routed surface — the one the route selected — under the two surface controls, with the same drawer and footer | +| `160x36` and larger | an identity row; three columns — the Transcript of agent work, the selected work with its own Draft, and Bindings/Sidekick — each with its own surface and the divider that starts it; the drawer layer; then one compact History row and one row of guidance | +| `120x30` and larger | the same three columns, narrower | +| `72x20` and larger | one routed surface — the one the route selected — under the two surface controls, with the same drawer, History and guidance | | smaller than `72x20` | a refusal saying the minimum, that growing the window recovers, and that Escape leaves; it shows nothing else, and offers no control to point at | +The **Transcript** on the left is the agent work this execution has, read as +requests and replies, as **one list**: the entry agents' turns and the +Sidekick's in the same list, neither of them under a heading of its own. Each +row says whose work it is and which conversation it was in, which is how the two +owners are told apart here — they keep their own authority and their own +recorded order, and one displayed list does not merge their histories. + +The **All sessions** selector at its head is the filter for that list. It is a +control: activating it shows the conversations this reading holds — the same +choices [Sessions](#sessions-two-owners-and-which-conversation-you-are-reading) +offers, each named by what the provider actually opened — and choosing one +leaves only that conversation's rows, whichever owner did them. **All sessions** +brings them all back. The choices are put away again by choosing one or by +activating the selector a second time, and putting them away chooses nothing. + +The **selected work** in the centre names the entry and the scopes you are +reading as breadcrumbs, with the way to the Entries catalog in front of them, and +shows that entry's exact source, its execution state and its results. Every name +in that trail except the last one is a way back out to it: the reading is already +at the last, and choosing an outer name leaves the reading there with the scopes +beneath it no longer selected — the same thing choosing that scope's row in the +catalog does. Below it is +the **Draft**: one field whose heading row carries its own Send — or Stop, while +an entry is running — and whose value takes the rows beneath. The Draft is always +the next entry's text, and a blank row in it is a row. + +**Bindings** and **Sidekick** share the right column as two tabs. Above the +Sidekick's composer, the trays this build offers sit on the left and the +Sidekick's own Send — or Stop, while a Send is active — on the right. + +What a build offers there is what it has. **This build ships the layout and the +two Stops, and offers neither the shared queue nor Follow**, so it draws no +QUEUE tab and no Follow control: a tab a reader can select and find nothing +behind, or a checkbox that changes nothing however it is drawn, is worse than no +control at all. **TODO** is drawn, inactive, as the placeholder for the shared +TODO (#901). The settings those deferred controls will carry are recorded in the +delivery record rather than advertised on a screen that cannot honour them. + +When the queue ships, QUEUE lists the messages waiting behind the active +exchange: each row shows where it came from, its text and an `X` whose +accessible name is *Remove message*. Its place in the list is its order, so the +rows carry no numbers and no routine acknowledgement. What you are typing in the +composer is not one of them until you send it. How the queue admits, orders and +holds those messages is the queue contract (#964). + +At `160x36` the first composition gives the columns 36, 66 and 58 cells, dividers +included, the body 33 rows and the Draft four of the centre's. That geometry and +the terminal adaptations it makes of the reference — cell rules for pixel edges, +fixed-cell glyphs, origin colours converted to truecolor, and source wrapped at +measured word boundaries rather than clipped — are under the Product Owner's image +review. Until that review accepts an image they are not a regression target, and +the `120x30` and `72x20` compositions follow it. This is the composition the +running command draws: the Transcript of agent work, the selected work with its +Draft, and the Bindings/Sidekick pane, under one identity row and above one +compact History row and one row of directions. The earlier Sessions/Entries +sidebar and seven-row footer are gone. + Resizing the window is ordinary. Dragging a corner produces a stream of sizes, and each one redraws the screen for the size that is actually there: the execution you are in, the surface you are on, what you had selected and what you @@ -1039,66 +1125,40 @@ outside `[A-Za-z0-9-._~:@]` is percent-encoded, so a location can never contain the quote that would close it. Source text, provider output and an event's own origin are content and are shown as written, URLs included. -The History band is five rows at every size: its own name and the head on the -first, entry captions and which position is being read on the second, the tall -marks of entries on the third, the rule itself on the fourth, and the short -stems of everything else — with the head's own marker — on the fifth. - +History is one compact row at every size: its name, one mark for each entry in +admission order, the head's own state, and the controls that are available now. It is a map of the whole recorded order, which is the one thing on this screen -not read at the selected prefix. The positions after the selected one are -where a reader can go next, so a rail that stopped at the selection would be a -map of the part of the file they had already reached. What crosses is a marker -and a kind per position, and nothing of what any of them holds. +not read at the selected prefix. The positions after the selected one are where +a reader can go next, so a map that stopped at the selection would be a map of +the part of the file they had already reached. What crosses is a marker and a +kind per position, and nothing of what any of them holds: an entry inside the +selected prefix shows the outcome it had reached by then, and a later one shows +only that it exists. + +An entry's mark is a way to go to that entry, not a checkpoint. Every exact +retained position — an entry's and every minor one between them — is still +reached through the History drawer, so a compact row never costs a reader a +position, and `[live]` returns from any of them to the head. The selected +position keeps its own surface and the head its own colour, so the two can sit +side by side. The map and the content are one reading, taken from one acknowledged array and carried together through the frame. Nothing on this screen pairs a prefix with -a rail the file no longer has: a record retained while the terminal is being +a map the file no longer has: a record retained while the terminal is being measured changes the next reading, not half of this one. Where that reading cannot be built at all, because the file holds a record this process can no -longer read, the whole screen is the refusal — not a rail standing beside a +longer read, the whole screen is the refusal — not a map standing beside a prefix that still looks plausible. -Where a mark goes is a proportion of the rail's measured columns: the first at -the start, the last at the end, the rest evenly between, and a lone position -where the head is. It says where something is in the order and nothing about -when it happened. Two positions that land on one column are one *visual* group -and stay two positions — the drawer offers each its own row, so a compact band -never costs a reader an exact position. An entry keeps its tall mark and a -minor position its stem even when they share a column. - -Everything the band reserves is measured. The right-hand caption is as wide as -the widest head label and the selection caption together, identically for every -head state, so a head changing from LIVE to SETTLING moves no mark and re-cuts -no caption; only the count, the selection or the size can do that. Entry -captions start one per entry, centred on its mark, and where two would touch -the leftmost pair becomes the span it actually covers — `Entry 4–7`, never a -number that is not there. A band that cannot fit its title and its head is the -frame refusal that already recovers on resize, never a clip. - -The selection is amber above the rule and at its junction, with its place in -the full order beside it; a live reading says `SELECTED · HEAD` instead and has -no diamond. The head keeps its own colour below the rail's end, so a selection -on that same column keeps its diamond above and the head its marker below. The -rail is read, not operated: nothing on it is a control, and every position is -reached through the drawer. - -The footer is seven rows at every size, in one order: the contextual status and -action row, then the five History rows, then the draft. The draft owns the last of -them, says `Draft:` so the row below a reading is not read as one more line of it, -and shares it with nothing — so what a person is typing stays visible however many -controls a state happens to offer. The drawer layer sits above the footer rather -than across it, so a question and the draft are readable at once. - -The action row holds the controls that are available now, side by side, each as -wide as its own label so a pointer on one reaches that one. Order is priority: a -row too narrow for everything keeps the controls and shortens the sentence beside -them, and a control that will not fit whole is not drawn at all rather than drawn -half off the row. It is not there in any sense — not a cell, not something a -pointer can reach, and not a stop Tab visits — because a control you can neither -see nor hit is worse than one that is honestly absent. The controls it keeps are -the first ones in that order, without exception: where a wide control will not -fit, a narrower one after it is left out too, so the row you read at one size is -a prefix of the row you read at a larger one and nothing moves under your hand. +The contextual controls sit at the right of the History row, side by side, each +as wide as its own label so a pointer on one reaches that one. Order is priority: +a control that will not fit whole is not drawn at all rather than drawn half off +the row. It is not there in any sense — not a cell, not something a pointer can +reach, and not a stop Tab visits — because a control you can neither see nor hit +is worse than one that is honestly absent. The controls it keeps are the first +ones in that order, without exception: where a wide control will not fit, a +narrower one after it is left out too, so the row you read at one size is a +prefix of the row you read at a larger one and nothing moves under your hand. Every row says what it has room to say. A row is as wide as the region the frame measured for it, and so is its text: a name nobody bounded — a prompt, a root, a @@ -1118,17 +1178,16 @@ described as though it had somewhere to be. A refusal of one action — a submission the entry ahead of it has not made room for, a navigation that selects nothing, a pause this process cannot perform — -appears on that row beside the control that was refused, shortened with an -ellipsis when the row is too narrow for all of it. It does not replace the -screen. - -Above whatever surface is showing, one row says how to operate what is in front -of you: where typing goes, what Enter does on the control that has focus, that -Tab and Shift+Tab move between controls, that a pointer does what Enter does, and -how to close a drawer when one is open. It is read off what is mounted rather than -fixed, so it never names an action this screen does not have — and when focus is on -a control rather than a field it says how to get back to the draft, because that -is the one moment a person's keystrokes reach nothing. +appears beside the control that was refused, shortened with an ellipsis when the +row is too narrow for all of it. It does not replace the screen. + +The last row says how to operate what is in front of you: where typing goes, +what Enter does on the control that has focus, that Tab and Shift+Tab move +between controls, that a pointer does what Enter does, and how to close a drawer +when one is open. It is read off what is mounted rather than fixed, so it never +names an action this screen does not have — and when focus is on a control +rather than a field it says how to get back to the draft, because that is the one +moment a person's keystrokes reach nothing. ## What the screen says the execution is doing @@ -1360,6 +1419,13 @@ counts, and the column is wide enough for every reading a badge can hold — including a cleanup wait, which says two things at once — so an element settling cannot reflow the document beside it. +The Draft's value and a queued message are wrapped the same way. A queued +message wraps between its origin and its `X`, and its continuation rows line up +with its text: a wrapped row is the same message, never a new one, and the `X` +stays one control on its first row. The reference this screen follows clips +source and scrolls the Draft sideways; this screen does neither, because a clip +hides somebody's source and a control nobody can see is not one to focus. + A question's own read-only preview is measured the same way, at the width its drawer was given. A drawer row is one row, so a line left unwrapped has nowhere to put its tail and the window has no second row to scroll to — reaching the @@ -1895,8 +1961,8 @@ nothing else: exactly the entries admitted by then, each with the outcome it had reached by then. It fills nothing from the live head: no live output, no waiting question, no pause capability, and it cannot open the question this process is asking. What it does not freeze is the draft, which is the next entry's and -belongs to no position — it stays editable, and `[live]` brings it back to the -head with the current catalog. Choosing a position from before the selected +belongs to no position — it stays editable beneath the selected work, and +`[live]` brings it back to the head with the current catalog. Choosing a position from before the selected entry was admitted clears that entry and the scopes beneath it rather than guessing another, and keeps the draft, the surface, the conversation filter and the position itself. Before the root closes, output this process has produced but the file has @@ -2188,7 +2254,7 @@ The form is the same bounded form an entry's question is asked through — the complete message, the fields in their declared order, an enum's offered values, the conditional required markers, what the last attempt was told was wrong, and the control that submits — shown through one window inside the pane that owns it, -above the unchanged seven-row footer. It covers that pane while it is open, so +above the unchanged rows below the body. It covers that pane while it is open, so Stop comes with it: a question a reply is waiting on can be stopped from in front of it. The way out of the form is its own close, exactly as an entry's drawer is left before anything behind it can be reached. @@ -2553,13 +2619,53 @@ answered. Its safe error retains that earlier answer as partial evidence. A preview, a successful first turn, idle state and a product verdict inside Output are distinct from the final exchange result. -Stop ends the asking and nothing else. It cancels the generation in flight, the -controls of the response being evaluated and the wait for an entry's outcome, -and it stops any turn that would have followed — and it returns only once all of +Follow is about execution, not scrolling: it decides whether a Sidekick Send +goes on after the entry it submitted settles, and has nothing to do with whether +a reading keeps its newest rows in view. **It is not in this build.** The +continuation it governs ships with the shared queue, and until then no screen +offers the choice and nothing watches an entry to start a turn after it. What +follows describes the behaviour that delivery brings, and is the contract it is +held to — not a description of what this command does today. When it ships it is +on when the REPL starts, stays as you set it across rounds and navigation, and +is not saved — the next REPL starts with it on again. Only an entry the Sidekick submitted during Send is a round; +a Draft Send is not, and a round is one admitted entry rather than one provider +turn. The evaluation the Sidekick invokes during Send creates and owns the +continuation, so nothing else watches entries and starts turns. + +When that exact entry settles, and the current exchange and its cleanup have +joined, the continuation gives the Sidekick the entry's actual outcome — +completed, failed or interrupted, each its own — and its actual output, as two +separate facts. The Submit answer is still the receipt described above; the +output comes through the continuation, never through the receipt's wording. With +Follow on, the next eligible queued message goes first and another autonomous +round only after it, under the queue's own rules (#964); no origin is preferred +and no held queue is restarted by it. Turning Follow off ends automatic +continuation once the current round is over and does not interrupt that round. +Fatal storage, disclosure and cleanup failures keep their outcome, feedback +whose acceptance is unknown is never resent, and only explicit evaluated Output +becomes the Sidekick's chat: the work associated with a message — its entry, its +state and its output — is shown beside the conversation as work, not as +something the Sidekick said. + +There are two Stops. The Draft's Stop is Entry Stop: it finishes the running +entry as interrupted, retains that outcome, and joins the entry's cleanup before +the next entry may be admitted. The stopped entry cannot be resumed as that +entry; what it completed stands and what it printed stays partial. Its +interrupted outcome reaches the Sidekick as the round's result, and Follow stays +on. Leaving with Exit is different and keeps its recovery: an entry Exit leaves +running stays unfinished, and reopening the history continues it. + +The Sidekick's Stop ends the asking and nothing else. It is there while a reply +is generating and while the Sidekick's question is waiting, whether or not an +entry is running. It ends the current Send and the continuation that Send would +have made, leaves Follow on for the next Send you make, cancels the generation in +flight, the controls of the response being evaluated and the wait for an entry's +outcome, and it stops any turn that would have followed — and it returns only once all of that has come down, so nobody is told their Sidekick stopped while it is still writing to their history. Everything already done stands: the chat, the drafts, the records, and an entry that was admitted, which belongs to the session and -runs on its own terms. +runs on its own terms — and its later settlement starts nothing for the Send that +was stopped. The boundary is the exact entry's durable admission, and nothing else stands in for it. A passed validation, a taken slot, a live session and an allocated task @@ -2574,8 +2680,10 @@ starts no turn. A cancelled startup leaves nothing behind it — no admission, no terminal, no settlement, and no entry for a later reading to find. The slot it held is free, -because there is no entry for anything to follow; an *entry* that was -interrupted still occupies it. The Submit intent stands as what it is: a +because there is no entry for anything to follow. An admitted entry holds the slot +until it has an outcome and its cleanup has joined: one left unfinished by Exit +or a lost process keeps holding it, and one finished by Entry Stop releases it +once its interrupted outcome is retained and its cleanup has joined. The Submit intent stands as what it is: a reserved request whose result was never acknowledged, and no completion receipt is manufactured for it. The history is re-read before Stop answers, because the cycle was writing to it and its own reading never finished;