Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Application mouse gesture lifetime

## Status

Implemented for left-button application gestures in the GPUI terminal.

## Context

Fullscreen input editors own their draft, caret, selection, and editing commands.
The terminal owns event delivery. Previously each move/release independently chose
between application reporting and terminal selection using the current modifiers
and hitbox. A drag leaving its pane lost its release, entering another pane could
emit an unpaired drag/release, and pressing Shift mid-gesture changed ownership.

## Evidence

- The GPUI pointer regressions reproduce missing releases and foreign input using
actual element hit testing and the bytes sent to a terminal session.
- Applications can disable and re-enable mouse reporting in one PTY batch, so
sampling the final mode flags cannot distinguish a new input lifetime.
- Native Claude Code 2.1.310 reasserts `1000h`, `1002h`, `1003h`, `1006h` while
interacting. Treating each tracking-detail change as a new lifetime dropped
valid releases. The regression now covers this sequence explicitly.
- Native Codex 0.165.0 receives selection deletion through the ordinary PTY path;
no terminal-side text deletion or draft mirror is required.

## Decision

The view records an application gesture only after encoding its left press. It
retains a weak terminal identity, the terminal's mouse-input generation, and the
last cell/modifiers. Window capture routes motion/release to this owner outside
its hitbox, before another pane handles them. Listeners exist before the press so
events between paint frames are also captured. Native selection, links and
scrollbars retain their own existing state; a foreign left drag cannot start an
application gesture.

The terminal core owns negotiation lifetime. Crossing the enabled/disabled
boundary, changing encoding, switching screens, reset, and grid resize invalidate
mouse coordinates. Reasserting tracking detail while reporting remains enabled
does not. This is a protocol/coordinate generation, not a process identity or an
application-name heuristic.

Loss of focus/visibility and layout changes release a live gesture at its last
valid cell. Late input after protocol/session replacement is discarded. A move
with no held left button recovers a missed OS release. Core generation checks
also cover transitions that occurred entirely between GUI events.

Keyboard, IME and paste keep their existing encoding/broadcast contracts. Mouse
reports target only the originating session. The terminal does not infer or copy
an application's selected text into a shell editing command.

## Rejected alternatives

- Re-evaluating Shift on every event loses the release owed to the press owner.
- Broadcasting coordinates or handing a drag to the hovered pane creates input
without a corresponding press in the receiving application.
- Tracking only final mode bits misses disable/re-enable in one parser batch.
- Invalidating on every mode setter breaks applications that reassert modes on
redraw; only input-lifetime/encoding/coordinate boundaries invalidate a press.
- Synthesizing Backspaces from terminal selection length cannot preserve the
application's Unicode boundaries, placeholders, undo state, or actual draft.

## Consequences

Each view holds at most one application left gesture and registers two small
capture listeners per paint. Idle listeners only check for an owner. No gesture
timer, editor buffer, worker, persistence, or dependency is introduced.

The generation cannot identify a process replacement that does not change any
terminal protocol or screen state. Applications still must reset their input
modes on exit; the PTY does not expose the identity of a byte-stream consumer.
Right/middle-button behavior and ordinary-shell editing are outside this change.

## Validation

- `term/mouse.rs` tests negotiation in every parser chunk size, idempotent
reassertion, encoding transitions, screen switches, reset and resize.
- `pointer/tests/application_mouse.rs` tests pane boundaries in both directions,
Shift changes, focus/visibility, session replacement, late release, resize,
legacy encoding, and redraw reassertion with real GPUI event dispatch.
- `application_mouse/native_tests.rs` provides an opt-in native-window/PTY fixture
for isolated application drafts, using real keyboard and pointer dispatch.
- Linux native checks do not establish Windows ConPTY or macOS compatibility;
those hosts require their native execution paths to be run separately.

## Supersedes

None.

## Revisit when

GPUI exposes a persistent pointer-capture owner, the terminal gains another mouse
encoding, or application mouse lifetime is exposed by a stronger protocol. Reuse
the terminal generation rather than adding per-frontend protocol state machines.
17 changes: 10 additions & 7 deletions nebula_app/src/gpui_shell/terminal/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,17 @@ impl TerminalElement {
Self { view }
}

