Skip to content

Commit b88da5e

Browse files
authored
Navigate recorded History positions with a measured REPL rail (#881, 3/3) (#887)
* 🚧 Give navigation its own snapshot, and measure the rail — layers 1 and 3a **Layer 1.** `ReplHistoryNavigation` is the one reading taken at the whole file rather than at the selected prefix: a marker and a kind per retained position, and the head. No label, no source, no name, no value, no outcome detail — a reader standing at an earlier position learns *that* later positions exist and what kind they are, and nothing about what they hold. Both halves now come from one acknowledged array. `reproject` snapshots it, projects the whole file for the summary and the requested prefix for every other pane, and drops the full model rather than keeping it, so nothing downstream can reach a later entry through it. A later malformed segment refuses the whole reading, including for an earlier marker. The head is derived on read, not cached: pausing, resuming and the last of an entry's resources releasing all change it while the file stands still. Its liveness fact is PR 2's entry-wide signal, which stays true until the outer Agent attachment and the consumers have gone — a body that returned and a root Close that was recorded are both earlier than that. **Layer 3a.** `history-rail.ts` prepares the five rows from engine measurements. The right-hand caption column is reserved once for *every* head label, so a head change re-cuts nothing; the rail is what remains; marks are placed at `round(i × (W−1)/(K−1))` and a lone position goes where the head is. Collisions become one visual group that keeps every member, and each category colours itself from its own members — a group holding a passed entry and an unreached minor position is both, and one colour would lose half of it. Two defects the tests found. The caption merge loop read its bound from the list it was shrinking, so it stopped before the last pair joined. And the band's minimum is its title plus the reserved head column — 49 columns — which three of my own cases were below; they were asking for a refusal and reading it as a bug. `repl-history.test.ts`: 16 steps, every width from a real engine. * 🚧 Draw the band as a measured rail, and give the drawer the whole order The band was a list of checkpoint labels wrapped across five rows. It is now the rail the handoff froze: a title and the head on row 1, entry captions and the selection caption on row 2, tall uprisers on 3, the rule on 4, minor stems and the head's marker on 5. Marks are placed from engine measurements, the right-hand caption column is reserved once for every head label, and layout receives inert text and roles — no Journal, no session, no capability. The History drawer now reads navigation rather than the selected prefix, and says what each position *is* — `12 · Agent turn recorded` — counted from the thin list rather than looked up, because looking it up would mean reading a payload the summary deliberately does not carry. One real defect, caught by the too-small profile: the band was being prepared for a frame that publishes no footer, turning a refusal the product already handles into a measurement failure. The replaced premises, itemized. Nine assertions across five suites, each with the claim it still makes: - `repl-terminal`'s label-grouping case is **retired**, not re-anchored: it asserted that a compact band shares labels and that each marker names the others sharing one, and the band has no labels. Its surviving claim — a compact band never costs a reader an exact position — is now `repl-history`'s "keeps every member", made against the real engine at two widths and checking ordinals as well as markers. It is not re-anchored in place because that suite drives a fixture surface that carries an empty band, so the case would have asserted grouping that nothing grouped. - `UI5` keeps placement and full-width rows and drops reading a label out of the band; `G1` asserted one accent for the whole band and now asserts that every cell is one of the band's own roles. - `UI15` had two premises this slice reverses: a drawer row holds no `·` (they all carry their ordinal now) and a frozen prefix lists fewer positions than the head (it lists the whole order — that *is* the navigation exception). - `repl-agent-journey`'s J3 asserted that an earlier prefix offers no later marker. It does now; what it must not do is show what that marker holds, and the pair is asserted together. - `focusedOn` steps past a row's ordinal, as it already stepped past the focus and selection cues. Frozen commands: 93 tests / 301 steps and 19 / 111, both exit 0; whitespace gate on the endpoint diff exits 0. * 🚧 Prove the head against a real session — layer 2's evidence H3 drives `openReplSession` rather than calling the derivation: empty before an entry exists, live from before its first record, settled once its outcome is retained and nothing of it is still standing. The case that matters is SETTLING. An installation of the entry's own reads the head at the moment it is released — after the root closed and before the entry's work had finished — and finds `settling`. A head taken from the root Close alone would have called that finished, which is the defect the frozen precedence exists to prevent. And the summary carries no payload: a `<Json>` holding `shibboleth` leaves nothing of it in the navigation, whose points have exactly `marker` and `kind`. * 📝 Say what the band shows and what the head means `specs/repl-spec.md` gains the five rows, the measured reservation, the proportional placement, grouping that keeps every position selectable, and the seven head readings with their precedence. `architecture.md` gains the two new modules and the navigation exception: one acknowledged array per wake, two projections of it, the full model discarded from child reachability, and a head that changes while the file stands still so it rides the existing wake. Both in present tense, and both say the thing that is easy to lose: the band is the one reading not taken at the selected prefix, and that exception is for the band and not for capabilities. * ✅ Prove navigation is the whole order while content is one prefix (#881) * ✅ Reach every recorded position, and open an early prefix cold (#881) * ✨ Read navigation and content from one snapshot, and prove the rest (#881) * 🚨 Sort through a copy, so the Node typecheck accepts the History proofs (#881) `tsconfig.node.json` pins `lib` to ES2022 and `toSorted` is ES2023, so the three `toSorted` calls in the History suite failed `tsc` in every `test-node` shard before a single test ran — ten identical shard failures, and the `green` gate with them. `deno check`, `deno task test` and `pnpm lint` all passed, and oxlint's `no-array-sort` actively recommends `toSorted`, so nothing local said otherwise. Sort a copy instead, which keeps what `toSorted` was chosen for: `ordinals` arrives `readonly`, and nothing observes the order of the array sorted here. `Object.keys` already returns a fresh array, so that one sorts in place. The three remaining `no-array-sort` warnings are the lib's cost, and the comment at the first site says so.
1 parent 9fe494d commit b88da5e

20 files changed

Lines changed: 3048 additions & 236 deletions

‎architecture.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5745,6 +5745,8 @@ DurableEvents -> frozen ReplModel -> resolved immutable view
57455745
| `description.ts` | opaque immutable descriptions and the closed action boundary |
57465746
| `reconcile.ts` | reconciling descriptions into one mounted Freedom tree |
57475747
| `handoff.ts` | the acknowledged commit boundary |
5748+
| `navigation.ts` | every retained position and the head, as the one reading taken at the whole file |
5749+
| `history-rail.ts` | the five-row History band, placed from engine measurements |
57485750
| `source-reading.ts` | one entry as output and then source, with what each element is doing |
57495751
| `presentation-text.ts` · `presentation-style.ts` | what the characters of a reading are, and the colours it is drawn in |
57505752
| `fitting.ts` | engine-measured widths, and the rows a reading is cut into |
@@ -5756,6 +5758,46 @@ DurableEvents -> frozen ReplModel -> resolved immutable view
57565758
| `components/` · `application.ts` | the screens, and the one state transition boundary |
57575759
| `storage.ts` · `program.ts` | where histories live, and the command as one scope |
57585760

5761+
### Navigation is the whole file; content is one prefix
5762+
5763+
Every pane reads the selected prefix. The History band and its drawer are the
5764+
explicit exception, and the only one: they read a thin summary of the *whole*
5765+
validated file — a marker and a kind per retained position, plus the head —
5766+
because the positions after the selected one are where a reader can go next.
5767+
No label, source, name, value, outcome detail or observation is in it, so
5768+
nothing a later position holds reaches a reading of an earlier one.
5769+
5770+
Both halves come from one acknowledged event array per wake: the full file for
5771+
the summary, the requested marker for everything else, through the projector's
5772+
existing whole-file validation. The full model is discarded from child
5773+
reachability rather than kept, so nothing downstream can walk to a later entry
5774+
through it, and navigation is never assembled by appending to a selected
5775+
model. A later malformed segment refuses the whole reading even for an earlier
5776+
marker — the alternative is stale navigation standing beside a prefix that
5777+
still looks plausible.
5778+
5779+
The session hands both halves over together, as one reading, and the root
5780+
carries that one value through resolution, paint and resize rather than asking
5781+
again for either half. It has to: the root reads the terminal's size between
5782+
resolving a frame and composing it, that read suspends, and a record can be
5783+
retained while it does. Content taken before it and navigation taken after it
5784+
would describe two different files on one screen. A resize recomposes the
5785+
reading that was resolved at a new size, never at a new snapshot.
5786+
5787+
When that reading cannot be built at all — a record this process wrote and can
5788+
no longer read — the root draws the refused view, the same screen a cold open
5789+
of the same file shows. That is a different outcome from a route that does not
5790+
resolve at its position: there, a reading still stands and the reason is said
5791+
beside it. A refusal has nothing to stand beside, so nothing is left on the
5792+
screen pretending to be current.
5793+
5794+
The head is derived on read from retained facts and from work this session
5795+
owns, never from a body's return, a recorded root Close, a component badge or
5796+
the selected content. It changes while the file stands still, so it publishes
5797+
through the wake the session and the controller already send; History adds no
5798+
stream, timer or clock. The inspected action row keeps its historical
5799+
restrictions: navigation is the exception for the band, not for capabilities.
5800+
57595801
### Sequential entries over one physical journal
57605802

57615803
One execution is one append-only stream, and entries are ranges of it. A segment

‎packages/cli/src/repl/application.ts‎

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,6 @@ import {
4747
drawerLayerProps,
4848
drawerRect,
4949
footerProps,
50-
historyBand,
51-
HISTORY_ROWS,
5250
inspectionWidth,
5351
paneColumnProps,
5452
paneContentProps,
@@ -82,6 +80,10 @@ import type { ReplAdmission, ReplWindow } from "./layout-admission.ts";
8280
import { ORDINARY, REPL_PALETTE, styleOf } from "./presentation-style.ts";
8381
import type { ReplPresentationRole, ReplRowStyle } from "./presentation-style.ts";
8482
import { jsonRuns, sourceRuns } from "./presentation-text.ts";
83+
import { NO_NAVIGATION, numbered, pointLabel } from "./navigation.ts";
84+
import type { ReplHistoryNavigation } from "./navigation.ts";
85+
import { blankBand, EMPTY_BAND, HISTORY_ROWS } from "./history-rail.ts";
86+
import type { ReplPreparedRail } from "./history-rail.ts";
8587
import { entryReading, NO_READING } from "./source-reading.ts";
8688
import type { ReplReading } from "./source-reading.ts";
8789
import { readingRuns } from "./fitting.ts";
@@ -532,6 +534,16 @@ export interface ReplTransition {
532534
export interface ReplView {
533535
readonly state: ReplState;
534536
readonly model: ReplModel;
537+
/**
538+
* Every position the history retained, and what the head is doing.
539+
*
540+
* The explicit exception to this view being a reading of one prefix: the
541+
* band is a map of the whole recorded order, because the positions after
542+
* the selected one are where a reader can go next. It is a marker and a
543+
* kind each — nothing a later position holds crosses — and it reaches the
544+
* band and the History drawer only. Every other pane gets the prefix.
545+
*/
546+
readonly navigation: ReplHistoryNavigation;
535547
readonly selection: ReplSelection;
536548
readonly live: ReplLive;
537549
/** The canonical location this view is at. */
@@ -758,6 +770,7 @@ export function viewFor(
758770
live: ReplLive,
759771
size: ReplTerminalSize,
760772
focused?: string,
773+
navigation: ReplHistoryNavigation = NO_NAVIGATION,
761774
): Result<ReplView> {
762775
// This process is the only thing that can say a question is waiting, so it
763776
// says so here rather than leaving resolution to infer it from a history that
@@ -790,6 +803,7 @@ export function viewFor(
790803
Object.freeze({
791804
state,
792805
model,
806+
navigation,
793807
selection: resolved.value,
794808
live: shown,
795809
location: encodeLocation(state.route),
@@ -812,6 +826,9 @@ export function refusedView(
812826
return Object.freeze({
813827
state,
814828
model: EMPTY_MODEL,
829+
// A refusal navigates nowhere: there is no validated reading to draw a
830+
// rail from, and a stale one beside a refusal would be the worst of both.
831+
navigation: NO_NAVIGATION,
815832
selection: Object.freeze({
816833
route: state.route,
817834
surface: state.route.surface,
@@ -2790,6 +2807,20 @@ export function readingOf(view: ReplView): ReplReading {
27902807
});
27912808
}
27922809

2810+
/**
2811+
* The band's five rows, or five empty ones where none was prepared.
2812+
*
2813+
* The measuring pass has no rail — it is the pass that answers how wide the
2814+
* band is — and an unmeasured frame describes no band content, exactly as it
2815+
* describes no windowed content.
2816+
*/
2817+
function bandRowsOf(
2818+
rail: ReplPreparedRail | undefined,
2819+
columns: number,
2820+
): readonly (readonly ReplTokenRun[])[] {
2821+
return rail === undefined ? blankBand(columns) : rail.rows;
2822+
}
2823+
27932824
/**
27942825
* The transcript rows the selected locus holds.
27952826
*
@@ -3383,14 +3414,20 @@ function drawerContent(
33833414
// not described, so it is not a cell, not a target and not a Tab stop —
33843415
// which is what makes reaching the last one a thing the controls do rather
33853416
// than something clipping hides.
3386-
for (const checkpoint of view.model.checkpoints) {
3417+
// The whole recorded order, not the selected prefix: the positions after
3418+
// the one being read are where a reader can go next, and a drawer that
3419+
// stopped at the selection would be a list of where they had already
3420+
// been. Every position gets its own row — a group is how the rail *draws*
3421+
// two of them, never how it counts them — and each says what kind it is
3422+
// without reading anything the position holds.
3423+
for (const one of numbered(view.navigation)) {
33873424
content.push({
33883425
described: row(
3389-
`drawer:marker:${checkpoint.marker}`,
3390-
padControl(checkpoint.label, width),
3391-
{ select: "marker", marker: checkpoint.marker },
3426+
`drawer:marker:${one.point.marker}`,
3427+
padControl(pointLabel(one.point, one.ordinal, one.entry), width),
3428+
{ select: "marker", marker: one.point.marker },
33923429
styleOf("history", {
3393-
selected: view.state.route.at === checkpoint.marker,
3430+
selected: view.state.route.at === one.point.marker,
33943431
inspected: true,
33953432
}),
33963433
{ here: view.focused },
@@ -4126,6 +4163,14 @@ export interface ReplPresentationContext {
41264163
* prepared rows; it cannot recover the rest of an unprepared one.
41274164
*/
41284165
readonly preview: readonly ReplFittedLine[] | undefined;
4166+
/**
4167+
* The History band this frame measured, or none before it has measured one.
4168+
*
4169+
* Prepared beside the reading: where a mark lands is a proportion of the
4170+
* rail's measured columns, so the pass that asks how wide the band is
4171+
* cannot already have placed anything on it.
4172+
*/
4173+
readonly rail: ReplPreparedRail | undefined;
41294174
}
41304175

41314176
/** One reading's descriptions, paired with where the engine places them. */
@@ -4451,13 +4496,10 @@ export function presentationFor(
44514496
}
44524497
const size = view.size;
44534498
const profile = profileFor(size);
4454-
const band = historyBand(
4455-
view.model.checkpoints.map((checkpoint) => ({
4456-
marker: checkpoint.marker,
4457-
label: checkpoint.label,
4458-
})),
4459-
size.columns,
4460-
);
4499+
// The band the frame measured. The pass that asks how wide it is has none,
4500+
// and describes none: a rail placed against a width still being asked for
4501+
// would be a rail placed on nothing.
4502+
const rail = context.rail;
44614503
if (profile === "too-small") {
44624504
// No cell beyond the sentence, so nothing this frame hides can be pointed
44634505
// at: a control that is not in the frame is not in its target map either.
@@ -4487,7 +4529,7 @@ export function presentationFor(
44874529
actions: undefined,
44884530
regions: Object.freeze([Object.freeze({ region: "refusal", id: "box:refusal" })]),
44894531
contents: Object.freeze([]),
4490-
history: band,
4532+
history: rail ?? EMPTY_BAND,
44914533
}),
44924534
};
44934535
}
@@ -4790,14 +4832,16 @@ export function presentationFor(
47904832
id: "box:footer:band",
47914833
region: "footer",
47924834
props: bandProps(),
4793-
children: band.rows.map((text, at) =>
4835+
children: bandRowsOf(rail, size.columns).map((row, at) =>
47944836
box({
47954837
id: `box:band:${at}`,
47964838
region: "footer",
47974839
props: rowProps(size.columns),
4798-
text,
4840+
text: runText(row),
4841+
runs: row,
47994842
// The one part of this screen that is not a mounted row, so
48004843
// its style comes from the band rather than from a candidate.
4844+
// Each cell carries its own role; this is the row's base.
48014845
style: styleOf("history", {
48024846
inspected: view.state.route.at !== undefined,
48034847
}),
@@ -4822,7 +4866,7 @@ export function presentationFor(
48224866
}),
48234867
regions: Object.freeze(regions.map((region) => Object.freeze(region))),
48244868
contents: Object.freeze(contents.map((content) => Object.freeze(content))),
4825-
history: band,
4869+
history: rail ?? EMPTY_BAND,
48264870
}),
48274871
};
48284872
}

0 commit comments

Comments
 (0)