Merge pull request #130 from levineuwirth/vterm-tui
feat(vterm): compose terminal views in the TUI
This commit is contained in:
commit
86fc1bccae
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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, <https://github.com/levineuwirth/pmacs/pull/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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, <https://github.com/levineuwirth/pmacs/pull/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,
|
||||
<https://github.com/levineuwirth/pmacs/pull/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:<code>` on
|
||||
normal exit, `TERM:<signal>` on signal, or `TERM:ERR` on crash, and appends
|
||||
` ↑<scroll_offset>` 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.
|
||||
|
|
|
|||
226
src/daemon.rs
226
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<FrontendId, crate::buffer::BufferId> = 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<FrontendId, (crate::buffer::BufferId, u64)> =
|
||||
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<FrontendId, (crate::buffer::BufferId, u64)>,
|
||||
) -> 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<FrontendId, RenderState>,
|
||||
|
|
@ -1460,6 +1532,7 @@ fn handle_dispatcher_event(
|
|||
term_sizes: &mut HashMap<FrontendId, CellSize>,
|
||||
last_dispatch_idle_sent: &mut HashMap<FrontendId, bool>,
|
||||
last_active_buffer_sent: &mut HashMap<FrontendId, crate::buffer::BufferId>,
|
||||
terminal_bell_baselines: &mut HashMap<FrontendId, (crate::buffer::BufferId, u64)>,
|
||||
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<FrontendId, crate::semantic_render::SemanticRenderState> =
|
||||
HashMap::new();
|
||||
let mut streams: HashMap<FrontendId, UnixStream> = 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'),
|
||||
|
|
|
|||
1065
src/editor.rs
1065
src/editor.rs
File diff suppressed because it is too large
Load Diff
|
|
@ -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<u8>) {
|
||||
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<u8>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<WindowId, TerminalSnapshot>,
|
||||
other_presences: &[crate::overlay_paint::OtherPresence],
|
||||
) -> Vec<InstanceMessage> {
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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::<crate::editor::InteractiveCommandOrigin>()
|
||||
.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::<SharedCore>().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<Table> {
|
||||
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<Value>)| {
|
||||
if let Some(core) = lua.app_data_ref::<SharedCore>() {
|
||||
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<SharedProcessSuperviso
|
|||
Ok(supervisor)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pmacs.terminal: owned terminal session surface (Arc 5 Stage 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the shared terminal registry and install strict raw Lua primitives.
|
||||
pub fn make_terminal_manager(
|
||||
lua: &Lua,
|
||||
supervisor: &SharedProcessSupervisor,
|
||||
) -> mlua::Result<crate::terminal::SharedTerminalManager> {
|
||||
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<SharedCore> {
|
||||
lua.app_data_ref::<SharedCore>()
|
||||
.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::<InteractiveCommandOrigin>()
|
||||
.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<crate::terminal::TerminalViewKey> {
|
||||
let frontend_id = lua
|
||||
.app_data_ref::<InteractiveCommandOrigin>()
|
||||
.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<u64> {
|
||||
match context.raw_get::<Value>(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<crate::buffer::BufferId> {
|
||||
match context.raw_get::<Value>("buffer")? {
|
||||
Value::UserData(buffer) => buffer
|
||||
.borrow::<BufferIdLua>()
|
||||
.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<Option<crate::terminal::TerminalViewKey>> {
|
||||
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<BufferIdLua> {
|
||||
let spec = parse_terminal_spec(&spec)?;
|
||||
let core = lua
|
||||
.app_data_ref::<SharedCore>()
|
||||
.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<Option<Table>> {
|
||||
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<crate::terminal::TerminalSpec> {
|
||||
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<Option<String>> {
|
||||
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<Vec<String>> {
|
||||
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<Vec<(String, String)>> {
|
||||
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<u16> {
|
||||
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<usize> {
|
||||
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<Table> {
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 { .. }),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<TerminalRow>,
|
||||
/// Active visible rows, including logical-line and soft-wrap metadata.
|
||||
pub visible_rows: Vec<TerminalRow>,
|
||||
/// Published cursor position when visible.
|
||||
pub cursor: Option<CellCoord>,
|
||||
/// Published terminal title.
|
||||
pub title: Option<String>,
|
||||
/// 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<CellCoord>,
|
||||
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<usize>,
|
||||
title: Option<String>,
|
||||
generation: u64,
|
||||
published: ScreenSnapshot,
|
||||
published: ScreenProjection,
|
||||
sync_started: Option<Instant>,
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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<BufferId, TerminalSession>,
|
||||
pub(super) sessions: HashMap<BufferId, TerminalSession>,
|
||||
process_to_buffer: HashMap<ProcessId, BufferId>,
|
||||
/// Removed buffers whose children are still being reaped. Their events
|
||||
/// remain manager-owned so Lua/LSP/MCP consumers cannot steal a batch.
|
||||
closing: HashSet<ProcessId>,
|
||||
/// Per-frontend/window projections over the one session screen.
|
||||
pub(super) views: HashMap<TerminalViewKey, TerminalViewState>,
|
||||
/// At most one authenticated frontend/window controls each session PTY.
|
||||
pub(super) controllers: HashMap<BufferId, TerminalController>,
|
||||
}
|
||||
|
||||
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<u64> {
|
||||
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<TerminalViewKey>,
|
||||
) {
|
||||
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<TerminalController> {
|
||||
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<TerminalSnapshot> {
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<dyn portable_pty::Child + Send + Sync>,
|
||||
writer: Box<dyn Write + Send>,
|
||||
_reader_thread: thread::JoinHandle<()>,
|
||||
_master: Box<dyn portable_pty::MasterPty + Send>,
|
||||
output: Arc<Mutex<Vec<u8>>>,
|
||||
master: Box<dyn portable_pty::MasterPty + Send>,
|
||||
}
|
||||
|
||||
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<u8> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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={:?}",
|
||||
|
|
|
|||
|
|
@ -57,7 +57,13 @@ fn paint(state: &EditorState, rows: u32, cols: u32) -> Vec<Cell> {
|
|||
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::<Vec<_>>(),
|
||||
["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())
|
||||
|
|
|
|||
|
|
@ -92,7 +92,13 @@ fn paint_full_frame(state: &EditorState, rows: u32, cols: u32) -> Vec<Cell> {
|
|||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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::<Value>()
|
||||
.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::<BufferIdLua>()
|
||||
.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::<bool>("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 = "<f12>", 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::<Value>()
|
||||
.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::<AnyUserData>()
|
||||
.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<u8> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue