fix(panel): G5k --- the gesture records its domain, and every tail obeys it

Answers review of 39b6fa7. The three-state disposition was sound; the
record and effect half downstream of it were not.

apply_panel_pointer returned a bare bool, so the record carried neither
the resolved target nor the reporting contract, and the daemon drove
accepted Drag/Up back through the mode-sensitive adapter. That adapter
re-reads Shift, the scrollback position and the child's mouse modes on
every event --- which is G5k's named mutation verbatim. A press reported
to the child followed by a release re-evaluated after the child turned
reporting off leaves that child holding a button down; the reverse
transition sends a child an Up for a Down it never saw. The recorded
completion had the same defect and additionally re-derived the current
side window, returning when it had changed --- precisely the transitions
task 19 must terminate, so the completion they need was the one thing
that refused to run.

The press now resolves a PanelGestureDomain --- Document{window},
TerminalChild{window, buffer, modes} or TerminalLocal{window, buffer}
--- and the record carries it. Tails and completions route through
replay_panel_gesture_in_domain, which gates on nothing: not Shift, not
the scroll position, not the child's current modes, not the panel's
current identity. apply_terminal_gesture reports which way it routed so
the domain is measured where the branch is taken. Arming now requires an
effect: a press the target refused records nothing.

G5k(a)-(d) plus P3's reporting leg, P4 and P5. Every row reads a TARGET
EFFECT --- the child's byte stream in order, the terminal drag state, or
the document selection --- never the latch. Each bites its own mutation,
and G5k's four legs all fail under the framing's own named mutation
applied verbatim.

Two seams exist because nothing else exposes what the child received: an
opt-in child-input tap, off by default, and a drag-state read.

Also corrects the recovery ledger, which still said implementation was
paused and the bool collision unfixed.

Records for the ci-red registry rather than hiding it: during this work
composition_overhead_under_ten_percent and pty_mode_child_sees_a_tty
redded together in one --lib run at load 21 and each passed in isolation
immediately after --- U9's signature, and neither path is touched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-20 21:40:02 +02:00
parent 39b6fa7dba
commit 48057d7667
No known key found for this signature in database
7 changed files with 853 additions and 101 deletions

View File

@ -281,11 +281,31 @@ from #171 and #215 — the correction the 1b lane missed, honoured here.
**`githubsucks/panel-pointer-replay` is the authoritative tip** (the
ref, not a SHA). Recover with
`git fetch githubsucks && git checkout panel-pointer-replay`.
- **No PR yet. Checkpoint: §5a framing revision 16 and its GUI Stage
1b revision-13 ownership amendment AWAITING APPROVAL (revision 15
was reviewed, corrected and folded into this branch);
IMPLEMENTATION STILL PAUSED, now on approval rather than on a
blocker.** §5a's **pre-merge** replay contract was approved at
- **No PR yet. Checkpoint: framing revision 16 APPROVED;
IMPLEMENTATION UNDER WAY.** Read the tip with
`git log --oneline githubsucks/main..HEAD`; no count or SHA is
recorded here, for the reason the §5b lane learned twice.
- **LANDED: Q#BP-R4's pre-effect disposition and the lifecycle
table** — `PanelPointerOutcome` (`Refused`/`Consumed`/`Accepted`)
decided before any target effect, with the resolution carried so
the daemon never re-derives.
- **LANDED: G5k's recorded gesture domain.** The first version was
reviewed and rejected: it drove tails through the mode-sensitive
adapter, which re-reads Shift, scroll position and the child's
modes per event — G5k's named mutation. The press now records
`PanelGestureDomain` (document / terminal-child-with-encoding /
terminal-local) and every tail and completion follows it.
- **Witnesses: G5k(a)–(d), P1, P3 both legs, P4, P5, P7, P8.** Each
reads a TARGET EFFECT — the child's byte stream, the terminal drag
state, or the document selection — never the latch alone. Each
bites its own mutation, including G5k's verbatim.
- **REMAINING, in order:** task 18's pending-release slot and drains,
task 19's four stranding transitions, then the full head-exact gate
and the PR.
- **Two test seams added for this:** an opt-in child-input tap
(`start_send_tap_for_test`) and a drag-state read
(`view_is_dragging_for_test`). Nothing else exposes what the child
actually received, which is what these rows must assert. §5a's **pre-merge** replay contract was approved at
revision 12; revisions 14–16 are the post-merge amendment now under
review. Revision 13 ruled Q#BP-R3 and blocked the lane on a
protocol-bearing mapping generation; **that block is DISCHARGED** —
@ -312,7 +332,8 @@ from #171 and #215 — the correction the 1b lane missed, honoured here.
discarded the uncommitted stub edit as obsolete and missed that its
**deletion** half was still owed. Removed; exactly one §5a and one
§5b remain.
- **Collision, ruled by revision 14 and NOT yet fixed in code:**
- **Collision, ruled by revision 14 and NOW FIXED IN CODE** (see the
checkpoint above; this bullet records what it was):
§5b and this lane gave `dispatch_semantic_panel_pointer`'s `bool`
different meanings — accepted-as-a-gesture versus consumed-here. A
mode-line press therefore **arms the latch** on this branch today.

