docs: Stage 4 revision 2 — three answers, and the contract revision 1 forgot

P1 — THE PROTOCOL-BUMP GATE WEAKENED THE SWEEP IT MEANT TO STRENGTHEN.
Revision 1 said `--tests --no-fail-fast` REPLACES the `--workspace`
line. Measured: `--tests` selects 108 targets, `--workspace` selects
110, and the two it drops are `pmacs_protocol` and `pmacs_gpu`. On a
PROTOCOL bump, dropping the protocol crate's tests is the wrong loss —
and it also silently dropped `--skip basedpyright`.

Worse, this lane's own remediation sweep used `--tests`, so it never
ran pmacs-protocol's 25 tests, including the `scroll::classify` tests
this lane had just written. They pass (verified 25/0), but by luck. A
correction that reproduces the shape of the mistake it corrects is
worth naming, so §3 and §5 both say `--workspace`, additive, with
`-- --skip basedpyright` retained in both feature configurations.

P1 — NO COORDINATE CONTRACT FOR view_left. Revision 1 decided what
moves the viewport and never said what its offset IS — the same
omission as shipping WrapMode with no DisplayCoord. Its verification
sketch named tabs and wide characters with no oracle for either,
because nothing defined what a left edge is.

Q#HS7 is new and BLOCKING, in four coupled parts: the unit; which
columns may be a left edge; the snap rule for an invalid one; and the
invariant rendering and coordinate mapping share. Votes recorded —
display column (tab stops come free, since the walk must start at
column 0 either way and a byte offset buys nothing); a left edge may
not fall inside a wide glyph; snap toward the line start (snapping left
can only reveal a character, snapping right can hide the one the user
scrolled to reach); and snap when the value is SET, not in the painter,
so one canonical value serves both readers.

Part (d) is why it blocks: if the painter clips where the mapper does
not, clicks land on the wrong character — silently, and only on lines
wide enough to scroll.

P2 — THE CAVEAT IS NOT IN THE SETTING DESCRIPTION. Revision 1 said it
was. builtin/runtime/linewrap.lua:23 says only "truncate at the edge";
"unreachable" lives in the toggle's status message and a source
comment, neither of which a user sees who sets the mode in init.lua.
That is a real, small user-facing gap shipped in #221. Claim corrected,
and amending the description is now a Stage 4 deliverable (§6) with the
text depending on which stage has landed.

THE THREE ANSWERS, recorded with the reasoning that decided them:

  HS1 — GPU is Stage 5. The distinction that matters is that Stage 3's
  defect was never "the frontends differ" but "the frontends differ and
  nobody chose that". Time box made concrete per the request: Stage 5
  is the immediately-next QoL lane, `wrap` stays default until it
  lands, release notes state the asymmetry, and the truncate
  affordances name the GUI gap meanwhile.

  HS2 — automatic only. No command surface; the cursor-visibility pass
  gains a horizontal component.

  HS6 — `wrap` stays default, and the reason given is stronger than the
  one revision 1 reasoned from. I had framed it as "if scroll makes
  truncate good, reconsider the default". With the GPU deferred, a
  truncate default would ship a mode navigable in the TUI and a dead
  end in the GUI for every user who never opened the setting. HS1 and
  HS6 are coupled: the split is only safe because the default does not
  move.

Q#HS4 is deferred rather than closed — not live under automatic-only,
but the snap-back hazard is real and rediscovering it costs more than
carrying the paragraph. Q#HS5 stands, with the caveat that §1.4 cites
the struct shape and not serde's behavior on a missing field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-07 21:01:46 +02:00
parent b110684a62
commit 5d041a4f17
No known key found for this signature in database
3 changed files with 311 additions and 163 deletions

View File

@ -449,9 +449,50 @@ tip — the ref, not a SHA, since any edit to this block advances past
whatever SHA it records. Recover: whatever SHA it records. Recover:
`git fetch githubsucks && git checkout horizontal-scroll`. `git fetch githubsucks && git checkout horizontal-scroll`.
**Status: framing, not approved.** `docs/horizontal-scroll-framing.md` **Status: framing, NOT approved.** `docs/horizontal-scroll-framing.md`
revision 1. No implementation may begin until its questions are revision 2. No implementation may begin.
answered.
**Answered by the user 2026-08-07:**
- **Q#HS1 — the GPU is Stage 5, not Stage 4.** A conscious, bounded
divergence rather than a repeat of Stage 3's accidental one. The time
box is concrete: Stage 5 is the *immediately-next* QoL lane after
Stage 4 merges, `wrap` stays the default until it lands, Stage 4's
release notes state the asymmetry, and the `truncate` affordances
name the GUI gap while it exists.
- **Q#HS2 — automatic only.** The cursor-visibility pass gains a
horizontal component; no commands, no bindings, no new interaction
island. Explicit `ui.scroll-*` is deliberately out.
- **Q#HS6 — `wrap` stays the default.** Coupled to Q#HS1: with the GPU
deferred, a `truncate` default would ship a mode that is navigable in
the TUI and a **dead end in the GUI** for anyone who never opened the
setting. Revisit after Stage 5, on use evidence.
**Still blocking:**
- **Q#HS7 (NEW) — what IS `view_left`?** Revision 1 decided what moves
the viewport without saying what its offset *is* — the same omission
as shipping `WrapMode` with no `DisplayCoord`. Four coupled parts:
the unit; which columns may be a left edge; the snap rule for an
invalid one; and the invariant rendering and coordinate mapping
share. Without the last, **a painter that clips where the mapper does
not puts clicks on the wrong character**, silently, only on lines
wide enough to scroll. §4's sketch has no oracle until this is
answered.
- **Q#HS5 — does `view_left` survive a restart?** Vote: yes, defaulted,
no `DESKTOP_VERSION` bump — *after* confirming `SavedLeaf`'s
deserializer tolerates a missing field, which is a claim about serde
and not about the struct shape §1.4 cites.
- **Q#HS3** is re-confirmed rather than open (per-window, per Q#LL2);
**Q#HS4** is deferred, live only if explicit commands arrive.
**One correction carried into revision 2.** Revision 1 claimed the
"unreachable past the edge" caveat is recorded in the setting's
description. It is not — `builtin/runtime/linewrap.lua:23` says only
"truncate at the edge"; the word appears in the toggle's status message
and a source comment, neither of which a user sees if they set the mode
in `init.lua`. **A real, small user-facing gap shipped in #221**;
amending the description is now a Stage 4 deliverable (framing §6).
### What Stage 3 shipped that Stage 4 must live with ### What Stage 3 shipped that Stage 4 must live with
@ -512,10 +553,21 @@ answered.
Stage 3 put eight broken version assertions on CI by running Stage 3 put eight broken version assertions on CI by running
`CLAUDE.md`'s short gate list instead of `docs/agent-handoff.md` §3's, `CLAUDE.md`'s short gate list instead of `docs/agent-handoff.md` §3's,
which includes a full sweep. **§3 is the authority.** If Stage 4 touches which includes a full sweep. **§3 is the authority.**
`PROTOCOL_VERSION` — an open question, since the GPU may need a wire —
the sweep must be `cargo test --tests --no-fail-fast` in **both** **Stage 4 should not need a protocol bump at all** — Q#HS1 puts the GPU
feature configurations. See §3 and §5's protocol-bump bullet. in Stage 5, and `view_left` is per-window TUI state with no wire. If
that changes, §3's protocol-bump form is
`cargo test --workspace --no-fail-fast -- --skip basedpyright` in
**both** feature configurations.
**`--workspace`, not `--tests`**, and the distinction is not cosmetic:
`--tests` selects 108 targets where `--workspace` selects 110, and the
two it drops are **`pmacs_protocol` and `pmacs_gpu`**. Stage 3's own
*remediation* sweep used `--tests`, so it never ran `pmacs-protocol`'s
25 tests — including the `scroll::classify` tests that lane had just
written. They passed, but by luck, and a correction that reproduces the
shape of its own mistake is worth naming.
### Not in scope ### Not in scope

View File

