diff --git a/builtin/runtime/terminal.lua b/builtin/runtime/terminal.lua new file mode 100644 index 0000000..6be0987 --- /dev/null +++ b/builtin/runtime/terminal.lua @@ -0,0 +1,95 @@ +-- terminal.lua --- Friendly Vterm Stage 2 command and modeline surface. + +local terminal = assert(pmacs.terminal, "pmacs.terminal raw bindings are required") +local raw_open = assert(terminal._open, "pmacs.terminal._open is required") + +local function bind_terminal_keys(buffer) + local function bind(sequence, command) + pmacs.keymap.bind { + scope = "buffer", + buffer = buffer, + sequence = sequence, + command = command, + } + end + bind("M-w", "terminal.copy-selection") + bind("M-v", "terminal.page-up") + bind("C-v", "terminal.page-down") + bind("M-<", "terminal.scroll-oldest") + bind("M->", "terminal.scroll-bottom") +end + +function terminal.open(spec) + local buffer = raw_open(spec) + bind_terminal_keys(buffer) + return buffer +end + +pmacs.command.define { + name = "terminal", + description = "Open a terminal running $SHELL (or /bin/sh).", + fn = function() + return terminal.open { + command = os.getenv("SHELL") or "/bin/sh", + } + end, +} + +pmacs.command.define { + name = "terminal.copy-selection", + description = "Copy the active terminal selection.", + fn = function() return terminal.copy_selection() end, +} + +pmacs.command.define { + name = "terminal.page-up", + description = "Scroll the active terminal viewport up one page.", + fn = function() return terminal._scroll_page(1) end, +} + +pmacs.command.define { + name = "terminal.page-down", + description = "Scroll the active terminal viewport down one page.", + fn = function() return terminal._scroll_page(-1) end, +} + +pmacs.command.define { + name = "terminal.scroll-oldest", + description = "Scroll the active terminal viewport to the oldest retained row.", + fn = function() return terminal.scroll(math.maxinteger) end, +} + +pmacs.command.define { + name = "terminal.scroll-bottom", + description = "Return the active terminal viewport to the live tail.", + fn = function() return terminal.scroll_to_bottom() end, +} + +pmacs.statusline.register { + name = "terminal", + side = "right", + priority = 10, + face = "ui.modeline.terminal", + fn = function(ctx) + if not terminal.is_terminal(ctx.buffer) then return nil end + local state = terminal.state(ctx.buffer) + local view = terminal.view_state(ctx) + if not view then return nil end + + local process = state.process + local text + if process.kind == "running" then + text = "TERM" + elseif process.kind == "exited" then + text = "TERM:" .. tostring(process.code) + elseif process.kind == "signaled" then + text = "TERM:" .. process.signal + else + text = "TERM:ERR" + end + if view.scroll_offset > 0 then + text = text .. " ↑" .. tostring(view.scroll_offset) + end + return text + end, +} diff --git a/docs/active-work.md b/docs/active-work.md index 9046819..909bf85 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -14,7 +14,7 @@ backlog. machine-local: `origin` may name this canonical URL, a release mirror, or something else, and therefore has no authority by name alone. - Canonical base at this snapshot: - `githubsucks/main` @ `b4b925d` (mode system wiring #129 merged; + `githubsucks/main` @ `d5d9b9c` (mode system handoff #131 merged; protocol v18). - On the transfer source, `origin/main` named a release mirror at `d3fa632` and lagged badly. On the current destination, `origin` names @@ -49,34 +49,55 @@ git worktree list git status --short --branch ``` -The first command must expose `b4b925d` or a newer intentional main. +The first command must expose `d5d9b9c` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. +## Vterm Stage 2 implementation lane - -## Vterm Stage 2 framing lane - -- Portable branch: `githubsucks/vterm-framing` -- Approved framing head: `fb4f8f0` -- Base: canonical `main` @ `643d1e1` (Vterm Stage 1 / PR #126 merged). - `main` has since advanced to `b4b925d` (config registry #127, the #128 - documentation merge, and mode system wiring #129); cut the Stage 2 lane - from current `main`, not from `643d1e1`. -- State: `docs/vterm-framing.md` Revision 7 is framing-only, reviewed, and - approved for implementation. It closes the final `at_bottom`, terminal - `C-c` binding-reachability, and context-implicit Lua failure-mode findings. - There is no Stage 2 runtime implementation or PR yet. -- Next lane: create `pmacs-vterm-tui` / `vterm-tui` from current canonical - `main`, carry the approved framing as its first commit, then implement and - gate Stage 2. Do not implement on `vterm-framing`. +- Portable branch: `githubsucks/vterm-tui` +- Integrated feature head: `3f0252f` (review-fix head `b9a7e40`) +- Base: originally canonical `main` @ `f1a2f75`; current canonical `main` + @ `d5d9b9c` (mode system wiring #129 and handoff #131) is integrated before + merge. +- PR: #130, , open against + canonical `main` and explicitly authorized for merge. +- State: `docs/vterm-framing.md` Revision 7 criteria 15–27 are implemented. + The lane composes per-frontend/window terminal views in the TUI, installs + the strict `pmacs.terminal` API and terminal-local bindings, drains + clipboard/BEL through the authenticated frontend, and routes daemon + terminal input by connection source. Protocol remains v18; Stage 2 changes + neither the wire schema nor the GPU renderer. +- Review round 1: addressed. Dispatch now requires `C-c` before terminal-local + editor bindings; non-terminal context operations error; controller + replacement is atomic per frontend; zero-area layouts retain view anchors; + view projection borrows retained rows instead of deep-cloning scrollback. +- Review round 2: addressed. Partial eviction clamps anchors to the first + surviving wrapped-line cell; `invoke_interactive` now inherits only an + authenticated dispatch origin; explicit context failures are named; terminal + mouse routing reads geometry without cloning cells; the framing records the + v18 semantic-controller boundary and bracketed-paste injection deferral. +- Implementation commits: `39e07cb`, `7c39535`, `0a846d9`, `0dacac7`, + `dc92257`, merge `0ddff24`, integration hardening `da8f6ae`, first-review + fixes `8702791`, second-review fixes `b9a7e40`, and current-main integration + `3f0252f`. +- Post-integration verification: `cargo fmt --check`; strict workspace + Clippy; 1,753 default + 1,929 CRDT library tests (3 ignored each); + mode-system acceptance 1 default + 1 CRDT; Stage 1 acceptance 9 default + + 10 CRDT; Stage 2 acceptance 4 default + 4 CRDT; statusline acceptance + 7 default + 8 CRDT; M4 114 passed (3 ignored, 1 filtered); required GPU + 109; workspace 2,882 passed across 82 suites (19 ignored, 1 filtered); + `git diff --check` clean. The first parallel M4 attempt timed out after + partial progress; a serial isolation pass and the immediate exact parallel + rerun both passed, and the workspace sweep also passed. +- Next: push the integrated head and merge PR #130 as authorized. Recovery worktree on a machine that does not already own the branch: ```sh git worktree add --track \ - -b vterm-framing \ - ../pmacs-vterm-framing \ - githubsucks/vterm-framing + -b vterm-tui \ + ../pmacs-vterm-tui \ + githubsucks/vterm-tui ``` ## Parked lane: kill-ring browser + persistence diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 81a202a..ef07948 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,9 +1,10 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-22, after mode system wiring (#129) landed on -`main`, atop the config registry (#127), Vterm Stage 1 terminal core -(#126), and completed Themes Arc 4 (#120/#124/#125). Vterm Stages 2 and 3 -are not implemented.** +**Last updated: 2026-07-22, after Vterm Stage 2 PR #130 review round 2 +was addressed and approved for merge; mode system wiring (#129) and its +handoff (#131), config registry (#127), Vterm Stage 1 terminal core (#126), +and completed Themes Arc 4 (#120/#124/#125) are landed on `main`. Vterm +Stage 3 is not implemented.** This file is the bridge between development machines. If you are an agent reading this on a fresh clone: this document plus the `docs/*-framing.md` @@ -17,7 +18,7 @@ commands, read `docs/active-work.md` immediately after this file. ## 1. Where the project stands (2026-07-22) -- `main` @ `b4b925d` (mode system wiring #129), protocol **v18** +- `main` @ `d5d9b9c` (mode system handoff #131), protocol **v18** (`SUPPORTED=[6..18]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 = `StatuslineSegments`). - **Config registry LANDED — #127** (`docs/config-registry-framing.md` @@ -230,7 +231,7 @@ commands, read `docs/active-work.md` immediately after this file. resize. Review round 2 rejects C0/C1 controls before they enter screen cells, preserves the released button code in SGR mouse reports, removes dead screen paths, and clears stale round-trip state during prune. Stage 2 - must uniquify default terminal buffer names. + now uniquifies default terminal buffer names transactionally. - Exact CUU/CUD and out-of-range DECSTBM clamping, combining across controls, xterm alternate-screen details, legacy non-SGR mouse, printable ASCII and CSI-dispatch allocation fast paths, and scrollback-cap naming are explicit @@ -244,12 +245,57 @@ commands, read `docs/active-work.md` immediately after this file. is a clean behavioral bite. The parser dispatch has its independent clean behavioral bite; the original `main`/crate-root bite remains explicitly weaker compile-time API evidence. - - Stage 2 reviews require a durable focus/input resize owner, owning - `FrontendId` for the global `C-c` continuation, and local clipboard/BEL - signal drainage. Stage 3 additionally owns `pmacs-gpu/src/attach.rs`, - authenticated source routing, protocol-owned wire types/limits, and a - deliberate complete-frame limit decision: 16 MiB is insufficient; use a - measured legal-worst cap or aggregate bound, never silent chunking. + - Stage 3 owns `pmacs-gpu/src/attach.rs`, authenticated source routing, + protocol-owned wire types/limits, and a deliberate complete-frame limit + decision: 16 MiB is insufficient; use a measured legal-worst cap or + aggregate bound, never silent chunking. + - **Stage 2 TUI is implemented on `vterm-tui`** (`docs/vterm-framing.md` + Revision 7, criteria 15–27). `TerminalViewKey` keys per-frontend/window + projection state over one shared process/screen; logical row anchors retain + scroll/selection through reflow. One authenticated frontend controls at + most one session, with atomic replacement and release on + focus/switch/kill/detach. + - The strict `pmacs.terminal` Lua surface owns open/state/view/send/terminate + and context-implicit scroll/copy commands; the latter error unless the + invoking frontend's active window is a terminal. Fixed `C-c` is the + per-frontend terminal escape: only its next key reaches terminal-local + editor bindings, while unescaped bound keys pass through to the child. + `C-c C-c` sends one literal interrupt. Copy drains through the acting + frontend's clipboard path; active BELs drain once locally and per daemon + frontend, while historical/passive bells are baseline-suppressed. + - TUI composition paints owned terminal cells/styles only inside each + window's content rectangle, suppresses document overlays, and keeps sibling + splits independent. Daemon key/mouse/paste/focus/resize/detach routing uses + the authenticated connection source rather than client-claimed IDs. + `builtin/runtime/terminal.lua` provides the terminal command, view commands, + and pure `ui.modeline.terminal` process/scroll segment. + - `tests/vterm_stage2_acceptance.rs` maps Lua transactionality, shared-view + isolation, clipboard/modeline behavior, and a hermetic real `/bin/sh` TUI + PTY smoke. Stage 2 changes no wire schema or GPU renderer; protocol remains + v18 until Stage 3. + - PR #130 review round 1 (`8702791`) aligned dispatch with the approved + escape-prefix contract, closed the non-terminal Lua error path, made + controller replacement atomic, retained zero-area view anchors, removed + duplicate detach work, and replaced per-view deep scrollback clones with + borrowed live/published row projections. Focused child-input coverage pins + both unescaped bound-key passthrough and `C-c C-c`. + - PR #130 review round 2 (`b9a7e40`) clamps anchors into the first + surviving cell when eviction cuts through a wrapped logical line, prevents + ambient `active_frontend` from minting interactive Lua authority, names + malformed explicit-context fields, restores dispatcher rationale, and + removes owned cell snapshots from terminal mouse routing. The framing now + records the transient v18 semantic-controller boundary and bracketed-paste + injection deferral. + - Current-main integration (`3f0252f`) preserves per-frontend terminal + dispatch while applying the landed mode-scoped keymap, and exposes the + `mode`, `terminal`, and `lsp` statusline providers together. + - Post-integration gate: `cargo fmt --check`; strict workspace Clippy; + 1,753 default + 1,929 CRDT library tests (3 ignored each); mode-system + acceptance 1 default + 1 CRDT; Stage 1 acceptance 9 default + 10 CRDT; + Stage 2 acceptance 4 default + 4 CRDT; statusline acceptance 7 default + + 8 CRDT; M4 114 passed (3 ignored, 1 filtered); required GPU 109; + workspace 2,882 passed across 82 suites (19 ignored, 1 filtered); + `git diff --check` clean. - **PARKED: kill-ring browser + persistence.** Revision 2 framing is preserved on branch `kill-ring-browser`, but its `0efb5cd` scout is stale and must be repeated before implementation. No PR or implementation is diff --git a/docs/roadmap-2026-07.md b/docs/roadmap-2026-07.md index a6f5d11..6c75a6f 100644 --- a/docs/roadmap-2026-07.md +++ b/docs/roadmap-2026-07.md @@ -80,16 +80,18 @@ preference at protocol v17; and composable per-window `pmacs.statusline` providers transported to semantic/GPU frontends by protocol-v18 `StatuslineSegments`. -### Arc 5 — Terminal, staged — VTERM CORE LANDED +### Arc 5 — Terminal, staged — VTERM STAGE 2 ON FEATURE BRANCH - **Compile mode landed in #113**: line-oriented PTY/ANSI output, error-regex navigation, and `M-x compile`. - **Vterm Stage 1 terminal core landed in #126**: compatibility parser - profiles, bounded VT screen/scrollback/reflow state, input encoders, - internal `TerminalManager`, read-only identity buffers, process lifecycle, - control-free renderer-boundary cells, and headless real-PTY acceptance. -- **Vterm Stage 2 TUI is next**: terminal-window composition, input/resize, - per-context scroll/selection/copy, and the Lua surface. + profiles, bounded VT screen/scrollback/reflow state, IND/NEL/RI, input + encoders, internal `TerminalManager`, read-only identity buffers, process + lifecycle, control-free renderer-boundary cells, and headless real-PTY + acceptance. +- **Vterm Stage 2 TUI is implemented on `vterm-tui`**: terminal-window + composition, input/resize, per-context scroll/selection/copy, authenticated + frontend ownership, BEL/clipboard drainage, and the strict Lua surface. - **Vterm Stage 3 protocol/GPU follows Stage 2**: additive protocol v19 complete frames, authenticated daemon routing, and native GPU terminal rendering. Its framing must resolve the 16 MiB transport cap's incompatibility diff --git a/docs/vterm-framing.md b/docs/vterm-framing.md index 7cd1d8d..84b50aa 100644 --- a/docs/vterm-framing.md +++ b/docs/vterm-framing.md @@ -1,18 +1,22 @@ # Vterm — framing (Arc 5 stage 2, three-PR delivery) -**Revision 5 — 2026-07-21. Status: Stage 1 landed on `main` through PR #126 -at merge `643d1e1`. Stages 2 and 3 are not implemented.** +**Revision 7 — 2026-07-21. Status: Stage 1 landed on `main` as PR #126 +at merge `643d1e1`; Stage 2 is implemented on branch `vterm-tui` and Stage 3 +is not implemented.** -Revision 5 establishes the renderer-facing cell invariant before Stage 2: -terminal text discards C0/C1 controls rather than storing host-terminal control -bytes in grapheme cells. SGR mouse release preserves the released button code; -review cleanups remove dead screen paths and stale round-trip state; and the -remaining VT-fidelity and allocation nits are explicit deferrals. Architecture -is unchanged: `C-c` is the terminal editor escape (`C-c C-c` sends interrupt); -main-screen resize reflows while alternate screen clips/pads; exited buffers -remain with an Emacs-style process message; protocol v19 is additive with -complete frames; shared `Style` stays unchanged; and one `BufferId` owns one -shared process/screen whose most recently active frontend controls size. +Revision 7 closes the final three precision findings: `at_bottom` is geometric +and distinct from live-tail following; the fixed `C-c` transport escape +deliberately makes ordinary `C-c`-leading bindings unreachable in terminal +windows; and context-implicit Lua view operations require an authenticated +interactive origin with exact boolean/error results. Revision 6's durable +controller and per-view identities, logical-cell anchors, frontend-explicit +grid rendering, per-frontend dispatch, authenticated v18 input, local +clipboard/BEL drainage, default-name uniquification, mouse override, and +resize ordering remain unchanged. Main-screen resize reflows while alternate +screen clips/pads; exited buffers remain with an Emacs-style process message; +protocol v19 is additive with complete frames; shared `Style` stays unchanged; +and one `BufferId` owns one shared process/screen whose most recently accepted +frontend/window context controls size. This framing follows the compile-mode terminal substrate that landed in PR #113. `src/process.rs` already owns PTY creation, process groups, bounded @@ -34,14 +38,14 @@ Arc 5 stage 2 ships as three separately reviewed PRs: There is no single mega-PR. Each stage is useful and testable by itself, and a later stage starts only after the preceding stage lands on `main`. -## 0. Revision 5 — Stage 1 implementation and review record +## 0. Revision 7 — landed Stage 1 and reviewed Stage 2 contract -The first of the three vterm PRs is implemented, reviewed, fully gated, and -landed on `main` as merge `643d1e1`. Initial feature commit `bbc1f33`, -first-review fixes through `bf972a7`, and second-review hardening `9797ada` -shipped through PR #126, . -It is deliberately headless: there is no `pmacs.terminal` Lua module, -interactive terminal command, TUI paint branch, or GPU/protocol surface yet. +The first of the three vterm PRs landed on `main` at merge `643d1e1`. +Implementation commits `bbc1f33` and `962944b`, first-review fixes through +`bf972a7`, and second-review hardening through `9797ada` shipped in PR #126, +. That landed stage is +deliberately headless; this Stage 2 branch adds the Lua and TUI surfaces while +leaving GPU/protocol integration for Stage 3. ### 0.1 Public seam and ownership @@ -145,26 +149,54 @@ terminal_cells_reject_child_control_characters` returned `bite: OK` with a clean behavioral failure: the pre-hardening screen stored control bytes in a grapheme cluster rather than preserving the blank snapshot. -### 0.4 Downstream review findings (not implemented) +### 0.4 Stage 2 re-scout resolutions -Stage 2 must derive PTY resize ownership from a durable accepted-input/focus -owner before render fan-out, never transient `EditorCore::active_frontend`. -Because `KeyDispatcher` pending state is global, a terminal `C-c` continuation -must carry its owning `FrontendId`. Terminal copy should use the existing core -kill-ring/clipboard setter, while the local run loop must drain/present -clipboard signals; active-terminal BEL likewise uses the out-of-band frontend -signal path. +The post-Stage 1 review and a fresh read of landed `main` found six +load-bearing Stage 2 seams. This revision resolves them before code: + +- PTY resize ownership is stored durably per terminal session as an + authenticated `(FrontendId, WindowId)` controller. Render-time + `EditorCore::active_frontend` is not an ownership signal. +- The single global `KeyDispatcher` cannot safely carry a terminal `C-c` + continuation in a multi-frontend daemon. Pending dispatch and terminal + escape state become per-`FrontendId`; dispatch-idle publication becomes + frontend-specific too. +- Terminal copy reuses the existing kill-ring/clipboard setter. The + in-process run loop must drain that signal and feed it through + `Frontend::present_messages`; the TUI must implement `InstanceSignal::Bell` + rather than drop it. +- `RenderState::render_frame` and `paint_frame` need the target + `FrontendId` explicitly. A transient mutable active frontend may still + attribute commands, but it may not select another frontend's layout, + terminal view state, statusline context, or resize owner during fan-out. +- Current v18 daemon key/mouse payload IDs are client supplied. Stage 2 routes + key, mouse, paste, focus, and resize through the authenticated connection + source before any terminal ownership or PTY effect. +- The Stage 1 screen already preserves logical-line IDs and row cell offsets + through main-screen reflow. Stage 2 selection and scroll anchors use those + coordinates; numeric distance from the moving tail is derived metadata, + never stored ownership state. + +The final framing review pinned three precision contracts: + +- `at_bottom` reports whether the live tail is currently visible; it is not + the separate internal “follow future output” predicate. +- `C-c` is a consumed transport escape, not an ordinary dispatcher prefix + map; `C-c`-leading user bindings are deliberately unavailable in terminal + windows for Stage 2. +- Context-implicit Lua view operations require an authenticated interactive + command origin and fail closed rather than borrowing a stale + `active_frontend`. Stage 3 additionally owns `pmacs-gpu/src/attach.rs` for gated terminal -resize/pointer sending and coalescing. Daemon handlers must authenticate source -frontend/buffer ownership before input, resize, or pointer routing. Wire-facing -terminal state, selection, and limits must live in or be re-exported from -`pmacs-protocol`. The current 16 MiB transport frame cap cannot hold the legal -worst complete terminal frame (up to roughly 64 MiB of cluster bytes before -encoding overhead): Stage 3 must either raise and test a measured cap at least -as large as the legal worst case (review estimate at least 80 MiB), or add a -shared aggregate payload bound. It must never silently chunk the locked -complete-frame protocol. +resize/pointer sending and coalescing. New daemon event variants must apply the +same authenticated-source rule. Wire-facing terminal state, selection, and +limits live in or are re-exported from `pmacs-protocol`. The current 16 MiB +transport frame cap cannot hold the legal worst complete terminal frame (up to +roughly 64 MiB of cluster bytes before encoding overhead): Stage 3 must either +raise and test a measured cap at least as large as the legal worst case +(review estimate at least 80 MiB), or add a shared aggregate payload bound. It +must never silently chunk the locked complete-frame protocol. ### 0.5 Stage 1 review round 1 @@ -540,8 +572,11 @@ cursor rewrites, erase operations, alternate-screen swaps, and resize. ### 4.3 Lua API -Stage 2 installs `pmacs.terminal` before user config, loads -`builtin/runtime/terminal.lua`, and registers the interactive command: +Stage 2 constructs the shared `TerminalManager` immediately after the process +supervisor, installs strict Rust primitives, then loads +`builtin/runtime/terminal.lua`; all happen before LSP/MCP builtins and before +user config. The Lua chunk owns friendly wrappers, the interactive command, +buffer-local bindings, and the built-in statusline provider. ```lua local buffer = pmacs.terminal.open { @@ -556,138 +591,293 @@ local buffer = pmacs.terminal.open { } pmacs.terminal.is_terminal(buffer) -- boolean -pmacs.terminal.state(buffer) -- fresh plain metadata table -pmacs.terminal.send(buffer, bytes) -- explicit raw bytes -pmacs.terminal.resize(buffer, rows, cols) +pmacs.terminal.state(buffer) -- fresh global metadata +pmacs.terminal.view_state(ctx) -- fresh per-window metadata +pmacs.terminal.send(buffer, bytes) -- explicit trusted raw bytes pmacs.terminal.terminate(buffer) -- SIGTERM; buffer remains -pmacs.terminal.scroll(lines) -- active terminal window -pmacs.terminal.scroll_to_bottom() -pmacs.terminal.copy_selection() -- active terminal window +pmacs.terminal.scroll(lines) -- boolean; interactive origin required +pmacs.terminal.scroll_to_bottom() -- boolean; interactive origin required +pmacs.terminal.copy_selection() -- boolean; interactive origin required ``` -`open` validates exact raw table fields before side effects. Unknown fields, -metatable-provided fields, holes in `args`, non-string env keys/values, -embedded NUL, non-integer dimensions, and out-of-range scrollback reject with -the field named. The copied spec is immune to caller mutation. Returned and -accepted identity is `BufferIdLua`, following the rest of the editor API. +`open` accepts exactly the fields shown. It validates raw table fields before +side effects: unknown fields, metatable-provided fields, holes in `args`, +non-string environment keys/values, embedded NUL, non-integer dimensions, and +out-of-range scrollback reject with the field named. The copied specification +is immune to caller mutation. Returned and accepted identity is `BufferIdLua`, +following the rest of the editor API. -The built-in chunk registers `terminal` as an interactive command. It opens -`$SHELL` without a shell-command interpolation layer. There is no command -string split and no implicit `sh -c`. -It also installs terminal-buffer-local commands used after the escape prefix: -`M-w` copies the terminal selection, `M-v`/`C-v` page scrollback up/down, and -`M-<`/`M->` move to the oldest retained row/bottom. These shadow ordinary -document commands only during the one-key editor escape; normal terminal input -still sends those keys to the child. +Generated names are reserved against the live buffer registry before insert: +`*terminal:sh*`, `*terminal:sh*<2>`, `*terminal:sh*<3>`, and so on. The lowest +available suffix wins; failed creation consumes no suffix. An explicit `name` +is preserved exactly after ordinary `TerminalSpec` validation. + +`state(buffer)` returns a fresh plain table: + +```lua +{ + buffer = buffer, pid = 123, rows = 24, cols = 80, + title = nil, screen_generation = 7, + process = { kind = "running" }, + -- or { kind="exited", code=0 }, + -- or { kind="signaled", signal="TERM" }, + -- or { kind="crashed", message="..." } +} +``` + +`view_state(ctx)` accepts the raw statusline context fields +`{frontend, window, buffer}` and returns +`{at_bottom=boolean, scroll_offset=integer, selection=boolean}`, or `nil` when +that exact context is not a live terminal view. `state`/`view_state` expose no +stored Lua tables, callbacks, or mutable manager state. + +The three context-implicit view operations require a live authenticated +interactive command origin. A plain programmatic `pmacs.command.invoke` +outside such a dispatch does not synthesize one and raises a Lua error; so does +an origin whose active window is not the addressed terminal context. Errors +name the operation and leave view, selection, clipboard, and controller state +unchanged. `scroll(lines)` requires an integer: positive moves toward older +rows, negative toward the live tail, and zero returns `false`; otherwise it +returns whether `top` changed. `scroll_to_bottom()` returns whether selection +or `top` was cleared. `copy_selection()` returns `true` only when bytes were +published and `false` for a valid terminal context with no selection. + +There is deliberately no public Lua `resize`: the active controller's computed +window content rectangle is the only geometry authority. `send` remains an +explicit trusted escape hatch for packages and tests; ordinary user paste and +keys use the mode-aware input path. + +The built-in `terminal` command calls the public wrapper with `$SHELL` or +`/bin/sh`, no shell-command interpolation, no string split, and no implicit +`sh -c`. The wrapper installs terminal-buffer-local one-key commands: +`M-w` copies, `M-v`/`C-v` page up/down, and `M-<`/`M->` move to the oldest +retained row/bottom. They run only after the terminal escape prefix; normal +terminal input sends those keys to the child. + +### 4.4 Stage 2 view and controller contracts + +Stage 2 adds `src/terminal/view.rs`. It is a projection over the one +`TerminalScreen`, not another screen: + +```rust +TerminalViewKey { frontend_id, window_id, buffer_id } +LogicalCellAnchor { logical_line_id, cell_offset } +TerminalSelection { anchor, head } // inclusive display cells +TerminalViewState { top, selection, drag } // keyed by TerminalViewKey +TerminalController { frontend_id, window_id } +``` + +`cell_offset` is the leading display-cell offset within one logical line. +Clicks on a wide continuation canonicalize to its lead. Ordering is resolved +against the current retained row sequence, not by comparing IDs. A top anchor +resolves to the physical row containing that logical offset after reflow. + +`TerminalManager` gains owned operations to register/retain/detach view keys, +claim or release a session controller, create a +`snapshot_for_view(key, viewport_size)`, scroll/select/copy one view, and query +fresh global/view metadata. `detach_frontend` and live-layout retention remove +stale view/controller state; buffer prune removes every context for that +session. The context-free Stage 1 `snapshot(buffer)` remains available for +headless/core callers. + +`snapshot_for_view` returns exactly `viewport_size.area()` cells for a nonzero +valid viewport. At bottom it tail-aligns rows, padding above and to the right +with default cells; a smaller view clips top/rows and right/columns. A +scrolled view starts at its resolved top anchor and pads only after retained +content is exhausted. Cursor and selection spans are translated into this +returned coordinate space. `scroll_offset` is a saturating derived count of +display rows between the viewport and live tail. + +`at_bottom` is purely geometric: it is `true` exactly when +`scroll_offset == 0`, meaning the live tail is currently visible. It is +independent of the follow predicate (`top == None && selection == None`). +Therefore a Shift-selection frozen at the tail has `top != None`, +`scroll_offset == 0`, and `at_bottom == true`: the active cursor remains +visible, unshifted supported child mouse reporting remains eligible, and the +statusline emits no `↑0`. The first later output retained behind that anchor +makes `scroll_offset > 0` and `at_bottom == false`. + +Main-screen reflow keeps logical anchors stable. Beginning a selection freezes +the current first visible row as `top`, even when it was the live tail, so +later output has an anchor to preserve. Oldest-row eviction clamps a missing +anchor once to the first surviving leading cell; a selection whose two ends +collapse is cleared. Alternate-screen entry/exit or a reset that removes the +referenced logical IDs clears affected selection/top state and returns the view +to bottom. Child output follows only views with `top=None` and no selection. ## 5. Stage 2 — TUI integration -### 5.1 Composition and cursor +### 5.1 Composition, cursor, and status -For every window whose `buffer_id` belongs to `TerminalManager`, the window -content rectangle is painted from a terminal snapshot instead of -`TextView::render`. Modeline/statusline composition remains unchanged. Normal -text overlays, line-number gutters, syntax, diagnostics, and wrapping are not -run over terminal cells. +`RenderState::render_frame` and `paint_frame` take an explicit target +`FrontendId`. Layout lookup, statusline evaluation, active-window choice, and +terminal view keys use that ID. A shared placement helper computes each +window's outer rectangle, one-row modeline reservation, and content rectangle; +both resize synchronization and painting consume the same result. -Each `(frontend_id, window_id, buffer_id)` owns a `TerminalViewState`. At -bottom, the last screen row aligns with the content rectangle's last row. -Scrolling records a stable logical-line top anchor rather than a numeric -distance from a moving tail. New child output therefore does not move a -scrolled-back viewport or selection. If retention evicts that anchor, it -clamps once to the oldest retained row. A “not at bottom” marker is available -to the built-in terminal statusline provider. +For every terminal buffer, the content rectangle is painted from +`snapshot_for_view` instead of `TextView::render`. Cell glyph/style values are +copied directly; no ANSI is reinterpreted. Line-number gutters, document +wrapping, syntax, diagnostics, inlays, ordinary selections, and local/peer +document overlays are suppressed for that window. Modeline/statusline, +sibling splits, and cells outside the rectangle retain the existing pipeline. -The active terminal cursor is translated from terminal-local coordinates into -the window rectangle. It is hidden when the child hid it, the window is not -active, the viewport is scrolled away from bottom, or the coordinate is -clipped. Other terminal windows do not paint a cursor. +Each `(frontend_id, window_id, buffer_id)` view follows §4.4. Merely rendering +a passive view never resizes the PTY. Zero-area content is skipped without +creating a view or changing the prior valid size. -A smaller window clips; a larger window pads with default cells. Merely -rendering a passive view never resizes the PTY. +The active terminal cursor is translated from snapshot-local coordinates into +the content rectangle. It is hidden when the child hid it, the window is not +the target frontend's active window, the viewport is scrolled from bottom, or +the coordinate is clipped. Other terminal windows do not paint a cursor. -BEL is forwarded only from the active terminal through the existing frontend -signal path. OSC title is sanitized and exposed in terminal metadata/frame and -the terminal statusline; it does not rename the identity buffer or directly -set the host window title. +BEL is an out-of-band `InstanceSignal::Bell` only when a new bell occurs in +the target frontend's active terminal. Historical bells are never replayed on +later activation, and one bell count is delivered at most once per frontend. +The in-process loop drains both clipboard and terminal signals and feeds them +through `Frontend::present_messages`; the TUI Bell arm emits one host BEL. +OSC title remains sanitized metadata (and a Stage 3 frame field). It never +renames the identity buffer, injects host control bytes, or sets the host title. -### 5.2 Input precedence +`terminal.lua` registers a right-side provider named `terminal`, priority 10, +face `ui.modeline.terminal`. It emits `TERM` while running, `TERM:` on +normal exit, `TERM:` on signal, or `TERM:ERR` on crash, and appends +` ↑` only when `scroll_offset > 0`. An unset child face +inherits the existing modeline foreground through the Arc 4 contract. -Modal editor surfaces remain authoritative. Input precedence is: +### 5.2 Input precedence and per-frontend dispatch + +Stage 2 replaces the one pending `KeyDispatcher` with dispatch state keyed by +`FrontendId`; keymaps and command registries remain shared. Terminal escape +state is stored beside that dispatcher. `dispatch_idle_for(frontend_id)` uses +the same frontend's pending prefix and active window; global modal surfaces +still make every frontend non-idle while they own input. + +`EditorState` carries an ephemeral authenticated command origin around +key/menu/M-x interactive invocation and clears it with the invocation guard. +Nested calls inherit that origin. Plain `pmacs.command.invoke` outside the +guard neither stamps command history nor creates terminal view authority. + +Input precedence is: 1. minibuffer, incremental search, completion/menu, query-replace, and other existing modal shadows; -2. terminal escape-prefix state; -3. terminal key/mouse/paste handling when the active buffer is terminal; -4. ordinary buffer-local/global keymaps and self-insert. +2. that frontend's terminal escape-prefix state; +3. terminal key/mouse/paste handling when that frontend's active buffer is a + terminal; +4. that frontend's ordinary buffer-local/global dispatcher and self-insert. -All terminal buffers remain in `round_trip_buffers`, so GPU/TUI input reaches -this daemon-owned decision before any optimistic edit. -Escape-prefix state is per frontend, so one attached user's pending escape -never captures another user's next key. +All terminal buffers stay in `round_trip_buffers`, so attached frontends reach +this daemon-owned decision before optimistic edit. One user's pending escape +or longer ordinary prefix cannot consume, cancel, or display as another +user's pending sequence. -When terminal input owns a normalized key, `terminal/input.rs` encodes: +When terminal input owns a normalized key, `terminal/input.rs` encodes UTF-8 +printable characters; Ctrl mappings and Alt ESC-prefixing; Enter, Tab, +Backspace, Escape; arrows/Home/End according to application-cursor mode; +Insert/Delete/Page, F1–F12, Shift-Tab, and supported modifier parameters. +Unknown/lock/media keys are ignored. Press is actionable; local repeat is +treated as another press and release is not forwarded. The normalized +protocol cannot distinguish number-row from numeric-keypad characters, so +application-keypad mode remains tracked but cannot transform them. -- UTF-8 printable characters; -- Ctrl-character mappings, Alt ESC-prefixing, Enter/Tab/Backspace/Escape; -- arrows/Home/End according to application-cursor mode; -- Insert/Delete/Page and F1–F12 xterm sequences; -- Shift-Tab and supported modifier parameters. +`C-c` is the fixed terminal escape prefix. It is consumed and makes the next +key run through the same frontend's ordinary dispatcher; a resulting longer +prefix stays in that frontend's dispatcher. `C-c C-c` instead sends literal +Ctrl-C. Modal shadows consume their keys before either rule. -Unknown/lock/media keys are ignored, never converted into text. Press is the -only actionable event in the current normalized protocol; repeat arrives as -repeated press and release is not forwarded. -The normalized protocol does not distinguish number-row digits from numeric -keypad digits, so application-keypad mode is tracked but cannot transform -those ambiguous `Key::Char` events. - -`C-c` is the fixed stage-2 terminal escape prefix. It is consumed and makes the -next key run through the ordinary editor dispatcher, allowing `C-c C-x ...` -for editor commands. `C-c C-c` sends the literal Ctrl-C byte required to -interrupt the child. This is an intentional fixed stage-2 policy. +This is deliberately a consumed transport escape, not an Emacs-style +`C-c` prefix map: the dispatcher receives the post-escape key as a fresh +sequence, while `C-c C-c` is reserved for literal interrupt. Consequently a +global or buffer-local binding whose first chord is `C-c` cannot fire in a +terminal window. Packages may bind a post-escape one-key sequence, as the +built-in terminal-local commands do. A configurable escape/prefix-map policy +remains the named §11 deferral. Paste sends exact bytes, wrapped in `ESC[200~` / `ESC[201~` only while the -child enabled bracketed paste. It never passes through a command shell or Lua. -When the child enabled focus reporting, authenticated frontend focus gain/loss -sends `ESC[I` / `ESC[O` for the controlling terminal. With the mode off, -focus changes send no PTY bytes. +child enabled bracketed paste. It never passes through a shell or Lua. +Authenticated focus gain claims the active terminal context and emits +`ESC[I` when enabled. Focus loss emits `ESC[O` only when that source currently +controls the session, then releases that controller; mode-off focus is silent. ### 5.3 Mouse, selection, copy, and scrollback -If the child enabled a supported mouse mode, pointer events inside the terminal -content rectangle are encoded as SGR mouse reports, with coordinates translated -to terminal-local 1-based cells. The active mode determines whether press, -release, drag, move, and wheel are reported. +Child mouse reporting owns a pointer only when the exact terminal view is at +bottom, the child enabled a supported tracking mode plus SGR encoding, and +Shift is not held. Events inside the content rectangle translate to +terminal-local 1-based cells; the active mode filters press, release, drag, +move, and wheel. Otherwise the editor owns the gesture: -- wheel changes the per-window scrollback offset; -- primary drag creates a terminal-cell selection across history and screen; -- copy serializes selected rows as UTF-8, trims only trailing default blank - cells, joins soft-wrapped rows without `\n`, and separates hard rows with - `\n`; -- wide-cell continuations are never emitted twice; -- a new plain click clears the old selection; -- child output does not move a scrolled-back viewport or selection anchor. +- wheel scrolls that `TerminalViewKey`; +- primary drag stores inclusive logical-cell endpoints across history/screen; +- Shift is the explicit editor-selection override while child mouse reporting + is active; +- an editor-owned right press follows the existing context-menu path; +- a new plain primary click clears the old selection before setting its anchor; +- child output does not move a scrolled viewport or selection. -`pmacs.terminal.copy_selection()` publishes through the existing kill-ring / -clipboard path. Ordinary document selection fields remain untouched. +Copy resolves anchors against retained logical rows, emits each leading glyph +once, trims only trailing default blank cells, joins soft wraps without `\n`, +and separates hard rows with `\n`. Wide continuations are never duplicated; +combining clusters remain one UTF-8 sequence. Reversed drags normalize by +retained row order. `pmacs.terminal.copy_selection()` writes through the +existing kill-ring/clipboard setter for the acting frontend; ordinary document +selection state is untouched. -### 5.4 Resize ownership +Scrolling uses physical retained display rows and clamps at oldest/bottom. +Reaching bottom clears `top` only when no selection is active. The explicit +`scroll_to_bottom`/`M->` action clears both selection and `top` so live-tail +following resumes; ordinary copy leaves selection intact. Page commands use +the current nonzero content-row count. Selection and scroll are available in +the alternate screen only over its visible rows; no alternate output enters +main history. -One PTY has one kernel window size even when displayed in several views. The -controlling view is the active window of `core.active_frontend` — the frontend -that most recently supplied accepted input/focus. Only that view may resize the -PTY. Passive views clip/pad. +### 5.4 Durable control and resize -For grid frontends, the daemon derives the terminal content `rows × cols` from -the computed split rectangle and modeline reservation. Focus/split/frontend -resize changes trigger one checked `resize_pty`; unchanged dimensions are -suppressed. The screen model resizes before the child receives `SIGWINCH`, so -its repaint lands into the new geometry. -If the computed content rectangle has zero rows or columns, rendering skips it -and the prior valid PTY size remains unchanged; zero is never sent to -`resize_pty`. +Each terminal session stores at most one `TerminalController`. Successful +`open` claims the newly active `(frontend, window)`. Later authenticated +terminal key, paste, editor-owned/child-owned pointer, or focus gain claims the +source's currently active terminal window. A render pass never claims control. +Switching that controller away, killing its window/buffer, focus loss, or +frontend detach releases it; size stays unchanged until another accepted +context claims it. + +Before the next terminal process drain and before paint, the instance +synchronizes live terminal layouts. Only a controller whose exact window still +shows that session may resize. Unchanged dimensions are suppressed before any +PTY syscall. The manager validates once, performs `resize_pty`, then applies +the same prevalidated `TerminalScreen::resize` in the same main-thread call; +no supervisor output drain can interleave between them. A PTY failure leaves +the prior screen geometry intact. The child may emit after `SIGWINCH` only on a +later drain, when the screen already has the new geometry. + +Content rows/columns come from the shared placement helper after modeline +reservation and with no document gutter. A zero row/column result performs no +resize and preserves the prior valid geometry. Passive splits/frontends only +clip or pad their own snapshots. + +### 5.5 Local and v18-daemon adapters + +The in-process event adapter handles key press/repeat, mouse, paste, +focus-gained/lost, and resize through the same `EditorState` terminal methods. +It synchronizes layout before `tick_processes`, then presents pending +clipboard/BEL messages. Host restoration remains the existing `Frontend` +drop/error contract. + +The daemon passes the authenticated connection `source` explicitly to grid +render and input. Client-supplied IDs in v18 Key, Mouse, Paste, Focus, Resize, +and Detach payloads never choose a view or controller. Session detach removes +that frontend's dispatcher, terminal views, controller claims, and bell +baseline. These are Stage 2 changes to existing v18 grid behavior; no protocol +bump or semantic/GPU terminal surface is added in this PR. + +One intentional Stage 2 boundary remains until Stage 3: an authenticated v18 +semantic frontend can send a key while its active buffer is a terminal, claim +that terminal's controller, and feed the PTY, but cannot display the screen. +The next accepted TUI terminal input reclaims control; v19 removes the invisible +interval by adding the semantic terminal surface. ## 6. Stage 3 — protocol v19 and GPU integration @@ -803,21 +993,22 @@ coherent. | Owner | Stable scope | Primary files | | --- | --- | --- | -| Lead/integrator | contracts first; `TerminalManager`, buffer/Lua/builtin wiring, cross-surface acceptance, gates, docs, branches/PRs | `src/terminal/session.rs`, `src/lua_bindings/mod.rs`, `builtin/runtime/terminal.lua`, `tests/vterm_*_acceptance.rs`, docs | -| VT core agent | streaming parser operations, screen state machine, input encoder, model units | `src/ansi.rs`, `src/terminal/screen.rs`, `src/terminal/input.rs` | -| TUI agent | terminal window composition, cursor, per-view scroll/selection/copy, grid input and resize | `src/terminal/view.rs`, owned sections of `src/editor.rs`, focused TUI tests | -| Protocol/GPU agent | v19 types/limits/gates, semantic terminal producer, authenticated daemon routing, GPU state/render/hit-test | `pmacs-protocol`, `src/protocol.rs`, `src/semantic_render.rs`, owned sections of `src/daemon.rs`, `pmacs-gpu/src/main.rs` | +| Lead/integrator | contracts first; `TerminalManager`, Lua/builtin wiring, lifecycle/signal integration, shared acceptance, gates, docs, branches/PRs | `src/terminal/session.rs`, `src/lua_bindings/mod.rs`, `builtin/runtime/terminal.lua`, narrow Stage 2 authenticated-dispatch sections of `src/daemon.rs`, `tests/vterm_*_acceptance.rs`, docs | +| VT core agent | encoder corrections and screen query seams needed by view projection; no renderer ownership | `src/ansi.rs`, `src/terminal/screen.rs`, `src/terminal/input.rs` | +| TUI agent | view projection, frontend-explicit grid composition/cursor, per-frontend dispatch, local events, scroll/selection/copy/resize | `src/terminal/view.rs`, `src/instance_render.rs`, `src/frontend.rs`, owned sections of `src/editor.rs`, focused TUI tests | +| Protocol/GPU agent | Stage 2 contract review; Stage 3 v19 types/limits/gates, semantic producer, authenticated new-event routing, GPU state/render/hit-test | `pmacs-protocol`, `src/protocol.rs`, `src/semantic_render.rs`, Stage 3 sections of `src/daemon.rs`, `pmacs-gpu/src/{main,attach}.rs` | Coordination rules: -- Lead establishes types and method signatures before another lane edits a - caller. +- Lead establishes types, invariants, and method signatures before another + lane edits callers. - Strict file ownership. `src/editor.rs` passes from lead to TUI only after - stage-1 construction wiring is settled; `src/daemon.rs` belongs only to the - protocol/GPU lane in stage 3. + construction wiring; lead and TUI coordinate exact non-overlapping + `src/daemon.rs`/signal hunks. Stage 3 daemon work begins only after Stage 2 + lands. - Workers do not update docs, ledgers, branches, or PRs and do not stash, checkout, rebase, or merge. -- Workers add focused tests in their owned modules. Lead alone owns shared +- Workers add focused tests in owned modules. Lead alone owns shared acceptance files. - Exact-path staging only; never `git add .`. - Four agents are the total vterm team, not four implementation workers plus a @@ -825,23 +1016,25 @@ Coordination rules: Per-stage utilization: -- Stage 1: lead + VT core implement; TUI and protocol/GPU owners review the - snapshot/input contracts against their future consumers. -- Stage 2: TUI implements; VT core owns encoder corrections; lead integrates - lifecycle/acceptance; protocol/GPU owner checks that no TUI-only assumption - enters the snapshot contract. -- Stage 3: protocol/GPU implements; TUI and VT core owners add parity cases in - their existing surfaces; lead integrates and gates. +- Stage 1: completed by lead + VT core; TUI and protocol/GPU reviewed future + consumer contracts. +- Stage 2: lead establishes manager/Lua contracts and authenticated adapters; + TUI implements projection/input/rendering; VT core adds only required + encoder/query corrections; protocol/GPU checks snapshot neutrality. +- Stage 3: protocol/GPU implements; TUI and VT core add parity cases in their + existing surfaces; lead integrates and gates. ## 8. Branch and PR plan -Stage 1 landed on `main` through PR #126 at merge `643d1e1`. Continue the -approved sequential plan: +Stage 1 landed on `main` as PR #126 at merge `643d1e1`. Continue one clean PR +at a time: -1. create `pmacs-vterm-tui`, branch `vterm-tui`, from post-#126 `main`; - implement, gate, and open the second PR; -2. after Stage 2 merges, create `pmacs-vterm-gpu`, branch `vterm-gpu`, from - the new `main`; implement, gate, and open the third PR. +1. Revision 7 framing review is complete and the implementation contract is + approved; Stage 2 is implemented on `vterm-tui`, then gated and opened as + the second PR; +2. merge Stage 2 only when the user says; +3. create `pmacs-vterm-gpu`, branch `vterm-gpu`, from the then-current `main`; + implement, gate, and open the third PR. The framing branch is `vterm-framing` in worktree `pmacs-vterm-framing`. Implementation branches are not stacked across an unmerged parent. This avoids @@ -891,49 +1084,126 @@ base-branch deletion/auto-close risk and makes each PR's gate evidence honest. ### Stage 2 — TUI -15. Lua `open` performs the same strict raw-field validation, publishes no - partial state on error, and switches the active window only after success. +15. Lua `open` enforces the strict owned table contract, uniquifies generated + names without consuming failed suffixes, publishes no partial state on + error, and switches/claims the active window only after success. + `state` and `view_state` return exact fresh plain tables; no public Lua + resize bypass exists. Context-implicit view operations error without an + authenticated interactive terminal origin and otherwise return the exact + changed/copied booleans without partial mutation. 16. A terminal window paints exact cells/styles inside its content rectangle; - statusline, sibling splits, and outside cells are untouched. -17. Active cursor translation, child-hidden cursor, passive window, clipping, - and scrolled-back hiding are exact. -18. Printable, Ctrl, Alt, arrows, Home/End, function keys, application cursor, - focus reporting, and unknown keys produce the specified PTY bytes. -19. `C-c` dispatches one editor key; `C-c C-c` sends Ctrl-C; modal minibuffer, - search, menu, and query-replace remain authoritative. -20. Paste is byte-exact with bracketed wrappers only when enabled. -21. Mouse-reporting modes receive translated SGR reports. With reporting off, - the same gestures scroll/select/copy and write no PTY bytes. -22. Copy handles soft/hard wraps, trailing blanks, wide/combining glyphs, - resize/reflow, eviction-clamped anchors, and selections crossing - history/screen exactly once. -23. The controlling active view alone resizes the PTY; passive split/frontend - renders never cause resize thrash. -24. A hermetic real TUI smoke opens `/bin/sh`, runs a cursor-addressed probe, - resizes, scrolls/copies, exits, and restores the host terminal cleanly. + top/right padding, clipping, modeline, sibling splits, outside cells, and + suppression of document/peer overlays are exact and control-free. +17. Per-context snapshots preserve logical top/selection anchors through + main-screen reflow, derive the correct scroll offset, clamp oldest-row + eviction once, and clear invalid alternate/reset anchors. A selection + frozen at the live tail pins `top != None`, `scroll_offset == 0`, and + `at_bottom == true`; cursor, child-mouse eligibility, status suffix, and + the first subsequent output transition follow that definition exactly. +18. Printable, Ctrl, Alt, navigation/function keys, application cursor, focus, + unknown keys, and paste produce exact PTY bytes; bracketed wrappers appear + only when enabled. +19. `C-c` dispatches one fresh editor key and retains the owning frontend + through a longer prefix; `C-c C-c` sends Ctrl-C. Ordinary bindings whose + first chord is `C-c` are deliberately unreachable in terminal windows. + Two frontends cannot consume one another's escape/pending state or + dispatch-idle value, and existing modal shadows remain authoritative. +20. Supported SGR mouse modes receive translated reports only at bottom without + Shift. Mode-off, scrolled, unsupported-legacy, and Shift gestures remain + editor-owned; wheel, drag, clear, and right-context behavior write no PTY + bytes. +21. Copy handles soft/hard wraps, trailing blanks, wide/combining glyphs, + reversed drags, resize/reflow, eviction, alternate screen, and + history/screen crossings exactly once, then publishes to the acting + frontend's kill-ring/clipboard path without touching document selection. +22. The durable authenticated controller alone resizes. Open/input/focus + claims and focus/switch/kill/detach releases are exact; unchanged, zero, + passive, and failed resize cases preserve prior geometry without thrash, + and screen resize precedes the next child-output drain. +23. Forged v18 grid payload frontend IDs for key, mouse, paste, focus, resize, + or detach cannot select or affect another frontend's terminal context. + Detach and layout retention remove only the matching views, dispatcher, + controller, and bell baseline. +24. Local and daemon-grid paths deliver clipboard and each new active-terminal + BEL exactly once. Historical/passive bells and OSC titles do not become + host control effects. The built-in terminal provider reports exact + process/scroll state for each split. +25. Two frontends and sibling splits over one terminal retain independent + bottom/scroll/selection snapshots while sharing one process, screen, title, + process outcome, and controller. +26. Killing the identity buffer terminates/reaps the child and removes all + views; switching away leaves it running; editor/drop and error paths + restore the host terminal and leak no child, reader, or stale round-trip + state. +27. A hermetic real TUI smoke opens `/bin/sh`, runs a cursor-addressed probe, + exercises key/paste, resize, scroll/select/copy, BEL, and clean exit, then + proves host raw/alternate-screen state is restored. + +#### Stage 2 verification map + +The cross-surface suite is `tests/vterm_stage2_acceptance.rs`; focused unit +coverage remains beside the owning implementation. The criteria map as follows: + +- **15:** `lua_surface_is_strict_fresh_transactional_and_context_safe`. +- **16:** `editor::tests::terminal_snapshot_composes_only_content_and_translates_cursor` + plus the real-TUI smoke. +- **17:** `terminal::view::tests::{tail_projection_pads_above_and_right_and_translates_cursor, + frozen_top_is_geometrically_at_bottom_when_view_still_reaches_tail, + alternate_switch_clears_view_anchors_and_selection}` and + `shared_screen_keeps_view_scroll_selection_and_controller_independent`. +- **18:** `terminal::input::tests::{utf8_ctrl_and_alt_boundaries, + application_cursor_and_xterm_modifiers, ambiguous_digits_ignore_application_keypad, + paste_and_focus_are_exact, unsupported_keys_are_invisible}` and the real-TUI + input/paste path. +- **19:** `editor::tests::dispatch_prefix_state_is_independent_per_frontend`, + `terminal_escape_gates_local_bindings_and_double_escape_sends_interrupt`, + `lua_surface_is_strict_fresh_transactional_and_context_safe`, and the + real-TUI escaped editor-binding/quit path. +- **20:** `terminal::input::tests::sgr_mouse_modes_modifiers_and_coordinates` + plus the real-TUI editor-owned scroll/drag/copy path. +- **21:** `terminal::view::tests::{copy_joins_soft_wraps_trims_default_blanks_and_separates_hard_rows, + wide_continuation_canonicalizes_to_lead_and_copies_once}`, + `lua_surface_is_strict_fresh_transactional_and_context_safe`, and the real + OSC 52 clipboard assertion. +- **22:** `lua_surface_is_strict_fresh_transactional_and_context_safe`, + `shared_screen_keeps_view_scroll_selection_and_controller_independent`, and + the real child-PTY resize assertion. +- **23:** `daemon::tests::forged_resize_mutates_only_the_authenticated_frontend`, + `daemon::tests::inbound_paste_uses_authenticated_source_not_the_claimed_id`, + the existing `m5_4_dispatch_{key,mouse}_threads_frontend_id_to_lua_surface` + tests, and the multi-frontend detach assertions in + `shared_screen_keeps_view_scroll_selection_and_controller_independent`. +- **24:** the built-in-provider and clipboard assertions in + `lua_surface_is_strict_fresh_transactional_and_context_safe`, + `daemon::tests::terminal_bell_baseline_suppresses_history_and_delivers_each_new_bell_once`, + and the real-TUI BEL/OSC 52 assertions. +- **25:** `shared_screen_keeps_view_scroll_selection_and_controller_independent`. +- **26:** both in-process acceptance tests' termination/cleanup assertions and + the real-TUI clean-exit/host-restoration assertions. +- **27:** `real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_and_bell`. ### Stage 3 — GPU/protocol -25. Protocol v19 appends all new variants after v18 pins; v18 grid traffic +28. Protocol v19 appends all new variants after v18 pins; v18 grid traffic round-trips unchanged and new outbound variants are version-gated. -26. Terminal frame validation accepts exact shared boundaries and atomically +29. Terminal frame validation accepts exact shared boundaries and atomically rejects over-area, bad area, out-of-bounds cursor, malformed cluster, orphan continuation, invalid selection spans, attachment, overlong title, and overlong process-state text while retaining the prior valid frame. -27. Semantic terminal activation suppresses document-only messages; switching +30. Semantic terminal activation suppresses document-only messages; switching back forces a complete document resync. -28. Two frontends/splits on one terminal keep independent scroll/selection +31. Two frontends/splits on one terminal keep independent scroll/selection snapshots; only the active controlling context resizes or writes input. -29. Forged frontend/buffer IDs in terminal resize/pointer events cannot affect +32. Forged frontend/buffer IDs in terminal resize/pointer events cannot affect another terminal or process. -30. Headless GPU rendering pins background rectangles, indexed/truecolor, +33. Headless GPU rendering pins background rectangles, indexed/truecolor, reverse, wide/combining cells, clipping, cursor visibility, status-band separation, and no frontend wrapping. -31. Font/window resize emits cell dimensions, never pixels, and identical +34. Font/window resize emits cell dimensions, never pixels, and identical resize requests are suppressed. -32. Theme/font/terminal generation changes invalidate exactly the affected +35. Theme/font/terminal generation changes invalidate exactly the affected caches; an unchanged terminal frame produces no redraw message. -33. A real daemon + required-GPU smoke runs a full-screen alternate-screen +36. A real daemon + required-GPU smoke runs a full-screen alternate-screen probe, handles input and resize, exits, and returns to the preserved main screen. @@ -970,6 +1240,9 @@ Not part of these three PRs: alternate-screen switches; - legacy X10 mouse byte encoding when a child enables mouse tracking without SGR mode; Stage 2 sends no report for that unsupported combination; +- bracketed-paste payload filtering: Stage 2 forwards exact paste bytes as + framed, so embedded `ESC[201~` can terminate the wrapper early; xterm-style + filtering/escaping requires a separate input-policy decision; - nonstandard `CSI 3 K` ignore semantics (the current core clears the line); - the ASCII fast path that avoids grapheme-candidate allocation and segmentation for every printable character after another ASCII character, @@ -991,7 +1264,8 @@ panic, unbounded allocation, or child leak. ## 12. Resolved decisions -The 2026-07-21 architecture discussion resolved every Revision 1 question: +The 2026-07-21 architecture, re-scout, and final framing review resolved every +current question: 1. Fixed terminal editor escape: `C-c`; `C-c C-c` sends literal Ctrl-C. 2. Resize: reflow main-screen soft wraps; clip/pad alternate screen. @@ -1000,5 +1274,22 @@ The 2026-07-21 architecture discussion resolved every Revision 1 question: no terminal surface. 5. GPU wire: complete visible frames with complete-payload suppression. 6. Style: preserve the shared encoding and defer unsupported attributes. -7. Identity: one process/screen per terminal `BufferId`; the most recently - active frontend's active view controls PTY size. +7. Identity: one process/screen per terminal `BufferId`. +8. View state: logical-line/cell anchors per + `(FrontendId, WindowId, BufferId)`; `at_bottom` means + `scroll_offset == 0`, while following additionally requires no top anchor + and no selection; no second screen. +9. Control: the most recently authenticated accepted terminal context owns + resize until focus/switch/kill/detach releases it; render never claims. +10. Dispatch: terminal escape and ordinary pending prefixes are per frontend; + the consumed `C-c` escape deliberately hides ordinary `C-c`-leading + bindings in terminal windows. +11. Mouse: Shift or scrollback forces editor selection; supported SGR child + reporting owns unshifted at-bottom gestures. +12. Resize order: validate, suppress unchanged, resize PTY, resize the screen + before any subsequent child-output drain; failure preserves old geometry. +13. Lua: strict open/state/view/send/terminate/scroll/copy surface; no public + geometry bypass; implicit view operations require authenticated interactive + origin and otherwise error without mutation. +14. Host effects: clipboard and BEL use explicit frontend signals; OSC title + remains sanitized metadata. diff --git a/src/daemon.rs b/src/daemon.rs index 3587622..f153625 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -869,6 +869,10 @@ fn dispatcher_loop( // Declared for both flavors (the follow path is crdt-gated; the // detach cleanup isn't). let mut last_active_buffer_sent: HashMap = HashMap::new(); + // Active-terminal BEL delivery baseline. Switching away forgets the + // terminal so historical bells are never replayed on later activation. + let mut terminal_bell_baselines: HashMap = + HashMap::new(); let mut session_registry = SessionRegistry::new(); // T M10.11 Q8 — jitter PRNG, seeded once so the // convergence-under-jitter scenario is deterministically @@ -1063,10 +1067,15 @@ fn dispatcher_loop( // the sweep below); other-frontend snapshots lag by // at most one tick. Imperceptible at frame cadence. let other_presences = session_registry.other_presences_for(*fid); + let render_size = render_states + .get(fid) + .expect("render_state present for attached grid fid") + .size(); + let terminal_snapshots = editor.prepare_terminal_views(*fid, render_size); let render_state = render_states .get_mut(fid) .expect("render_state present for attached grid fid"); - render_state.render_frame(editor, &other_presences) + render_state.render_frame(editor, *fid, &terminal_snapshots, &other_presences) }; // T M10.6 per-frontend presence sweep. The snapshot is @@ -1082,6 +1091,14 @@ fn dispatcher_loop( // — initial-after-attach (`last_dispatch_idle_sent` absent) // and value-change emissions only. let mut write_failed = false; + if take_pending_terminal_bell(editor, *fid, &mut terminal_bell_baselines) + && let Some(stream) = streams.get_mut(fid) + && let Err(error) = + write_message(stream, &InstanceMessage::Signal(InstanceSignal::Bell)) + { + eprintln!("pmacs: write terminal Bell for {fid:?} failed: {error}"); + write_failed = true; + } if session_registry.session_state(*fid).is_some_and(|s| { // Filter on both the `crdt_replica` capability (only // optimistic-apply frontends care) and the negotiated @@ -1089,7 +1106,7 @@ fn dispatcher_loop( s.negotiated_capabilities.crdt_replica && s.negotiated_protocol_version >= 4 }) && let Some(stream) = streams.get_mut(fid) { - let idle_now = editor.dispatch_idle(); + let idle_now = editor.dispatch_idle_for(*fid); if last_dispatch_idle_sent.get(fid) != Some(&idle_now) { if let Err(e) = write_message(stream, &InstanceMessage::DispatchIdle { idle: idle_now }) @@ -1283,6 +1300,8 @@ fn dispatcher_loop( term_sizes.remove(fid); last_dispatch_idle_sent.remove(fid); last_active_buffer_sent.remove(fid); + terminal_bell_baselines.remove(fid); + editor.detach_frontend_input(*fid); session_registry.unregister_session(*fid); editor .statusline_registry @@ -1328,6 +1347,7 @@ fn dispatcher_loop( &mut term_sizes, &mut last_dispatch_idle_sent, &mut last_active_buffer_sent, + &mut terminal_bell_baselines, &mut session_registry, ); // Drain a burst of immediately-available events to @@ -1344,6 +1364,7 @@ fn dispatcher_loop( &mut term_sizes, &mut last_dispatch_idle_sent, &mut last_active_buffer_sent, + &mut terminal_bell_baselines, &mut session_registry, ); } @@ -1352,6 +1373,15 @@ fn dispatcher_loop( Err(mpsc::RecvTimeoutError::Disconnected) => break, } + // Accepted terminal context controls PTY size. Apply any focus, + // window, or resize changes before consuming another child-output + // batch so screen reflow and subsequent bytes share one geometry. + for frontend_id in &attached_fids { + if let Some(size) = term_sizes.get(frontend_id).copied() { + editor.sync_terminal_layout(*frontend_id, size); + } + } + // `tick_async` last: the M4.5 async bridge settles awaiters // inside `tick_lsp` (via the message bus); draining + resuming // in the same frame keeps LSP `:await()` latency at one frame @@ -1375,7 +1405,49 @@ fn dispatcher_loop( /// initial-full-grid analogue — it emits nothing until the frontend /// declares a viewport); every other session keeps the M5.3 /// force-full-grid grid path. -#[allow(clippy::too_many_arguments)] +fn take_pending_terminal_bell( + editor: &EditorState, + frontend_id: FrontendId, + baselines: &mut HashMap, +) -> bool { + let buffer_id = editor + .core + .borrow() + .active_window_for(frontend_id) + .map(|window| window.buffer_id); + let Some((buffer_id, count)) = buffer_id.and_then(|buffer_id| { + editor + .terminal_manager + .borrow() + .bell_count(buffer_id) + .map(|count| (buffer_id, count)) + }) else { + baselines.remove(&frontend_id); + return false; + }; + + match baselines.get_mut(&frontend_id) { + Some((baseline_buffer, delivered)) + if *baseline_buffer == buffer_id && count > *delivered => + { + *delivered += 1; + true + } + Some((baseline_buffer, delivered)) if *baseline_buffer == buffer_id => { + *delivered = count; + false + } + _ => { + baselines.insert(frontend_id, (buffer_id, count)); + false + } + } +} + +#[allow( + clippy::too_many_arguments, + reason = "one session bootstrap transaction" +)] fn handle_session_established( editor: &mut EditorState, render_states: &mut HashMap, @@ -1460,6 +1532,7 @@ fn handle_dispatcher_event( term_sizes: &mut HashMap, last_dispatch_idle_sent: &mut HashMap, last_active_buffer_sent: &mut HashMap, + terminal_bell_baselines: &mut HashMap, session_registry: &mut SessionRegistry, ) { match event { @@ -1597,6 +1670,12 @@ fn handle_dispatcher_event( editor.dispatch_menu_pointer(source, index, invoke); } } + FrontendEvent::FocusGained(_) => { + editor.dispatch_focus(source, true); + } + FrontendEvent::FocusLost(_) => { + editor.dispatch_focus(source, false); + } FrontendEvent::Paste { frontend_id: claimed_fid, data, @@ -1619,7 +1698,9 @@ fn handle_dispatcher_event( // source's command chain (Q#KR2), and it fires // `buffer.after-edit` like any other edit (Q#KR10b) // — previously it never did, so LSP missed pastes. - handle_inbound_paste(editor, source, claimed_fid, &data); + if !editor.dispatch_paste(source, &data) { + handle_inbound_paste(editor, source, claimed_fid, &data); + } } _ => { let term_size = *term_sizes @@ -1627,7 +1708,7 @@ fn handle_dispatcher_event( .expect("term_size present for source"); let mut term_size = term_size; if let Some(render_state) = render_states.get_mut(&source) { - apply_event(editor, event, &mut term_size, render_state); + apply_event(editor, source, event, &mut term_size, render_state); term_sizes.insert(source, term_size); } else if semantic_states.contains_key(&source) { // Phase B (session B1) — a semantic (grid-less) @@ -1641,7 +1722,7 @@ fn handle_dispatcher_event( // arm dropped these events — the "M11.5 scope" // posture — which is why typing in pmacs-gpu did // nothing before B1.) - apply_semantic_input_event(editor, event, term_size); + apply_semantic_input_event(editor, source, event, term_size); } else { debug_assert!( false, @@ -1659,6 +1740,8 @@ fn handle_dispatcher_event( term_sizes.remove(&frontend_id); last_dispatch_idle_sent.remove(&frontend_id); last_active_buffer_sent.remove(&frontend_id); + terminal_bell_baselines.remove(&frontend_id); + editor.detach_frontend_input(frontend_id); session_registry.unregister_session(frontend_id); editor .statusline_registry @@ -2461,16 +2544,21 @@ fn build_presence_snapshot(editor: &EditorState, frontend_id: FrontendId) -> Pre /// `Paste` (Q#KR10a) are handled in their own dispatcher arms and /// never reach here. #[allow(clippy::needless_pass_by_value)] // consumes the event, mirroring `apply_event`. -fn apply_semantic_input_event(editor: &mut EditorState, ev: FrontendEvent, term_size: CellSize) { +fn apply_semantic_input_event( + editor: &mut EditorState, + source: FrontendId, + ev: FrontendEvent, + term_size: CellSize, +) { match ev { FrontendEvent::Key(pmacs_key) => { if let Some(ct_key) = key_to_crossterm(&pmacs_key) { - editor.dispatch_key(pmacs_key.frontend_id, ct_key); + editor.dispatch_key(source, ct_key); } } FrontendEvent::Mouse(pmacs_mouse) => { let ct_mouse = mouse_to_crossterm(&pmacs_mouse); - editor.dispatch_mouse(pmacs_mouse.frontend_id, ct_mouse, term_size); + editor.dispatch_mouse(source, ct_mouse, term_size); } _ => {} } @@ -2482,6 +2570,7 @@ fn apply_semantic_input_event(editor: &mut EditorState, ev: FrontendEvent, term_ #[allow(clippy::needless_pass_by_value)] fn apply_event( editor: &mut EditorState, + source: FrontendId, ev: FrontendEvent, term_size: &mut CellSize, render_state: &mut RenderState, @@ -2489,14 +2578,14 @@ fn apply_event( match ev { FrontendEvent::Key(pmacs_key) => { if let Some(ct_key) = key_to_crossterm(&pmacs_key) { - editor.dispatch_key(pmacs_key.frontend_id, ct_key); + editor.dispatch_key(source, ct_key); } // `Key::Unknown` keys (media buttons etc.) have no // crossterm equivalent and do not actuate commands; drop. } FrontendEvent::Mouse(pmacs_mouse) => { let ct_mouse = mouse_to_crossterm(&pmacs_mouse); - editor.dispatch_mouse(pmacs_mouse.frontend_id, ct_mouse, *term_size); + editor.dispatch_mouse(source, ct_mouse, *term_size); } FrontendEvent::Resize { size, .. } => { render_state.resize(size); @@ -2970,6 +3059,119 @@ mod tests { ); } + #[test] + fn forged_resize_mutates_only_the_authenticated_frontend() { + let source = FrontendId(41); + let forged = FrontendId(42); + let old_size = CellSize::new(24, 80); + let new_size = CellSize::new(31, 97); + let mut editor = EditorState::new(); + let mut render_states = HashMap::from([ + (source, RenderState::new(old_size)), + (forged, RenderState::new(old_size)), + ]); + let mut semantic_states: HashMap = + HashMap::new(); + let mut streams: HashMap = HashMap::new(); + let mut term_sizes = HashMap::from([(source, old_size), (forged, old_size)]); + let mut last_dispatch_idle_sent = HashMap::new(); + let mut last_active_buffer_sent = HashMap::new(); + let mut terminal_bell_baselines = HashMap::new(); + let mut session_registry = SessionRegistry::new(); + + handle_dispatcher_event( + DispatcherEvent::FrontendEvent { + source, + event: FrontendEvent::Resize { + frontend_id: forged, + size: new_size, + }, + }, + &mut editor, + &mut render_states, + &mut semantic_states, + &mut streams, + &mut term_sizes, + &mut last_dispatch_idle_sent, + &mut last_active_buffer_sent, + &mut terminal_bell_baselines, + &mut session_registry, + ); + + assert_eq!(render_states[&source].size(), new_size); + assert_eq!(term_sizes[&source], new_size); + assert_eq!(render_states[&forged].size(), old_size); + assert_eq!(term_sizes[&forged], old_size); + } + #[test] + fn terminal_bell_baseline_suppresses_history_and_delivers_each_new_bell_once() { + let mut editor = EditorState::new(); + let mut spec = crate::terminal::TerminalSpec::new("/bin/sh"); + spec.args = vec![ + "-c".into(), + "printf '\\a'; IFS= read -r _; printf '\\a'; sleep 30".into(), + ]; + let buffer_id = editor + .terminal_manager + .borrow_mut() + .open( + spec, + &mut editor.core.borrow_mut(), + &mut editor.process_supervisor.borrow_mut(), + ) + .expect("open bell probe"); + editor + .core + .borrow_mut() + .switch_active_buffer_for(FrontendId::LOCAL, buffer_id) + .expect("display bell probe"); + + let deadline = Instant::now() + Duration::from_secs(5); + while editor.terminal_manager.borrow().bell_count(buffer_id) != Some(1) { + editor.tick_processes(); + assert!(Instant::now() < deadline, "initial terminal bell timed out"); + thread::sleep(Duration::from_millis(10)); + } + let mut baselines = HashMap::new(); + assert!(!take_pending_terminal_bell( + &editor, + FrontendId::LOCAL, + &mut baselines + )); + + editor + .terminal_manager + .borrow() + .send( + buffer_id, + b"\n", + &mut editor.process_supervisor.borrow_mut(), + ) + .expect("advance bell probe"); + let deadline = Instant::now() + Duration::from_secs(5); + while editor.terminal_manager.borrow().bell_count(buffer_id) != Some(2) { + editor.tick_processes(); + assert!(Instant::now() < deadline, "second terminal bell timed out"); + thread::sleep(Duration::from_millis(10)); + } + assert!(take_pending_terminal_bell( + &editor, + FrontendId::LOCAL, + &mut baselines + )); + assert!(!take_pending_terminal_bell( + &editor, + FrontendId::LOCAL, + &mut baselines + )); + + editor + .terminal_manager + .borrow_mut() + .terminate(buffer_id, &mut editor.process_supervisor.borrow_mut()) + .expect("terminate bell probe"); + } + /// Kill ring Q#KR10a — the unified paste route trusts only the /// dispatcher's authenticated source. A forged payload id must not /// paste into another frontend's active window, and the paste @@ -3160,6 +3362,7 @@ mod tests { apply_semantic_input_event( &mut editor, + fid, FrontendEvent::Key(KeyEvent { frontend_id: fid, key: Key::Char('X'), @@ -3236,6 +3439,7 @@ mod tests { // A key now edits the *displayed* buffer, advancing its cursor. apply_semantic_input_event( &mut editor, + fid, FrontendEvent::Key(KeyEvent { frontend_id: fid, key: Key::Char('Z'), diff --git a/src/editor.rs b/src/editor.rs index 642ecd0..38a8003 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -12,8 +12,8 @@ //! them through the dispatcher, and invokes the resulting Lua commands //! until the user quits. -use std::cell::RefCell; -use std::collections::HashMap; +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, HashSet}; use std::io; use std::path::PathBuf; use std::rc::Rc; @@ -24,7 +24,7 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::async_runtime::SharedAsyncRuntime; -use crate::cell::CellCoord; +use crate::cell::{CellCoord, CellSize}; use crate::editor_core::EditorCore; use crate::file_io::load_file; use crate::frontend::{Event, Frontend, KeyEvent, KeyEventKind, MouseEvent, install_panic_hook}; @@ -33,10 +33,53 @@ use crate::keymap_stack::{Action, KeyDispatcher}; use crate::lua::LuaHost; use crate::lua_bindings::SharedCore; use crate::minibuffer::Minibuffer; -use crate::protocol::FrontendId; +use crate::protocol::{ + FrontendId, InstanceMessage, InstanceSignal, Key as TerminalKey, + Modifiers as TerminalModifiers, MouseButton as TerminalMouseButton, + MouseKind as TerminalMouseKind, +}; +use crate::terminal::TerminalSnapshot; +use crate::terminal::view::TerminalViewKey; use crate::view::{View, Viewport}; use crate::window::{Rect, WindowId}; +/// Ephemeral authenticated origin for one interactive command invocation. +/// +/// The shared slot is installed as Lua app data so Rust dispatch and nested +/// `pmacs.command.invoke_interactive` calls use the same authority. Guards +/// restore the prior value, which makes nesting safe and clears the outermost +/// origin even when a Lua command errors. +#[derive(Clone, Default)] +pub(crate) struct InteractiveCommandOrigin(Rc>>); + +impl InteractiveCommandOrigin { + /// Current authenticated frontend while an interactive command runs. + #[must_use] + pub(crate) fn current(&self) -> Option { + self.0.get() + } + + /// Enter an interactive command scope for `frontend_id`. + pub(crate) fn enter(&self, frontend_id: FrontendId) -> InteractiveCommandOriginGuard { + let previous = self.0.replace(Some(frontend_id)); + InteractiveCommandOriginGuard { + origin: self.clone(), + previous, + } + } +} + +pub(crate) struct InteractiveCommandOriginGuard { + origin: InteractiveCommandOrigin, + previous: Option, +} + +impl Drop for InteractiveCommandOriginGuard { + fn drop(&mut self) { + self.origin.0.set(self.previous); + } +} + // --------------------------------------------------------------------------- // EditorState // --------------------------------------------------------------------------- @@ -48,8 +91,10 @@ pub struct EditorState { pub core: SharedCore, /// The embedded Lua VM and its command/keymap registries. pub lua_host: LuaHost, - /// Multi-key prefix state machine. Driven by the run loop. - pub dispatcher: KeyDispatcher, + /// Independent key-prefix and terminal-escape state per authenticated frontend. + dispatchers: HashMap, + /// Authenticated frontend scoped to the current interactive invocation. + pub(crate) interactive_origin: InteractiveCommandOrigin, /// Main-thread async runtime (T M3.3). Owns the worker pool and /// the message bus pair; [`Self::tick_async`] drives one /// drain-and-resume pass per run-loop iteration. @@ -114,6 +159,12 @@ pub struct EditorState { mouse_click: Option, } +#[derive(Default)] +struct FrontendDispatchState { + dispatcher: KeyDispatcher, + terminal_escape: bool, +} + impl Drop for EditorState { /// Tear down the worker-pool threads. /// @@ -177,6 +228,8 @@ impl EditorState { Rc::new(RefCell::new(crate::buffer_registry::BufferRegistry::new())); let core = Rc::new(RefCell::new(EditorCore::new(registry.clone()))); let mut lua_host = LuaHost::with_registry(registry).expect("Lua runtime initialization"); + let interactive_origin = InteractiveCommandOrigin::default(); + lua_host.lua().set_app_data(interactive_origin.clone()); lua_host .attach_editor(&core) .expect("editor bindings + builtin chunks"); @@ -247,7 +300,15 @@ impl EditorState { // shutdown enforces no-zombie cleanup at editor exit. let process_supervisor = crate::lua_bindings::make_process_supervisor(lua_host.lua()) .expect("install pmacs.process"); - let terminal_manager = Rc::new(RefCell::new(crate::terminal::TerminalManager::new())); + let terminal_manager = + crate::lua_bindings::make_terminal_manager(lua_host.lua(), &process_supervisor) + .expect("install pmacs.terminal"); + lua_host + .eval( + Some("@pmacs/builtin/runtime/terminal.lua"), + include_str!("../builtin/runtime/terminal.lua"), + ) + .expect("load terminal builtin chunk"); // T M4.5 LSP manager. Wires onto the same supervisor so its // spawn/restart/I/O machinery is shared with `pmacs.process.*`. // The manager itself is reachable from Lua as `pmacs.lsp.*`. @@ -486,7 +547,8 @@ impl EditorState { Self { core, lua_host, - dispatcher: KeyDispatcher::new(), + dispatchers: HashMap::new(), + interactive_origin, async_runtime, syntax_registry, process_supervisor, @@ -656,51 +718,44 @@ impl EditorState { let _ = core.switch_active_buffer(buffer_id); } - /// Translate a key event into a chord, run it through the - /// dispatcher, and invoke the resolved command (or the - /// self-insert fallback for an unbound printable chord). + /// Whether `frontend_id` may optimistically self-insert its next key. /// - /// Whether the daemon's key-dispatch path is currently "idle" in - /// the sense that the *next* key event would self-insert into the - /// active buffer rather than being intercepted. - /// - /// `false` when either: - /// - /// - the dispatcher holds a pending multi-key prefix (e.g. the - /// user has typed `C-x` and the daemon is waiting for the next - /// chord), or - /// - a minibuffer prompt is active and absorbing keys, or - /// - an incremental search is running and absorbing keys (Q#SR5). - /// - /// Used by the daemon to drive the `InstanceMessage::DispatchIdle` - /// wire signal that gates `crdt_replica` frontends' optimistic-apply - /// path. Without this signal the optimistic layer would Insert a - /// plain-char keystroke into the active document while the - /// daemon's actual intent is to route the keystroke into the - /// minibuffer prompt — the M10.10 "documented limitation" that - /// surfaced during session-5 manual validation. Isearch reuses the - /// exact same gate: while a search runs every keystroke must - /// round-trip so the daemon's `dispatch_search_key` receives it - /// (extend the query / step) instead of the frontend self-inserting - /// it into the buffer. + /// `false` while a prefix, terminal escape, modal surface, or round-trip + /// buffer owns input. The daemon publishes this as `DispatchIdle`; returning + /// `true` while one of those surfaces is active would let a CRDT frontend + /// edit the document locally while the daemon routes the same key elsewhere + /// (M10.10, Q#SR5, Q#CM1, Q#QR1, Arc 1b Q#P6). #[must_use] - pub fn dispatch_idle(&self) -> bool { - if !self.dispatcher.pending().is_empty() { + pub fn dispatch_idle_for(&self, frontend_id: FrontendId) -> bool { + if self + .dispatchers + .get(&frontend_id) + .is_some_and(|state| state.terminal_escape || !state.dispatcher.pending().is_empty()) + { return false; } let core = self.core.borrow(); - // A live context menu shadows the keymap too (Q#CM1): keys must - // round-trip so the daemon's `dispatch_menu_key` drives the menu - // rather than the frontend self-inserting. A round-trip buffer - // (Arc 1b Q#P6 — a focused panel) is the buffer-shaped member of - // the same family: RET must reach its buffer-local bindings and - // typing must reach its read-only intercept, neither of which an - // optimistic local edit would do. !core.minibuffer.is_active() && !core.search_active() && !core.query_replace_active() && !core.menu_is_open() - && !core.active_buffer_round_trips() + && core + .active_window_for(frontend_id) + .is_some_and(|window| !core.buffer_round_trips(window.buffer_id)) + } + + /// Local-frontend compatibility wrapper. + #[must_use] + pub fn dispatch_idle(&self) -> bool { + self.dispatch_idle_for(FrontendId::LOCAL) + } + + /// Drop one detached frontend's pending key and terminal escape state. + pub fn detach_frontend_input(&mut self, frontend_id: FrontendId) { + self.dispatchers.remove(&frontend_id); + self.terminal_manager + .borrow_mut() + .detach_frontend(frontend_id); } /// `frontend_id` records which frontend produced the event. v0.1 @@ -710,22 +765,22 @@ impl EditorState { /// (`pmacs.frontend.id()`). Sets [`EditorCore::active_frontend`] /// before any command body runs, so observers always see a fresh /// value. + #[allow( + clippy::too_many_lines, + reason = "single input-precedence state machine" + )] pub fn dispatch_key(&mut self, frontend_id: FrontendId, key: KeyEvent) { - let Some(chord) = key_event_to_chord(key) else { + if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { return; - }; + } + // Authenticate every path through this input event, including modal + // callbacks such as M-x minibuffer acceptance. + let _origin = self.interactive_origin.enter(frontend_id); + let chord = key_event_to_chord(key); { let mut core = self.core.borrow_mut(); core.status.clear(); core.active_frontend = frontend_id; - } - - // Modal surfaces beat the completion popup (Q#C3): if a menu / - // search / minibuffer opened while the popup was up, close the - // popup before the modal shadow swallows this key --- otherwise - // it would linger, rendered but unreachable. - { - let mut core = self.core.borrow_mut(); if core.completion_popup_is_open() && (core.menu_is_open() || core.search_active() @@ -736,67 +791,100 @@ impl EditorState { } } - // Context-menu interception (Q#CM1): while a menu is open every - // key drives it (navigate / invoke / dismiss), shadowing the - // global keymap like search and the minibuffer. Same shared path - // both frontends reach via the `FrontendEvent::Key` round-trip. + // Modal surfaces beat both terminal transport and the completion popup. + // Menu/search/query-replace/minibuffer are full keymap shadows shared by + // grid and semantic input; each returns before the ordinary post-command + // edit check, so shadow handlers own any required hook fan-out (Q#CM1, + // Q#SR5, Q#QR1). + // Global modal surfaces own input before terminal transport. if self.core.borrow().menu_is_open() { - self.dispatch_menu_key(chord); + if let Some(chord) = chord { + self.dispatch_menu_key(frontend_id, chord); + } return; } - - // Incremental-search interception: while an isearch is running, - // every key routes through the search handler (the global keymap - // is shadowed, like the minibuffer). Printable chars extend the - // query; C-s / C-r step; RET accepts; C-g / Esc cancel. This is - // the shared input path for both frontends — the daemon's - // `FrontendEvent::Key` round-trip lands here too. if self.core.borrow().search_active() { - self.dispatch_search_key(chord); + if let Some(chord) = chord { + self.dispatch_search_key(chord); + } return; } - - // Query-replace interception (Arc 2): the fifth modal shadow. - // While the interactive phase runs, every key drives it - // (y/n/!/./q), shadowing the global keymap like search. Both - // frontends reach this via the `FrontendEvent::Key` round-trip - // (`dispatch_idle` is false while it runs). The handler fires - // `buffer.after-edit` itself — a modal shadow returns before the - // normal post-command edit check below (Q#QR1). if self.core.borrow().query_replace_active() { - self.dispatch_query_replace_key(chord); + if let Some(chord) = chord { + self.dispatch_query_replace_key(chord); + } return; } - - // Minibuffer interception: when a prompt is active, every key - // routes through the minibuffer's hardcoded handler. The main - // editor's keymap is bypassed; the user can still cancel with - // C-g and resume normal dispatch. if self.core.borrow().minibuffer.is_active() { - self.dispatch_minibuffer_key(chord); + if let Some(chord) = chord { + self.dispatch_minibuffer_key(frontend_id, chord); + } return; } - // In-buffer completion popup (Q#C3): a PARTIAL shadow, the - // fourth member of the family above. Only the popup-control - // chords (TAB / RET / C-n / C-p / Up / Down / Esc / C-g) are - // intercepted; every other key falls through to normal dispatch - // below, so typing keeps self-inserting and motion keys keep - // moving. The post-dispatch validation at the bottom of this - // function closes the session when a fallen-through key breaks - // the anchor/prefix invariant. A pending multi-key prefix owns - // the keyboard: while one is in flight (`C-x ...`) the popup - // must not steal its continuation or its `C-g` abort --- and - // the Pending arm below closes the popup anyway, so this guard - // only covers the same-dispatch race. + // Completion is the one partial modal shadow (Q#C3): only its control + // chords are intercepted. A pending per-frontend prefix owns those + // chords instead, and ordinary keys continue to terminal/keymap dispatch. + let dispatcher_pending = self + .dispatchers + .get(&frontend_id) + .is_some_and(|state| !state.dispatcher.pending().is_empty()); if self.core.borrow().completion_popup_is_open() - && self.dispatcher.pending().is_empty() - && let Some(key) = CompletionPopupKey::from_chord(chord) + && !dispatcher_pending + && let Some(popup_key) = chord.and_then(CompletionPopupKey::from_chord) { - self.dispatch_completion_key(key); + self.dispatch_completion_key(popup_key); return; } + // Terminal transport precedes ordinary buffer/global bindings. `C-c` + // opens a fixed one-key editor escape; all unescaped keys go to the child. + let terminal_key = self.active_terminal_key(frontend_id); + let escaped = self + .dispatchers + .get(&frontend_id) + .is_some_and(|state| state.terminal_escape); + if let Some(view_key) = terminal_key { + if escaped { + self.dispatchers + .entry(frontend_id) + .or_default() + .terminal_escape = false; + if chord.is_some_and(is_terminal_escape_chord) { + self.claim_terminal_controller(view_key); + self.send_terminal_bytes(view_key.buffer_id, &[0x03]); + return; + } + // The post-escape key starts a fresh ordinary sequence below. + } else if !dispatcher_pending { + if chord.is_some_and(is_terminal_escape_chord) { + let state = self.dispatchers.entry(frontend_id).or_default(); + state.terminal_escape = true; + state.dispatcher = KeyDispatcher::new(); + self.claim_terminal_controller(view_key); + return; + } + let Some((terminal_key, modifiers)) = terminal_key_from_crossterm(key) else { + return; + }; + let modes = self + .terminal_manager + .borrow() + .modes_for_view(view_key) + .unwrap_or_default(); + if let Some(bytes) = + crate::terminal::input::encode_key(terminal_key, modifiers, modes) + { + self.claim_terminal_controller(view_key); + self.send_terminal_bytes(view_key.buffer_id, &bytes); + } + return; + } + } + + let Some(chord) = chord else { + return; + }; // Buffer- and mode-scope keybindings resolve against the active // buffer. Keep its mode borrowed from the registry only while the // pure keymap lookup runs: `Option::as_slice` provides the required @@ -810,21 +898,18 @@ impl EditorState { .ok() .and_then(|buffer| buffer.major_mode()); let stack = self.lua_host.keymaps().borrow(); - self.dispatcher + self.dispatchers + .entry(frontend_id) + .or_default() + .dispatcher .dispatch(chord, &stack, Some(active_buffer), active_mode.as_slice()) }; - - // Snapshot the active buffer's edit revision before the command - // runs so we can fire `buffer.after-edit` only when it changes. - // Stale-id paths (active buffer killed mid-dispatch) fall back - // to "no edit observed", which is the correct conservative call. let pre_revision = self.active_buffer_revision(); match action { + // Kill ring Q#KR2: stamp the authenticated frontend before the + // body so nested interactive calls inherit the same origin. Action::Run { command, .. } => { - // Kill ring Q#KR2: record the command boundary before the - // body runs, so the body's own `ed.last_command()` reads - // its *predecessor* (Emacs `last-command` semantics). self.core.borrow_mut().rotate_command(frontend_id, &command); if let Err(e) = self .lua_host @@ -834,30 +919,18 @@ impl EditorState { format!("error in {command}: {}", first_line(&e.to_string())); } } + // A prefix is rendered from dispatcher state; dismissing the + // popup prevents its partial shadow from stealing continuation. Action::Pending { .. } => { - // The pending prefix is rendered from - // `dispatcher.pending()`; no command runs yet. Starting - // a command sequence dismisses the completion popup: - // leaving it open would route the sequence's `C-g` - // abort (and its continuation chords) into the popup's - // shadow instead of the dispatcher. self.core.borrow_mut().completion_popup_close(); } Action::Unbound { sequence } => { + // Self-insert is an interactive command boundary (Q#KR2). + // Arm Q#AP9 typed-edit metadata only across this dispatch. if let Some(ch) = printable_char(&sequence) { - // Typing a character is a command too (Q#KR2): it - // must break a kill chain — `C-k x C-k` is two ring - // entries, not an append. self.core .borrow_mut() .rotate_command(frontend_id, "buffer.self-insert"); - // Auto-pairing Q#AP9: this dispatch is the typed - // self-insert producer — arm the exact typed-edit - // record so the after-edit fan-out below can - // expose it. The insert primitive completes the - // record with the effective (post-intercept) edit; - // `typed_edit_finish` takes it back on every path - // out of this dispatch. self.core.borrow_mut().typed_edit_arm(frontend_id, ch); let mut args = mlua::MultiValue::new(); args.push_back(mlua::Value::Integer(ch as i64)); @@ -866,8 +939,7 @@ impl EditorState { format!("self-insert failed: {}", first_line(&e.to_string())); } } else { - // An unbound key still breaks the chain (Q#KR2) — - // Emacs's `undefined` runs as a command. + // Emacs `undefined` is still a command boundary (Q#KR2). self.core.borrow_mut().break_command_chain(frontend_id); self.core.borrow_mut().status = format!("{}: not bound", display_sequence(&sequence)); @@ -875,15 +947,7 @@ impl EditorState { } } - // Auto-pairing Q#AP9: take back the typed-edit arm on every - // path out of this dispatch — command error, rejected insert, - // and the no-revision-change case all land here with either a - // completed record or nothing. The record is armed for Lua - // only across the one after-edit fan-out below and cleared - // the moment it returns, so paste, later dispatches, and - // manual hook runs can never observe a stale record. let typed_edit = self.core.borrow_mut().typed_edit_finish(frontend_id); - let post_revision = self.active_buffer_revision(); if pre_revision != post_revision { if let Some(record) = typed_edit { @@ -895,15 +959,245 @@ impl EditorState { .run_hook("buffer.after-edit", mlua::MultiValue::new()); self.core.borrow_mut().typed_edit_clear_armed(); } - - // Q#C3 post-dispatch validation, deliberately AFTER the - // after-edit hook: the Lua driver may have just refreshed (or - // re-anchored) the popup for this very edit, and validation - // must judge the fresh session, not the stale one. A closed - // popup makes this a single mutex peek. self.core.borrow_mut().completion_popup_validate(); } + fn active_terminal_key(&self, frontend_id: FrontendId) -> Option { + let core = self.core.borrow(); + let view = core.views.get(&frontend_id)?; + let window = core.windows.get(&view.active)?; + let key = TerminalViewKey::new(frontend_id, window.id, window.buffer_id); + self.terminal_manager + .borrow() + .is_terminal(window.buffer_id) + .then_some(key) + } + + fn claim_terminal_controller(&self, key: TerminalViewKey) { + let mut manager = self.terminal_manager.borrow_mut(); + let _ = manager.register_view(key); + let _ = manager.claim_controller(key); + } + + fn send_terminal_bytes(&self, buffer_id: crate::buffer::BufferId, bytes: &[u8]) { + let result = self.terminal_manager.borrow().send( + buffer_id, + bytes, + &mut self.process_supervisor.borrow_mut(), + ); + if let Err(error) = result { + self.core.borrow_mut().status = error.to_string(); + } + } + + /// Consume a paste as terminal input for one authenticated frontend. + /// + /// Returns `false` when modal/document paste handling must run instead. + pub fn dispatch_paste(&mut self, frontend_id: FrontendId, bytes: &[u8]) -> bool { + { + let mut core = self.core.borrow_mut(); + core.active_frontend = frontend_id; + if core.menu_is_open() + || core.search_active() + || core.query_replace_active() + || core.minibuffer.is_active() + { + return false; + } + } + let Some(key) = self.active_terminal_key(frontend_id) else { + return false; + }; + let modes = self + .terminal_manager + .borrow() + .modes_for_view(key) + .unwrap_or_default(); + let encoded = crate::terminal::input::encode_paste(bytes, modes.bracketed_paste); + self.claim_terminal_controller(key); + self.send_terminal_bytes(key.buffer_id, &encoded); + true + } + + /// Apply authenticated frontend focus to terminal control/reporting. + pub fn dispatch_focus(&mut self, frontend_id: FrontendId, gained: bool) { + self.core.borrow_mut().active_frontend = frontend_id; + if gained { + let Some(key) = self.active_terminal_key(frontend_id) else { + return; + }; + let modes = self + .terminal_manager + .borrow() + .modes_for_view(key) + .unwrap_or_default(); + self.claim_terminal_controller(key); + if let Some(bytes) = crate::terminal::input::encode_focus(true, modes.focus_reporting) { + self.send_terminal_bytes(key.buffer_id, &bytes); + } + return; + } + + let controlled = self + .terminal_manager + .borrow() + .controller_view_for_frontend(frontend_id); + let Some(key) = controlled else { + return; + }; + let modes = self + .terminal_manager + .borrow() + .modes_for_view(key) + .unwrap_or_default(); + if let Some(bytes) = crate::terminal::input::encode_focus(false, modes.focus_reporting) { + self.send_terminal_bytes(key.buffer_id, &bytes); + } + let _ = self.terminal_manager.borrow_mut().release_controller(key); + } + + /// Resize the one session durably controlled by `frontend_id`. + /// + /// This is called before process drain and paint, never from rendering. + pub fn sync_terminal_layout(&mut self, frontend_id: FrontendId, term_size: CellSize) -> bool { + let Some(key) = self + .terminal_manager + .borrow() + .controller_view_for_frontend(frontend_id) + else { + return false; + }; + let content = { + let core = self.core.borrow(); + let Some(view) = core.views.get(&frontend_id) else { + let _ = self.terminal_manager.borrow_mut().release_controller(key); + return false; + }; + if view.active != key.window_id + || core + .windows + .get(&key.window_id) + .is_none_or(|window| window.buffer_id != key.buffer_id) + { + let _ = self.terminal_manager.borrow_mut().release_controller(key); + return false; + } + let Some(placement) = window_placements(&core, frontend_id, term_size) + .get(&key.window_id) + .copied() + else { + let _ = self.terminal_manager.borrow_mut().release_controller(key); + return false; + }; + placement.content + }; + if content.size.rows == 0 || content.size.cols == 0 { + return false; + } + let old_size = self + .terminal_manager + .borrow() + .snapshot(key.buffer_id) + .map(|snapshot| snapshot.size); + if old_size == Some(content.size) { + return false; + } + let Ok(rows) = u16::try_from(content.size.rows) else { + return false; + }; + let Ok(cols) = u16::try_from(content.size.cols) else { + return false; + }; + let result = self.terminal_manager.borrow_mut().resize( + key.buffer_id, + rows, + cols, + &mut self.process_supervisor.borrow_mut(), + ); + if let Err(error) = result { + self.core.borrow_mut().status = error.to_string(); + false + } else { + true + } + } + + /// Precompute owned terminal view snapshots before entering paint borrows. + pub fn prepare_terminal_views( + &mut self, + frontend_id: FrontendId, + term_size: CellSize, + ) -> HashMap { + let (live, sizes) = { + let core = self.core.borrow(); + let placements = window_placements(&core, frontend_id, term_size); + let mut live = HashSet::new(); + if let Some(view) = core.views.get(&frontend_id) { + for window_id in view.layout.iter_ids() { + let Some(window) = core.windows.get(&window_id) else { + continue; + }; + if self.terminal_manager.borrow().is_terminal(window.buffer_id) { + live.insert(TerminalViewKey::new( + frontend_id, + window_id, + window.buffer_id, + )); + } + } + } + let mut sizes = Vec::new(); + for (window_id, placement) in placements { + let Some(window) = core.windows.get(&window_id) else { + continue; + }; + let key = TerminalViewKey::new(frontend_id, window_id, window.buffer_id); + if !live.contains(&key) + || placement.content.size.rows == 0 + || placement.content.size.cols == 0 + { + continue; + } + sizes.push((key, placement.content.size)); + } + (live, sizes) + }; + let mut manager = self.terminal_manager.borrow_mut(); + manager.retain_frontend_views(frontend_id, &live); + sizes + .into_iter() + .filter_map(|(key, size)| { + manager + .snapshot_for_view(key, size) + .map(|snapshot| (key.window_id, snapshot)) + }) + .collect() + } + + /// Drain local-only terminal/clipboard output signals after a frame. + pub fn take_local_signals(&mut self) -> Vec { + let frontend_id = FrontendId::LOCAL; + let active = self.active_terminal_key(frontend_id); + let mut messages = Vec::new(); + if self + .terminal_manager + .borrow_mut() + .take_bell_for_frontend(frontend_id, active) + { + messages.push(InstanceMessage::Signal(InstanceSignal::Bell)); + } + if let Some((target, bytes)) = self.core.borrow_mut().take_pending_clipboard() { + debug_assert_eq!( + target, frontend_id, + "local signal drain received a non-local clipboard target" + ); + if target == frontend_id { + messages.push(InstanceMessage::Signal(InstanceSignal::Clipboard(bytes))); + } + } + messages + } + /// Active buffer's edit revision, or `None` if the registry no /// longer knows about the active buffer (e.g. a command killed it /// mid-dispatch). @@ -969,13 +1263,13 @@ impl EditorState { /// Keys without a handler are silently ignored --- this matches /// Emacs's behaviour, where minibuffer mode shadows the global /// keymap. - fn dispatch_minibuffer_key(&mut self, chord: Chord) { + fn dispatch_minibuffer_key(&mut self, frontend_id: FrontendId, chord: Chord) { use crate::minibuffer::MinibufferAction; use crossterm::event::{KeyCode, KeyModifiers}; let action = MinibufferAction::from_chord(chord); match action { - MinibufferAction::Accept => self.minibuffer_accept(), + MinibufferAction::Accept => self.minibuffer_accept(frontend_id), MinibufferAction::Cancel => self.minibuffer_cancel(), MinibufferAction::Complete => self.minibuffer_complete(), MinibufferAction::HistoryPrev => self.with_minibuffer(Minibuffer::history_prev), @@ -1118,11 +1412,11 @@ impl EditorState { } /// Drive an open context menu from a keystroke (Q#CM1). - fn dispatch_menu_key(&mut self, chord: Chord) { + fn dispatch_menu_key(&mut self, frontend_id: FrontendId, chord: Chord) { match MenuKey::from_chord(chord) { MenuKey::Next => self.core.borrow_mut().menu_step(1), MenuKey::Prev => self.core.borrow_mut().menu_step(-1), - MenuKey::Invoke => self.menu_invoke_active(), + MenuKey::Invoke => self.menu_invoke_active(frontend_id), MenuKey::Cancel | MenuKey::Dismiss => self.core.borrow_mut().menu_close(), } } @@ -1131,7 +1425,7 @@ impl EditorState { /// menu closes *first* so the command runs against a clean state /// (and a command that itself opens a menu isn't immediately torn /// down). - fn menu_invoke_active(&mut self) { + fn menu_invoke_active(&mut self, frontend_id: FrontendId) { let command = self.core.borrow().menu_active_command(); self.core.borrow_mut().menu_close(); if let Some(command) = command { @@ -1139,14 +1433,11 @@ impl EditorState { // rotate the boundary so a menu Cut chains like a keybound // one. The invoke below bypasses dispatch_key, which would // otherwise leave the boundary stale. - { - let mut core = self.core.borrow_mut(); - let fid = core.active_frontend; - core.rotate_command(fid, &command); - } + self.core.borrow_mut().rotate_command(frontend_id, &command); // Q#KR10b: menu invocation bypasses dispatch_key's // revision check — a menu Cut's edit must still fire // `buffer.after-edit`. + let _origin = self.interactive_origin.enter(frontend_id); self.with_after_edit_check(|state| { if let Err(e) = state .lua_host @@ -1214,7 +1505,13 @@ impl EditorState { /// Drive an open menu from a mouse event (Q#CM1): hover highlights, /// left-click invokes, a click outside (or right-click) dismisses. - fn dispatch_menu_mouse(&mut self, ev: MouseEvent, cell_row: u32, cell_col: u32) { + fn dispatch_menu_mouse( + &mut self, + frontend_id: FrontendId, + ev: MouseEvent, + cell_row: u32, + cell_col: u32, + ) { use crossterm::event::{MouseButton, MouseEventKind}; let hit = self.core.borrow().menu_hit(cell_row, cell_col); match ev.kind { @@ -1226,7 +1523,7 @@ impl EditorState { MouseEventKind::Down(MouseButton::Left) => match hit { Some(row) => { self.core.borrow_mut().menu_set_active_row(row); - self.menu_invoke_active(); + self.menu_invoke_active(frontend_id); } None => self.core.borrow_mut().menu_close(), }, @@ -1250,7 +1547,7 @@ impl EditorState { } } - fn minibuffer_accept(&mut self) { + fn minibuffer_accept(&mut self, frontend_id: FrontendId) { let outcome = self.core.borrow_mut().minibuffer.accept(); let Some((on_accept, contents)) = outcome else { return; @@ -1269,6 +1566,7 @@ impl EditorState { // post-command revision check (the minibuffer interception // returns before it), so an M-x'd editing command would never // fire `buffer.after-edit` without this wrapper. + let _origin = self.interactive_origin.enter(frontend_id); self.with_after_edit_check(|state| { if let Err(e) = on_accept.call::(args) { state.core.borrow_mut().status = format!( @@ -1320,6 +1618,10 @@ impl EditorState { /// (the click neither activates the window nor positions the /// cursor; that gesture is reserved for future binding to /// "switch to this window" without disturbing buffer state). + #[allow( + clippy::too_many_lines, + reason = "shared document/terminal mouse router" + )] pub fn dispatch_mouse( &mut self, frontend_id: FrontendId, @@ -1336,17 +1638,38 @@ impl EditorState { // outside dismisses) — handled before window hit-testing so an // outside click anywhere closes it. if self.core.borrow().menu_is_open() { - self.dispatch_menu_mouse(ev, cell_row, cell_col); + self.dispatch_menu_mouse(frontend_id, ev, cell_row, cell_col); return; } - let Some((win_id, rect)) = - window_at_cell(&self.core.borrow(), term_size, cell_row, cell_col) - else { + let Some((win_id, rect)) = window_at_cell( + &self.core.borrow(), + frontend_id, + term_size, + cell_row, + cell_col, + ) else { return; }; let inner_rows = rect.size.rows.saturating_sub(1); let local_row = cell_row.saturating_sub(rect.origin.row); + let buffer_id = self.core.borrow().windows[&win_id].buffer_id; + if self.terminal_manager.borrow().is_terminal(buffer_id) { + let content_size = CellSize::new(inner_rows, rect.size.cols); + if local_row >= inner_rows || content_size.rows == 0 || content_size.cols == 0 { + self.mouse_click = None; + return; + } + let local = CellCoord::new(local_row, cell_col.saturating_sub(rect.origin.col)); + self.dispatch_terminal_mouse( + TerminalViewKey::new(frontend_id, win_id, buffer_id), + content_size, + local, + ev, + (cell_row, cell_col), + ); + return; + } // UX gutter (Q#UX6): subtract the reserved gutter width so the // hit-test lands on the right text byte. A click inside the gutter // strip (raw < gutter_w) saturates to column 0 → the start of that @@ -1432,6 +1755,69 @@ impl EditorState { } } + fn dispatch_terminal_mouse( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + coord: CellCoord, + event: MouseEvent, + global: (u32, u32), + ) { + use crossterm::event::{MouseButton, MouseEventKind}; + + let kind = terminal_mouse_kind(event.kind); + let modifiers = terminal_modifiers(event.modifiers); + let shift = modifiers.contains(TerminalModifiers::SHIFT); + let (at_bottom, modes, screen_size) = { + let mut manager = self.terminal_manager.borrow_mut(); + let Some(status) = manager.view_status_for_size(key, viewport_size) else { + return; + }; + let modes = manager.modes_for_view(key).unwrap_or_default(); + let screen_size = manager.screen_size_for_view(key).unwrap_or(viewport_size); + (status.at_bottom, modes, screen_size) + }; + + if !shift + && at_bottom + && modes.mouse_sgr + && coord.row < screen_size.rows + && coord.col < screen_size.cols + && let Some(bytes) = crate::terminal::input::encode_mouse(kind, coord, modifiers, modes) + { + self.claim_terminal_controller(key); + self.send_terminal_bytes(key.buffer_id, &bytes); + return; + } + + self.claim_terminal_controller(key); + let mut manager = self.terminal_manager.borrow_mut(); + match event.kind { + MouseEventKind::ScrollUp => { + let _ = manager.scroll_view(key, viewport_size, SCROLL_LINES); + } + MouseEventKind::ScrollDown => { + let _ = manager.scroll_view(key, viewport_size, -SCROLL_LINES); + } + MouseEventKind::Down(MouseButton::Left) => { + let _ = manager.begin_selection(key, viewport_size, coord); + } + MouseEventKind::Drag(MouseButton::Left) => { + let _ = manager.update_selection(key, viewport_size, coord); + } + MouseEventKind::Up(MouseButton::Left) => { + let _ = manager.finish_selection(key, viewport_size, coord); + } + MouseEventKind::Down(MouseButton::Right) => { + drop(manager); + self.core.borrow_mut().break_command_chain(key.frontend_id); + let rows = self.build_menu_rows(); + self.core.borrow_mut().menu_open(rows, global); + } + _ => {} + } + } + fn is_double_click( &self, frontend_id: FrontendId, @@ -1615,7 +2001,7 @@ impl EditorState { (Some(i), false) => self.core.borrow_mut().menu_set_active_row(i as usize), (Some(i), true) => { self.core.borrow_mut().menu_set_active_row(i as usize); - self.menu_invoke_active(); + self.menu_invoke_active(frontend_id); } (None, true) => self.core.borrow_mut().menu_close(), (None, false) => {} @@ -1717,6 +2103,42 @@ impl EditorState { /// readline / Emacs default and is what most terminal users expect. const SCROLL_LINES: i32 = 3; +/// Shared outer/content geometry consumed by terminal paint and PTY resize. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct WindowPlacement { + pub(crate) outer: Rect, + pub(crate) content: Rect, +} + +/// Compute one explicit frontend's split geometry. +#[must_use] +pub(crate) fn window_placements( + core: &EditorCore, + frontend_id: FrontendId, + term_size: CellSize, +) -> HashMap { + if term_size.rows < 2 || term_size.cols == 0 { + return HashMap::new(); + } + let Some(view) = core.views.get(&frontend_id) else { + return HashMap::new(); + }; + let area = Rect::new(0, 0, term_size.rows - 1, term_size.cols); + view.layout + .compute(area) + .into_iter() + .map(|(window_id, outer)| { + let content = Rect::new( + outer.origin.row, + outer.origin.col, + outer.size.rows.saturating_sub(1), + outer.size.cols, + ); + (window_id, WindowPlacement { outer, content }) + }) + .collect() +} + /// Find the leaf window whose viewport rectangle contains /// `(cell_row, cell_col)` in the global cell grid. Used by the mouse /// dispatcher to route clicks. The bottom row of the terminal (status @@ -1724,26 +2146,23 @@ const SCROLL_LINES: i32 = 3; /// `None`. fn window_at_cell( core: &EditorCore, - term_size: crate::cell::CellSize, + frontend_id: FrontendId, + term_size: CellSize, cell_row: u32, cell_col: u32, ) -> Option<(WindowId, Rect)> { - if term_size.rows < 2 { + if cell_row >= term_size.rows.saturating_sub(1) { return None; } - let text_rows = term_size.rows - 1; - if cell_row >= text_rows { - return None; - } - let area = Rect::new(0, 0, text_rows, term_size.cols); - let placements = core.active_layout().compute(area); - placements.iter().find_map(|(id, rect)| { + let placements = window_placements(core, frontend_id, term_size); + placements.iter().find_map(|(id, placement)| { + let rect = placement.outer; if cell_row >= rect.origin.row && cell_row < rect.origin.row + rect.size.rows && cell_col >= rect.origin.col && cell_col < rect.origin.col + rect.size.cols { - Some((*id, *rect)) + Some((*id, rect)) } else { None } @@ -1834,8 +2253,12 @@ pub fn run(file: Option) -> io::Result<()> { let mut render_state = crate::instance_render::RenderState::new(frontend.size()); loop { - // In-process TUI never has remote frontends; no overlays. - let messages = render_state.render_frame(&state, &[]); + let size = frontend.size(); + let _ = state.sync_terminal_layout(FrontendId::LOCAL, size); + let terminal_snapshots = state.prepare_terminal_views(FrontendId::LOCAL, size); + let mut messages = + render_state.render_frame(&state, FrontendId::LOCAL, &terminal_snapshots, &[]); + messages.extend(state.take_local_signals()); frontend.present_messages(&messages)?; if state.core.borrow().quit { break; @@ -1877,6 +2300,7 @@ pub fn run(file: Option) -> io::Result<()> { // resumption by a full frame. The documented invariant is // only `tick_processes → tick_lsp → tick_mcp` (same-batch // supervisor I/O ordering), which is preserved. + let _ = state.sync_terminal_layout(FrontendId::LOCAL, frontend.size()); state.tick_processes(); state.tick_lsp(); state.tick_mcp(); @@ -1916,7 +2340,19 @@ fn process_event(state: &mut EditorState, ev: Event, term_size: crate::cell::Cel Event::Mouse(m) => { state.dispatch_mouse(frontend_id, m, term_size); } - _ => {} + Event::Paste(bytes) => { + if !state.dispatch_paste(frontend_id, bytes.as_bytes()) { + state.core.borrow_mut().active_frontend = frontend_id; + state.with_after_edit_check(|state| { + if let Err(error) = state.core.borrow_mut().paste_inbound(bytes.as_bytes()) { + state.core.borrow_mut().status = error; + } + }); + } + } + Event::FocusGained => state.dispatch_focus(frontend_id, true), + Event::FocusLost => state.dispatch_focus(frontend_id, false), + Event::Key(_) | Event::Resize(_, _) => {} } } @@ -2139,20 +2575,24 @@ impl CompletionPopupKey { /// future non-crossterm frontend) can drive it directly against a /// Vec-backed [`crate::cell::CellGrid`] without going through a /// `RenderState`. +#[allow( + clippy::implicit_hasher, + reason = "the public renderer contract uses the canonical snapshot HashMap" +)] #[allow(clippy::too_many_lines, reason = "linear paint pipeline")] pub fn paint_frame( state: &EditorState, + frontend_id: FrontendId, + terminal_snapshots: &HashMap, grid: &mut crate::cell::CellGrid<'_>, - term_size: crate::cell::CellSize, + term_size: CellSize, ) -> Option { if term_size.rows < 2 || term_size.cols == 0 { return None; } - let text_rows = term_size.rows - 1; // Statusline callbacks may call arbitrary editor APIs. Evaluate the // complete visible-window fan-out before the long mutable core borrow // below, then paint only the transactionally validated owned results. - let frontend_id = state.core.borrow().active_frontend; let statusline_evaluation = crate::statusline::evaluate_statusline( state.lua_host.lua(), &state.core, @@ -2176,15 +2616,17 @@ pub fn paint_frame( let t = handle.lock().expect("theme mutex poisoned"); t.clone() }; + let empty_dispatcher = KeyDispatcher::new(); + let dispatcher = state + .dispatchers + .get(&frontend_id) + .map_or(&empty_dispatcher, |state| &state.dispatcher); let mut core_ref = state.core.borrow_mut(); let core: &mut EditorCore = &mut core_ref; - // Compute per-window rectangles. The text area is the term size - // minus the bottom row (status / minibuffer). - let text_area = crate::window::Rect::new(0, 0, text_rows, term_size.cols); - let placements = core.active_layout().compute(text_area); - let active = core.active_window_id(); + let placements = window_placements(core, frontend_id, term_size); + let active = core.views.get(&frontend_id)?.active; // Clear the whole grid first so windows that shrink on resize // don't leak the old contents. @@ -2196,12 +2638,15 @@ pub fn paint_frame( // Scroll the active window so its cursor stays visible. Inactive // windows keep their existing scroll. - if let Some(active_rect) = placements.get(&active) { - let inner_rows = inner_rows(active_rect); + if let Some(active_placement) = placements.get(&active) { + let inner_rows = active_placement.content.size.rows; let registry = core.registry.clone(); let reg = registry.borrow(); - let buf_id = core.active_buffer_id(); - if let Ok(buf) = reg.get(buf_id) { + let buf_id = core.windows.get(&active).map(|window| window.buffer_id); + if !terminal_snapshots.contains_key(&active) + && let Some(buf_id) = buf_id + && let Ok(buf) = reg.get(buf_id) + { let aw = core.windows.get_mut(&active).expect( "invariant: active_window_id always references a live window in core.windows", ); @@ -2222,16 +2667,46 @@ pub fn paint_frame( let reg = registry.borrow(); let diag_store = state.lsp_manager.borrow().diag_store(); for (id, window) in &mut core.windows { - let Some(rect) = placements.get(id).copied() else { + let Some(placement) = placements.get(id).copied() else { continue; }; - let inner_rows = inner_rows(&rect); + let rect = placement.outer; + let inner_rows = placement.content.size.rows; // Record viewport height for page motion (cursor.page-down / // cursor.page-up consume this). window.last_visible_rows = inner_rows; if inner_rows == 0 || rect.size.cols == 0 { continue; } + if let Some(snapshot) = terminal_snapshots.get(id) { + paint_terminal_snapshot(grid, placement.content, snapshot, &theme); + let Ok(buf) = reg.get(window.buffer_id) else { + continue; + }; + let cursor = snapshot.cursor.unwrap_or_default(); + let scroll = if snapshot.scroll_offset == 0 { + String::new() + } else { + format!("↑{}", snapshot.scroll_offset) + }; + let custom = statusline_by_window.get(id); + paint_mode_line( + grid, + &rect, + buf.name(), + false, + *id == active, + cursor.row, + cursor.col, + &scroll, + "", + mode_line_style(&theme), + custom.map_or(&[], |segments| segments.left.as_slice()), + custom.map_or(&[], |segments| segments.right.as_slice()), + &theme, + ); + continue; + } let Ok(buf) = reg.get(window.buffer_id) else { continue; }; @@ -2305,14 +2780,7 @@ pub fn paint_frame( } drop(reg); - paint_status_line( - grid, - core, - &state.lua_host, - &state.dispatcher, - term_size, - &theme, - ); + paint_status_line(grid, core, &state.lua_host, dispatcher, term_size, &theme); // An active isearch owns the bottom row (its prompt + match // readout), but the terminal cursor stays in the buffer at the @@ -2330,7 +2798,20 @@ pub fn paint_frame( if let Some(col) = mb_cursor_col { return Some(CellCoord::new(term_size.rows - 1, col)); } - let active_rect = placements.get(&active).copied()?; + let active_placement = placements.get(&active).copied()?; + if let Some(snapshot) = terminal_snapshots.get(&active) { + let cursor = snapshot.cursor?; + if cursor.row >= active_placement.content.size.rows + || cursor.col >= active_placement.content.size.cols + { + return None; + } + return Some(CellCoord::new( + active_placement.content.origin.row + cursor.row, + active_placement.content.origin.col + cursor.col, + )); + } + let active_rect = active_placement.outer; let registry = core.registry.clone(); let reg = registry.borrow(); let aw = &core.windows[&active]; @@ -2353,6 +2834,47 @@ pub fn paint_frame( Some(CellCoord::new(grid_row, grid_col)) } +fn paint_terminal_snapshot( + grid: &mut crate::cell::CellGrid<'_>, + content: Rect, + snapshot: &TerminalSnapshot, + theme: &crate::highlight::Theme, +) { + let rows = content.size.rows.min(snapshot.size.rows); + let cols = content.size.cols.min(snapshot.size.cols); + for row in 0..rows { + for col in 0..cols { + let source = row as usize * snapshot.size.cols as usize + col as usize; + *grid.at(CellCoord::new( + content.origin.row + row, + content.origin.col + col, + )) = snapshot.cells[source].clone(); + } + } + let overlay = theme.face("ui.selection").map_or( + crate::cell::Style { + reverse: true, + ..crate::cell::Style::default() + }, + |face| crate::cell::Style { + bg: face.bg, + ..crate::cell::Style::default() + }, + ); + for span in &snapshot.selection { + if span.row >= rows { + continue; + } + for col in span.start_col.min(cols)..span.end_col.min(cols) { + let cell = grid.at(CellCoord::new( + content.origin.row + span.row, + content.origin.col + col, + )); + cell.style = crate::overlay::merge_styles(cell.style, overlay); + } + } +} + /// The mode-line row style (themes arc Q#TH5): a set `ui.modeline` /// face owns the surface within its {fg, bg, reverse} mask — the row /// resets to plain plus the face's in-mask components — else today's @@ -3105,6 +3627,42 @@ fn sanitize_single_line(s: &str) -> String { .collect() } +fn is_terminal_escape_chord(chord: Chord) -> bool { + chord.code == KeyCode::Char('c') && chord.modifiers == KeyModifiers::CONTROL +} + +fn terminal_key_from_crossterm(key: KeyEvent) -> Option<(TerminalKey, TerminalModifiers)> { + let modifiers = crate::protocol::crossterm_translate::mods_from_crossterm(key.modifiers); + let key = crate::protocol::crossterm_translate::keycode_from_crossterm(key.code); + if matches!(key, TerminalKey::Unknown(_)) { + return None; + } + Some((key, modifiers)) +} + +fn terminal_modifiers(modifiers: KeyModifiers) -> TerminalModifiers { + crate::protocol::crossterm_translate::mods_from_crossterm(modifiers) +} + +fn terminal_mouse_kind(kind: crossterm::event::MouseEventKind) -> TerminalMouseKind { + use crossterm::event::{MouseButton, MouseEventKind}; + let button = |button| match button { + MouseButton::Left => TerminalMouseButton::Left, + MouseButton::Right => TerminalMouseButton::Right, + MouseButton::Middle => TerminalMouseButton::Middle, + }; + match kind { + MouseEventKind::Down(value) => TerminalMouseKind::Down(button(value)), + MouseEventKind::Up(value) => TerminalMouseKind::Up(button(value)), + MouseEventKind::Drag(value) => TerminalMouseKind::Drag(button(value)), + MouseEventKind::Moved => TerminalMouseKind::Move, + MouseEventKind::ScrollUp => TerminalMouseKind::ScrollUp, + MouseEventKind::ScrollDown => TerminalMouseKind::ScrollDown, + MouseEventKind::ScrollLeft => TerminalMouseKind::ScrollLeft, + MouseEventKind::ScrollRight => TerminalMouseKind::ScrollRight, + } +} + fn key_event_to_chord(key: KeyEvent) -> Option { // Accept Press and Repeat. Some terminals (notably ones speaking // the kitty keyboard protocol with auto-repeat) deliver held-key @@ -3156,6 +3714,77 @@ mod tests { use super::*; use crate::frontend::KeyEventKind; + fn local_dispatcher(state: &EditorState) -> &KeyDispatcher { + &state + .dispatchers + .get(&FrontendId::LOCAL) + .expect("local dispatcher registered by dispatch") + .dispatcher + } + + #[test] + fn dispatch_prefix_state_is_independent_per_frontend() { + let mut state = fresh_with(b""); + let other = FrontendId(77); + state.dispatch_key(FrontendId::LOCAL, ctrl('x')); + state.dispatch_key(other, plain(KeyCode::Char('a'))); + assert_eq!(local_dispatcher(&state).pending().len(), 1); + assert!( + state + .dispatchers + .get(&other) + .expect("other dispatcher registered") + .dispatcher + .pending() + .is_empty() + ); + assert_eq!(state.core.borrow().active_buffer_len(), 1); + } + + #[test] + fn terminal_snapshot_composes_only_content_and_translates_cursor() { + let state = fresh_with(b""); + let window_id = state.core.borrow().active_window_id(); + let buffer_id = state.core.borrow().active_buffer_id(); + let size = CellSize::new(4, 5); + let viewport = CellSize::new(2, 5); + let mut cells = vec![crate::cell::Cell::default(); viewport.area() as usize]; + cells[0].glyph = crate::cell::Glyph::Char('T'); + cells[7].glyph = crate::cell::Glyph::Char('X'); + let snapshot = TerminalSnapshot { + buffer_id, + size: viewport, + cells, + cursor: Some(CellCoord::new(1, 2)), + title: Some("shell".into()), + screen_generation: 1, + selection: vec![crate::terminal::TerminalSelectionSpan { + row: 0, + start_col: 0, + end_col: 1, + }], + scroll_offset: 0, + at_bottom: true, + pid: 1, + process: crate::terminal::TerminalProcessState::Running, + }; + let snapshots = HashMap::from([(window_id, snapshot)]); + let mut backing = vec![crate::cell::Cell::default(); size.area() as usize]; + let cursor = { + let mut grid = crate::cell::CellGrid { + cells: &mut backing, + stride: size.cols, + size, + }; + paint_frame(&state, FrontendId::LOCAL, &snapshots, &mut grid, size) + }; + assert_eq!(backing[0].glyph, crate::cell::Glyph::Char('T')); + assert!(backing[0].style.reverse); + assert_eq!(backing[7].glyph, crate::cell::Glyph::Char('X')); + assert_ne!(backing[10].glyph, crate::cell::Glyph::Char('X')); + assert_eq!(cursor, Some(CellCoord::new(1, 2))); + } + #[test] fn line_number_gutter_renders_right_aligned_digits() { use crate::buffer::{Buffer, BufferId}; @@ -3380,10 +4009,10 @@ mod tests { fn cx_cc_quits() { let mut s = fresh_with(b""); s.dispatch_key(FrontendId::LOCAL, ctrl('x')); - assert_eq!(s.dispatcher.pending().len(), 1); + assert_eq!(local_dispatcher(&s).pending().len(), 1); s.dispatch_key(FrontendId::LOCAL, ctrl('c')); assert!(s.core.borrow().quit); - assert!(s.dispatcher.pending().is_empty()); + assert!(local_dispatcher(&s).pending().is_empty()); } #[test] @@ -3505,10 +4134,10 @@ mod tests { let size = crate::cell::CellSize::new(24, 80); let mut rs = crate::instance_render::RenderState::new(size); - let _ = rs.render_frame(&s, &[]); + let _ = rs.render_frame(&s, FrontendId::LOCAL, &HashMap::new(), &[]); process_event(&mut s, Event::Key(ctrl('s')), size); assert!(s.core.borrow().search_active(), "C-s starts the search"); - let _ = rs.render_frame(&s, &[]); + let _ = rs.render_frame(&s, FrontendId::LOCAL, &HashMap::new(), &[]); for c in "foo".chars() { process_event( @@ -3516,7 +4145,7 @@ mod tests { Event::Key(key(KeyCode::Char(c), KeyModifiers::NONE)), size, ); - let _ = rs.render_frame(&s, &[]); + let _ = rs.render_frame(&s, FrontendId::LOCAL, &HashMap::new(), &[]); } assert_eq!( s.core.borrow().search_query(), @@ -3544,7 +4173,7 @@ mod tests { stride: size.cols, size, }; - let _ = paint_frame(&s, &mut grid, size); + let _ = paint_frame(&s, FrontendId::LOCAL, &HashMap::new(), &mut grid, size); // The active match [0,3) washes row 0's first cells (bright // Indexed(11); lazy matches would be Indexed(3)). @@ -3670,10 +4299,10 @@ mod tests { state: crossterm::event::KeyEventState::NONE, }; s.dispatch_key(FrontendId::LOCAL, cx_press); - assert_eq!(s.dispatcher.pending().len(), 1); + assert_eq!(local_dispatcher(&s).pending().len(), 1); s.dispatch_key(FrontendId::LOCAL, cb_repeat); assert!( - s.dispatcher.pending().is_empty(), + local_dispatcher(&s).pending().is_empty(), "Repeat-kind C-b did not resolve the pending C-x prefix" ); assert_eq!(s.core.borrow().active_buffer_name(), "*buffer-list*"); @@ -3700,7 +4329,7 @@ mod tests { s.dispatch_key(FrontendId::LOCAL, cx_press); s.dispatch_key(FrontendId::LOCAL, cx_release); assert_eq!( - s.dispatcher.pending().len(), + local_dispatcher(&s).pending().len(), 1, "Release events should be ignored, but the prefix was disturbed" ); @@ -3763,10 +4392,14 @@ mod tests { // window to the *buffer-list* buffer). let mut s = fresh_with(b""); s.dispatch_key(FrontendId::LOCAL, ctrl('x')); - assert_eq!(s.dispatcher.pending().len(), 1, "C-x should start prefix"); + assert_eq!( + local_dispatcher(&s).pending().len(), + 1, + "C-x should start prefix" + ); s.dispatch_key(FrontendId::LOCAL, ctrl('b')); assert!( - s.dispatcher.pending().is_empty(), + local_dispatcher(&s).pending().is_empty(), "C-x C-b should resolve, leaving no pending prefix; status: {}", s.core.borrow().status ); @@ -3824,9 +4457,9 @@ mod tests { fn unknown_chord_continuation_clears_prefix_with_message() { let mut s = fresh_with(b""); s.dispatch_key(FrontendId::LOCAL, ctrl('x')); - assert_eq!(s.dispatcher.pending().len(), 1); + assert_eq!(local_dispatcher(&s).pending().len(), 1); s.dispatch_key(FrontendId::LOCAL, ctrl('q')); - assert!(s.dispatcher.pending().is_empty()); + assert!(local_dispatcher(&s).pending().is_empty()); assert!(s.core.borrow().status.contains("not bound")); } @@ -3867,7 +4500,7 @@ mod tests { key(KeyCode::Char('u'), KeyModifiers::NONE), ); assert_eq!(s.core.borrow().active_buffer_len(), 0); - assert!(s.dispatcher.pending().is_empty()); + assert!(local_dispatcher(&s).pending().is_empty()); } #[test] @@ -4210,7 +4843,7 @@ mod tests { #[test] fn empty_status_row_is_blank() { let s = fresh_with(b"hello\n"); - let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 80); + let line = build_status_line(&s.core.borrow(), &s.lua_host, &KeyDispatcher::new(), 80); assert_eq!(line, "", "status row should be empty when nothing to say"); } @@ -4218,7 +4851,7 @@ mod tests { fn captured_lua_error_appears_in_status_line() { let mut s = fresh_with(b""); let _ = s.lua_host.eval(Some("usercfg"), "error('kapow')"); - let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200); + let line = build_status_line(&s.core.borrow(), &s.lua_host, &KeyDispatcher::new(), 200); assert!(line.contains("lua: "), "status line: {line}"); assert!(line.contains("kapow"), "status line: {line}"); } @@ -4233,7 +4866,7 @@ mod tests { let s = fresh_with(b""); s.core.borrow_mut().status = "M-x error: command \"foo\" not found\nstack traceback:\n\t[C]: in ?".into(); - let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200); + let line = build_status_line(&s.core.borrow(), &s.lua_host, &KeyDispatcher::new(), 200); assert!(!line.contains('\n'), "status line leaked newline: {line:?}"); assert!(!line.contains('\r'), "status line leaked CR: {line:?}"); assert!( @@ -4252,7 +4885,7 @@ mod tests { let _ = s .lua_host .eval(Some("usercfg"), "error('boom\\nlots\\nof\\nlines')"); - let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200); + let line = build_status_line(&s.core.borrow(), &s.lua_host, &KeyDispatcher::new(), 200); assert!(!line.contains('\n'), "status line leaked newline: {line:?}"); assert!(line.contains("lua: "), "status line: {line}"); } @@ -4262,7 +4895,7 @@ mod tests { let mut s = fresh_with(b""); let _ = s.lua_host.eval(None, "error('latent')"); s.core.borrow_mut().status = "saved foo".into(); - let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200); + let line = build_status_line(&s.core.borrow(), &s.lua_host, &KeyDispatcher::new(), 200); assert!(line.contains("saved foo")); assert!(!line.contains("lua: ")); } @@ -5087,7 +5720,7 @@ mod tests { "raw status leaked newline: {raw:?} (default.lua should take first line)" ); assert!(raw.starts_with("M-x error: "), "raw status: {raw}"); - let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200); + let line = build_status_line(&s.core.borrow(), &s.lua_host, &KeyDispatcher::new(), 200); assert!( !line.contains('\n'), "rendered status line leaked newline: {line:?} (raw: {raw:?})" @@ -7076,7 +7709,13 @@ mod tests { stride: cols, size: crate::cell::CellSize::new(rows, cols), }; - let cursor = paint_frame(s, &mut grid, crate::cell::CellSize::new(rows, cols)); + let cursor = paint_frame( + s, + FrontendId::LOCAL, + &HashMap::new(), + &mut grid, + crate::cell::CellSize::new(rows, cols), + ); (backing, cols, cursor) } diff --git a/src/editor_core.rs b/src/editor_core.rs index fea6261..ded8143 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -2484,8 +2484,13 @@ impl EditorCore { /// read (`C-k`'s killed line, an appended chain), so the Lua ring /// pushes the exact bytes here. pub fn clipboard_set(&mut self, bytes: Vec) { + self.clipboard_set_for(self.active_frontend, bytes); + } + + /// Set and publish clipboard bytes to one authenticated frontend. + pub fn clipboard_set_for(&mut self, frontend_id: FrontendId, bytes: Vec) { self.clipboard_slot.clone_from(&bytes); - self.pending_clipboard = Some((self.active_frontend, bytes)); + self.pending_clipboard = Some((frontend_id, bytes)); } /// The clipboard slot's current bytes, or `None` when empty (kill @@ -2821,6 +2826,12 @@ impl EditorCore { self.round_trip_buffers.contains(&self.active_buffer_id()) } + /// Whether an explicit buffer requires daemon-owned round-trip input. + #[must_use] + pub fn buffer_round_trips(&self, buffer_id: BufferId) -> bool { + self.round_trip_buffers.contains(&buffer_id) + } + /// Ensure the active window carries a /// [`crate::completion::CompletionView`] overlay (deduped by kind). /// The view reads the shared popup, so one instance suffices; it @@ -2889,15 +2900,21 @@ impl EditorCore { .map_err(|e| e.to_string()) } - /// Switch the active window to a different buffer, allocating a - /// fresh [`TextView`] for it. - pub fn switch_active_buffer(&mut self, buffer_id: BufferId) -> Result<(), String> { + /// Switch one frontend's active window to a different buffer, allocating + /// a fresh [`TextView`] for it without changing global active state. + pub fn switch_active_buffer_for( + &mut self, + frontend_id: FrontendId, + buffer_id: BufferId, + ) -> Result<(), String> { let text_view = { let reg = self.registry.borrow(); let buf = reg.get(buffer_id).map_err(|e| e.to_string())?; TextView::new(buf) }; - let aw = self.active_window_mut(); + let aw = self + .active_window_mut_for(frontend_id) + .ok_or_else(|| format!("frontend {frontend_id:?} has no active window"))?; aw.buffer_id = buffer_id; aw.text_view = text_view; // Overlays were keyed to the previous buffer's coordinates; @@ -2911,6 +2928,11 @@ impl EditorCore { aw.goal_col = None; Ok(()) } + + /// Switch the globally active frontend's active window. + pub fn switch_active_buffer(&mut self, buffer_id: BufferId) -> Result<(), String> { + self.switch_active_buffer_for(self.active_frontend, buffer_id) + } } // --------------------------------------------------------------------------- diff --git a/src/frontend.rs b/src/frontend.rs index 8fe6831..6c1ca45 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -348,8 +348,11 @@ impl Frontend { let payload = format!("\x1b]52;c;{}\x07", osc52_base64(data)); queue!(self.out, Print(payload))?; } + InstanceMessage::Signal(InstanceSignal::Bell) => { + queue!(self.out, Print("\x07"))?; + } InstanceMessage::ModeLine(_) - // Bell / window-title Signals stay reserved for v0.3. + // Window-title requests remain metadata-only. | InstanceMessage::Signal(_) | InstanceMessage::Goodbye(_) // T M10.5: CrdtOp's wire shape exists; the v1.0 TUI doesn't diff --git a/src/instance_render.rs b/src/instance_render.rs index 4cbe405..3c8cf17 100644 --- a/src/instance_render.rs +++ b/src/instance_render.rs @@ -18,7 +18,10 @@ use crate::cell::{Cell, CellGrid, CellSize, diff}; use crate::editor::{EditorState, paint_frame}; -use crate::protocol::{CursorState, InstanceMessage}; +use crate::protocol::{CursorState, FrontendId, InstanceMessage}; +use crate::terminal::TerminalSnapshot; +use crate::window::WindowId; +use std::collections::HashMap; /// Owns the cell buffers and runs the paint-and-diff cycle. pub struct RenderState { @@ -95,6 +98,8 @@ impl RenderState { pub fn render_frame( &mut self, state: &EditorState, + frontend_id: FrontendId, + terminal_snapshots: &HashMap, other_presences: &[crate::overlay_paint::OtherPresence], ) -> Vec { if self.size.rows < 2 || self.size.cols == 0 { @@ -107,7 +112,7 @@ impl RenderState { stride: self.size.cols, size: self.size, }; - let coord = paint_frame(state, &mut grid, self.size); + let coord = paint_frame(state, frontend_id, terminal_snapshots, &mut grid, self.size); // T M10.9 — overlay paint after main paint, before diff. // Modifies cells in `next`; diff captures the changes // as ordinary style updates. @@ -186,7 +191,7 @@ mod tests { #[test] fn render_returns_cell_delta_and_cursor() { let mut r = RenderState::new(CellSize::new(24, 80)); - let msgs = r.render_frame(&empty_state(), &[]); + let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); assert_eq!(msgs.len(), 2); assert!(matches!(msgs[0], InstanceMessage::CellDelta { .. })); assert!(matches!(msgs[1], InstanceMessage::Cursor(_))); @@ -195,7 +200,7 @@ mod tests { #[test] fn first_frame_is_full_grid_sync() { let mut r = RenderState::new(CellSize::new(24, 80)); - let msgs = r.render_frame(&empty_state(), &[]); + let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, .. } => assert!(*full_grid), _ => panic!("expected CellDelta first"), @@ -205,8 +210,8 @@ mod tests { #[test] fn second_frame_is_differential() { let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&empty_state(), &[]); - let msgs = r.render_frame(&empty_state(), &[]); + let _ = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); + let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, .. } => assert!(!*full_grid), _ => panic!("expected CellDelta first"), @@ -217,8 +222,8 @@ mod tests { fn unchanged_state_produces_empty_spans_after_first_frame() { let state = empty_state(); let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&state, &[]); - let msgs = r.render_frame(&state, &[]); + let _ = r.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]); + let msgs = r.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[0] { InstanceMessage::CellDelta { spans, .. } => assert!( spans.is_empty(), @@ -231,7 +236,7 @@ mod tests { #[test] fn resize_reallocates_and_flags_full_grid() { let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&empty_state(), &[]); + let _ = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); assert!(!r.needs_full_grid); r.resize(CellSize::new(40, 120)); @@ -240,7 +245,7 @@ mod tests { assert_eq!(r.next.len(), 40 * 120); assert!(r.needs_full_grid); - let msgs = r.render_frame(&empty_state(), &[]); + let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, .. } => assert!(*full_grid), _ => unreachable!(), @@ -250,7 +255,7 @@ mod tests { #[test] fn resize_to_same_size_is_noop() { let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&empty_state(), &[]); + let _ = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); assert!(!r.needs_full_grid); r.resize(CellSize::new(24, 80)); // No reallocation, no full-grid flip. @@ -260,7 +265,7 @@ mod tests { #[test] fn force_full_grid_resync_flips_flag() { let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&empty_state(), &[]); + let _ = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); assert!(!r.needs_full_grid); r.force_full_grid_resync(); assert!(r.needs_full_grid); @@ -270,16 +275,22 @@ mod tests { fn too_small_grid_returns_empty_messages() { // rows < 2 means we can't paint a text-area + status row. let mut r = RenderState::new(CellSize::new(1, 80)); - assert!(r.render_frame(&empty_state(), &[]).is_empty()); + assert!( + r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]) + .is_empty() + ); let mut r = RenderState::new(CellSize::new(24, 0)); - assert!(r.render_frame(&empty_state(), &[]).is_empty()); + assert!( + r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]) + .is_empty() + ); } #[test] fn cursor_message_carries_coord_when_paint_returns_one() { let mut r = RenderState::new(CellSize::new(24, 80)); - let msgs = r.render_frame(&empty_state(), &[]); + let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[1] { InstanceMessage::Cursor(Some(cs)) => { assert!(cs.visible); @@ -309,7 +320,7 @@ mod tests { // Criterion 1: the first frame after construction is a full-grid // CellDelta carrying every non-default cell. let mut r = RenderState::new(CellSize::new(24, 80)); - let msgs = r.render_frame(&empty_state(), &[]); + let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, spans } => { assert!(*full_grid, "first frame must be flagged full_grid=true"); @@ -335,7 +346,7 @@ mod tests { let mut state = EditorState::new(); let mut r = RenderState::new(size); // Seat the prev buffer. - let _ = r.render_frame(&state, &[]); + let _ = r.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]); // Single character insert. state.dispatch_key( @@ -347,7 +358,7 @@ mod tests { state: KeyEventState::empty(), }, ); - let msgs = r.render_frame(&state, &[]); + let msgs = r.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, spans } => { assert!(!*full_grid, "differential frame must not flag full_grid"); @@ -374,7 +385,7 @@ mod tests { let mut r = RenderState::new(size); // First render: seats prev with the painted frame. - let first = r.render_frame(&empty_state(), &[]); + let first = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); let baseline_changed: usize = match &first[0] { InstanceMessage::CellDelta { spans, .. } => spans.iter().map(|s| s.cells.len()).sum(), _ => unreachable!(), @@ -383,7 +394,7 @@ mod tests { // A second render with no state change normally produces zero // spans (the state matches prev exactly). - let unchanged = r.render_frame(&empty_state(), &[]); + let unchanged = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &unchanged[0] { InstanceMessage::CellDelta { full_grid, spans } => { assert!(!*full_grid); @@ -396,7 +407,7 @@ mod tests { // what's on screen. force_full_grid_resync flags the next frame // for full sync. r.force_full_grid_resync(); - let resync = r.render_frame(&empty_state(), &[]); + let resync = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &resync[0] { InstanceMessage::CellDelta { full_grid, spans } => { assert!(*full_grid, "post-resync frame must be full_grid=true"); diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 71d4c3a..ae04b3a 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -54,6 +54,7 @@ use crate::buffer::{BufferId, EditOp, MarkGravity, MarkId}; use crate::buffer_registry::BufferRegistry; use crate::cell::{Color, Style, UnderlineStyle}; use crate::command::{Command, CommandError, CommandRegistry, SourceLocation}; +use crate::editor::InteractiveCommandOrigin; use crate::editor_core::EditorCore; use crate::highlight::SyntaxHighlightView; use crate::hook::{Hook, HookRegistry}; @@ -5208,6 +5209,26 @@ fn install_buffer_kill(lua: &Lua, core: &SharedCore) -> mlua::Result<()> { Ok(()) } +fn rotate_interactive_command(lua: &Lua, name: &str) -> mlua::Result<()> { + let origin = lua + .app_data_ref::() + .ok_or_else(|| { + mlua::Error::external( + "pmacs.command.invoke_interactive: interactive frontend context is unavailable", + ) + })?; + let frontend_id = origin.current().ok_or_else(|| { + mlua::Error::external( + "pmacs.command.invoke_interactive: requires an active interactive frontend context", + ) + })?; + let core = lua.app_data_ref::().ok_or_else(|| { + mlua::Error::external("pmacs.command.invoke_interactive: editor core is unavailable") + })?; + core.borrow_mut().rotate_command(frontend_id, name); + Ok(()) +} + fn install_command_module(lua: &Lua, commands: &SharedCommandRegistry) -> mlua::Result { let command = lua.create_table()?; @@ -5280,11 +5301,7 @@ fn install_command_module(lua: &Lua, commands: &SharedCommandRegistry) -> mlua:: command.set( "invoke_interactive", lua.create_function(move |lua, (name, args): (String, Variadic)| { - if let Some(core) = lua.app_data_ref::() { - let mut core = core.borrow_mut(); - let fid = core.active_frontend; - core.rotate_command(fid, &name); - } + rotate_interactive_command(lua, &name)?; let body = { let r = cmds.borrow(); r.get(&name) @@ -8189,6 +8206,607 @@ pub fn make_process_supervisor(lua: &Lua) -> mlua::Result mlua::Result { + let manager = Rc::new(RefCell::new(crate::terminal::TerminalManager::new())); + install_terminal(lua, &manager, supervisor)?; + Ok(manager) +} + +fn terminal_shared_core(lua: &Lua, operation: &str) -> mlua::Result { + lua.app_data_ref::() + .map(|core| core.clone()) + .ok_or_else(|| { + mlua::Error::external(format!( + "pmacs.terminal.{operation}: editor core unavailable" + )) + }) +} + +fn terminal_command_frontend(lua: &Lua, core: &SharedCore) -> crate::protocol::FrontendId { + lua.app_data_ref::() + .and_then(|origin| origin.current()) + .unwrap_or_else(|| core.borrow().active_frontend) +} + +fn active_terminal_view_key( + lua: &Lua, + core: &SharedCore, + manager: &crate::terminal::SharedTerminalManager, + operation: &str, +) -> mlua::Result { + let frontend_id = lua + .app_data_ref::() + .and_then(|origin| origin.current()) + .ok_or_else(|| { + mlua::Error::external(format!( + "pmacs.terminal.{operation}: requires an interactive frontend context" + )) + })?; + let core = core.borrow(); + let window = core.active_window_for(frontend_id).ok_or_else(|| { + mlua::Error::external(format!( + "pmacs.terminal.{operation}: invoking frontend has no active window" + )) + })?; + if !manager.borrow().is_terminal(window.buffer_id) { + return Err(mlua::Error::external(format!( + "pmacs.terminal.{operation}: invoking frontend's active window is not a terminal" + ))); + } + Ok(crate::terminal::TerminalViewKey::new( + frontend_id, + core.views + .get(&frontend_id) + .expect("active window implies registered frontend view") + .active, + window.buffer_id, + )) +} + +fn terminal_context_integer(context: &Table, field: &str) -> mlua::Result { + match context.raw_get::(field)? { + Value::Integer(value) => u64::try_from(value).map_err(|_| { + mlua::Error::external(format!( + "pmacs.terminal.view_state: `{field}` must be nonnegative" + )) + }), + Value::Nil => Err(mlua::Error::external(format!( + "pmacs.terminal.view_state: missing field `{field}`" + ))), + other => Err(mlua::Error::external(format!( + "pmacs.terminal.view_state: `{field}` must be an integer, got {}", + other.type_name() + ))), + } +} + +fn terminal_context_buffer(context: &Table) -> mlua::Result { + match context.raw_get::("buffer")? { + Value::UserData(buffer) => buffer + .borrow::() + .map(|buffer| buffer.0) + .map_err(|_| { + mlua::Error::external("pmacs.terminal.view_state: `buffer` must be a buffer id") + }), + Value::Nil => Err(mlua::Error::external( + "pmacs.terminal.view_state: missing field `buffer`", + )), + other => Err(mlua::Error::external(format!( + "pmacs.terminal.view_state: `buffer` must be a buffer id, got {}", + other.type_name() + ))), + } +} + +fn terminal_view_key_from_context( + core: &SharedCore, + context: &Table, +) -> mlua::Result> { + const FIELDS: &[&str] = &["frontend", "window", "buffer", "active"]; + let mut unknown = None; + context.clone().for_each(|key: Value, _: Value| { + if unknown.is_none() { + match key { + Value::String(value) => { + let value = value.to_str()?; + if !FIELDS.contains(&value.as_ref()) { + unknown = Some(value.to_owned()); + } + } + other => unknown = Some(format!("{other:?}")), + } + } + Ok(()) + })?; + if let Some(field) = unknown { + return Err(mlua::Error::external(format!( + "pmacs.terminal.view_state: unknown field `{field}`" + ))); + } + let frontend_id = crate::protocol::FrontendId(terminal_context_integer(context, "frontend")?); + let window_raw = terminal_context_integer(context, "window")?; + let buffer_id = terminal_context_buffer(context)?; + + let core = core.borrow(); + let Some(view) = core.views.get(&frontend_id) else { + return Ok(None); + }; + let Some(window_id) = view + .layout + .iter_ids() + .into_iter() + .find(|window_id| window_id.raw() == window_raw) + else { + return Ok(None); + }; + if core + .windows + .get(&window_id) + .is_none_or(|window| window.buffer_id != buffer_id) + { + return Ok(None); + } + Ok(Some(crate::terminal::TerminalViewKey::new( + frontend_id, + window_id, + buffer_id, + ))) +} + +#[allow( + clippy::too_many_lines, + reason = "single strict Lua module installation" +)] +fn install_terminal( + lua: &Lua, + manager: &crate::terminal::SharedTerminalManager, + supervisor: &SharedProcessSupervisor, +) -> mlua::Result<()> { + let pmacs: Table = lua.globals().get("pmacs")?; + let terminal = lua.create_table()?; + + { + let manager = manager.clone(); + let supervisor = supervisor.clone(); + terminal.set( + "_open", + lua.create_function(move |lua, spec: Table| -> mlua::Result { + let spec = parse_terminal_spec(&spec)?; + let core = lua + .app_data_ref::() + .map(|core| core.clone()) + .ok_or_else(|| { + mlua::Error::external("pmacs.terminal.open: editor core unavailable") + })?; + let frontend_id = terminal_command_frontend(lua, &core); + if core.borrow().active_window_for(frontend_id).is_none() { + return Err(mlua::Error::external( + "pmacs.terminal.open: target frontend has no active window", + )); + } + let buffer_id = { + let mut manager = manager.borrow_mut(); + manager + .open(spec, &mut core.borrow_mut(), &mut supervisor.borrow_mut()) + .map_err(mlua::Error::external)? + }; + let key = { + let mut core = core.borrow_mut(); + if let Err(error) = core.switch_active_buffer_for(frontend_id, buffer_id) { + let _ = core.registry.borrow_mut().remove(buffer_id); + manager + .borrow_mut() + .prune(&mut core, &mut supervisor.borrow_mut()); + return Err(mlua::Error::external(format!( + "pmacs.terminal.open: active-window switch failed: {error}" + ))); + } + crate::terminal::TerminalViewKey::new( + frontend_id, + core.views + .get(&frontend_id) + .expect("checked frontend has active view") + .active, + buffer_id, + ) + }; + let claimed = { + let mut manager = manager.borrow_mut(); + manager.register_view(key) && manager.claim_controller(key) + }; + if !claimed { + let mut core = core.borrow_mut(); + let _ = core.registry.borrow_mut().remove(buffer_id); + manager + .borrow_mut() + .prune(&mut core, &mut supervisor.borrow_mut()); + return Err(mlua::Error::external( + "pmacs.terminal.open: failed to claim the new terminal view", + )); + } + run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new()); + Ok(BufferIdLua(buffer_id)) + })?, + )?; + } + + { + let manager = manager.clone(); + terminal.set( + "is_terminal", + lua.create_function(move |_, buffer: BufferIdLua| { + Ok(manager.borrow().is_terminal(buffer.0)) + })?, + )?; + } + + { + let manager = manager.clone(); + terminal.set( + "state", + lua.create_function(move |lua, buffer: BufferIdLua| { + let snapshot = manager.borrow().snapshot(buffer.0).ok_or_else(|| { + mlua::Error::external(format!( + "pmacs.terminal.state: buffer {:?} is not a terminal", + buffer.0 + )) + })?; + terminal_state_table(lua, snapshot) + })?, + )?; + } + + { + let manager = manager.clone(); + let supervisor = supervisor.clone(); + terminal.set( + "send", + lua.create_function(move |_, (buffer, bytes): (BufferIdLua, mlua::String)| { + manager + .borrow() + .send( + buffer.0, + bytes.as_bytes().as_ref(), + &mut supervisor.borrow_mut(), + ) + .map_err(mlua::Error::external) + })?, + )?; + } + + { + let manager = manager.clone(); + let supervisor = supervisor.clone(); + terminal.set( + "terminate", + lua.create_function(move |_, buffer: BufferIdLua| { + manager + .borrow_mut() + .terminate(buffer.0, &mut supervisor.borrow_mut()) + .map_err(mlua::Error::external) + })?, + )?; + } + + { + let manager = manager.clone(); + terminal.set( + "view_state", + lua.create_function(move |lua, context: Table| -> mlua::Result> { + let core = terminal_shared_core(lua, "view_state")?; + let Some(key) = terminal_view_key_from_context(&core, &context)? else { + return Ok(None); + }; + let Some(status) = manager.borrow_mut().view_status(key) else { + return Ok(None); + }; + let table = lua.create_table()?; + table.set("at_bottom", status.at_bottom)?; + table.set("scroll_offset", status.scroll_offset)?; + table.set("selection", status.selection)?; + Ok(Some(table)) + })?, + )?; + } + + { + let manager = manager.clone(); + terminal.set( + "scroll", + lua.create_function(move |lua, lines: i64| { + let lines = i32::try_from(lines).unwrap_or_else(|_| { + if lines.is_negative() { + i32::MIN + } else { + i32::MAX + } + }); + let core = terminal_shared_core(lua, "scroll")?; + let key = active_terminal_view_key(lua, &core, &manager, "scroll")?; + Ok(manager.borrow_mut().scroll_lines(key, lines)) + })?, + )?; + } + + { + let manager = manager.clone(); + terminal.set( + "_scroll_page", + lua.create_function(move |lua, direction: i64| { + let direction = i32::try_from(direction).map_err(|_| { + mlua::Error::external( + "pmacs.terminal._scroll_page: `direction` exceeds i32 range", + ) + })?; + let core = terminal_shared_core(lua, "_scroll_page")?; + let key = active_terminal_view_key(lua, &core, &manager, "_scroll_page")?; + Ok(manager.borrow_mut().scroll_page(key, direction)) + })?, + )?; + } + + { + let manager = manager.clone(); + terminal.set( + "scroll_to_bottom", + lua.create_function(move |lua, ()| { + let core = terminal_shared_core(lua, "scroll_to_bottom")?; + let key = active_terminal_view_key(lua, &core, &manager, "scroll_to_bottom")?; + Ok(manager.borrow_mut().scroll_to_bottom(key)) + })?, + )?; + } + + { + let manager = manager.clone(); + terminal.set( + "copy_selection", + lua.create_function(move |lua, ()| { + let core = terminal_shared_core(lua, "copy_selection")?; + let key = active_terminal_view_key(lua, &core, &manager, "copy_selection")?; + let Some(bytes) = manager.borrow_mut().copy_selection(key) else { + return Ok(false); + }; + core.borrow_mut().clipboard_set_for(key.frontend_id, bytes); + Ok(true) + })?, + )?; + } + + pmacs.set("terminal", terminal) +} + +fn parse_terminal_spec(table: &Table) -> mlua::Result { + const FIELDS: &[&str] = &[ + "command", + "args", + "cwd", + "env", + "name", + "rows", + "cols", + "scrollback_rows", + ]; + let mut unknown = None; + table.clone().for_each(|key: Value, _: Value| { + let key = match key { + Value::String(key) => key.to_str()?.to_owned(), + other => { + unknown = Some(format!("<{} key>", other.type_name())); + return Ok(()); + } + }; + if !FIELDS.contains(&key.as_str()) { + unknown = Some(key); + } + Ok(()) + })?; + if let Some(field) = unknown { + return Err(mlua::Error::external(format!( + "pmacs.terminal.open: unknown field `{field}`" + ))); + } + + let command = strict_terminal_string(table.raw_get("command")?, "command", false)? + .ok_or_else(|| mlua::Error::external("pmacs.terminal.open: missing field `command`"))?; + let args = strict_terminal_args(table.raw_get("args")?)?; + let cwd = + strict_terminal_string(table.raw_get("cwd")?, "cwd", true)?.map(std::path::PathBuf::from); + let env = strict_terminal_env(table.raw_get("env")?)?; + let name = strict_terminal_string(table.raw_get("name")?, "name", true)?; + let rows = strict_terminal_u16(table.raw_get("rows")?, "rows", 24)?; + let cols = strict_terminal_u16(table.raw_get("cols")?, "cols", 80)?; + let scrollback_rows = strict_terminal_usize( + table.raw_get("scrollback_rows")?, + "scrollback_rows", + crate::terminal::DEFAULT_TERMINAL_SCROLLBACK_ROWS, + )?; + + Ok(crate::terminal::TerminalSpec { + command, + args, + cwd, + env, + name, + rows, + cols, + scrollback_rows, + }) +} + +fn strict_terminal_string( + value: Value, + field: &'static str, + optional: bool, +) -> mlua::Result> { + match value { + Value::Nil if optional => Ok(None), + Value::String(value) => Ok(Some(value.to_str()?.to_owned())), + Value::Nil => Err(mlua::Error::external(format!( + "pmacs.terminal.open: missing field `{field}`" + ))), + other => Err(mlua::Error::external(format!( + "pmacs.terminal.open: `{field}` must be a string, got {}", + other.type_name() + ))), + } +} + +fn strict_terminal_args(value: Value) -> mlua::Result> { + let Value::Table(table) = value else { + return match value { + Value::Nil => Ok(Vec::new()), + other => Err(mlua::Error::external(format!( + "pmacs.terminal.open: `args` must be a dense string array, got {}", + other.type_name() + ))), + }; + }; + let mut entries = std::collections::BTreeMap::new(); + table.for_each(|key: Value, value: Value| { + let Value::Integer(index) = key else { + return Err(mlua::Error::external( + "pmacs.terminal.open: `args` keys must be positive integers", + )); + }; + let index = usize::try_from(index).map_err(|_| { + mlua::Error::external("pmacs.terminal.open: `args` keys must be positive integers") + })?; + if index == 0 { + return Err(mlua::Error::external( + "pmacs.terminal.open: `args` keys must be positive integers", + )); + } + let Value::String(value) = value else { + return Err(mlua::Error::external(format!( + "pmacs.terminal.open: `args[{index}]` must be a string" + ))); + }; + entries.insert(index, value.to_str()?.to_owned()); + Ok(()) + })?; + let mut args = Vec::with_capacity(entries.len()); + for expected in 1..=entries.len() { + let value = entries.remove(&expected).ok_or_else(|| { + mlua::Error::external(format!( + "pmacs.terminal.open: `args` has a hole at index {expected}" + )) + })?; + args.push(value); + } + if let Some((&index, _)) = entries.first_key_value() { + return Err(mlua::Error::external(format!( + "pmacs.terminal.open: `args` has a hole before index {index}" + ))); + } + Ok(args) +} + +fn strict_terminal_env(value: Value) -> mlua::Result> { + let Value::Table(table) = value else { + return match value { + Value::Nil => Ok(Vec::new()), + other => Err(mlua::Error::external(format!( + "pmacs.terminal.open: `env` must be a string-to-string table, got {}", + other.type_name() + ))), + }; + }; + let mut env = Vec::new(); + table.for_each(|key: Value, value: Value| { + let Value::String(key) = key else { + return Err(mlua::Error::external( + "pmacs.terminal.open: `env` keys must be strings", + )); + }; + let key = key.to_str()?.to_owned(); + let Value::String(value) = value else { + return Err(mlua::Error::external(format!( + "pmacs.terminal.open: `env[{key}]` must be a string" + ))); + }; + env.push((key, value.to_str()?.to_owned())); + Ok(()) + })?; + env.sort_unstable_by(|left, right| left.0.cmp(&right.0)); + Ok(env) +} + +fn strict_terminal_u16(value: Value, field: &'static str, default: u16) -> mlua::Result { + match value { + Value::Nil => Ok(default), + Value::Integer(value) => u16::try_from(value).map_err(|_| { + mlua::Error::external(format!( + "pmacs.terminal.open: `{field}` must be an integer in 0..={}", + u16::MAX + )) + }), + other => Err(mlua::Error::external(format!( + "pmacs.terminal.open: `{field}` must be an integer, got {}", + other.type_name() + ))), + } +} + +fn strict_terminal_usize(value: Value, field: &'static str, default: usize) -> mlua::Result { + match value { + Value::Nil => Ok(default), + Value::Integer(value) => usize::try_from(value).map_err(|_| { + mlua::Error::external(format!( + "pmacs.terminal.open: `{field}` must be a non-negative integer" + )) + }), + other => Err(mlua::Error::external(format!( + "pmacs.terminal.open: `{field}` must be an integer, got {}", + other.type_name() + ))), + } +} + +fn terminal_state_table( + lua: &Lua, + snapshot: crate::terminal::TerminalSnapshot, +) -> mlua::Result
{ + let state = lua.create_table()?; + state.set("buffer", BufferIdLua(snapshot.buffer_id))?; + state.set("pid", i64::from(snapshot.pid))?; + state.set("rows", i64::from(snapshot.size.rows))?; + state.set("cols", i64::from(snapshot.size.cols))?; + if let Some(title) = snapshot.title { + state.set("title", title)?; + } + state.set( + "screen_generation", + i64::try_from(snapshot.screen_generation).unwrap_or(i64::MAX), + )?; + let process = lua.create_table()?; + match snapshot.process { + crate::terminal::TerminalProcessState::Running => process.set("kind", "running")?, + crate::terminal::TerminalProcessState::Exited(code) => { + process.set("kind", "exited")?; + process.set("code", code)?; + } + crate::terminal::TerminalProcessState::Signaled(signal) => { + process.set("kind", "signaled")?; + process.set("signal", signal)?; + } + crate::terminal::TerminalProcessState::Crashed(message) => { + process.set("kind", "crashed")?; + process.set("message", message)?; + } + } + state.set("process", process)?; + Ok(state) +} + // --------------------------------------------------------------------------- // pmacs.lsp: LSP client surface (T M4.5) // --------------------------------------------------------------------------- diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 14ff5a7..2d3ec0a 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -3824,7 +3824,7 @@ mod tests { let buffer_id = active_buffer(&state); sem.set_viewport(buffer_id, ByteRange { start: 0, end: 80 }, 0); - let grid_msgs = grid.render_frame(&state, &[]); + let grid_msgs = grid.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]); let sem_msgs = sem.render_frame(&state); assert!( matches!(grid_msgs[0], InstanceMessage::CellDelta { .. }), diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index ca205c5..267ff32 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -8,11 +8,16 @@ pub mod input; /// Stateful terminal screen model. pub mod screen; pub mod session; +/// Per-context terminal viewport, selection, and controller identities. +pub mod view; pub use session::{ SharedTerminalManager, TerminalError, TerminalManager, TerminalProcessState, TerminalSelectionSpan, TerminalSnapshot, TerminalSpec, }; +pub use view::{ + LogicalCellAnchor, TerminalController, TerminalSelection, TerminalViewKey, TerminalViewState, +}; /// Maximum terminal rows accepted at creation or resize. pub const MAX_TERMINAL_ROWS: u16 = 512; diff --git a/src/terminal/screen.rs b/src/terminal/screen.rs index fe4939f..11c92ef 100644 --- a/src/terminal/screen.rs +++ b/src/terminal/screen.rs @@ -97,6 +97,60 @@ pub struct ScreenSnapshot { pub generation: u64, } +/// Owned row projection published atomically to terminal views. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScreenProjection { + /// Terminal grid dimensions. + pub size: CellSize, + /// Whether the projected visible rows belong to the alternate screen. + pub alternate_active: bool, + /// Retained main-screen history, empty while the alternate screen is active. + pub history: Vec, + /// Active visible rows, including logical-line and soft-wrap metadata. + pub visible_rows: Vec, + /// Published cursor position when visible. + pub cursor: Option, + /// Published terminal title. + pub title: Option, + /// Screen generation represented by this projection. + pub generation: u64, +} + +/// Borrowed, publication-consistent row projection for in-process views. +#[allow(missing_docs)] +#[derive(Clone, Copy)] +pub(crate) struct BorrowedScreenProjection<'a> { + pub size: CellSize, + pub alternate_active: bool, + pub history_head: &'a [TerminalRow], + pub history_tail: &'a [TerminalRow], + pub visible_rows: &'a [TerminalRow], + pub cursor: Option, + pub title: Option<&'a str>, + pub generation: u64, +} + +impl BorrowedScreenProjection<'_> { + pub(crate) fn history_len(self) -> usize { + self.history_head.len() + self.history_tail.len() + } +} + +impl ScreenProjection { + pub(crate) fn as_borrowed(&self) -> BorrowedScreenProjection<'_> { + BorrowedScreenProjection { + size: self.size, + alternate_active: self.alternate_active, + history_head: &self.history, + history_tail: &[], + visible_rows: &self.visible_rows, + cursor: self.cursor, + title: self.title.as_deref(), + generation: self.generation, + } + } +} + #[derive(Clone, Copy, Debug, Default)] struct Cursor { row: usize, @@ -138,7 +192,7 @@ pub struct TerminalScreen { tab_stops: BTreeSet, title: Option, generation: u64, - published: ScreenSnapshot, + published: ScreenProjection, sync_started: Option, next_line_id: u64, scrollback_rows: usize, @@ -159,9 +213,11 @@ impl TerminalScreen { let mut next_line_id = 1; let main = Grid::new(size, &mut next_line_id); let alt = Grid::new(size, &mut next_line_id); - let published = ScreenSnapshot { + let published = ScreenProjection { size, - cells: flatten(&main.rows), + alternate_active: false, + history: Vec::new(), + visible_rows: main.rows.clone(), cursor: Some(CellCoord::new(0, 0)), title: None, generation: 0, @@ -502,15 +558,58 @@ impl TerminalScreen { pub fn snapshot(&self) -> ScreenSnapshot { if self.modes.synchronized_output { - self.published.clone() + snapshot_from_projection(&self.published) } else { self.current_snapshot() } } + /// Return one owned, publication-consistent row projection. + #[must_use] + pub fn projection(&self) -> ScreenProjection { + if self.modes.synchronized_output { + self.published.clone() + } else { + self.current_projection() + } + } + + /// Borrow one publication-consistent row projection without cloning cells. + pub(crate) fn projection_ref(&self) -> BorrowedScreenProjection<'_> { + if self.modes.synchronized_output { + return self.published.as_borrowed(); + } + let (history_head, history_tail) = if self.alt_active { + (&[][..], &[][..]) + } else { + self.main.history.as_slices() + }; + BorrowedScreenProjection { + size: self.size, + alternate_active: self.alt_active, + history_head, + history_tail, + visible_rows: &self.active().rows, + cursor: self + .modes + .cursor_visible + .then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)), + title: self.title.as_deref(), + generation: self.generation, + } + } pub fn modes(&self) -> TerminalModes { self.modes } + /// Return whether the published active screen is alternate. + #[must_use] + pub fn alternate_active(&self) -> bool { + if self.modes.synchronized_output { + self.published.alternate_active + } else { + self.alt_active + } + } pub fn bell_count(&self) -> u64 { self.bell_count } @@ -1380,8 +1479,26 @@ impl TerminalScreen { generation: self.generation, } } + fn current_projection(&self) -> ScreenProjection { + ScreenProjection { + size: self.size, + alternate_active: self.alt_active, + history: if self.alt_active { + Vec::new() + } else { + self.main.history.iter().cloned().collect() + }, + visible_rows: self.active().rows.clone(), + cursor: self + .modes + .cursor_visible + .then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)), + title: self.title.clone(), + generation: self.generation, + } + } fn publish(&mut self) { - self.published = self.current_snapshot(); + self.published = self.current_projection(); } } @@ -1491,6 +1608,15 @@ fn glyph_width(glyph: &Glyph) -> usize { Glyph::Continuation => 0, } } +fn snapshot_from_projection(projection: &ScreenProjection) -> ScreenSnapshot { + ScreenSnapshot { + size: projection.size, + cells: flatten(&projection.visible_rows), + cursor: projection.cursor, + title: projection.title.clone(), + generation: projection.generation, + } +} fn resize_grid_clip( grid: &mut Grid, old: CellSize, @@ -1625,18 +1751,21 @@ mod tests { #[test] fn alternate_screen_preserves_main_and_has_no_history() { let mut s = screen(2, 4); + assert!(!s.alternate_active()); s.apply_event(AnsiEvent::Text("main".into())); let main = s.snapshot(); s.apply_event(AnsiEvent::AlternateScreen { mode: AlternateScreenMode::Mode1049, enabled: true, }); + assert!(s.alternate_active()); s.apply_event(AnsiEvent::Text("alt\nmore".into())); assert!(s.history().is_empty()); s.apply_event(AnsiEvent::AlternateScreen { mode: AlternateScreenMode::Mode1049, enabled: false, }); + assert!(!s.alternate_active()); assert_eq!(&s.snapshot().cells[..4], &main.cells[..4]); } @@ -1668,17 +1797,59 @@ mod tests { } #[test] - fn synchronized_output_gates_snapshot_and_finish_releases() { + fn synchronized_output_gates_snapshot_and_row_projection_until_release() { let mut s = screen(2, 4); - let before = s.snapshot(); + s.apply_event(AnsiEvent::Text("main".into())); + s.apply_event(AnsiEvent::LineFeed); + s.apply_event(AnsiEvent::LineFeed); + let before_snapshot = s.snapshot(); + let before_projection = s.projection(); + assert_eq!(before_projection.history.len(), 1); + assert!(!before_projection.alternate_active); + s.apply_event(AnsiEvent::SetMode { mode: TerminalMode::SynchronizedOutput, enabled: true, }); - s.apply_event(AnsiEvent::Text("x".into())); - assert_eq!(s.snapshot(), before); + s.apply_event(AnsiEvent::AlternateScreen { + mode: AlternateScreenMode::Mode1049, + enabled: true, + }); + s.apply_event(AnsiEvent::Text("alt".into())); + assert_eq!(s.snapshot(), before_snapshot); + assert_eq!(s.projection(), before_projection); + assert!(!s.alternate_active()); + s.finish_output(); - assert_ne!(s.snapshot(), before); + let released = s.projection(); + assert!(released.alternate_active); + assert!(released.history.is_empty()); + assert_eq!(s.snapshot().cells, flatten(&released.visible_rows)); + assert!(s.alternate_active()); + } + + #[test] + fn borrowed_projection_reuses_live_and_published_row_storage() { + let mut s = screen(2, 4); + s.apply_event(AnsiEvent::Text("main".into())); + s.apply_event(AnsiEvent::LineFeed); + s.apply_event(AnsiEvent::LineFeed); + let (live_history, _) = s.main.history.as_slices(); + let live_history_ptr = live_history.as_ptr(); + let live_visible_ptr = s.main.rows.as_ptr(); + let projection = s.projection_ref(); + assert_eq!(projection.history_head.as_ptr(), live_history_ptr); + assert_eq!(projection.visible_rows.as_ptr(), live_visible_ptr); + + s.apply_event(AnsiEvent::SetMode { + mode: TerminalMode::SynchronizedOutput, + enabled: true, + }); + let published_history_ptr = s.published.history.as_ptr(); + let published_visible_ptr = s.published.visible_rows.as_ptr(); + let projection = s.projection_ref(); + assert_eq!(projection.history_head.as_ptr(), published_history_ptr); + assert_eq!(projection.visible_rows.as_ptr(), published_visible_ptr); } #[test] diff --git a/src/terminal/session.rs b/src/terminal/session.rs index 2c1876b..686214b 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -17,6 +17,7 @@ use crate::process::{ RestartPolicy, StdinMode, TerminalMode, }; use crate::terminal::screen::TerminalScreen; +use crate::terminal::view::{TerminalController, TerminalViewKey, TerminalViewState}; use crate::terminal::{ MAX_TERMINAL_COLS, MAX_TERMINAL_HISTORY_CELLS, MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, @@ -206,22 +207,26 @@ pub enum TerminalError { Process(String), } -struct TerminalSession { - process_id: ProcessId, - pid: u32, - screen: TerminalScreen, - process: TerminalProcessState, - annotated: bool, +pub(super) struct TerminalSession { + pub(super) process_id: ProcessId, + pub(super) pid: u32, + pub(super) screen: TerminalScreen, + pub(super) process: TerminalProcessState, + pub(super) annotated: bool, } /// Owns the one-buffer/one-process/one-screen terminal registry. #[derive(Default)] pub struct TerminalManager { - sessions: HashMap, + pub(super) sessions: HashMap, process_to_buffer: HashMap, /// Removed buffers whose children are still being reaped. Their events /// remain manager-owned so Lua/LSP/MCP consumers cannot steal a batch. closing: HashSet, + /// Per-frontend/window projections over the one session screen. + pub(super) views: HashMap, + /// At most one authenticated frontend/window controls each session PTY. + pub(super) controllers: HashMap, } impl TerminalManager { @@ -255,7 +260,12 @@ impl TerminalManager { let screen = TerminalScreen::new(size, spec.scrollback_rows) .map_err(|error| TerminalError::Screen(error.to_string()))?; - let buffer_name = spec.buffer_name(); + let base_name = spec.buffer_name(); + let buffer_name = if spec.name.is_some() { + base_name + } else { + unique_terminal_name(core, &base_name) + }; let buffer_id = BufferId::next(); let mut buffer = Buffer::new(buffer_id, buffer_name.clone()); buffer.set_read_only(true); @@ -338,6 +348,99 @@ impl TerminalManager { .map(|session| session.process_id) } + /// Monotonic terminal BEL count used for per-frontend delivery baselines. + #[must_use] + pub fn bell_count(&self, buffer_id: BufferId) -> Option { + self.sessions + .get(&buffer_id) + .map(|session| session.screen.bell_count()) + } + + /// Ensure an exact terminal view exists without changing its controller. + /// + /// Returns `false` when the key's buffer is not a published terminal. + pub fn register_view(&mut self, key: TerminalViewKey) -> bool { + if !self.sessions.contains_key(&key.buffer_id) { + return false; + } + self.views.entry(key).or_default(); + true + } + + /// Borrow fresh mutable state for an already registered exact view. + pub fn view_state_mut(&mut self, key: TerminalViewKey) -> Option<&mut TerminalViewState> { + self.views.get_mut(&key) + } + + /// Borrow fresh state for an already registered exact view. + #[must_use] + pub fn view_state(&self, key: TerminalViewKey) -> Option<&TerminalViewState> { + self.views.get(&key) + } + + /// Retain only `live` views belonging to one authenticated frontend. + pub fn retain_frontend_views( + &mut self, + frontend_id: crate::protocol::FrontendId, + live: &HashSet, + ) { + self.views.retain(|key, _| { + key.frontend_id != frontend_id + || (live.contains(key) && self.sessions.contains_key(&key.buffer_id)) + }); + self.controllers.retain(|buffer_id, controller| { + controller.frontend_id != frontend_id + || live.contains(&TerminalViewKey::new( + frontend_id, + controller.window_id, + *buffer_id, + )) + }); + } + + /// Drop all view and controller state owned by a detached frontend. + pub fn detach_frontend(&mut self, frontend_id: crate::protocol::FrontendId) { + self.views.retain(|key, _| key.frontend_id != frontend_id); + self.controllers + .retain(|_, controller| controller.frontend_id != frontend_id); + } + + /// Give an exact registered view durable PTY control for its session. + /// + /// A frontend controls at most one session. Claiming another registered + /// view atomically releases that frontend's previous session first. + pub fn claim_controller(&mut self, key: TerminalViewKey) -> bool { + if !self.views.contains_key(&key) || !self.sessions.contains_key(&key.buffer_id) { + return false; + } + self.controllers.retain(|buffer_id, controller| { + controller.frontend_id != key.frontend_id || *buffer_id == key.buffer_id + }); + self.controllers + .insert(key.buffer_id, TerminalController::from_view(key)); + true + } + + /// Release control only when `key` is the current controller. + pub fn release_controller(&mut self, key: TerminalViewKey) -> bool { + if self + .controllers + .get(&key.buffer_id) + .is_some_and(|controller| controller.matches(key)) + { + self.controllers.remove(&key.buffer_id); + true + } else { + false + } + } + + /// Current durable controller for one terminal session. + #[must_use] + pub fn controller(&self, buffer_id: BufferId) -> Option { + self.controllers.get(&buffer_id).copied() + } + /// Capture context-free owned visible state after the latest tick. #[must_use] pub fn snapshot(&self, buffer_id: BufferId) -> Option { @@ -488,6 +591,8 @@ impl TerminalManager { continue; }; self.process_to_buffer.remove(&session.process_id); + self.views.retain(|key, _| key.buffer_id != buffer_id); + self.controllers.remove(&buffer_id); match supervisor.state(session.process_id) { Some( ProcessState::Starting @@ -528,9 +633,25 @@ impl TerminalManager { } self.sessions.clear(); self.process_to_buffer.clear(); + self.views.clear(); + self.controllers.clear(); } } +fn unique_terminal_name(core: &EditorCore, base: &str) -> String { + let registry = core.registry.borrow(); + if registry.find_by_name(base).is_none() { + return base.to_owned(); + } + for suffix in 2usize.. { + let candidate = format!("{base}<{suffix}>"); + if registry.find_by_name(&candidate).is_none() { + return candidate; + } + } + unreachable!("unbounded terminal suffix search must find a free name") +} + fn finish_session(session: &mut TerminalSession, outcome: TerminalProcessState) { if session.annotated { session.process = outcome; diff --git a/src/terminal/view.rs b/src/terminal/view.rs new file mode 100644 index 0000000..b8cd2ad --- /dev/null +++ b/src/terminal/view.rs @@ -0,0 +1,1032 @@ +//! Per-frontend terminal viewport, selection, copy, and bell projection. +//! +//! A view stores only logical anchors into one [`TerminalScreen`]. Cells, +//! modes, history, and process state remain session-owned. + +use crate::buffer::BufferId; +use crate::cell::{Cell, CellCoord, CellSize, Glyph, Style}; +use crate::protocol::FrontendId; +use crate::terminal::screen::{BorrowedScreenProjection, TerminalModes, TerminalRow}; +use crate::terminal::session::{TerminalManager, TerminalSelectionSpan, TerminalSnapshot}; +use crate::terminal::{MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS}; +use crate::window::WindowId; + +/// One frontend/window projection of a terminal session. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct TerminalViewKey { + /// Authenticated frontend that owns this view state. + pub frontend_id: FrontendId, + /// Stable editor window showing the terminal. + pub window_id: WindowId, + /// Identity buffer whose session is projected. + pub buffer_id: BufferId, +} + +impl TerminalViewKey { + /// Construct an exact terminal view identity. + #[must_use] + pub const fn new(frontend_id: FrontendId, window_id: WindowId, buffer_id: BufferId) -> Self { + Self { + frontend_id, + window_id, + buffer_id, + } + } +} + +/// Leading display-cell offset within one retained logical line. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LogicalCellAnchor { + /// Stable logical line identity preserved by main-screen reflow. + pub logical_line_id: u64, + /// Leading display-cell offset within that logical line. + pub cell_offset: u32, +} + +/// Inclusive terminal selection endpoints. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TerminalSelection { + /// Fixed endpoint where the drag began. + pub anchor: LogicalCellAnchor, + /// Moving endpoint under the pointer. + pub head: LogicalCellAnchor, +} + +/// Fresh context metadata for Lua/statusline consumers. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TerminalViewStatus { + /// Geometric live-tail visibility. + pub at_bottom: bool, + /// Physical retained rows between this viewport and the live tail. + pub scroll_offset: u32, + /// Whether the view owns a nonempty selection. + pub selection: bool, +} + +/// Mutable state for one [`TerminalViewKey`]. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct TerminalViewState { + /// First visible retained logical cell, or live-tail following when absent. + pub top: Option, + /// Inclusive logical-cell selection. + pub selection: Option, + /// Current editor-owned drag endpoint; cleared on release. + pub drag: Option, + pub(super) alternate_active: Option, + pub(super) last_bell_count: u64, + pub(super) viewport_size: Option, + pub(super) selection_froze_top: bool, +} + +/// The one authenticated frontend/window allowed to control a session's PTY. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TerminalController { + /// Authenticated controlling frontend. + pub frontend_id: FrontendId, + /// Active terminal window on that frontend. + pub window_id: WindowId, +} + +impl TerminalController { + /// Construct a controller from an exact view identity. + #[must_use] + pub const fn from_view(key: TerminalViewKey) -> Self { + Self { + frontend_id: key.frontend_id, + window_id: key.window_id, + } + } + + /// Whether this controller names `key`'s frontend and window. + #[must_use] + pub fn matches(self, key: TerminalViewKey) -> bool { + self.frontend_id == key.frontend_id && self.window_id == key.window_id + } +} + +impl TerminalManager { + /// Project one exact view into an owned viewport-sized snapshot. + /// + /// Zero or out-of-range viewports and unknown sessions return `None` + /// without registering view state. + #[must_use] + pub fn snapshot_for_view( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + ) -> Option { + if !valid_viewport(viewport_size) { + return None; + } + let session = self.sessions.get(&key.buffer_id)?; + let projection = session.screen.projection_ref(); + let pid = session.pid; + let process = session.process.clone(); + let bell_count = session.screen.bell_count(); + + let state = self.views.entry(key).or_insert_with(|| TerminalViewState { + alternate_active: Some(projection.alternate_active), + last_bell_count: bell_count, + ..TerminalViewState::default() + }); + normalize_state(state, projection); + state.viewport_size = Some(viewport_size); + Some(project_snapshot( + key.buffer_id, + viewport_size, + projection, + state, + pid, + process, + )) + } + + /// Scroll one view by physical retained rows. + /// + /// Positive values move toward older rows and negative values move toward + /// the live tail. Returns whether the top anchor changed. + pub fn scroll_view( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + lines: i32, + ) -> bool { + if lines == 0 || !valid_viewport(viewport_size) { + return false; + } + let Some(session) = self.sessions.get(&key.buffer_id) else { + return false; + }; + let projection = session.screen.projection_ref(); + let bell_count = session.screen.bell_count(); + let state = self.views.entry(key).or_insert_with(|| TerminalViewState { + alternate_active: Some(projection.alternate_active), + last_bell_count: bell_count, + ..TerminalViewState::default() + }); + normalize_state(state, projection); + state.viewport_size = Some(viewport_size); + let rows = retained_rows(projection); + if rows.is_empty() { + return false; + } + let geometry = view_geometry(&rows, state, viewport_size.rows); + let tail_start = rows.len().saturating_sub(viewport_size.rows as usize); + let magnitude = lines.unsigned_abs() as usize; + let next = if lines > 0 { + geometry.start.saturating_sub(magnitude) + } else { + geometry.start.saturating_add(magnitude).min(tail_start) + }; + if next == geometry.start { + return false; + } + state.top = if next == tail_start && state.selection.is_none() { + None + } else { + Some(row_lead(rows.get(next).expect("bounded retained row"))) + }; + true + } + + /// Scroll by `lines` using the last nonzero rendered viewport. + pub fn scroll_lines(&mut self, key: TerminalViewKey, lines: i32) -> bool { + if lines == 0 { + return false; + } + let Some(size) = self.views.get(&key).and_then(|state| state.viewport_size) else { + return false; + }; + self.scroll_view(key, size, lines) + } + + /// Scroll by one last-rendered page in `direction` (`1` older, `-1` tail). + pub fn scroll_page(&mut self, key: TerminalViewKey, direction: i32) -> bool { + if direction == 0 { + return false; + } + let Some(size) = self.views.get(&key).and_then(|state| state.viewport_size) else { + return false; + }; + let rows = i32::try_from(size.rows).unwrap_or(i32::MAX); + self.scroll_view(key, size, rows.saturating_mul(direction.signum())) + } + + /// Register or refresh one view at `viewport_size` and return its geometry + /// without allocating an owned cell snapshot. + #[must_use] + pub(crate) fn view_status_for_size( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + ) -> Option { + if !valid_viewport(viewport_size) { + return None; + } + let session = self.sessions.get(&key.buffer_id)?; + let projection = session.screen.projection_ref(); + let bell_count = session.screen.bell_count(); + let state = self.views.entry(key).or_insert_with(|| TerminalViewState { + alternate_active: Some(projection.alternate_active), + last_bell_count: bell_count, + ..TerminalViewState::default() + }); + normalize_state(state, projection); + state.viewport_size = Some(viewport_size); + let rows = retained_rows(projection); + let geometry = view_geometry(&rows, state, viewport_size.rows); + Some(TerminalViewStatus { + at_bottom: geometry.scroll_offset == 0, + scroll_offset: geometry.scroll_offset, + selection: state.selection.is_some(), + }) + } + + /// Return the publication-consistent child grid size for one view. + #[must_use] + pub(crate) fn screen_size_for_view(&self, key: TerminalViewKey) -> Option { + self.sessions + .get(&key.buffer_id) + .map(|session| session.screen.projection_ref().size) + } + + /// Return fresh geometric status for one registered view. + #[must_use] + pub fn view_status(&mut self, key: TerminalViewKey) -> Option { + let size = self.views.get(&key)?.viewport_size?; + self.view_status_for_size(key, size) + } + + /// Clear selection and resume live-tail following for one view. + pub fn scroll_to_bottom(&mut self, key: TerminalViewKey) -> bool { + let Some(state) = self.views.get_mut(&key) else { + return false; + }; + let changed = state.top.is_some() || state.selection.is_some() || state.drag.is_some(); + state.top = None; + state.selection = None; + state.drag = None; + state.selection_froze_top = false; + changed + } + + /// Serialize one view's current selection from retained terminal rows. + #[must_use] + pub fn copy_selection(&mut self, key: TerminalViewKey) -> Option> { + let session = self.sessions.get(&key.buffer_id)?; + let projection = session.screen.projection_ref(); + let state = self.views.get_mut(&key)?; + normalize_state(state, projection); + let selection = state.selection?; + let rows = retained_rows(projection); + copy_selection_bytes(&rows, selection) + } + + /// Start an editor-owned primary selection at a viewport coordinate. + pub fn begin_selection( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + coord: CellCoord, + ) -> bool { + let Some(session) = self.sessions.get(&key.buffer_id) else { + return false; + }; + let projection = session.screen.projection_ref(); + let bell_count = session.screen.bell_count(); + let state = self.views.entry(key).or_insert_with(|| TerminalViewState { + alternate_active: Some(projection.alternate_active), + last_bell_count: bell_count, + ..TerminalViewState::default() + }); + normalize_state(state, projection); + let rows = retained_rows(projection); + let geometry = view_geometry(&rows, state, viewport_size.rows); + let Some(anchor) = anchor_at(&rows, &geometry, viewport_size, coord) else { + state.selection = None; + state.drag = None; + return false; + }; + state.selection_froze_top = state.top.is_none(); + if state.top.is_none() { + state.top = rows.get(geometry.start).map(row_lead); + } + state.selection = Some(TerminalSelection { + anchor, + head: anchor, + }); + state.drag = Some(anchor); + true + } + + /// Move an active editor-owned terminal selection. + pub fn update_selection( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + coord: CellCoord, + ) -> bool { + let Some(session) = self.sessions.get(&key.buffer_id) else { + return false; + }; + let projection = session.screen.projection_ref(); + let Some(state) = self.views.get_mut(&key) else { + return false; + }; + normalize_state(state, projection); + if state.drag.is_none() { + return false; + } + let rows = retained_rows(projection); + let geometry = view_geometry(&rows, state, viewport_size.rows); + let Some(head) = anchor_at(&rows, &geometry, viewport_size, coord) else { + return false; + }; + let changed = state + .selection + .is_some_and(|selection| selection.head != head); + if let Some(selection) = state.selection.as_mut() { + selection.head = head; + } + state.drag = Some(head); + changed + } + + /// Finish an editor-owned terminal selection. + pub fn finish_selection( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + coord: CellCoord, + ) -> bool { + let moved = self.update_selection(key, viewport_size, coord); + let Some(state) = self.views.get_mut(&key) else { + return false; + }; + let was_dragging = state.drag.take().is_some(); + if state + .selection + .is_some_and(|selection| selection.anchor == selection.head) + { + state.selection = None; + if state.selection_froze_top { + state.top = None; + } + state.selection_froze_top = false; + } + moved || was_dragging + } + + /// Clear one view's terminal selection without changing its scroll anchor. + pub fn clear_selection(&mut self, key: TerminalViewKey) -> bool { + let Some(state) = self.views.get_mut(&key) else { + return false; + }; + state.selection.take().is_some() || state.drag.take().is_some() + } + + /// Current child input modes for one session. + #[must_use] + pub fn modes_for_view(&self, key: TerminalViewKey) -> Option { + self.sessions + .get(&key.buffer_id) + .map(|session| session.screen.modes()) + } + + /// Exact controlled view for one frontend, if it still exists. + #[must_use] + pub fn controller_view_for_frontend(&self, frontend_id: FrontendId) -> Option { + self.controllers.iter().find_map(|(buffer_id, controller)| { + (controller.frontend_id == frontend_id) + .then(|| TerminalViewKey::new(frontend_id, controller.window_id, *buffer_id)) + }) + } + + /// Observe BEL counters for every live view on one frontend. + /// + /// Returns `true` only for a new bell in `active`; all other counters are + /// advanced so historical bells cannot replay after a later activation. + pub fn take_bell_for_frontend( + &mut self, + frontend_id: FrontendId, + active: Option, + ) -> bool { + let mut ring = false; + for (key, state) in &mut self.views { + if key.frontend_id != frontend_id { + continue; + } + let Some(session) = self.sessions.get(&key.buffer_id) else { + continue; + }; + let current = session.screen.bell_count(); + if Some(*key) == active && current > state.last_bell_count { + ring = true; + } + state.last_bell_count = current; + } + ring + } +} + +#[derive(Clone, Copy)] +struct ResolvedCell { + row: usize, + col: usize, +} + +struct ViewGeometry { + start: usize, + top_padding: usize, + scroll_offset: u32, +} + +#[derive(Clone, Copy)] +struct RetainedRows<'a> { + projection: BorrowedScreenProjection<'a>, +} + +impl<'a> RetainedRows<'a> { + fn len(self) -> usize { + self.projection.history_len() + self.projection.visible_rows.len() + } + + fn is_empty(self) -> bool { + self.len() == 0 + } + + fn get(self, index: usize) -> Option<&'a TerminalRow> { + let head_len = self.projection.history_head.len(); + if index < head_len { + return self.projection.history_head.get(index); + } + let index = index - head_len; + let tail_len = self.projection.history_tail.len(); + if index < tail_len { + return self.projection.history_tail.get(index); + } + self.projection.visible_rows.get(index - tail_len) + } + + fn first(self) -> Option<&'a TerminalRow> { + self.get(0) + } + + fn iter(self) -> impl Iterator { + self.projection + .history_head + .iter() + .chain(self.projection.history_tail) + .chain(self.projection.visible_rows) + } +} + +fn valid_viewport(size: CellSize) -> bool { + size.rows > 0 + && size.cols > 0 + && size.rows <= u32::from(MAX_TERMINAL_ROWS) + && size.cols <= u32::from(MAX_TERMINAL_COLS) + && size.area() as usize <= MAX_TERMINAL_VISIBLE_CELLS +} + +fn retained_rows(projection: BorrowedScreenProjection<'_>) -> RetainedRows<'_> { + RetainedRows { projection } +} + +fn row_lead(row: &TerminalRow) -> LogicalCellAnchor { + LogicalCellAnchor { + logical_line_id: row.logical_line_id, + cell_offset: row.cell_offset, + } +} + +fn resolve_anchor(rows: &RetainedRows<'_>, anchor: LogicalCellAnchor) -> Option { + rows.iter().enumerate().find_map(|(row_index, row)| { + if row.logical_line_id != anchor.logical_line_id { + return None; + } + let start = row.cell_offset; + let end = start.saturating_add(row.cells.len() as u32); + if anchor.cell_offset < start || anchor.cell_offset >= end { + return None; + } + let col = (anchor.cell_offset - start) as usize; + Some(ResolvedCell { + row: row_index, + col: canonical_col(row, col), + }) + }) +} + +fn canonical_col(row: &TerminalRow, mut col: usize) -> usize { + col = col.min(row.cells.len().saturating_sub(1)); + while col > 0 && matches!(row.cells[col].glyph, Glyph::Continuation) { + col -= 1; + } + col +} + +fn anchor_for(rows: &RetainedRows<'_>, resolved: ResolvedCell) -> LogicalCellAnchor { + let row = rows.get(resolved.row).expect("resolved retained row"); + LogicalCellAnchor { + logical_line_id: row.logical_line_id, + cell_offset: row.cell_offset.saturating_add(resolved.col as u32), + } +} + +fn clamp_or_clear(rows: &RetainedRows<'_>, anchor: LogicalCellAnchor) -> Option { + if let Some(resolved) = resolve_anchor(rows, anchor) { + return Some(anchor_for(rows, resolved)); + } + let first = rows.first()?; + (anchor.logical_line_id < first.logical_line_id + || (anchor.logical_line_id == first.logical_line_id + && anchor.cell_offset < first.cell_offset)) + .then(|| row_lead(first)) +} + +fn normalize_state(state: &mut TerminalViewState, projection: BorrowedScreenProjection<'_>) { + if state + .alternate_active + .is_some_and(|active| active != projection.alternate_active) + { + state.top = None; + state.selection = None; + state.drag = None; + state.selection_froze_top = false; + } + state.alternate_active = Some(projection.alternate_active); + let rows = retained_rows(projection); + state.top = state.top.and_then(|anchor| clamp_or_clear(&rows, anchor)); + state.selection = state.selection.and_then(|selection| { + let anchor = clamp_or_clear(&rows, selection.anchor)?; + let head = clamp_or_clear(&rows, selection.head)?; + let collapsed_by_clamp = + anchor == head && (anchor != selection.anchor || head != selection.head); + (!collapsed_by_clamp).then_some(TerminalSelection { anchor, head }) + }); + state.drag = state.drag.and_then(|anchor| clamp_or_clear(&rows, anchor)); + if state.selection.is_none() { + state.drag = None; + state.selection_froze_top = false; + } +} + +fn view_geometry( + rows: &RetainedRows<'_>, + state: &TerminalViewState, + viewport_rows: u32, +) -> ViewGeometry { + let viewport_rows = viewport_rows as usize; + let follow = state.top.is_none() && state.selection.is_none(); + let tail_start = rows.len().saturating_sub(viewport_rows); + let start = if follow { + tail_start + } else { + state + .top + .and_then(|anchor| resolve_anchor(rows, anchor)) + .map_or(tail_start, |resolved| resolved.row) + }; + let top_padding = if follow && rows.len() < viewport_rows { + viewport_rows - rows.len() + } else { + 0 + }; + let rows_after_view = rows + .len() + .saturating_sub(start.saturating_add(viewport_rows)); + ViewGeometry { + start, + top_padding, + scroll_offset: u32::try_from(rows_after_view).unwrap_or(u32::MAX), + } +} + +fn viewport_row( + geometry: &ViewGeometry, + viewport_rows: usize, + retained_row: usize, +) -> Option { + if retained_row < geometry.start { + return None; + } + let row = geometry + .top_padding + .saturating_add(retained_row - geometry.start); + (row < viewport_rows).then_some(row) +} + +fn anchor_at( + rows: &RetainedRows<'_>, + geometry: &ViewGeometry, + viewport_size: CellSize, + coord: CellCoord, +) -> Option { + if coord.row >= viewport_size.rows || coord.col >= viewport_size.cols { + return None; + } + let viewport_row = coord.row as usize; + if viewport_row < geometry.top_padding { + return None; + } + let retained_row = geometry + .start + .saturating_add(viewport_row - geometry.top_padding); + let row = rows.get(retained_row)?; + if coord.col as usize >= row.cells.len() { + return None; + } + let col = canonical_col(row, coord.col as usize); + Some(LogicalCellAnchor { + logical_line_id: row.logical_line_id, + cell_offset: row.cell_offset.saturating_add(col as u32), + }) +} + +fn normalized_selection( + rows: &RetainedRows<'_>, + selection: TerminalSelection, +) -> Option<(ResolvedCell, ResolvedCell)> { + let mut start = resolve_anchor(rows, selection.anchor)?; + let mut end = resolve_anchor(rows, selection.head)?; + if (start.row, start.col) > (end.row, end.col) { + std::mem::swap(&mut start, &mut end); + } + Some((start, end)) +} + +fn glyph_width(row: &TerminalRow, col: usize) -> usize { + if col + 1 < row.cells.len() && matches!(row.cells[col + 1].glyph, Glyph::Continuation) { + 2 + } else { + 1 + } +} + +fn project_snapshot( + buffer_id: BufferId, + viewport_size: CellSize, + projection: BorrowedScreenProjection<'_>, + state: &TerminalViewState, + pid: u32, + process: crate::terminal::session::TerminalProcessState, +) -> TerminalSnapshot { + let rows = retained_rows(projection); + let geometry = view_geometry(&rows, state, viewport_size.rows); + let mut cells = vec![Cell::default(); viewport_size.area() as usize]; + for (retained_row, row) in rows.iter().enumerate().skip(geometry.start) { + let Some(target_row) = viewport_row(&geometry, viewport_size.rows as usize, retained_row) + else { + continue; + }; + let copy_cols = row.cells.len().min(viewport_size.cols as usize); + let target = target_row * viewport_size.cols as usize; + cells[target..target + copy_cols].clone_from_slice(&row.cells[..copy_cols]); + } + + let selection = state + .selection + .and_then(|selection| normalized_selection(&rows, selection)) + .map_or_else(Vec::new, |(start, end)| { + let mut spans = Vec::new(); + for (retained_row, row) in rows.iter().enumerate().take(end.row + 1).skip(start.row) { + let Some(target_row) = + viewport_row(&geometry, viewport_size.rows as usize, retained_row) + else { + continue; + }; + let start_col = if retained_row == start.row { + start.col + } else { + 0 + }; + let end_col = if retained_row == end.row { + end.col.saturating_add(glyph_width(row, end.col)) + } else { + row.cells.len() + } + .min(viewport_size.cols as usize); + if start_col < end_col && start_col < viewport_size.cols as usize { + spans.push(TerminalSelectionSpan { + row: target_row as u32, + start_col: start_col as u32, + end_col: end_col as u32, + }); + } + } + spans + }); + + let cursor = if geometry.scroll_offset == 0 { + projection.cursor.and_then(|cursor| { + let retained_row = projection.history_len().saturating_add(cursor.row as usize); + let row = viewport_row(&geometry, viewport_size.rows as usize, retained_row)?; + (cursor.col < viewport_size.cols).then(|| CellCoord::new(row as u32, cursor.col)) + }) + } else { + None + }; + + TerminalSnapshot { + buffer_id, + size: viewport_size, + cells, + cursor, + title: projection.title.map(str::to_owned), + screen_generation: projection.generation, + selection, + scroll_offset: geometry.scroll_offset, + at_bottom: geometry.scroll_offset == 0, + pid, + process, + } +} + +fn is_default_blank(cell: &Cell) -> bool { + matches!(cell.glyph, Glyph::Char(' ')) + && cell.style == Style::default() + && cell.attachment.is_none() +} + +fn copy_selection_bytes(rows: &RetainedRows<'_>, selection: TerminalSelection) -> Option> { + let (start, end) = normalized_selection(rows, selection)?; + let mut out = Vec::new(); + for (row_index, row) in rows.iter().enumerate().take(end.row + 1).skip(start.row) { + let from = if row_index == start.row { start.col } else { 0 }; + let mut to = if row_index == end.row { + end.col.saturating_add(glyph_width(row, end.col)) + } else { + row.cells.len() + }; + while to > from && is_default_blank(&row.cells[to - 1]) { + to -= 1; + } + for cell in &row.cells[from..to] { + match &cell.glyph { + Glyph::Char(ch) => { + let mut bytes = [0; 4]; + out.extend_from_slice(ch.encode_utf8(&mut bytes).as_bytes()); + } + Glyph::Cluster(bytes) => out.extend_from_slice(bytes), + Glyph::Continuation => {} + } + } + if row_index < end.row && !row.soft_wrapped { + out.push(b'\n'); + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::terminal::screen::ScreenProjection; + use crate::terminal::session::TerminalProcessState; + + fn row(id: u64, offset: u32, text: &str, soft_wrapped: bool) -> TerminalRow { + TerminalRow { + cells: text + .chars() + .map(|ch| Cell { + glyph: Glyph::Char(ch), + style: Style::default(), + attachment: None, + }) + .collect(), + logical_line_id: id, + cell_offset: offset, + soft_wrapped, + } + } + + fn projection(history: Vec, visible_rows: Vec) -> ScreenProjection { + let cols = visible_rows.first().map_or(1, |row| row.cells.len() as u32); + ScreenProjection { + size: CellSize::new(visible_rows.len() as u32, cols), + alternate_active: false, + history, + visible_rows, + cursor: None, + title: Some("shell".into()), + generation: 7, + } + } + + #[test] + fn tail_projection_pads_above_and_right_and_translates_cursor() { + let mut source = projection( + Vec::new(), + vec![row(1, 0, "abc", false), row(2, 0, "def", false)], + ); + source.cursor = Some(CellCoord::new(1, 2)); + let snapshot = project_snapshot( + BufferId::next(), + CellSize::new(4, 5), + source.as_borrowed(), + &TerminalViewState::default(), + 42, + TerminalProcessState::Running, + ); + assert_eq!(snapshot.cells.len(), 20); + assert!( + snapshot.cells[..10] + .iter() + .all(|cell| *cell == Cell::default()) + ); + assert_eq!(snapshot.cells[10].glyph, Glyph::Char('a')); + assert_eq!(snapshot.cells[13], Cell::default()); + assert_eq!(snapshot.cells[15].glyph, Glyph::Char('d')); + assert_eq!(snapshot.cursor, Some(CellCoord::new(3, 2))); + assert!(snapshot.at_bottom); + assert_eq!(snapshot.scroll_offset, 0); + } + + #[test] + fn frozen_top_is_geometrically_at_bottom_when_view_still_reaches_tail() { + let source = projection( + vec![row(1, 0, "aaa", false)], + vec![row(2, 0, "bbb", false), row(3, 0, "ccc", false)], + ); + let state = TerminalViewState { + top: Some(LogicalCellAnchor { + logical_line_id: 1, + cell_offset: 0, + }), + selection: Some(TerminalSelection { + anchor: LogicalCellAnchor { + logical_line_id: 2, + cell_offset: 0, + }, + head: LogicalCellAnchor { + logical_line_id: 2, + cell_offset: 1, + }, + }), + ..TerminalViewState::default() + }; + let snapshot = project_snapshot( + BufferId::next(), + CellSize::new(3, 3), + source.as_borrowed(), + &state, + 1, + TerminalProcessState::Running, + ); + assert!(snapshot.at_bottom); + assert_eq!(snapshot.scroll_offset, 0); + } + + #[test] + fn copy_joins_soft_wraps_trims_default_blanks_and_separates_hard_rows() { + let rows = [ + row(1, 0, "ab ", true), + row(1, 3, "cd ", false), + row(2, 0, "e ", false), + ]; + let source = projection(Vec::new(), rows.into()); + let retained = retained_rows(source.as_borrowed()); + let bytes = copy_selection_bytes( + &retained, + TerminalSelection { + anchor: LogicalCellAnchor { + logical_line_id: 1, + cell_offset: 0, + }, + head: LogicalCellAnchor { + logical_line_id: 2, + cell_offset: 2, + }, + }, + ) + .expect("selection resolves"); + assert_eq!(bytes, b"abcd\ne"); + } + + #[test] + fn wide_continuation_canonicalizes_to_lead_and_copies_once() { + let wide = TerminalRow { + cells: vec![ + Cell { + glyph: Glyph::Char('界'), + style: Style::default(), + attachment: None, + }, + Cell { + glyph: Glyph::Continuation, + style: Style::default(), + attachment: None, + }, + Cell::default(), + ], + logical_line_id: 9, + cell_offset: 0, + soft_wrapped: false, + }; + let source = projection(Vec::new(), vec![wide]); + let retained = retained_rows(source.as_borrowed()); + let continuation = resolve_anchor( + &retained, + LogicalCellAnchor { + logical_line_id: 9, + cell_offset: 1, + }, + ) + .expect("continuation resolves"); + assert_eq!(continuation.col, 0); + let bytes = copy_selection_bytes( + &retained, + TerminalSelection { + anchor: LogicalCellAnchor { + logical_line_id: 9, + cell_offset: 0, + }, + head: LogicalCellAnchor { + logical_line_id: 9, + cell_offset: 1, + }, + }, + ) + .expect("wide selection resolves"); + assert_eq!(bytes, "界".as_bytes()); + } + + #[test] + fn partially_evicted_wrapped_anchor_clamps_to_first_surviving_cell() { + let source = projection( + vec![row(7, 4, "tail", true)], + vec![row(8, 0, "next", false)], + ); + let first_survivor = LogicalCellAnchor { + logical_line_id: 7, + cell_offset: 4, + }; + let mut state = TerminalViewState { + top: Some(LogicalCellAnchor { + logical_line_id: 7, + cell_offset: 1, + }), + selection: Some(TerminalSelection { + anchor: LogicalCellAnchor { + logical_line_id: 7, + cell_offset: 2, + }, + head: LogicalCellAnchor { + logical_line_id: 8, + cell_offset: 1, + }, + }), + ..TerminalViewState::default() + }; + + normalize_state(&mut state, source.as_borrowed()); + + assert_eq!(state.top, Some(first_survivor)); + assert_eq!( + state.selection, + Some(TerminalSelection { + anchor: first_survivor, + head: LogicalCellAnchor { + logical_line_id: 8, + cell_offset: 1, + }, + }) + ); + } + + #[test] + fn alternate_switch_clears_view_anchors_and_selection() { + let source = ScreenProjection { + size: CellSize::new(1, 3), + alternate_active: true, + history: Vec::new(), + visible_rows: vec![row(10, 0, "alt", false)], + cursor: None, + title: None, + generation: 2, + }; + let mut state = TerminalViewState { + top: Some(LogicalCellAnchor { + logical_line_id: 1, + cell_offset: 0, + }), + selection: Some(TerminalSelection { + anchor: LogicalCellAnchor { + logical_line_id: 1, + cell_offset: 0, + }, + head: LogicalCellAnchor { + logical_line_id: 1, + cell_offset: 1, + }, + }), + alternate_active: Some(false), + ..TerminalViewState::default() + }; + normalize_state(&mut state, source.as_borrowed()); + assert_eq!(state.top, None); + assert_eq!(state.selection, None); + assert_eq!(state.alternate_active, Some(true)); + } +} diff --git a/tests/common/pty.rs b/tests/common/pty.rs index f500899..a1f16c8 100644 --- a/tests/common/pty.rs +++ b/tests/common/pty.rs @@ -12,6 +12,7 @@ use std::ffi::OsString; use std::io::{Read, Write}; use std::path::Path; +use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; @@ -26,7 +27,8 @@ pub struct PmacsPty { child: Box, writer: Box, _reader_thread: thread::JoinHandle<()>, - _master: Box, + output: Arc>>, + master: Box, } impl PmacsPty { @@ -36,6 +38,27 @@ impl PmacsPty { self.writer.flush() } + /// Resize the real host PTY in terminal cells. + pub fn resize(&self, rows: u16, cols: u16) -> Result<(), String> { + self.master + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|error| error.to_string()) + } + + /// Snapshot all bytes emitted by pmacs to its host terminal. + #[must_use] + pub fn output(&self) -> Vec { + self.output + .lock() + .expect("PTY output mutex poisoned") + .clone() + } + /// Poll-wait for pmacs to exit, up to `timeout`. Returns the /// exit status on success, `None` on timeout (and leaves the /// child running for the caller to clean up). @@ -103,16 +126,19 @@ pub fn spawn_pmacs_in_pty(args: &[&str], envs: &[(&str, &Path)], rows: u16, cols let writer = pair.master.take_writer().expect("take_writer"); let mut reader = pair.master.try_clone_reader().expect("try_clone_reader"); - // Drain reader to /dev/null so pmacs's writes never block on a - // backed-up terminal output buffer. We don't need to inspect the - // bytes; the tests assert on exit status and side effects, not - // on screen content. + let output = Arc::new(Mutex::new(Vec::new())); + let captured = output.clone(); + // Drain and retain reader bytes so terminal-mode restoration can be + // asserted without ever exposing pmacs to the test runner's own TTY. let reader_thread = thread::spawn(move || { let mut buf = [0u8; 4096]; loop { match reader.read(&mut buf) { Ok(0) | Err(_) => return, - Ok(_) => {} + Ok(read) => captured + .lock() + .expect("PTY output mutex poisoned") + .extend_from_slice(&buf[..read]), } } }); @@ -121,6 +147,7 @@ pub fn spawn_pmacs_in_pty(args: &[&str], envs: &[(&str, &Path)], rows: u16, cols child, writer, _reader_thread: reader_thread, - _master: pair.master, + output, + master: pair.master, } } diff --git a/tests/m9_6_acceptance.rs b/tests/m9_6_acceptance.rs index 39c4182..4b5166d 100644 --- a/tests/m9_6_acceptance.rs +++ b/tests/m9_6_acceptance.rs @@ -26,8 +26,11 @@ use std::path::PathBuf; use std::time::{Duration, Instant}; +use crossterm::event::{KeyCode, KeyModifiers}; use pmacs::editor::EditorState; +use pmacs::frontend::KeyEvent; use pmacs::lua_bindings::PackageInstallOverride; +use pmacs::protocol::FrontendId; use tempfile::TempDir; fn fake_mcp_path() -> String { @@ -813,17 +816,19 @@ fn m9_6_mx_palette_invokes_mcp_tool_through_minibuffer_reentry() { .lua_host .eval( Some("type-cmd-name"), - r#" - pmacs.minibuffer.set_contents("m9_6-echo") - pmacs.minibuffer.accept() - "#, + r#"pmacs.minibuffer.set_contents("m9_6-echo")"#, ) .expect("accept command name"); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + // Step 3: the tool's argument prompt must now be active. This is // the re-entrant minibuffer behavior: outer session was taken, // inner session was begun, and the begin happened from inside - // the outer accept's on_accept callback. + // the outer accept's authenticated dispatch callback. let inner_active: bool = state .lua_host .lua() @@ -841,12 +846,13 @@ fn m9_6_mx_palette_invokes_mcp_tool_through_minibuffer_reentry() { .lua_host .eval( Some("type-arg"), - r#" - pmacs.minibuffer.set_contents("through M-x") - pmacs.minibuffer.accept() - "#, + r#"pmacs.minibuffer.set_contents("through M-x")"#, ) .expect("accept arg"); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); assert!( pump_until_status_contains(&mut state, "through M-x", Duration::from_secs(2)), "M-x → arg-prompt → dispatch must reach status; status={:?}", diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index 67ce0c0..120c71f 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -57,7 +57,13 @@ fn paint(state: &EditorState, rows: u32, cols: u32) -> Vec { stride: cols, size: CellSize::new(rows, cols), }; - let _ = pmacs::editor::paint_frame(state, &mut grid, CellSize::new(rows, cols)); + let _ = pmacs::editor::paint_frame( + state, + FrontendId::LOCAL, + &std::collections::HashMap::new(), + &mut grid, + CellSize::new(rows, cols), + ); cells } @@ -124,9 +130,14 @@ fn a01_04_registry_contract_limits_epochs_and_results() { assert!(baseline_mode.ends_with(" L1:C1 All ")); let initial = state.statusline_registry.borrow().providers(); - assert_eq!(initial.len(), 2, "builtin providers are discoverable"); - assert!(initial.iter().any(|provider| provider.name == "mode")); - assert!(initial.iter().any(|provider| provider.name == "lsp")); + assert_eq!( + initial + .iter() + .map(|provider| provider.name.as_str()) + .collect::>(), + ["mode", "terminal", "lsp"], + "built-in providers are discoverable in registration order" + ); let before_epochs = { let registry = state.statusline_registry.borrow(); (registry.layout_epoch(), registry.face_set_epoch()) diff --git a/tests/theme_faces_acceptance.rs b/tests/theme_faces_acceptance.rs index c591839..c81ace7 100644 --- a/tests/theme_faces_acceptance.rs +++ b/tests/theme_faces_acceptance.rs @@ -92,7 +92,13 @@ fn paint_full_frame(state: &EditorState, rows: u32, cols: u32) -> Vec { stride: cols, size: CellSize::new(rows, cols), }; - let _cursor = pmacs::editor::paint_frame(state, &mut grid, CellSize::new(rows, cols)); + let _cursor = pmacs::editor::paint_frame( + state, + FrontendId::LOCAL, + &std::collections::HashMap::new(), + &mut grid, + CellSize::new(rows, cols), + ); backing } diff --git a/tests/vterm_stage1_acceptance.rs b/tests/vterm_stage1_acceptance.rs index b01d5b5..d1a4a63 100644 --- a/tests/vterm_stage1_acceptance.rs +++ b/tests/vterm_stage1_acceptance.rs @@ -104,16 +104,6 @@ fn strict_owned_spec_rejects_before_spawn_and_is_mutation_independent() { lua_processes, 0, "terminal-owned ProcessId must not be exposed through pmacs.process" ); - let terminal_module_absent: bool = state - .lua_host - .lua() - .load("return pmacs.terminal == nil") - .eval() - .expect("terminal module absence"); - assert!( - terminal_module_absent, - "Stage 1 must not publish an unrenderable interactive Lua terminal API" - ); let process_id = state .terminal_manager diff --git a/tests/vterm_stage2_acceptance.rs b/tests/vterm_stage2_acceptance.rs new file mode 100644 index 0000000..017a838 --- /dev/null +++ b/tests/vterm_stage2_acceptance.rs @@ -0,0 +1,708 @@ +//! Stage 2 terminal/TUI integration and real-host acceptance. + +mod common; + +use std::fs; +use std::path::Path; +use std::thread; +use std::time::{Duration, Instant}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use mlua::{AnyUserData, Table, Value}; +use pmacs::cell::{CellCoord, CellSize, Glyph}; +use pmacs::editor::EditorState; +use pmacs::lua_bindings::BufferIdLua; +use pmacs::protocol::FrontendId; +use pmacs::statusline::{ + StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline, +}; +use pmacs::terminal::{TerminalProcessState, TerminalSpec, TerminalViewKey}; +use pmacs::window::WindowId; + +use common::pty::{PmacsPty, spawn_pmacs_in_pty}; + +fn tick_until( + state: &mut EditorState, + timeout: Duration, + mut done: impl FnMut(&EditorState) -> bool, +) { + let deadline = Instant::now() + timeout; + loop { + state.tick_processes(); + if done(state) { + return; + } + assert!(Instant::now() < deadline, "terminal condition timed out"); + thread::sleep(Duration::from_millis(10)); + } +} + +fn lua_string(value: &str) -> String { + format!("{value:?}") +} + +fn snapshot_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String { + let mut text = String::new(); + for cell in &snapshot.cells { + match &cell.glyph { + Glyph::Char(ch) => text.push(*ch), + Glyph::Cluster(bytes) => text.push_str(&String::from_utf8_lossy(bytes)), + Glyph::Continuation => {} + } + } + text +} + +#[test] +#[allow( + clippy::too_many_lines, + reason = "cross-surface Lua transaction scenario" +)] +fn lua_surface_is_strict_fresh_transactional_and_context_safe() { + let mut state = EditorState::new(); + let command_lua = lua_string("/bin/sh"); + let baseline_buffer_id = state.core.borrow().active_buffer_id(); + + let baseline_sessions = state.terminal_manager.borrow().len(); + let baseline_buffers = state.core.borrow().registry.borrow().ids().len(); + { + let lua = state.lua_host.lua(); + let error = lua + .load(format!( + "return pmacs.terminal.open {{ command = {command_lua}, unknown = true }}" + )) + .eval::() + .expect_err("unknown open field must fail"); + assert!(error.to_string().contains("unknown field `unknown`")); + } + assert_eq!(state.terminal_manager.borrow().len(), baseline_sessions); + assert_eq!( + state.core.borrow().registry.borrow().ids().len(), + baseline_buffers + ); + + { + let lua = state.lua_host.lua(); + let kind: String = lua + .load(format!( + r#" + TERM_BUFFER = pmacs.terminal.open {{ + command = {command_lua}, + args = {{ "-c", "printf 'copy-me\\n'; sleep 30" }}, + rows = 4, + cols = 30, + }} + local first = pmacs.terminal.state(TERM_BUFFER) + first.process.kind = "poisoned" + first.injected = true + local second = pmacs.terminal.state(TERM_BUFFER) + assert(second.injected == nil) + assert(pmacs.terminal.resize == nil) + return second.process.kind + "# + )) + .eval() + .expect("open terminal and read fresh state"); + assert_eq!(kind, "running"); + } + + let buffer_id = { + let userdata: AnyUserData = state + .lua_host + .lua() + .globals() + .get("TERM_BUFFER") + .expect("terminal buffer global"); + userdata + .borrow::() + .expect("BufferId userdata") + .0 + }; + let buffer_name = state + .core + .borrow() + .registry + .borrow() + .get(buffer_id) + .expect("terminal identity buffer") + .name() + .to_owned(); + assert_eq!(buffer_name, "*terminal:sh*"); + tick_until(&mut state, Duration::from_secs(5), |state| { + state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .is_some_and(|snapshot| snapshot_text(&snapshot).contains("copy-me")) + }); + + let frontend_id = FrontendId::LOCAL; + let window_id = state.core.borrow().active_window_id(); + assert!(state.sync_terminal_layout(frontend_id, CellSize::new(8, 30))); + let snapshots = state.prepare_terminal_views(frontend_id, CellSize::new(8, 30)); + assert!(snapshots.contains_key(&window_id)); + let modeline = evaluate_statusline( + state.lua_host.lua(), + &state.core, + &state.statusline_registry, + StatuslineEvaluationTarget::Grid { frontend_id }, + ); + let StatuslineEvaluationOutcome::Ready(windows) = modeline.outcome else { + panic!("terminal statusline provider must evaluate successfully"); + }; + assert!( + windows + .iter() + .flat_map(|window| &window.right) + .any(|segment| segment.text.starts_with("TERM")), + "built-in terminal statusline provider must report live process state" + ); + let snapshot = snapshots.get(&window_id).expect("active terminal snapshot"); + let snapshot_text = snapshot_text(snapshot); + let start = snapshot_text + .find("copy-me") + .unwrap_or_else(|| panic!("copy probe missing from projected snapshot: {snapshot_text:?}")); + let cols = usize::try_from(snapshot.size.cols).expect("column count fits"); + let row = u32::try_from(start / cols).expect("row fits"); + let col = u32::try_from(start % cols).expect("column fits"); + let view_key = TerminalViewKey::new(frontend_id, window_id, buffer_id); + { + let mut manager = state.terminal_manager.borrow_mut(); + assert!(manager.begin_selection(view_key, snapshot.size, CellCoord::new(row, col))); + assert!(manager.finish_selection(view_key, snapshot.size, CellCoord::new(row, col + 7))); + } + assert!( + state + .prepare_terminal_views(frontend_id, CellSize::new(1, 1)) + .is_empty(), + "zero-area terminal placements paint no snapshot" + ); + assert!( + state + .terminal_manager + .borrow() + .view_state(view_key) + .is_some_and(|view| view.selection.is_some()), + "a transient zero-area placement must retain existing view anchors" + ); + assert!( + state + .prepare_terminal_views(frontend_id, CellSize::new(8, 30)) + .contains_key(&window_id) + ); + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), + ); + let (clipboard_frontend, clipboard) = state + .core + .borrow_mut() + .take_pending_clipboard() + .expect("terminal copy must queue host clipboard bytes"); + assert_eq!(clipboard_frontend, frontend_id); + assert_eq!(clipboard, b"copy-me"); + { + let lua = state.lua_host.lua(); + let status: Table = lua + .load(format!( + r" + local first = pmacs.terminal.view_state {{ + frontend = 1, window = {}, buffer = TERM_BUFFER, active = true + }} + first.injected = true + local second = pmacs.terminal.view_state {{ + frontend = 1, window = {}, buffer = TERM_BUFFER, active = true + }} + assert(second.injected == nil) + return second + ", + window_id.raw(), + window_id.raw() + )) + .eval() + .expect("fresh exact view state"); + assert!(status.get::("at_bottom").expect("at_bottom")); + + let (ok, error): (bool, String) = lua + .load( + r" + local ok, err = pcall(function() pmacs.terminal.scroll(1) end) + return ok, tostring(err) + ", + ) + .eval() + .expect("pcall implicit scroll"); + assert!(!ok); + assert!(error.contains("interactive frontend context")); + + let (ok, error): (bool, String) = lua + .load( + r" + local ok, err = pcall(function() + pmacs.command.invoke_interactive('terminal.scroll-up') + end) + return ok, tostring(err) + ", + ) + .eval() + .expect("pcall ambient interactive invoke"); + assert!(!ok); + assert!(error.contains("active interactive frontend context")); + + for (field, source) in [ + ( + "frontend", + "pmacs.terminal.view_state { window = 1, buffer = TERM_BUFFER, active = true }", + ), + ( + "window", + "pmacs.terminal.view_state { frontend = 1, buffer = TERM_BUFFER, active = true }", + ), + ( + "buffer", + "pmacs.terminal.view_state { frontend = 1, window = 1, active = true }", + ), + ] { + let (_, error): (bool, String) = lua + .load(format!( + "local ok, err = pcall(function() {source} end); return ok, tostring(err)" + )) + .eval() + .expect("pcall incomplete explicit context"); + assert!( + error.contains(&format!("missing field `{field}`")), + "missing `{field}` surfaced as {error:?}" + ); + } + } + + state + .lua_host + .lua() + .load( + r#" + pmacs.command.define { + name = "test.terminal-context", + description = "Exercise context-implicit terminal failure", + fn = function() pmacs.terminal.scroll(1) end, + } + pmacs.keymap.bind { + scope = "global", sequence = "", command = "test.terminal-context" + } + "#, + ) + .exec() + .expect("install context failure probe"); + state + .core + .borrow_mut() + .switch_active_buffer_for(frontend_id, baseline_buffer_id) + .expect("switch to document buffer"); + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE), + ); + assert!( + state + .core + .borrow() + .status + .contains("active window is not a terminal"), + "context-implicit terminal command must raise a named Lua error" + ); + state + .core + .borrow_mut() + .switch_active_buffer_for(frontend_id, buffer_id) + .expect("restore terminal buffer"); + + state + .terminal_manager + .borrow_mut() + .terminate(buffer_id, &mut state.process_supervisor.borrow_mut()) + .expect("terminate terminal child"); + tick_until(&mut state, Duration::from_secs(5), |state| { + state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .is_some_and(|snapshot| !matches!(snapshot.process, TerminalProcessState::Running)) + }); + state + .core + .borrow_mut() + .kill_buffer(buffer_id) + .expect("kill retained terminal buffer"); + state.tick_processes(); + assert_eq!(state.terminal_manager.borrow().len(), baseline_sessions); + assert_eq!( + state.core.borrow().registry.borrow().ids().len(), + baseline_buffers + ); + + state.process_supervisor.borrow_mut().shutdown(); + let _error = state + .lua_host + .lua() + .load(format!( + "return pmacs.terminal.open {{ command = {command_lua} }}" + )) + .eval::() + .expect_err("closed supervisor must make spawn fail transactionally"); + assert_eq!(state.terminal_manager.borrow().len(), baseline_sessions); + assert_eq!( + state.core.borrow().registry.borrow().ids().len(), + baseline_buffers + ); +} + +#[test] +#[allow(clippy::too_many_lines, reason = "shared view and controller scenario")] +fn shared_screen_keeps_view_scroll_selection_and_controller_independent() { + let mut state = EditorState::new(); + let mut spec = TerminalSpec::new("/bin/sh"); + spec.args = vec![ + "-c".into(), + "i=0; while [ $i -lt 30 ]; do printf 'row%02d\\n' \"$i\"; i=$((i+1)); done; sleep 30" + .into(), + ]; + spec.rows = 6; + spec.cols = 24; + let buffer_id = state + .terminal_manager + .borrow_mut() + .open( + spec, + &mut state.core.borrow_mut(), + &mut state.process_supervisor.borrow_mut(), + ) + .expect("open terminal"); + tick_until(&mut state, Duration::from_secs(5), |state| { + state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .is_some_and(|snapshot| snapshot_text(&snapshot).contains("row29")) + }); + let mut replacement_spec = TerminalSpec::new("/bin/sh"); + replacement_spec.args = vec!["-c".into(), "sleep 30".into()]; + replacement_spec.rows = 4; + replacement_spec.cols = 24; + let replacement_buffer = state + .terminal_manager + .borrow_mut() + .open( + replacement_spec, + &mut state.core.borrow_mut(), + &mut state.process_supervisor.borrow_mut(), + ) + .expect("open replacement terminal"); + + let size = CellSize::new(4, 24); + let first = TerminalViewKey::new(FrontendId(11), WindowId::next(), buffer_id); + let second = TerminalViewKey::new(FrontendId(22), WindowId::next(), buffer_id); + let replacement = TerminalViewKey::new(FrontendId(11), WindowId::next(), replacement_buffer); + { + let mut manager = state.terminal_manager.borrow_mut(); + let first_tail = manager + .snapshot_for_view(first, size) + .expect("first snapshot"); + let second_tail = manager + .snapshot_for_view(second, size) + .expect("second snapshot"); + manager + .snapshot_for_view(replacement, size) + .expect("replacement snapshot"); + assert_eq!(first_tail.title, second_tail.title); + assert_eq!(first_tail.process, second_tail.process); + + assert!(manager.scroll_lines(first, 3)); + let first_status = manager.view_status(first).expect("first status"); + let second_status = manager.view_status(second).expect("second status"); + assert_eq!(first_status.scroll_offset, 3); + assert_eq!(second_status.scroll_offset, 0); + + assert!(manager.begin_selection(first, size, CellCoord::new(0, 0))); + assert!(manager.finish_selection(first, size, CellCoord::new(0, 4))); + assert!( + manager + .view_status(first) + .expect("selected status") + .selection + ); + assert!( + !manager + .view_status(second) + .expect("passive status") + .selection + ); + assert!( + !manager + .copy_selection(first) + .expect("copied selection") + .is_empty() + ); + assert!(manager.copy_selection(second).is_none()); + + assert!(manager.claim_controller(first)); + assert!(manager.claim_controller(replacement)); + assert_eq!( + manager.controller_view_for_frontend(FrontendId(11)), + Some(replacement), + "claiming another session atomically replaces a frontend's controller" + ); + assert!(manager.controller(buffer_id).is_none()); + assert!(manager.claim_controller(second)); + assert_eq!( + manager.controller_view_for_frontend(FrontendId(22)), + Some(second) + ); + manager.detach_frontend(FrontendId(11)); + assert!(manager.view_status(first).is_none()); + assert!(manager.view_status(second).is_some()); + assert!( + manager + .controller_view_for_frontend(FrontendId(11)) + .is_none() + ); + assert_eq!( + manager.controller_view_for_frontend(FrontendId(22)), + Some(second) + ); + } + + state + .terminal_manager + .borrow_mut() + .terminate(buffer_id, &mut state.process_supervisor.borrow_mut()) + .expect("terminate terminal child"); + state + .terminal_manager + .borrow_mut() + .terminate( + replacement_buffer, + &mut state.process_supervisor.borrow_mut(), + ) + .expect("terminate replacement terminal child"); +} + +#[test] +fn terminal_escape_gates_local_bindings_and_double_escape_sends_interrupt() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let ready_path = temp.path().join("ready"); + let input_path = temp.path().join("input"); + let probe = format!( + concat!( + "import os, tty\n", + "tty.setraw(0)\n", + "open({:?}, 'wb').write(b'1')\n", + "data = b''\n", + "while len(data) < 5: data += os.read(0, 5 - len(data))\n", + "open({:?}, 'wb').write(data)\n", + ), + ready_path.to_str().expect("UTF-8 ready path"), + input_path.to_str().expect("UTF-8 input path") + ); + let mut state = EditorState::new(); + state + .lua_host + .lua() + .load(format!( + r#" + return pmacs.terminal.open {{ + command = "/usr/bin/python3", + args = {{ "-c", {} }}, + rows = 4, + cols = 20, + }} + "#, + lua_string(&probe) + )) + .eval::() + .expect("open raw input probe"); + assert_eq!(wait_for_file(&ready_path, Duration::from_secs(5)), b"1"); + + let frontend_id = FrontendId::LOCAL; + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT), + ); + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), + ); + + assert_eq!( + wait_for_file(&input_path, Duration::from_secs(5)), + b"\x1bv\x03\x1bw", + "unescaped local bindings reach the child; C-c C-c sends one literal interrupt" + ); +} + +fn wait_for_output(pty: &PmacsPty, needle: &[u8], timeout: Duration) { + let deadline = Instant::now() + timeout; + loop { + if pty + .output() + .windows(needle.len()) + .any(|window| window == needle) + { + return; + } + if Instant::now() >= deadline { + let output = pty.output(); + let start = output.len().saturating_sub(4_000); + panic!( + "host output never contained {:?}; tail: {}", + String::from_utf8_lossy(needle), + output[start..].escape_ascii() + ); + } + thread::sleep(Duration::from_millis(20)); + } +} + +fn wait_for_file(path: &Path, timeout: Duration) -> Vec { + let deadline = Instant::now() + timeout; + loop { + if let Ok(bytes) = fs::read(path) { + return bytes; + } + assert!( + Instant::now() < deadline, + "file was not published: {}", + path.display() + ); + thread::sleep(Duration::from_millis(20)); + } +} + +#[test] +#[allow(clippy::too_many_lines, reason = "one real-host lifecycle scenario")] +fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_and_bell() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let config_root = temp.path().join("config"); + let config_dir = config_root.join("pmacs"); + let state_root = temp.path().join("state"); + let input_path = temp.path().join("child-input"); + let size_path = temp.path().join("child-size"); + fs::create_dir_all(&config_dir).expect("config dir"); + + let probe = format!( + concat!( + "import os\n", + "def read_exact(count):\n", + " data = b''\n", + " while len(data) < count:\n", + " data += os.read(0, count - len(data))\n", + " return data\n", + "def read_until(marker):\n", + " data = b''\n", + " while marker not in data:\n", + " data += os.read(0, 4096)\n", + "os.write(1, b'\\x1b[?1049h\\x1b[2J')\n", + "for i in range(20): os.write(1, b'alt%02d\\n' % i)\n", + "os.write(1, b'VTERM_ALT_READY')\n", + "read_until(b'ALT_GATE\\n')\n", + "os.write(1, b'\\x1b[?1049l')\n", + "for i in range(40): os.write(1, b'main%02d\\n' % i)\n", + "os.write(1, b'VTERM_MAIN_READY\\x07')\n", + "data = read_exact(18)\n", + "open({:?}, 'wb').write(data)\n", + "size = os.get_terminal_size(0)\n", + "open({:?}, 'w').write(f'{{size.lines}} {{size.columns}}\\n')\n" + ), + input_path.to_str().expect("UTF-8 input path"), + size_path.to_str().expect("UTF-8 size path") + ); + let init = format!( + r#" + local terminal_buffer = pmacs.terminal.open {{ + command = "/bin/sh", + args = {{ "-c", "exec /usr/bin/python3 -c \"$1\"", "pmacs-vterm-probe", {} }}, + rows = 10, + cols = 40, + scrollback_rows = 200, + }} + local ticks = 0 + pmacs.hook.add("process.after-tick", function() + ticks = ticks + 1 + if ticks == 120 then + pmacs.terminal.send(terminal_buffer, "ALT_GATE\n") + elseif ticks == 180 then + pmacs.terminal.send(terminal_buffer, "VTERM_INPUT_SMOKE\n") + end + end) + "#, + lua_string(&probe) + ); + fs::write(config_dir.join("init.lua"), init).expect("write init.lua"); + + let mut pty = spawn_pmacs_in_pty( + &[], + &[ + ("XDG_CONFIG_HOME", config_root.as_path()), + ("PMACS_STATE_HOME", state_root.as_path()), + ("TERM", Path::new("xterm-256color")), + ], + 24, + 80, + ); + wait_for_output(&pty, b"VTERM_ALT_READY", Duration::from_secs(10)); + + pty.resize(30, 90).expect("resize host PTY"); + thread::sleep(Duration::from_millis(150)); + pty.write_input(b"\x03\x1bv") + .expect("escaped terminal page-up binding"); + // Inject editor-owned scrolling, selection, and copy gestures while the + // real terminal child is active; focused tests pin their exact state. + pty.write_input(b"\x1b[<4;2;2M\x1b[<36;8;2M\x1b[<4;8;2m") + .expect("terminal selection drag"); + pty.write_input(b"\x03\x1bw") + .expect("escaped copy selection binding"); + wait_for_output(&pty, b"\x1b]52;c;", Duration::from_secs(5)); + let input = wait_for_file(&input_path, Duration::from_secs(5)); + assert_eq!(input, b"VTERM_INPUT_SMOKE\n"); + pty.write_input(b"\x1b[200~PASTE_AFTER_EXIT\x1b[201~") + .expect("route a real host paste event"); + let size = String::from_utf8(wait_for_file(&size_path, Duration::from_secs(5))) + .expect("UTF-8 stty size"); + assert_eq!(size.trim(), "28 90", "child PTY must receive cell geometry"); + thread::sleep(Duration::from_millis(200)); + pty.write_input(b"\x03\x18\x03").expect("quit pmacs"); + + let status = pty + .wait_for_exit(Duration::from_secs(5)) + .expect("pmacs should exit after terminal smoke"); + assert!(status.success(), "pmacs exit status: {status:?}"); + thread::sleep(Duration::from_millis(50)); + let output = pty.output(); + assert!( + output.windows(8).any(|window| window == b"\x1b[?1049h"), + "pmacs must enter its host alternate screen" + ); + assert!( + output.windows(8).any(|window| window == b"\x1b[?1049l"), + "pmacs must restore the host main screen" + ); + assert!( + output.contains(&0x07), + "one active-terminal BEL must reach the local host" + ); + assert!( + output.windows(8).any(|window| window == b"\x1b[?2004l"), + "pmacs must disable host bracketed paste on exit" + ); +}