View File

@ -1060,16 +1060,20 @@ fn replay_panel_pointer(
// A chrome press begins nothing.
return;
}
let reached_child = editor.apply_panel_pointer(source, &disposition, coord, kind, mods);
// ARMED FROM THE EFFECT RESULT, and only if there was one.
// `None` means the target refused the press --- a terminal
// view that is gone, for instance --- and arming over that
// would record a gesture no tail can deliver.
let Some(domain) = editor.apply_panel_pointer(source, &disposition, coord, kind, mods)
else {
return;
};
if let Some(state) = semantic_states.get_mut(&source) {
// Armed FROM THE EFFECT RESULT: `reached_child` is
// measured where the report branch is taken, not
// predicted from the modes beforehand.
state.arm_accepted_gesture(crate::semantic_render::AcceptedPanelGesture {
button: MouseButton::Left,
coord,
buffer_id,
reached_child,
domain,
});
}
}
@ -1080,7 +1084,18 @@ fn replay_panel_pointer(
// pointer is not over content.
return;
}
let _ = editor.apply_panel_pointer(source, &disposition, coord, kind, mods);
// THE TAIL FOLLOWS THE RECORD, not a fresh classification.
// The disposition above decided only whether this cell is
// ours and in content; WHERE the drag goes is the domain the
// press resolved (G5k).
let Some(domain) = semantic_states
.get(&source)
.and_then(crate::semantic_render::SemanticRenderState::accepted_gesture)
.map(|record| record.domain)
else {
return;
};
editor.replay_panel_gesture_in_domain(source, domain, coord, kind, mods);
if let Some(state) = semantic_states.get_mut(&source) {
state.note_gesture_content_cell(coord);
}
@ -1094,11 +1109,25 @@ fn replay_panel_pointer(
}
match outcome {
Outcome::Accepted => {
let _ = editor.apply_panel_pointer(source, &disposition, coord, kind, mods);
if let Some(state) = semantic_states.get_mut(&source) {
// In content, so the ordinary completion runs --- but
// still in the RECORDED domain, or a mid-gesture mode
// flip would route this release away from the target
// that received the press (G5k).
let record = semantic_states.get_mut(&source).and_then(
crate::semantic_render::SemanticRenderState::consume_accepted_gesture,
);
if let Some(record) = record {
// Taken WITHOUT counting a cancellation, and
// without also running the recorded completion.
let _ = state.consume_accepted_gesture();
// exactly one completion: this is the ordinary
// one, so `complete_panel_gesture` must not also
// run (P5).
editor.replay_panel_gesture_in_domain(
source,
record.domain,
coord,
kind,
mods,
);
}
}
Outcome::Consumed => {
@ -7376,7 +7405,7 @@ mod tests {
button: pmacs_protocol::MouseButton::Left,
coord: pmacs_protocol::CellCoord::new(0, 0),
buffer_id: buffer_a,
reached_child: false,
domain: crate::editor::PanelGestureDomain::Document { window: panel_a },
},
);
@ -7813,6 +7842,531 @@ mod tests {
);
}
/// A panel session whose side window holds a live TERMINAL, with
/// the send tap armed.
///
/// Legacy, deliberately: reading the live mapping generation
/// ADVANCES the key, and §5b wired a key advance to cancel the live
/// gesture, so a mapped fixture destroys the gesture these rows are
/// about.
type TerminalPanelFixture = (
crate::editor::EditorState,
HashMap<FrontendId, crate::semantic_render::SemanticRenderState>,
HashMap<FrontendId, RenderState>,
crate::window::WindowId,
crate::buffer::BufferId,
(u64, u64),
);
fn terminal_panel_session(fid: FrontendId, reporting: bool) -> TerminalPanelFixture {
use crate::terminal::TerminalSpec;
let (editor, mut states, render, _document, panel, _epochs) =
panel_session_at(LEGACY_PANEL_VERSION, fid);
let mut spec = TerminalSpec::new("/bin/sh");
spec.args = vec!["-c".into(), "sleep 30".into()];
spec.rows = 4;
spec.cols = 20;
let terminal_buffer = editor
.terminal_manager
.borrow_mut()
.open(
spec,
&mut editor.core.borrow_mut(),
&mut editor.process_supervisor.borrow_mut(),
)
.expect("open panel terminal");
{
let mut core = editor.core.borrow_mut();
let view = {
let registry = core.registry.clone();
let registry = registry.borrow();
crate::text_view::TextView::new(
registry.get(terminal_buffer).expect("terminal buffer"),
)
};
let window = core.windows.get_mut(&panel).expect("panel window");
window.buffer_id = terminal_buffer;
window.text_view = view;
}
editor
.terminal_manager
.borrow_mut()
.set_mouse_reporting_for_test(terminal_buffer, reporting);
editor.terminal_manager.borrow().start_send_tap_for_test();
let epochs = shipped_declaration(&editor, fid, &mut states);
(editor, states, render, panel, terminal_buffer, epochs)
}
/// Everything the child received, in order.
fn child_stream(editor: &crate::editor::EditorState) -> Vec<Vec<u8>> {
editor
.terminal_manager
.borrow()
.take_send_tap_for_test()
.into_iter()
.map(|(_, bytes)| bytes)
.collect()
}
/// Send one legacy panel gesture.
#[expect(
clippy::too_many_arguments,
reason = "one call shape for every G5k leg"
)]
fn send_panel(
editor: &mut crate::editor::EditorState,
states: &mut HashMap<FrontendId, crate::semantic_render::SemanticRenderState>,
render: &mut HashMap<FrontendId, RenderState>,
fid: FrontendId,
epochs: (u64, u64),
buffer_id: crate::buffer::BufferId,
coord: pmacs_protocol::CellCoord,
kind: pmacs_protocol::MouseKind,
mods: pmacs_protocol::Modifiers,
) {
let mut event = arm_pointer(PanelArm::Legacy, fid, epochs, buffer_id, 0, coord, kind);
if let FrontendEvent::PanelPointer { mods: slot, .. } = &mut event {
*slot = mods;
}
dispatch_panel_event(editor, fid, LEGACY_PANEL_VERSION, states, render, event);
}
// -----------------------------------------------------------------
// G5k — the terminal gesture-domain matrix.
//
// Four legs. In each, the press resolves a domain and then the
// condition that chose it REVERSES before the tail. The gesture must
// finish in the domain it began in; re-reading Shift, the scroll
// position or the child's modes per event is the framing's named
// mutation, and it either strands a child press or sends the child
// an `Up` for a `Down` it never saw.
// -----------------------------------------------------------------
/// G5k(a) — child press, then the child turns REPORTING OFF: the
/// release still reaches the child, and no local selection forms.
#[test]
fn g5k_a_reporting_off_mid_gesture_still_releases_to_the_child() {
let fid = FrontendId(794);
let (mut editor, mut states, mut render, panel, buffer_id, epochs) =
terminal_panel_session(fid, true);
let cell = pmacs_protocol::CellCoord::new(1, 2);
let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left);
let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left);
let none = pmacs_protocol::Modifiers::default();
send_panel(
&mut editor,
&mut states,
&mut render,
fid,
epochs,
buffer_id,
cell,
press,
none,
);
let after_press = child_stream(&editor);
assert_eq!(after_press.len(), 1, "fixture: the press reached the child");
assert!(
states[&fid]
.accepted_gesture()
.is_some_and(crate::semantic_render::AcceptedPanelGesture::reached_child),
"fixture: the record says the child owns this gesture"
);
// The child stops reporting MID-GESTURE.
editor
.terminal_manager
.borrow_mut()
.set_mouse_reporting_for_test(buffer_id, false);
send_panel(
&mut editor,
&mut states,
&mut render,
fid,
epochs,
buffer_id,
cell,
release,
none,
);
assert_eq!(
child_stream(&editor).len(),
1,
"the release must still reach the child: it holds a button \
down that only this release can lift, and re-reading the \
modes here is what strands it"
);
let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id);
assert!(
!editor
.terminal_manager
.borrow()
.view_is_dragging_for_test(key),
"and no local selection was started behind the child's back"
);
}
/// G5k(b) — child press, then SHIFT is held before the release: the
/// release still reaches the child.
#[test]
fn g5k_b_shift_before_the_release_still_releases_to_the_child() {
let fid = FrontendId(795);
let (mut editor, mut states, mut render, panel, buffer_id, epochs) =
terminal_panel_session(fid, true);
let cell = pmacs_protocol::CellCoord::new(1, 2);
let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left);
let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left);
let none = pmacs_protocol::Modifiers::default();
let shift = pmacs_protocol::Modifiers::SHIFT;
send_panel(
&mut editor,
&mut states,
&mut render,
fid,
epochs,
buffer_id,
cell,
press,
none,
);
assert_eq!(
child_stream(&editor).len(),
1,
"fixture: press reached the child"
);
send_panel(
&mut editor,
&mut states,
&mut render,
fid,
epochs,
buffer_id,
cell,
release,
shift,
);
assert_eq!(
child_stream(&editor).len(),
1,
"Shift is the LOCAL-HANDLING override for a NEW gesture, not \
a way to abandon one already delivered to the child"
);
let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id);
assert!(
!editor
.terminal_manager
.borrow()
.view_is_dragging_for_test(key),
"and no local selection was started"
);
}
/// G5k(c) — SHIFT press starts a LOCAL gesture; releasing without
/// Shift finishes locally and sends the child nothing.
#[test]
fn g5k_c_a_shift_started_gesture_finishes_locally() {
let fid = FrontendId(796);
let (mut editor, mut states, mut render, panel, buffer_id, epochs) =
terminal_panel_session(fid, true);
let cell = pmacs_protocol::CellCoord::new(1, 2);
let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left);
let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left);
let none = pmacs_protocol::Modifiers::default();
let shift = pmacs_protocol::Modifiers::SHIFT;
let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id);
send_panel(
&mut editor,
&mut states,
&mut render,
fid,
epochs,
buffer_id,
cell,
press,
shift,
);
assert!(
child_stream(&editor).is_empty(),
"fixture: a Shift press is handled locally"
);
assert!(
editor
.terminal_manager
.borrow()
.view_is_dragging_for_test(key),
"fixture: it began a local drag"
);
// Shift released before the button.
send_panel(
&mut editor,
&mut states,
&mut render,
fid,
epochs,
buffer_id,
cell,
release,
none,
);
assert!(
child_stream(&editor).is_empty(),
"the child must receive NOTHING: it never saw the press, so \
an Up here is a release for a Down that never happened"
);
assert!(
!editor
.terminal_manager
.borrow()
.view_is_dragging_for_test(key),
"and the local drag finishes"
);
}
/// G5k(d) — a press taken locally because reporting was OFF stays
/// local when the child turns reporting ON mid-gesture.
#[test]
fn g5k_d_reporting_on_mid_gesture_does_not_capture_a_local_gesture() {
let fid = FrontendId(797);
let (mut editor, mut states, mut render, panel, buffer_id, epochs) =
terminal_panel_session(fid, false);
let cell = pmacs_protocol::CellCoord::new(1, 2);
let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left);
let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left);
let none = pmacs_protocol::Modifiers::default();
let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id);
send_panel(
&mut editor,
&mut states,
&mut render,
fid,
epochs,
buffer_id,
cell,
press,
none,
);
assert!(
child_stream(&editor).is_empty(),
"fixture: reporting is off, so the press is local"
);
assert!(
editor
.terminal_manager
.borrow()
.view_is_dragging_for_test(key),
"fixture: it began a local drag"
);
// The child turns reporting ON mid-gesture.
editor
.terminal_manager
.borrow_mut()
.set_mouse_reporting_for_test(buffer_id, true);
send_panel(
&mut editor,
&mut states,
&mut render,
fid,
epochs,
buffer_id,
cell,
release,
none,
);
assert!(
child_stream(&editor).is_empty(),
"the child must receive NOTHING --- it never saw this press"
);
assert!(
!editor
.terminal_manager
.borrow()
.view_is_dragging_for_test(key),
"and the local gesture still finishes locally"
);
}
/// P3, reporting leg — a chrome release still delivers the child's
/// release, in the RECORDED encoding.
#[test]
fn r4_p3_child_a_chrome_release_still_reaches_the_reporting_child() {
let fid = FrontendId(798);
let (mut editor, mut states, mut render, _panel, buffer_id, epochs) =
terminal_panel_session(fid, true);
let cell = pmacs_protocol::CellCoord::new(1, 2);
let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left);
let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left);
let none = pmacs_protocol::Modifiers::default();
let chrome = {
let core = editor.core.borrow();
let grid = core.panel_grid_size(fid).expect("grid");
pmacs_protocol::CellCoord::new(grid.rows.saturating_sub(1), 0)
};
send_panel(
&mut editor,
&mut states,
&mut render,
fid,
epochs,
buffer_id,
cell,
press,
none,
);
assert_eq!(
child_stream(&editor).len(),
1,
"fixture: press reached the child"
);
send_panel(
&mut editor,
&mut states,
&mut render,
fid,
epochs,
buffer_id,
chrome,
release,
none,
);
let sent = child_stream(&editor);
assert_eq!(
sent.len(),
1,
"the chrome release must still terminate the child's gesture \
--- the terminal path returns before replay, so without the \
recorded completion the child holds the button forever"
);
assert!(
!states[&fid].has_accepted_gesture(),
"and the record is taken"
);
}
/// P5 — an accepted in-content release delivers EXACTLY ONE child
/// release, never the ordinary one plus a record-driven one.
#[test]
fn r4_p5_an_accepted_release_reaches_the_child_exactly_once() {
let fid = FrontendId(799);
let (mut editor, mut states, mut render, _panel, buffer_id, epochs) =
terminal_panel_session(fid, true);
let cell = pmacs_protocol::CellCoord::new(1, 2);
let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left);
let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left);
let none = pmacs_protocol::Modifiers::default();
send_panel(
&mut editor,
&mut states,
&mut render,
fid,
epochs,
buffer_id,
cell,
press,
none,
);
assert_eq!(
child_stream(&editor).len(),
1,
"fixture: press reached the child"
);
send_panel(
&mut editor,
&mut states,
&mut render,
fid,
epochs,
buffer_id,
cell,
release,
none,
);
assert_eq!(
child_stream(&editor).len(),
1,
"EXACTLY ONE release: the in-content path already completed \
the gesture, so running the recorded completion as well \
sends the child two Ups for one Down"
);
}
/// P4 — a REFUSED release performs no completion and retains the
/// record, so a later authoritative cancellation can still end it.
#[test]
fn r4_p4_a_refused_release_neither_completes_nor_takes_the_record() {
let fid = FrontendId(800);
let (mut editor, mut states, mut render, _panel, buffer_id, epochs) =
terminal_panel_session(fid, true);
let cell = pmacs_protocol::CellCoord::new(1, 2);
let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left);
let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left);
let none = pmacs_protocol::Modifiers::default();
let outside = {
let core = editor.core.borrow();
let grid = core.panel_grid_size(fid).expect("grid");
pmacs_protocol::CellCoord::new(grid.rows, 0)
};
send_panel(
&mut editor,
&mut states,
&mut render,
fid,
epochs,
buffer_id,
cell,
press,
none,
);
assert_eq!(
child_stream(&editor).len(),
1,
"fixture: press reached the child"
);
send_panel(
&mut editor,
&mut states,
&mut render,
fid,
epochs,
buffer_id,
outside,
release,
none,
);
assert!(
child_stream(&editor).is_empty(),
"a refused release must deliver NO completion --- the daemon \
cannot tell it concerns this gesture at all"
);
assert!(
states[&fid].has_accepted_gesture(),
"and it must RETAIN the record, so an authoritative \
cancellation can still end the gesture properly"
);
}
/// The panel's buffer, and a coordinate one row past its grid.
fn panel_buffer_and_outside_coord(
editor: &crate::editor::EditorState,

View File

@ -432,6 +432,82 @@ pub enum PanelPointerOutcome {
Accepted,
}
/// The domain a live panel gesture was RESOLVED INTO at its accepted
/// press, and which every tail and its completion must follow
/// (parent 48 G5k).
///
/// **Re-deciding this per event is G5k's forbidden mutation.** The
/// terminal adapter picks between reporting to the child and handling
/// locally by reading Shift, the scrollback position and the child's
/// current mouse modes — all three of which can change mid-gesture.
/// A press reported to the child followed by a release re-evaluated
/// after the child turned reporting off leaves that child holding a
/// button down forever; the reverse transition sends a child an `Up`
/// for a `Down` it never saw.
///
/// So the press records the contract and the tails obey it. The
/// encoding travels with it for the same reason: the report must be
/// framed the way the press was framed, not the way the child would
/// ask for it now.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PanelGestureDomain {
/// A document panel; every write is addressed to `window`.
Document {
/// The side window the gesture belongs to.
window: WindowId,
},
/// A terminal panel whose press REACHED THE CHILD.
TerminalChild {
/// The side window the gesture belongs to.
window: WindowId,
/// The terminal buffer the child is attached to.
buffer_id: crate::buffer::BufferId,
/// The modes the press was encoded under, replayed verbatim.
modes: crate::terminal::screen::TerminalModes,
},
/// A terminal panel handled LOCALLY — Shift, reporting off, or a
/// scrolled-back view at press time.
TerminalLocal {
/// The side window the gesture belongs to.
window: WindowId,
/// The terminal buffer whose local selection is being built.
buffer_id: crate::buffer::BufferId,
},
}
impl PanelGestureDomain {
/// The side window this gesture is addressed to.
#[must_use]
pub fn window(&self) -> WindowId {
match self {
Self::Document { window }
| Self::TerminalChild { window, .. }
| Self::TerminalLocal { window, .. } => *window,
}
}
/// Whether the press reached the child, so a release is OWED to it.
#[must_use]
pub fn reached_child(&self) -> bool {
matches!(self, Self::TerminalChild { .. })
}
}
/// Which way the shared terminal adapter routed one gesture.
///
/// Returned rather than recomputed so a panel press can RECORD the
/// route it actually took (G5k). Other callers of the adapter ignore
/// it — they have no gesture latch to bind.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TerminalGestureRoute {
/// Nothing ran: the view is gone or has no status at that size.
None,
/// Reported to the child, under these modes.
Child(crate::terminal::screen::TerminalModes),
/// Handled locally — scrollback, local selection, or the menu.
Local,
}
/// A classified panel gesture: the outcome, and the resolution it was
/// decided from.
///
@ -2952,10 +3028,11 @@ impl EditorState {
/// Bare hover neither focuses nor claims, on either kind.
///
/// **Only `Accepted` reaches a target.** A `Consumed` disposition
/// returns without effect: the claim was the effect. Returns whether
/// the gesture reached a CHILD, which is what the accepted-gesture
/// latch records — the framing requires arming "from the effect
/// result", so this is measured rather than assumed.
/// returns without effect: the claim was the effect.
///
/// Returns the [`PanelGestureDomain`] the effect RESOLVED INTO, so
/// an accepted press can record the contract its tails must follow
/// (G5k). `None` means nothing ran and nothing may be recorded.
pub fn apply_panel_pointer(
&mut self,
frontend_id: FrontendId,
@ -2963,15 +3040,13 @@ impl EditorState {
coord: CellCoord,
kind: pmacs_protocol::MouseKind,
mods: pmacs_protocol::Modifiers,
) -> bool {
) -> Option<PanelGestureDomain> {
use pmacs_protocol::MouseKind as PKind;
if disposition.outcome != PanelPointerOutcome::Accepted {
return false;
return None;
}
let Some(target) = disposition.resolved.as_ref() else {
return false;
};
let target = disposition.resolved.as_ref()?;
let activates = if target.is_terminal {
!matches!(kind, PKind::Move)
@ -2991,18 +3066,95 @@ impl EditorState {
// cell and put every clamp one row out.
let viewport = CellSize::new(target.content_rows, target.cols);
let key = TerminalViewKey::new(frontend_id, target.side, target.buffer_id);
return self.apply_terminal_gesture(
return match self.apply_terminal_gesture(
key,
viewport,
coord,
kind,
mods,
(coord.row, coord.col),
);
) {
TerminalGestureRoute::None => None,
TerminalGestureRoute::Child(modes) => Some(PanelGestureDomain::TerminalChild {
window: target.side,
buffer_id: target.buffer_id,
modes,
}),
TerminalGestureRoute::Local => Some(PanelGestureDomain::TerminalLocal {
window: target.side,
buffer_id: target.buffer_id,
}),
};
}
self.replay_panel_document_gesture(frontend_id, target.side, coord, kind, mods);
false
Some(PanelGestureDomain::Document {
window: target.side,
})
}
/// Replay a tail or a completion in the gesture's RECORDED domain
/// (parent 48 G5k).
///
/// **Nothing here re-decides the domain.** The press resolved it and
/// the record carries it, so Shift going down mid-drag, the view
/// scrolling back, or the child flipping its mouse modes cannot move
/// a live gesture from one target to another. Re-evaluating those
/// per event is G5k's forbidden mutation, and it strands a child
/// press in one direction and fabricates an unmatched child release
/// in the other.
///
/// **The window is read from the record too.** An earlier version
/// re-derived the current side window and returned when it had
/// changed — which is exactly the set of transitions the stranding
/// rows must terminate, so the completion they need would have been
/// the one thing that refused to run.
pub fn replay_panel_gesture_in_domain(
&mut self,
frontend_id: FrontendId,
domain: PanelGestureDomain,
coord: CellCoord,
kind: pmacs_protocol::MouseKind,
mods: pmacs_protocol::Modifiers,
) {
match domain {
PanelGestureDomain::Document { window } => {
self.replay_panel_document_gesture(frontend_id, window, coord, kind, mods);
}
PanelGestureDomain::TerminalChild {
window,
buffer_id,
modes,
} => {
// Encoded under the RECORDED modes, and gated on
// nothing: not Shift, not the scroll position, not the
// child's current modes. Those are precisely the three
// inputs G5k forbids re-reading.
let key = TerminalViewKey::new(frontend_id, window, buffer_id);
let _ = key;
if let Some(bytes) = crate::terminal::input::encode_mouse(kind, coord, mods, modes)
{
self.send_terminal_bytes(buffer_id, &bytes);
}
}
PanelGestureDomain::TerminalLocal { window, buffer_id } => {
let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else {
return;
};
let viewport = CellSize::new(size.rows.saturating_sub(1), size.cols);
let key = TerminalViewKey::new(frontend_id, window, buffer_id);
let mut manager = self.terminal_manager.borrow_mut();
match kind {
pmacs_protocol::MouseKind::Drag(pmacs_protocol::MouseButton::Left) => {
let _ = manager.update_selection(key, viewport, coord);
}
pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left) => {
let _ = manager.finish_selection(key, viewport, coord);
}
_ => {}
}
}
}
}
/// Deliver the RECORDED completion for a gesture the ordinary path
@ -3014,69 +3166,23 @@ impl EditorState {
/// already performed the ordinary in-content completion, and doing
/// both is the duplicate P5 forbids.
///
/// **The domain is read from the record, not from where the pointer
/// is now.** `buffer_id` says which panel the gesture belongs to and
/// `reached_child` says whether a press was ever delivered, so the
/// three completions separate without a fourth field:
///
/// * reporting terminal → the child gets its release;
/// * local terminal → the drag's selection is finalised;
/// * document → the gesture completes and an empty selection is
/// cleared without moving point.
///
/// Delivery goes through the SAME paths an in-content release uses,
/// at the record's last valid content cell (R-c2). A second
/// implementation of "what a release does" would drift from the
/// first, which is the whole reason the shared terminal path exists.
/// Delivery goes through the gesture's recorded domain at its last
/// valid content cell (R-c2), so the release is framed the way its
/// press was.
pub fn complete_panel_gesture(
&mut self,
frontend_id: FrontendId,
record: &crate::semantic_render::AcceptedPanelGesture,
mods: pmacs_protocol::Modifiers,
) {
use pmacs_protocol::{MouseButton as PButton, MouseKind as PKind};
let release = PKind::Up(PButton::Left);
let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else {
return;
};
let content_rows = size.rows.saturating_sub(1);
if content_rows == 0 {
return;
}
let side = {
let core = self.core.borrow();
let Some(side) = core.side_window_for(frontend_id) else {
return;
};
// The panel must still be showing the buffer the gesture
// belongs to. If it is not, the four stranding transitions
// own this record, not an ordinary completion.
if core.windows.get(&side).map(|window| window.buffer_id) != Some(record.buffer_id) {
return;
}
side
};
if self.terminal_manager.borrow().is_terminal(record.buffer_id) {
// Both terminal domains route here: `apply_terminal_gesture`
// reports to the child when the modes allow and finalises the
// local selection otherwise, which is exactly the split this
// completion needs.
let viewport = CellSize::new(content_rows, size.cols);
let key = TerminalViewKey::new(frontend_id, side, record.buffer_id);
let _ = self.apply_terminal_gesture(
key,
viewport,
record.coord,
release,
mods,
(record.coord.row, record.coord.col),
);
return;
}
self.replay_panel_document_gesture(frontend_id, side, record.coord, release, mods);
let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left);
self.replay_panel_gesture_in_domain(
frontend_id,
record.domain,
record.coord,
release,
mods,
);
}
/// Replay one accepted gesture into a DOCUMENT panel (parent 48).
@ -4047,13 +4153,12 @@ impl EditorState {
/// A second copy of this precedence in the GPU lane is exactly how
/// Shift-drag or scrolled-back selection would silently diverge
/// between frontends.
/// Returns whether the gesture REACHED THE CHILD as a mouse report.
/// Returns WHICH WAY it routed the gesture (G5k).
///
/// Parent 48 Q#BP-R4 arms the accepted-gesture latch "from the
/// effect result", so `reached_child` has to be measured where the
/// branch is taken rather than predicted from the modes beforehand:
/// the report is gated on five conditions and `encode_mouse` can
/// still decline.
/// The route has to be measured where the branch is taken, not
/// predicted from the modes beforehand: the report is gated on five
/// conditions and `encode_mouse` can still decline. A panel press
/// records the answer and binds its whole gesture to it.
fn apply_terminal_gesture(
&mut self,
key: TerminalViewKey,
@ -4062,12 +4167,12 @@ impl EditorState {
kind: TerminalMouseKind,
modifiers: TerminalModifiers,
global: (u32, u32),
) -> bool {
) -> TerminalGestureRoute {
let shift = modifiers.contains(TerminalModifiers::SHIFT);
let (at_bottom, modes, screen_size) = {
let mut manager = self.terminal_manager.borrow_mut();
let Some(status) = manager.view_status_for_size(key, viewport_size) else {
return false;
return TerminalGestureRoute::None;
};
let modes = manager.modes_for_view(key).unwrap_or_default();
let screen_size = manager.screen_size_for_view(key).unwrap_or(viewport_size);
@ -4097,7 +4202,7 @@ impl EditorState {
self.claim_terminal_controller(key);
}
self.send_terminal_bytes(key.buffer_id, &bytes);
return true;
return TerminalGestureRoute::Child(modes);
}
if claims_control {
@ -4130,7 +4235,7 @@ impl EditorState {
}
// The local branch: scrollback, local selection or the menu. The
// child heard nothing.
false
TerminalGestureRoute::Local
}
fn is_double_click(

View File

@ -174,11 +174,24 @@ pub struct AcceptedPanelGesture {
pub coord: pmacs_protocol::CellCoord,
/// The panel buffer the gesture belongs to.
pub buffer_id: BufferId,
/// Whether the press actually REACHED the child, for a reporting
/// terminal. A release is owed only if a press was delivered;
/// synthesising one for a press the child never saw is the same
/// defect in the other direction.
pub reached_child: bool,
/// The domain the accepted press RESOLVED INTO, and which every
/// tail and the completion must follow (G5k).
///
/// This replaces a bare `reached_child` flag. The flag said whether
/// a release was owed to a child but not how to frame it, nor which
/// window to address, so a tail had to re-derive both from state
/// that moves mid-gesture — Shift, the scroll position, the child's
/// modes, and the panel's own identity. Recording the resolution
/// is what makes the tail independent of all four.
pub domain: crate::editor::PanelGestureDomain,
}
impl AcceptedPanelGesture {
/// Whether the press reached the child, so a release is OWED to it.
#[must_use]
pub fn reached_child(&self) -> bool {
self.domain.reached_child()
}
}
/// Owns one `semantic_render` session's projection state: the last

View File

@ -619,6 +619,22 @@ impl TerminalScreen {
pub fn modes(&self) -> TerminalModes {
self.modes
}
/// Turn SGR mouse reporting on or off, for parent 48 G5k.
///
/// The domain matrix needs a child that reports and then STOPS
/// reporting mid-gesture. Driving that through the ANSI parser
/// would make the row depend on escape-sequence handling it is not
/// testing; this sets the two modes the adapter actually reads.
#[doc(hidden)]
pub fn set_mouse_reporting_for_test(&mut self, enabled: bool) {
self.modes.mouse_sgr = enabled;
self.modes.mouse_tracking = if enabled {
MouseTrackingMode::Button
} else {
MouseTrackingMode::Off
};
}
/// Return whether the published active screen is alternate.
#[must_use]
pub fn alternate_active(&self) -> bool {

View File

@ -247,10 +247,21 @@ pub(super) struct EscapeCache {
pub(super) reported_invalid: Option<String>,
}
/// What each child was sent, in order, while the G5k tap is armed.
type ChildSendLog = Vec<(BufferId, Vec<u8>)>;
/// Owns the one-buffer/one-process/one-screen terminal registry.
#[derive(Default)]
pub struct TerminalManager {
pub(super) sessions: HashMap<BufferId, TerminalSession>,
/// An OPT-IN tap on child input, for parent 48 G5k's witnesses.
///
/// The gesture-domain rows have to read what the child actually
/// received --- a release delivered in the recorded encoding, and
/// exactly one of it --- and no other seam exposes that. Off by
/// default, so production pays one `is_some` check per send and
/// never accumulates.
send_tap: RefCell<Option<ChildSendLog>>,
/// Total escape-key parses performed (Q#TC4c observability).
escape_parses: u64,
process_to_buffer: HashMap<ProcessId, BufferId>,
@ -564,11 +575,35 @@ impl TerminalManager {
.sessions
.get(&buffer_id)
.ok_or(TerminalError::NotTerminal(buffer_id))?;
if let Some(tap) = self.send_tap.borrow_mut().as_mut() {
tap.push((buffer_id, bytes.to_vec()));
}
supervisor
.write_stdin(session.process_id, bytes)
.map_err(TerminalError::Process)
}
/// Begin recording child input for G5k's witnesses.
#[doc(hidden)]
pub fn start_send_tap_for_test(&self) {
*self.send_tap.borrow_mut() = Some(Vec::new());
}
/// Take everything sent to children since the tap was started.
///
/// Returns the sends in ORDER, because "one release, not two" and
/// "the old gesture's release before the new gesture's press" are
/// both ordering claims that a set cannot express.
#[doc(hidden)]
#[must_use]
pub fn take_send_tap_for_test(&self) -> ChildSendLog {
self.send_tap
.borrow_mut()
.as_mut()
.map(std::mem::take)
.unwrap_or_default()
}
/// Resolve this terminal's effective escape chord, parsing at most
/// once per `(terminal, config epoch)` (Q#TC4c).
///

View File

@ -525,6 +525,14 @@ impl TerminalManager {
state.selection.take().is_some() || state.drag.take().is_some()
}
/// Turn SGR mouse reporting on or off for one session (G5k).
#[doc(hidden)]
pub fn set_mouse_reporting_for_test(&mut self, buffer_id: BufferId, enabled: bool) {
if let Some(session) = self.sessions.get_mut(&buffer_id) {
session.screen.set_mouse_reporting_for_test(enabled);
}
}
/// Current child input modes for one session.
#[must_use]
pub fn modes_for_view(&self, key: TerminalViewKey) -> Option<TerminalModes> {