@ -467,11 +467,14 @@ someone forgot.
why this bullet exists rather than just a pointer. Plain why this bullet exists rather than just a pointer. Plain
`--workspace` stops at the first failing target and builds one `--workspace` stops at the first failing target and builds one
feature configuration, so it would have shown one or two of the feature configuration, so it would have shown one or two of the
eight. **On any protocol bump run `cargo test --tests --no-fail-fast` eight. §3 now carries the strengthened form: **`--workspace
in BOTH configurations** — `--no-fail-fast` for the stop-at-first --no-fail-fast -- --skip basedpyright` in BOTH configurations.**
behavior, `--features crdt` because three of the eight were
crdt-gated real-daemon tests asserting on a live socket's negotiated **Note `--workspace`, not `--tests`** — the lane's own remediation
version, which is the blindness the bullet below already names. sweep used `--tests`, which silently drops the `pmacs_protocol` and
`pmacs_gpu` targets. On a *protocol* bump that omits the protocol
crate's tests, which is how a correction can reproduce the shape of
the mistake it is correcting.
**Sort the failures before fixing them.** A *tripwire* **Sort the failures before fixing them.** A *tripwire*
(`assert_eq!(PROTOCOL_VERSION, N)`) is meant to fire and takes a (`assert_eq!(PROTOCOL_VERSION, N)`) is meant to fire and takes a
@ -2124,17 +2127,33 @@ this one continues. Long-lines Stage 3 followed the short form and put
eight broken version assertions on CI. When the two disagree, **this eight broken version assertions on CI. When the two disagree, **this
list wins**. list wins**.
**Touching `PROTOCOL_VERSION` replaces the sweep line with:** **Touching `PROTOCOL_VERSION` STRENGTHENS the sweep line. It does not
replace it:**
``` ```
cargo test --tests --no-fail-fast cargo test --workspace --no-fail-fast -- --skip basedpyright
cargo test --tests --features crdt --no-fail-fast cargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright
``` ```
Plain `--workspace` stops at the first failing target and builds one Every part is load-bearing:
feature configuration, so on a version bump it reports one or two of
what may be many. See §5's protocol-bump bullet for how to sort the - **`--workspace`, never `--tests`.** `--tests` selects 108 targets
failures it finds — some are tripwires doing their job. where `--workspace` selects 110, and the two it drops are
**`pmacs_protocol` and `pmacs_gpu`**. On a protocol bump, dropping
the protocol crate's own unit tests is precisely the wrong loss.
Long-lines Stage 3 swept with `--tests` and so never ran
`pmacs-protocol`'s 25 tests — including the `scroll::classify` tests
that same lane had just written. They passed, but by luck.
- **`--no-fail-fast`**, because cargo stops at the first failing
target: without it a bump that breaks eight assertions reports one.
- **`--features crdt`**, because crdt-gated real-daemon tests assert on
a live socket's negotiated version and are invisible otherwise.
- **`-- --skip basedpyright`** for the same reason the line above
carries it.
See §5's protocol-bump bullet for how to sort the failures this finds —
some are tripwires doing their job, and one pin
(`ADVERTISED_PROTOCOL_VERSION`) must never be edited at all.
Machine-specific caveats — re-verify on a machine you haven't used Machine-specific caveats — re-verify on a machine you haven't used
before trusting them: before trusting them:

View File