fn capture_completion_scrollbar_drag(&self, window: &mut Window, cx: &App) {
if self.view.read(cx).completion_viewport.scrollbar_grab.is_none() {
return;
}
fn capture_pointer_drag(&self, window: &mut Window) {
// Register even before a press: input can arrive between paint frames.
// The owning view decides whether to consume the gesture at dispatch.
let view = self.view.downgrade();
window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, cx| {
if phase == DispatchPhase::Capture
&& view
.update(cx, |view, cx| view.move_completion_popup_scrollbar(event, cx))
.update(cx, |view, cx| {
view.move_application_mouse(event)
|| view.move_completion_popup_scrollbar(event, cx)
})
.unwrap_or(false)
{
cx.stop_propagation();
Expand All @@ -69,7 +71,8 @@ impl TerminalElement {
&& event.button == MouseButton::Left
&& view
.update(cx, |view, cx| {
view.finish_completion_popup_scrollbar(event.position, cx)
view.finish_application_mouse(Some(event))
|| view.finish_completion_popup_scrollbar(event.position, cx)
})
.unwrap_or(false)
{
Expand Down Expand Up @@ -252,7 +255,7 @@ impl Element for TerminalElement {
window: &mut Window,
cx: &mut App,
) {
self.capture_completion_scrollbar_drag(window, cx);
self.capture_pointer_drag(window);
let theme = self.view.read(cx).palette.clone();
// 元素不画底色:卡容器统一负责"卡底色(带窗口透明度)→ 壁纸"
// 两层(旧壳 draw_window_backdrop 同模型),元素只画单元格背景、
Expand Down
5 changes: 5 additions & 0 deletions nebula_app/src/gpui_shell/terminal/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#[cfg(all(test, feature = "gpui-test-support"))]
mod activity_tests;
mod agent_activity;
mod application_mouse;
mod broadcast;
mod completion;
#[cfg(all(test, feature = "gpui-test-support"))]
Expand Down Expand Up @@ -490,6 +491,7 @@ pub struct TerminalView {
copy_on_select: bool,
/// 鼠标模式下最后上报的单元格:move 事件按"进入新单元格"去重。
last_report_point: Option<TermPoint>,
application_mouse: Option<application_mouse::Gesture>,
/// GPUI 没有旧壳 scheduler 的 `BlinkCursor` 事件,视图自己只维护可见相位;
/// 光标是否允许闪烁仍由共享 `Term::cursor_style()` 裁定。
cursor_visible: bool,
Expand Down Expand Up @@ -612,6 +614,7 @@ impl TerminalView {
cx: &mut Context<Self>,
) {
if !visible {
self.cancel_pointer_gesture();
self.clear_output_hover(cx);
}
if self.output_visible != visible {
Expand Down Expand Up @@ -865,6 +868,8 @@ impl TerminalView {

/// `Exited` 只对宿主发一次;重复的退出信号(ChildExit 之后必然跟 Exit)只更新文案。
fn mark_exited(&mut self, message: String, cx: &mut Context<Self>) {
self.application_mouse = None;
self.cancel_pointer_gesture();
self.clear_output_hover(cx);
self.reading.cancel_query();
self.reading.cancel_copy();
Expand Down
155 changes: 155 additions & 0 deletions nebula_app/src/gpui_shell/terminal/view/application_mouse.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
//! A left-button press owns its motion/release, even outside the pane's hitbox.
//! Text ranges and edits remain owned by the application reading the PTY.
use super::*;
use nebula_terminal::Term;
use nebula_terminal::sync::FairMutex;
use std::sync::Weak;

#[cfg(all(test, feature = "gpui-test-support"))]
mod native_tests;

pub(super) struct Gesture {
terminal: Weak<FairMutex<Term<session::EventProxy>>>,
generation: u64,
point: TermPoint,
modifiers: gpui::Modifiers,
}

impl TerminalView {
pub(super) fn begin_application_mouse(
&mut self,
event: &MouseDownEvent,
cx: &mut Context<Self>,
) {
let Some(session) = self.session.as_ref() else { return };
let (point, _) = self.grid_point(event.position);
let mut term = session.term.lock();
if !term.mode().intersects(TermMode::MOUSE_MODE) || self.exited.is_some() {
return;
}
let Some(bytes) = mouse_protocol::report(
term.mode(),
point,
mouse_protocol::BUTTON_LEFT,
true,
report_mods(event.modifiers),
) else {
return;
};
self.application_mouse = Some(Gesture {
terminal: Arc::downgrade(&session.term),
generation: term.mouse_input_generation(),
point,
modifiers: event.modifiers,
});
term.selection = None;
drop(term);
self.selecting = false;
self.stop_selection_scroll();
self.last_report_point = Some(point);
self.write_bytes(bytes);
cx.notify();
}

/// Capture listeners call this for every pane. Only the press owner consumes it.
pub(in crate::gpui_shell::terminal) fn move_application_mouse(
&mut self,
event: &MouseMoveEvent,
) -> bool {
let Some(gesture) = self.application_mouse.as_ref() else { return false };
if event.pressed_button != Some(MouseButton::Left) {
// Recover from a missed OS release without moving the selection to a
// new hover position. Never synthesize a new press on re-entry.
self.finish_application_mouse(None);
return false;
}
let (point, _) = self.grid_point(event.position);
if point != gesture.point || event.modifiers != gesture.modifiers {
self.report_application_mouse(point, event.modifiers, true);
}
true
}

pub(in crate::gpui_shell::terminal) fn finish_application_mouse(
&mut self,
event: Option<&MouseUpEvent>,
) -> bool {
let Some(gesture) = self.application_mouse.as_ref() else { return false };
let (point, modifiers) = match event {
Some(event) => (self.grid_point(event.position).0, event.modifiers),
None => (gesture.point, gesture.modifiers),
};
self.report_application_mouse(point, modifiers, false);
self.application_mouse = None;
self.last_report_point = None;
true
}

fn report_application_mouse(
&mut self,
point: TermPoint,
modifiers: gpui::Modifiers,
drag: bool,
) {
let Some(gesture) = self.application_mouse.as_ref() else { return };
let Some(session) = self.session.as_ref() else {
self.application_mouse = None;
return;
};
let term = session.term.lock();
if !std::ptr::eq(gesture.terminal.as_ptr(), Arc::as_ptr(&session.term))
|| gesture.generation != term.mouse_input_generation()
|| !term.mode().intersects(TermMode::MOUSE_MODE)
|| self.exited.is_some()
{
self.application_mouse = None;
return;
}
if drag && !term.mode().intersects(TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION) {
return;
}
let encode = |point| {
mouse_protocol::report(
term.mode(),
point,
mouse_protocol::BUTTON_LEFT + if drag { mouse_protocol::DRAG_OFFSET } else { 0 },
drag,
report_mods(modifiers),
)
};
let (point, bytes) = match encode(point) {
Some(bytes) => (point, bytes),
None if !drag => {
// Scrollback and legacy coordinate limits cannot encode this
// position. The app still needs a release for its valid press.
let Some(bytes) = encode(gesture.point) else { return };
(gesture.point, bytes)
},
None => return,
};
drop(term);
if let Some(gesture) = self.application_mouse.as_mut() {
gesture.point = point;
gesture.modifiers = modifiers;
}
self.last_report_point = Some(point);
self.write_bytes(bytes);
}

pub(super) fn cancel_pointer_gesture(&mut self) {
self.finish_application_mouse(None);
self.selecting = false;
self.stop_selection_scroll();
self.scrollbar_drag = None;
self.completion_viewport.scrollbar_grab = None;
self.pending_link_open = false;
}
}

fn report_mods(modifiers: gpui::Modifiers) -> mouse_protocol::ReportMods {
mouse_protocol::ReportMods {
shift: modifiers.shift,
alt: modifiers.alt,
control: modifiers.control,
}
}
Loading
Loading