# GPU terminal input — the double terminal-layout sync **Revision 2 — approved 2026-07-25. Scouted against canonical `main` @ `8c86d34`; implemented on branch `gpu-terminal-input` off `main` @ `46a1b8f`, whose only delta (#161) touches no file on this fix surface. Protocol stays v20.** Revision 2 answers Q#GT4 from the code instead of deferring it, which changes the proposed fix from a one-line guard to a **split of `sync_terminal_layout` into a frontend-kind-neutral liveness half and a grid-only geometry half**; rescores B1 as half-false; gives acceptance criteria 2 and 3 a landable observation seam; and corrects four line citations plus the criterion-4 rationale. Revision 1's diagnosis is unchanged — the defect, its measurements, and the three falsified hypotheses all stand. Reported symptom: *"Text input within the terminal doesn't work on GUI, this is fine in TUI."* This is a bug-fix framing, not a feature. It repairs a defect in Vterm Stage 3 (#135) that ships on `main` today, and it closes the acceptance hole that let the defect ship: the Stage 3 real-path acceptance drives a terminal session end to end, and *still could not see this*. ## Summary of the defect Every dispatcher tick, the daemon applies **both** terminal-layout syncs to **every** attached frontend: ```rust // src/daemon.rs:1536-1554 (current main) for frontend_id in &attached_fids { if let Some(size) = term_sizes.get(frontend_id).copied() { editor.sync_terminal_layout(*frontend_id, size); // GRID path } if let Some((buffer_id, size)) = semantic_states .get(frontend_id) .and_then(SemanticRenderState::terminal_viewport) { editor.sync_semantic_terminal_layout(*frontend_id, buffer_id, size); // SEMANTIC path } } ``` They are written as twins — the comment on the semantic arm even says *"right beside the grid sync"* — but they are applied as **siblings, not alternatives**. A GPU session has an entry in `term_sizes` (its `AttachRequest` carries an initial cell size, and `Resize` events maintain it) *and* a semantic terminal declaration. So both run, every tick. The two disagree by construction, and the semantic arm's own doc comment says why: > the frontend declared a CONTENT rectangle, so this consumes the size > directly instead of running the TUI placement helper, **which would subtract > a modeline the GPU never drew**. That is exactly what the grid arm then does. Measured, on a real daemon with a real PTY and the real GPU attach client: ``` PROBE sync_semantic old=Some(24x80) declared=25x92 PROBE manager.resize BufferId(3) 25x92 PROBE manager.resize BufferId(3) 22x80 PROBE sync_semantic old=Some(22x80) declared=25x92 PROBE manager.resize BufferId(3) 25x92 PROBE manager.resize BufferId(3) 22x80 ... ``` The PTY is resized **twice per dispatcher tick, forever**. Each resize is a `TIOCSWINSZ` + `SIGWINCH` to the child and a screen reflow in `TerminalScreen`, so the child gets a SIGWINCH storm at tick cadence and the screen alternates between two geometries. An interactive line editor (readline, zle, fish's reader) redraws on every SIGWINCH, so what the user types is continuously destroyed before it can settle — while ordinary child *output* keeps flowing, which is why the terminal looks alive. Measured user-visible effect, real bash `-i` in the real GPU path, typing one character: | | frames for a static screen | typed `Z` ever visible at the prompt | |---|---|---| | `main` today | **730** in a 20 s window | **no** | | with the guard | **2** | (see Q#GT5 — a separate question) | The TUI is unaffected: a grid session has no semantic terminal declaration, so only one arm ever runs for it. This is a **frontend-kind** defect, which is why it presents as "GUI broken, TUI fine". ## Ground truth (measured this session, not inferred) Everything below was established against `main` @ `8c86d34` with a real daemon, a real PTY child, and the real `pmacs-gpu` attach client. The probe harness is preserved (see "Verification plan"). ### What is *not* wrong — three hypotheses falsified Recording these because each is a plausible-looking cause that a future reader (or a review round) will re-propose. 1. **The GPU's optimistic-CRDT path is not implicated.** The first hypothesis was that a typed character becomes a `CrdtOp` against the read-only terminal identity buffer and is dropped. It does not. Terminal buffers are already marked round-trip — `core.set_round_trip_input(buffer_id, true)` at `src/terminal/session.rs:338`, beside `set_read_only(true)` — so `dispatch_idle_for` returns **false** while a terminal window is focused, the daemon publishes `DispatchIdle { idle: false }`, and the GPU's `daemon_intercepts_keys()` is true. Measured on the wire: `dispatch_idle_in_terminal=false`, `intercept_in_terminal=true`, `input_route=send_key(intercept)`. The optimistic gate is shut. 2. **Key transport is not implicated.** The keystroke reaches the daemon, resolves a terminal view key, encodes, and is written to the PTY without error: `PROBE dispatch_key ... terminal_key=Some(TerminalViewKey { .. })`, `PROBE terminal transport encode=Some([90])`, `PROBE after send status=""`. With a `cat` child the byte comes back on screen through the whole real GPU path (`echoed_typed_char=true`). 3. **The `pmacs --attach` TUI replica does *not* share the defect.** It gates its optimistic path on `dispatch_idle` alone (`src/attach.rs:843`), and that signal is already correct for terminals per (1). ### The mechanism - `EditorInstance::sync_terminal_layout` (`src/editor.rs:1195`) is the grid path: it runs the TUI placement helper over the frontend's *frame* size. - `EditorInstance::sync_semantic_terminal_layout` (`src/editor.rs:1331`) is the semantic path: it consumes a declared *content* rectangle directly. - Both resolve the same controller and call `TerminalManager::resize` on the same session. Each has a correct `old_size == size` idempotence guard (`src/editor.rs:1239` grid, `src/editor.rs:1360` semantic) — the guards are individually sound and jointly useless, because each arm sees the size the *other* just installed. - `TerminalViewStore::record_view_size` (`src/terminal/view.rs:276-292`) returns `true` for any valid declaration with no unchanged-size dedupe, which is why the semantic arm re-fires every tick against the grid arm's flip rather than settling. - Loop order is grid first, semantic second, so the screen *ends* each tick at the declared size. That is why rendering looks alive while the child is whipsawed — and why a frame-based assertion is the wrong instrument (acceptance criteria 2 and 3). - `TerminalScreen::changed` bumps `generation` per mutation (`src/terminal/screen.rs:1467`), which is why generation advances by **exactly 2** per tick — one bump per resize. - Frame suppression is full-struct equality (`self.last_terminal_frame.as_ref() == Some(&frame)`, `src/semantic_render.rs:882`). It is behaving correctly: the frames really do differ. The churn is upstream, and fixing the churn fixes the frame storm. **No suppression change is proposed.** ### Why the Stage 3 acceptance could not catch it `a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session` (`tests/vterm_stage3_acceptance.rs:637`) is a genuine real-daemon + real-PTY + real-wgpu path, and it still passes on the broken tree. Three reasons, each worth keeping: 1. Its child is `sh` printing 400 rows on a timer. **A frame storm is invisible against a child that legitimately produces ~400 frames**, and its only frame-count assertion is `frames >= 2`. 2. Its input step is `client.send_key(...)` called **directly** (`pmacs-gpu/src/main.rs:784-785`), so it pins transport, not routing — and it asserts nothing about the result of that input reaching the child. 3. It resizes **once, deliberately**, and asserts the new width comes back. A geometry that oscillates *through* the asserted width satisfies that assertion. This is the project's own "a geometric readout is not a state predicate" lesson (`docs/active-work.md`, bottom-panel round 2) in a new place: `observed_resized_frame` says "a frame at this width arrived", not "the geometry settled at this width". ## Decisions **Q#GT1 — Where does the fix go?** `sync_terminal_layout` is **split**, and only its geometry half is gated by frontend kind. A bare "skip the grid arm for semantic frontends" guard is wrong — see Q#GT4, which establishes that the grid arm is also the only per-tick controller-liveness release. Not by removing `term_sizes` for semantic sessions: semantic key and mouse dispatch hard-depend on it (`src/daemon.rs:2191-2213`). The function has three separable concerns (`src/editor.rs:1195-1260`), and they do not split where the name suggests: | lines | concern | frontend kind | |---|---|---| | 1199 | `reconcile_panel_layout` (Q#BP2b per-tick defensive) | **neutral** | | 1200-1221 | controller liveness: released when the frontend has no view, or its active window no longer shows that terminal | **neutral** — reads only `core.views` / `core.windows` / the controller, never `term_size` | | 1222-1259 | TUI placement (`window_placements`) + `resize` | **grid only** | So the daemon loop becomes: run the neutral half for every attached frontend every tick, then exactly one geometry arm per frontend kind. `sync_terminal_layout` survives as the composition of both halves, so `editor::run`'s in-process loop and `LOCAL` keep byte-identical behavior. The liveness half must run **once** per frontend per tick — reconciliation is idempotent, so a double call is safe rather than wrong, but the loop should not pay for it. **The trap inside the split:** the third release, at `src/editor.rs:1226` (no placement found for the window), looks like liveness and is **not** — it is grid geometry. A semantic frontend has no `window_placements` entry at all, so moving that arm into the neutral half would release a GPU session's controller on every single tick. That would be a new defect of exactly the family this framing fixes, so it stays in the grid half. **Q#GT2 — Which arm wins for a semantic frontend?** The semantic one, unconditionally. It is the only arm that consumes a *content* rectangle; the grid arm's modeline subtraction is meaningless for a frontend that draws no modeline into the terminal band. A GPU frontend that has not yet declared a terminal viewport gets **neither** arm, which is correct: the terminal keeps the geometry it was opened with until the frontend declares one. **Q#GT3 — Is the guard "no semantic state" or "not a semantic session"?** `semantic_states` keyed by frontend id is the same map the semantic arm reads one line later, so the two arms become provably exclusive by construction rather than by two independent predicates that could drift apart. Rejected alternative: keying on the negotiated `semantic_render` capability bit — it is the *same* fact one indirection away, and the pair could then disagree. **Q#GT4 — Does anything else in `sync_terminal_layout` need to keep running for a semantic frontend? Yes: the controller-liveness release, and the semantic arm neither performs it nor can be made to.** Revision 1 left this open; the code answers it. `release_controller` is called from exactly five sites, all in `src/editor.rs`: the three grid-arm early returns (1210, 1219, 1226), `dispatch_focus(gained = false)` (1189), and `reconcile_panel_layout`'s unsatisfiable-panel path (848). **`sync_semantic_terminal_layout` releases nothing.** When the window has switched away, `semantic_terminal_key` returns `None` (`src/editor.rs:1278` — `window.buffer_id != buffer_id`) and the arm returns `false` without touching the controller. Growing a release inside the semantic arm — revision 1's stated fallback for B1 — **cannot work**, and the reason is worth keeping: when a GPU window switches from the terminal to a document, the buffer-follow snapshot clears the viewport declaration (`on_buffer_snapshot_sent` sets `terminal_viewport = None`, `src/semantic_render.rs:574`), so `terminal_viewport()` returns `None` and the semantic arm **stops running entirely** for that frontend. A release placed inside it would never execute in precisely the scenario that needs it. Nor do the other two sites cover it: `dispatch_focus(false)` fires on whole-frontend focus loss, not on a window or buffer switch, and semantic sessions are not panel-capable yet (`panel_capable_for` is false for them — `src/daemon.rs:1893-1898`), so 848 never fires either. Consequence of shipping revision 1's guard as written: a GPU frontend that switches away from its terminal **holds the controller indefinitely**. Because another frontend's grid sync early-returns on a `controller_view_for_frontend` mismatch, that peer then cannot resize the PTY until it explicitly re-claims. This is why Q#GT1 splits the function instead of gating it. **Q#GT7 — The per-tick defensive panel reconcile stays for semantic frontends.** It is the only per-tick pre-paint reconcile the Q#BP2b contract names (`src/editor.rs:822-830`), and today the grid arm supplies it for GPU sessions too. Putting it in the neutral half of the split preserves that exactly. It is harmless-either-way today — semantic sessions have unknown frame geometry until the bottom-panel GPU band lands — but "harmless today" is not a reason to remove a contract's only per-tick enforcement point in a PR about something else. **Q#GT5 — Typed characters not echoing by an interactive shell is a *separate* question and is deliberately out of scope.** Measured: with `bash --norc -i` on a `TerminalMode::Raw` PTY, typed characters are not echoed to the screen — **and this reproduces identically in-process**, i.e. on the TUI's own path, where the user reports the terminal works. Because it is not frontend-specific it cannot be the GUI/TUI asymmetry, and folding it in would make this PR two features. It gets its own scout: whether `TerminalMode::Raw` is the right mode for a `pmacs.terminal.open` child, and what pmacs owes a child that expects to own its termios. Named, not silently dropped. **Q#GT6 — Protocol impact: none.** No wire shape, no negotiation, no version change. Stays v20. ## Bets - **B1 — SCORED HALF-FALSE before implementation (revision 2).** "Removing the grid arm for semantic frontends removes the storm without removing any behavior a GPU session relies on." The first clause holds (measured). The second is **false**: it also removes the only controller-liveness release and the only per-tick Q#BP2b reconcile a GPU session gets (Q#GT4, Q#GT7). Its stated contingency — "the semantic arm grows the release" — is false too, for a structural reason (`terminal_viewport` is cleared by the very snapshot that signals the switch-away). Hence the split in Q#GT1. Recorded rather than deleted: the failure mode is one a reviewer or a future simplification will re-propose. - **B2.** The user's reported symptom is this defect. *Partially scored: the storm is proven and GUI-only, and its shape (line editor unusable, output still flowing) matches the report. Not fully scored until the user, or an acceptance running the **user's own shell**, confirms typing works after the fix. Q#GT5 is the reason this bet is stated rather than assumed.* - **B3.** No other pair of per-frontend-kind daemon operations is applied as siblings rather than alternatives. *Scored by an explicit audit of the dispatcher's per-frontend loop during implementation — this defect's shape is "twins applied as siblings", and it would be negligent to fix one instance without looking for others.* ## Deferred (named) - Interactive-shell echo on a raw-mode PTY (Q#GT5) — its own scout. - **A geometry change appears to clear the visible screen.** Observed while building acceptance 4: after the probe's deliberate 25×92 → 20×71 resize, the next frame's visible grid is entirely blank even though the content (two short lines near the top) should survive a shrink of that size. It reproduces on the pre-fix tree, so it is neither caused nor fixed here, and it is why acceptance 4 latches its observation across frames instead of reading the final one. Not investigated: it could be correct reflow behaviour given where the child leaves its cursor (frames show the cursor on the bottom row), or a real reflow defect. Named because the next person to write a resize assertion will hit it. - `TerminalFrame` suppression including `screen_generation` in its equality: correct today and load-bearing for correctness, but it means any future content-neutral generation bump re-emits a frame. Recorded, not changed. - The `a37` probe's structural weaknesses beyond what the acceptance below fixes (it still cannot exercise `App::window_event`'s routing, because that logic is inline in the winit handler with no extractable seam). Making GPU key routing testable is a real refactor and belongs to its own lane. ## Acceptance criteria **The observation seam (revision 2).** Criteria 2, 3 and 6 assert daemon-side state, and `TestDaemon` runs the daemon as a **subprocess** (`tests/common/daemon.rs:90`), so nothing in-process can see it and the scouting instrumentation does not land. The seam that does land: **extract the dispatcher loop's per-frontend terminal-layout step into a named function** that takes `(&mut EditorState, &[FrontendId], &term_sizes, &semantic_states)`. That is required by Q#GT1's split anyway, it makes the grid/semantic exclusivity structural rather than two adjacent `if`s, and it lets an in-process test in the style of the existing `src/daemon.rs` unit tests (3375ff) drive **the real loop body** rather than a re-implementation — which is the a37 lesson applied to this PR's own tests. The observable is `TerminalScreen::generation`, reachable through `TerminalManager::snapshot(..).generation`. It advances once per screen mutation (`src/terminal/screen.rs:1467`), so with a quiet child it is a **state predicate**, not a readout: "the geometry settled" is exactly "generation stopped advancing". 1. On a real daemon + real PTY + real GPU attach, a terminal session that receives no child output produces a **bounded** number of terminal frames (settling to zero new frames once the screen is static) — not one per tick. Fails on `main` with ~730 frames in 20 s; passes with ≤ a small constant. 2. Driving the extracted loop body N times against a semantic frontend with a fixed declaration and a quiet child: `TerminalManager::resize` takes effect **exactly once** (generation advances once, then is constant for the remaining N-1 iterations). Fails on `main`, where generation advances by two per iteration. 3. After a declaration, `screen_size(buffer)` **equals the declared content rectangle and stays equal** across subsequent iterations — the state predicate, not the "a frame at this width arrived" readout that `observed_resized_frame` provides today. 4. A character sent through the real GPU attach client reaches the child and its echo appears in a rendered frame. **This is a keep-working pin, not a fix discriminator: it already passes on today's broken `main`** (falsified hypothesis 2 measured `echoed_typed_char=true`). Pinned with a `cat` child — not because `cat` echoes (termios `ECHO` is off in raw mode; nothing echoes) but because `cat` *copies stdin to stdout*, so the byte comes back exactly once, with no line discipline and no double echo to disambiguate. 5. A grid (TUI) session's terminal resize behavior is **unchanged** — pinned against the existing Stage 2 real-TUI PTY smoke, which must stay green without modification. 6. A semantic frontend whose window stops showing the terminal **releases its controller** (Q#GT4), pinned through the extracted loop body — driven by an actual buffer switch, not by calling the release directly. This one bites against **revision 1's naive guard**, and deliberately **passes on `main`**: today's sibling arms do supply the release, by the accident of the grid arm running for a frontend it should never have run for. It is the pin that stops the fix from trading one defect for another. 7. End-to-end SIGWINCH count through the real PTY: a child trapping `WINCH` and printing a **fresh distinct breadcrumb per signal** (`WINCH 1`, `WINCH 2`, …) shows a bounded count. The distinctness is load-bearing — the established PTY-paint trap is that cell diffing skips both spaces and already-matching cells, so a repeated identical marker can assert nothing. 8. Bite-verified against **two** pre-images, because one is not enough here — the naive guard fixes the storm and introduces a different defect, so a single revert would score the fix complete when it is not. Measured (`cargo test --lib`, manual revert since these tests share `src/daemon.rs` with the production code): | pin | `main` (sibling arms) | rev-1 naive guard | the split | |---|---|---|---| | acc 2+3 settle | **FAIL** | pass | pass | | acc 6 controller release | pass | **FAIL** | pass | | acc 5 grid still resizes | pass | pass | pass | The middle column is B1's half-false score made executable: the naive guard's first clause holds (the storm stops) and its second does not. Criteria 1, 2 and 7 are deliberately expressed as **quiet-child** assertions, because the existing acceptance's chatty child is exactly what hid this. ## Coherence impact (`COHERENCE.md` §20) - **§2 golden journey, step 8 ("Open a terminal")** — currently graded *"Works but undiscoverable"*. On the GPU frontend it does not work; this restores the step for the frontend the document calls the more capable one. Priority 1 explicitly treats journey regressions as release blockers. - **§16 Productize the Semantic Frontend Architecture** — graded *strong*, with "graceful per-frontend degradation is practiced, not aspirational" as its evidence, citing per-frontend fold projection. This defect is the counter-example: a per-frontend-kind operation applied to both kinds at once. The section's claim survives, but the audit should record that the practice is enforced by convention, not by structure — two arms that must be alternatives are currently just two adjacent `if`s. Q#GT1's extracted loop body makes this one structural; the audit note should say the *pattern* is still convention-enforced everywhere else (B3). - **§6 Eliminate Hardcoded Interaction Islands** — the audit's row 6 note that the GPU optimistic classifier "is kept honest by `dispatch_idle_for`" is **confirmed correct** by this investigation (falsified hypothesis 1), and the §6 citation `crate::optimistic::classify_key` should be corrected: that symbol is `src/optimistic.rs`, the **`pmacs --attach` TUI replica's** classifier. The GPU's separate, unrelated classifier is `optimistic_insert_text` / `optimistic_crdt_insert` in `pmacs-gpu/src/main.rs`. Two replica frontends, two classifiers; the audit conflates them. - **§19 Product Coherence Acceptance Tests** — this is a concrete instance of the section's thesis. Every subsystem test passed; the defect lives in how two correct subsystems compose per frontend kind. Criterion 1's quiet-child shape is the transferable technique. - No interaction island added, no config registry surface, no background-work attribution change. ## Verification plan Full gate suite per `CLAUDE.md`, plus: - `cargo test --features crdt --test vterm_stage3_acceptance` (the suite this repairs) and `--test vterm_stage2_acceptance` (the TUI no-regression pin). - `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`. - The scouting harness is preserved and should be re-run against the branch: a quiet-child variant of the `a37` probe plus daemon-side resize tracing, saved as `scratch_gui_terminal_input.rs`, `scratch_inproc_input.rs`, and `gui-terminal-probe-instrumentation.patch`. The instrumentation is scratch; the acceptance criteria above are what lands. - Manual confirmation with the user's own shell (fish) in a real GPU window, since B2 is not fully scored by any automated test (Q#GT5). **Ops.** This doc is currently untracked in a detached-HEAD worktree (`../pmacs-gui-term-input`), so it does not travel. On approval it becomes the branch's first commit before any implementation, per the standing workflow — no cross-machine expectation should attach to it until then.