@ -1,7 +1,8 @@
# Horizontal scroll — QoL Stage 4 # Horizontal scroll — QoL Stage 4
**Status: revision 1 — NOT APPROVED. Six questions open (Q#HS1HS6). **Status: revision 2 — NOT APPROVED. Q#HS1, HS2 and HS6 answered by the
No implementation may begin.** user (2026-08-07); Q#HS3HS5 stand; Q#HS7 is NEW and BLOCKING. No
implementation may begin.**
This closes the QoL arc opened by one daily-driver report. Stage 1 This closes the QoL arc opened by one daily-driver report. Stage 1
(#219) made the TUI survive terminal zoom; Stage 2 (#220) gave the GUI (#219) made the TUI survive terminal zoom; Stage 2 (#220) gave the GUI
@ -12,11 +13,20 @@ default. Stage 4 is the other half of the user's own sentence:
> should also be something that the user can configure, whether to wrap > should also be something that the user can configure, whether to wrap
> or scrollable. > or scrollable.
Stage 3 shipped the *mode*. It did not ship the *navigation*, and said Stage 3 shipped the *mode*. It did not ship the *navigation*: under
so: under `truncate`, text past the right edge is not merely off-screen `truncate`, text past the right edge is not merely off-screen but
but **unreachable**. That is recorded in the setting's description and **unreachable**.
in `ui.toggle-line-wrap`'s status message. Stage 4 removes that caveat
or the caveat stands permanently. **Revision 1 claimed that caveat "is recorded in the setting's
description". It is not.** `builtin/runtime/linewrap.lua:23` says only
*"How a line wider than the window is shown: wrap onto following rows,
or truncate at the edge."* The word "unreachable" appears in
`ui.toggle-line-wrap`'s status message and in a source comment — neither
of which a user sees if they set `ui.line-wrap = "truncate"` in
`init.lua` and never invoke the toggle. **That is a real, if small,
user-facing gap shipped in #221**, and §6 makes amending the
description a Stage 4 deliverable rather than leaving the false claim
standing.
--- ---
@ -29,28 +39,26 @@ same `CellGrid`; every claim below carries a citation for that reason.
### 1.1 There is no horizontal scroll anywhere ### 1.1 There is no horizontal scroll anywhere
No `view_left`, `scroll_left`, or `hscroll` in `src/` or `builtin/`. No `view_left`, `scroll_left`, or `hscroll` in `src/` or `builtin/`.
This is greenfield. The window carries `view_top`, `cursor`, and The window carries `view_top`, `cursor`, and `goal_col`
`goal_col` (`src/window.rs:374-376`) and nothing horizontal. (`src/window.rs:374-376`) and nothing horizontal. This is greenfield —
not "extend the vertical mechanism sideways", because there is no
That matters for estimating: this is not "extend the vertical shared abstraction to extend.
mechanism sideways". There is no shared abstraction to extend.
### 1.2 The grid walk starts every line at column 0 ### 1.2 The grid walk starts every line at column 0
`paint_line` (`src/text_view.rs:266`) walks from the line's first `paint_line` (`src/text_view.rs:266`) walks from the line's first
character with no offset parameter, exactly as it did before Stage 3 — character with no offset parameter, exactly as before Stage 3 —
wrapping changed *where rows break*, not *where the walk starts*. wrapping changed *where rows break*, not *where the walk starts*.
`place_of_byte` and `byte_at_place` have the same shape. `place_of_byte` and `byte_at_place` have the same shape.
So `view_left` enters the same functions Stage 3 just rewrote. **The So `view_left` enters the same functions Stage 3 just rewrote, and
wrap rule must stay written exactly once** (`advance_wrapped`, **the wrap rule must stay written exactly once** (`advance_wrapped`,
`src/text_view.rs:396`); a second copy differing by an offset is the `src/text_view.rs:396`). A second copy differing by an offset is the
defect Stage 3 spent its review budget avoiding. defect Stage 3 spent its review budget avoiding.
### 1.3 The GPU cannot use cosmic-text's horizontal scroll ### 1.3 The GPU cannot use cosmic-text's horizontal scroll
**This is the finding most likely to invert the cost estimate, and it **The finding most likely to invert the cost estimate, so it leads.**
is the Stage 4 analog of revision 1's error — so it leads.**
`Scroll::horizontal` is discarded throughout the GPU, and not by `Scroll::horizontal` is discarded throughout the GPU, and not by
oversight: **glyphon 0.11 never applies it when placing glyphs.** oversight: **glyphon 0.11 never applies it when placing glyphs.**
@ -58,191 +66,260 @@ Documented at `pmacs-gpu/src/main.rs:1611`, `:6316`, `:8020`, and
*asserted* by tests at `:16266` (`"horizontal is discarded"`), `:16337`, *asserted* by tests at `:16266` (`"horizontal is discarded"`), `:16337`,
`:16737`. `:16737`.
So the GPU's half of Stage 4 cannot be "set the scroll and reshape". It The GPU's half therefore cannot be "set the scroll and reshape". It
needs a different mechanism — a shifted text origin at paint time, needs a mechanism that does not exist — a shifted text origin at paint
adjusted clip bounds, or something else — and that mechanism has to time, adjusted clip bounds, or something else — interacting correctly
interact correctly with the gutter, the caret (`code_byte_px`), with the gutter, the caret (`code_byte_px`), decoration geometry
decoration geometry (`push_glyph_extent_rects`), and hit testing (`push_glyph_extent_rects`), and hit testing (`gutter_aware_rel_x`),
(`gutter_aware_rel_x`), each of which currently assumes x starts at each of which assumes x starts at `text_left()`.
`text_left()`.
**Q#HS1 asks whether the GPU is in scope for Stage 4 at all.** **Answered in Q#HS1: the GPU is Stage 5.**
### 1.4 `view_top` is persisted; a `view_left` would want to be ### 1.4 `view_top` is persisted; a `view_left` would want to be
`SavedLeaf` carries `path`, `cursor`, and `view_top` at `SavedLeaf` carries `path`, `cursor`, and `view_top` at
`DESKTOP_VERSION = 1` (`src/desktop.rs:33`, `:276-280`). The restore `DESKTOP_VERSION = 1` (`src/desktop.rs:33`, `:276-280`); the restore
path clamps `view_top` against the line count (`:512`). path clamps `view_top` against the line count (`:512`). **Q#HS5.**
A horizontal offset that does not survive restart is defensible; one
that does needs a defaulted field or a version bump. **Q#HS5.**
### 1.5 The cursor-follow hazard is already documented ### 1.5 The cursor-follow hazard is already documented
`scroll_window` (`src/editor.rs:3628`) carries the cursor with a `scroll_window` (`src/editor.rs:3628`) carries the cursor with a
vertical scroll, and its comment says exactly why: vertical scroll, and its comment says why:
> The cursor must follow the scroll: the renderer has an "auto-scroll > The cursor must follow the scroll: the renderer has an "auto-scroll
> to keep cursor visible" pass that would otherwise snap `view_top` > to keep cursor visible" pass that would otherwise snap `view_top`
> straight back to wherever the cursor sits, so the user's mouse-wheel > straight back to wherever the cursor sits, so the user's mouse-wheel
> scroll would feel stuck after one notch. > scroll would feel stuck after one notch.
A horizontal analog hits the identical problem. Stage 3's Q#LL3 Under Q#HS2's answer (automatic-only) this pass is not a hazard but
deferred the choice here deliberately: **does explicit horizontal **the entire mechanism** — Stage 4 adds its horizontal component. The
scroll drag the cursor, or does the next motion snap back?** That is hazard returns with explicit commands, which is why Q#HS4 is deferred
**Q#HS4**. rather than closed.
### 1.6 `goal_col` exists and its relationship to `view_left` is unexamined ### 1.6 `goal_col` exists and its relationship to `view_left` is unexamined
`goal_col` (`src/window.rs:376`) remembers a target column across `goal_col` (`src/window.rs:376`) remembers a target column across
vertical motion and is cleared at seven sites in `src/editor.rs`. It is vertical motion, cleared at seven sites in `src/editor.rs`. It is a
a *column within the line*, not a viewport offset — but both are *column within the line*; `view_left` is a *viewport offset*. Both are
"horizontal position" state on the same window, and a design that horizontal state on the same window, and a design ignoring the
ignores the interaction will produce a cursor that jumps on the first interaction produces a cursor that jumps on the first vertical motion
vertical motion after a horizontal scroll. Called out so it is designed after a horizontal scroll. Feeds **Q#HS7**.
rather than discovered.
--- ---
## 2. The scope question, stated before the answers ## 2. The scope fact, stated before the answers
Stage 3 ended with `wrap` as the default. **Under `wrap`, horizontal **Under `wrap`, horizontal scroll is meaningless** — nothing sits past
scroll is meaningless** — there is nothing past the right edge. So the right edge. So Stage 4's entire surface is conditional on a
Stage 4's entire surface is conditional on a buffer-local mode. buffer-local mode: one user-facing question ("how do I see the rest of
this line?") gets two disjoint answers depending on a setting. Stated
That is a coherence fact, not only an implementation one: one here because `COHERENCE.md` §20 requires it, and answered in Q#HS6.
user-facing concept ("how do I see the rest of this line?") now has two
disjoint answers depending on a setting, and the commands, key
bindings, and status affordances for the `truncate` half do not exist
under `wrap`. `COHERENCE.md` §20 requires this be stated. **Q#HS6.**
--- ---
## 3. Open questions ## 3. Questions
### Q#HS1 — is the GPU in scope for Stage 4? ### Q#HS1 — is the GPU in scope? **ANSWERED: no — Stage 5**
The strongest argument for **yes**: Stage 3's entire thesis was that > **Answered 2026-08-07 (user):** split the GPU work into Stage 5.
the two frontends should stop disagreeing by accident. Shipping > *"This is a conscious, bounded divergence, not a repeat of Stage 3's
horizontal scroll in the TUI only would recreate exactly the divergence > accidental one. Make the time box concrete and keep `wrap` default
`ui.line-wrap` was built to close — a `truncate` buffer would be > until parity lands."*
navigable in one frontend and not the other.
The strongest argument for **no, name it Stage 5**: §1.3. The GPU needs The distinction is the load-bearing part. Stage 3's defect was never
a mechanism that does not exist yet, touching caret placement, "the frontends differ" — it was "the frontends differ and **nobody
decoration geometry, and hit testing. That is plausibly larger than the chose that**". A divergence that is decided, recorded, and bounded is a
TUI half, and bundling them makes one reviewable change into two different object from one inherited from a library default.
unreviewable ones.
**My vote: split it, and say so in the setting's description.** Ship **The time box, concrete** (the user's requirement, and the part that
the TUI half as Stage 4 and the GPU half as Stage 5, with the makes this a decision rather than a deferral):
divergence *documented and time-boxed* rather than accidental — which
is the distinction Stage 3 actually drew. Stage 3's defect was never
"the frontends differ"; it was "the frontends differ and nobody chose
that". But this is a product call about shipping a known asymmetry, and
it is not mine to make.
### Q#HS2 — what moves the viewport? 1. **Stage 5 is the immediately-next QoL lane after Stage 4 merges**
not backlogged behind another arc. If something displaces it, that
displacement is itself a decision to record here.
2. **`wrap` stays the default until Stage 5 lands** (independently
reaffirmed in Q#HS6). This is what keeps the divergence invisible to
anyone who has not opted in: a default-configuration user is never
exposed to it.
3. **Stage 4's release notes must state the asymmetry** — horizontal
scroll works in the TUI and not yet the GUI — in the same way #221's
had to state the word-wrap loss.
4. **While the gap exists, the `truncate` affordances must name it.**
§6's description amendment is where that lands, so a GUI user
choosing `truncate` learns the limitation from the setting rather
than from the behavior.
Options, not mutually exclusive: ### Q#HS2 — what moves the viewport? **ANSWERED: automatic only**
- **Automatic only** — the cursor-visibility pass gains a horizontal > **Answered 2026-08-07 (user):** *"Automatic-only first. It makes the
component, so moving the cursor past the edge scrolls the view. No > report's text reachable with no new command surface."*
new commands, no new bindings. Smallest surface; makes a long line
readable by arrowing along it.
- **Explicit commands**`ui.scroll-left` / `ui.scroll-right`, bound
or not, plus the `goal_col` and cursor-follow questions.
- **Both**, which is what every editor with this feature ships.
**My vote: automatic first, as its own stage-within-a-stage.** It is The cursor-visibility pass gains a horizontal component, so moving the
the smallest change that makes the reported text *reachable*, and it cursor past the edge scrolls the view. No new commands, no binding
needs no binding decisions. Explicit commands can follow with the decisions, no new interaction island.
evidence of use.
### Q#HS3 — per window or per buffer? Explicit `ui.scroll-left` / `ui.scroll-right` are **not** in Stage 4.
They can follow with evidence of use, and they are what re-opens Q#HS4.
### Q#HS3 — per window or per buffer? **NOT ACTUALLY OPEN**
`view_left` is **per window**, unambiguously: two panes on one buffer `view_left` is **per window**, unambiguously: two panes on one buffer
must scroll independently, exactly as they already hold independent must scroll independently, exactly as they already hold independent
`view_top`s (`src/desktop.rs:92`). Stage 3's Q#LL2 already recorded `view_top`s (`src/desktop.rs:92`). Stage 3's Q#LL2 recorded this and
this and accepted the consequence — the *mode* is buffer-local while accepted the consequence — the *mode* is buffer-local while the
the *offset* is per-window, so one user-facing concept spans two *offset* is per-window, so one user-facing concept spans two scopes.
scopes.
**This is not really open**; it is listed so the accepted split is Listed so the accepted split is re-confirmed where it takes effect
re-confirmed at the moment it takes effect rather than inherited rather than inherited silently.
silently.
### Q#HS4 — does the cursor follow an explicit scroll? ### Q#HS4 — does the cursor follow an explicit scroll? **DEFERRED, not answered**
Only live if Q#HS2 includes explicit commands. §1.5 has the precedent Not live under Q#HS2's answer: automatic-only means every viewport move
and the hazard. **My vote: follow, matching `scroll_window`** — the already originates from a cursor move. §1.5 holds the precedent and the
existing snap-back pass makes the alternative feel broken, and the hazard for whenever explicit commands arrive.
vertical behavior is already the answer users have been trained on
*in this editor*.
### Q#HS5 — does `view_left` survive a restart? Deferring rather than deleting, because the hazard is real and
rediscovering it costs more than carrying the paragraph.
### Q#HS5 — does `view_left` survive a restart? **OPEN**
`view_top` does (§1.4). Consistency argues yes; a defaulted field `view_top` does (§1.4). Consistency argues yes; a defaulted field
avoids a `DESKTOP_VERSION` bump. avoids a `DESKTOP_VERSION` bump.
**My vote: yes, defaulted, no version bump** — but confirm that **My vote: yes, defaulted, no version bump** — but **confirm
`SavedLeaf`'s deserializer tolerates a missing field before relying on `SavedLeaf`'s deserializer tolerates a missing field before relying on
it, because §1.4 is a citation of the *shape*, not of serde's it.** §1.4 cites the struct's *shape*, not serde's behavior on it, and
behavior on it. that gap is exactly the kind §1.3 exists to warn about.
### Q#HS6 — what does the coherence statement say? ### Q#HS6 — the coherence statement **ANSWERED: keep `wrap` default**
Per `COHERENCE.md` §20 this framing must state its coherence impact. > **Answered 2026-08-07 (user):** *"Keep `wrap` as default for now.
The honest version is uncomfortable: Stage 4 adds capability that > Revisit only after GPU parity and use evidence; changing to
exists **only under a non-default mode**, which is a new conditional > `truncate` before Stage 5 would make the default unreachable in the
surface rather than a uniform improvement. Journey step 4 ("Understand > GUI."*
interface") is the row it serves.
**Q#HS6 is whether that is acceptable, or whether the arc should That last clause is the argument revision 1 missed. I had framed this
instead reconsider the default.** Naming the alternative honestly: if as "if scroll makes `truncate` good, the default deserves
horizontal scroll makes `truncate` genuinely good, `wrap` being the re-examination" — but with the GPU deferred to Stage 5, a `truncate`
default is a choice worth re-examining rather than treating as settled default would ship a mode that is **navigable in the TUI and a dead end
— and Stage 3 chose it partly *because* scroll did not exist. in the GUI**, for every user who never opened the setting. Q#HS1 and
Q#HS6 are therefore coupled: the split is only safe *because* the
default does not move.
Revisit after Stage 5, on use evidence, not before.
### Q#HS7 — what IS `view_left`? **NEW, BLOCKING**
**Revision 1 decided what moves the viewport without ever saying what
the viewport offset is.** That is the same omission Stage 3 would have
made had it shipped `WrapMode` without `DisplayCoord`: the mode is
useless until the coordinate contract is written down, and the contract
is where every sharp edge lives.
Revision 1's verification sketch named tabs and wide characters. **It
had no oracle for either**, because nothing defined what a left edge
is. Four things must be settled together:
**(a) The unit.** Candidates: a source byte offset within the line; a
display column (cells from the line start, after tab expansion); or a
cell boundary with an explicit validity rule.
*My vote: display column.* Tab expansion depends on the absolute column
from the line start, so the walk must begin at column 0 and compute
forward **regardless** of the offset — which makes a byte offset buy
nothing and lose tab correctness. Starting at 0 and suppressing paint
until `col >= view_left` preserves tab stops **for free**, and costs no
more than `paint_line` already pays under wrapping.
**(b) Which columns may be a left edge.** A tab straddling the edge is
unambiguous: its expansion is width-1 spaces, so the remaining ones
paint. **A wide (width-2) glyph is not** — a grid cannot paint half of
one.
*My vote: a left edge may not fall inside a wide glyph's cells.*
**(c) The snap rule when an invalid edge is requested.** Stage 3's
coordinate contract is *"identity on canonical inputs; otherwise
projection to the contract's **designated** canonical representative"* —
designated, not nearest, because the direction differs per function and
"nearest" hides that.
*My vote: snap toward the line start.* Snapping left can only reveal a
character, never hide one that was visible; snapping right can hide the
very glyph the user scrolled to reach. And snapping at the moment
`view_left` is **set** — rather than clipping in the painter — keeps
one canonical value that both painter and mapper read, instead of two
that can disagree.
**(d) The invariant rendering and coordinate mapping share.** For a
given `view_left`, `byte_at_place` must invert `place_of_byte` on every
canonical input, and `paint_line` must place exactly the bytes
`place_of_byte` claims. If the painter clips where the mapper does not,
**clicks land on the wrong character** — silently, and only for lines
wide enough to scroll.
*This is the invariant the verification sketch needs as its oracle*,
and it is why Q#HS7 blocks: §4 cannot be written until it exists.
--- ---
## 4. Verification sketch (not final — depends on Q#HS1/HS2) ## 4. Verification sketch (depends on Q#HS7)
- Cell-level tests at several window widths with a non-zero offset — - Cell-level tests at several window widths **at non-zero offset**
which is the Stage 4 case Stage 3's sketch explicitly refused to the case Stage 3's sketch explicitly refused, because "at non-zero
write, because "at non-zero offset" was a `view_left` requirement offset" was a `view_left` requirement smuggled into a wrap lane.
smuggled into a wrap lane.
- **A `wrap` control for every claim**, asserting the wrap path is - **A `wrap` control for every claim**, asserting the wrap path is
byte-identical with a horizontal offset present, since under `wrap` byte-identical with a horizontal offset present. Under `wrap` the
the offset must be inert. offset must be **inert**, not merely harmless.
- Round-trip identity for `place_of_byte` / `byte_at_place` at non-zero - Round-trip identity for `place_of_byte` / `byte_at_place` at non-zero
offset, including the wide-character and tab cases Stage 3 settled at offset — the Q#HS7(d) invariant, walked exhaustively over a short
offset 0. line rather than sampled, as Stage 3 established.
- **The Q#HS7(b)/(c) cases, which have no oracle until it is
answered**: a wide glyph straddling the left edge; a tab whose
expansion straddles it; a snap request landing inside each.
- **A PTY acceptance test for reachability**, following - **A PTY acceptance test for reachability**, following
`tests/long_line_readable_acceptance.rs`: in `truncate`, the tail of `tests/long_line_readable_acceptance.rs`. That file's `truncate`
a long line must reach the terminal *after* whatever Q#HS2 chooses control currently asserts the tail is **absent** — Stage 4 must
moves the view. That file's `truncate` control currently asserts the update it, and **that update is itself the proof the caveat is
tail is **absent** — Stage 4 must update it, and that update is gone**.
itself the proof the caveat is gone. - **No GPU witness in Stage 4** (Q#HS1). Stage 5 owes one that the
- If the GPU is in scope: a headless witness that the caret, a caret, a decoration, and a hit test all agree with the shifted
decoration, and a hit test all agree with the shifted origin — the origin — the three consumers §1.3 names.
three consumers §1.3 names.
--- ---
## 5. Coherence impact (§20 requirement) ## 5. Coherence impact (§20 requirement)
- **Journey step 4, "Understand interface — Partial."** Stage 3's - **Journey step 4, "Understand interface — Partial."** Stage 4
framing said the scorecard should name "a line that cannot be read in completes "a line that cannot be read in full" for `truncate` **in
full" against this step. Stage 4 completes that only for `truncate`. the TUI only**. The scorecard row should say so rather than reading
- **§16 Semantic Frontend Architecture.** Q#HS1 decides whether this as closed.
lane *narrows* or *widens* frontend divergence. If the GPU is - **§16 Semantic Frontend Architecture.** Q#HS1 *widens* frontend
deferred, the divergence is deliberate and time-boxed — which is divergence for the duration of the Stage 4→5 gap. Per the answer,
materially different from Stage 3's inherited accident, and the this is deliberate and time-boxed, and materially different from
release notes must say which kind it is. Stage 3's inherited accident — but the release notes must say which
- **No new interaction island** if Q#HS2 lands automatic-only. kind it is, or a reader cannot tell them apart.
Explicit commands would go in the ordinary command registry, not a - **No new interaction island** — automatic-only adds no command
new surface. surface (Q#HS2).
- **Config registry adoption**: none new expected. Stage 4 navigates - **Config registry: no new settings expected.** Stage 4 navigates the
the mode Stage 3 declared; if it needs a setting, that is a signal mode Stage 3 declared. If it needs a setting, that is a signal the
the design has drifted. design has drifted, not a feature.
---
## 6. A Stage 4 deliverable that is not scroll
**Amend `ui.line-wrap`'s description** (`builtin/runtime/linewrap.lua`).
Today it says only *"…or truncate at the edge"*, which does not tell a
user that the edge is a wall. The honest text depends on where Stage 4
lands:
- **Before Stage 4**, `truncate` means unreachable in both frontends.
- **After Stage 4**, it means reachable in the TUI and unreachable in
the GUI until Stage 5 (Q#HS1's time box, item 4).
- **After Stage 5**, the caveat is gone and the sentence should shrink
back.
Carried here rather than filed elsewhere because it is the one place
the arc's user-visible honesty is currently wrong, and revision 1
asserted it was already right.