diff --git a/Cargo.toml b/Cargo.toml index 82fcbc3..f5c22ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,4 +83,6 @@ uds_windows = "1" ctrlc = "3" # `GetLocalTime` for the wall clock. Already in the lockfile transitively; # adding it directly here so the feature gate is explicit. -windows-sys = { version = "0.61", features = ["Win32_System_Time", "Win32_Storage_FileSystem", "Win32_Foundation"] } +# `SetConsoleCtrlHandler` for `Win32_System_Console`: clearing the inherited +# ignore-Ctrl-C flag so panes get an interrupt, not just the 0x03 byte. +windows-sys = { version = "0.61", features = ["Win32_System_Time", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_System_Console"] } diff --git a/docs/architecture/ui.md b/docs/architecture/ui.md index e7e41f2..2c665b9 100644 --- a/docs/architecture/ui.md +++ b/docs/architecture/ui.md @@ -39,6 +39,13 @@ `Ctrl+T/W/L/O/P/Q` 등은 control byte로 PTY에 간다(리더 `Ctrl+F`만 arm하고 통과하지 않는다). bare F키는 앱이 가로채므로 pane 안 프로그램(htop, mc 등)의 F키 메뉴는 동작하지 않는다 — 수정자를 붙인 `Ctrl+F1`, `Shift+F5` 등은 통과한다. +- **Paste**: `Event::Paste`는 `dispatch_paste`로 가고, terminal focus면 ESC·NUL을 걷어낸 뒤 pane + 프로그램이 DECSET 2004를 켰을 때만 `ESC[200~ … ESC[201~`으로 감싼다(`input::paste`). + **Windows에는 paste input record가 없어** 문자 단위 key burst로 들어오므로 5 ms 간극까지 이어 + 훑어(최대 8192건 / 250 ms) synthetic `Event::Paste`로 바꾼다(`input::burst`). 콘솔이 붙여넣기를 + 점진적으로 넣기 때문에 zero-wait poll은 단어 중간에서 끊긴다. 판정은 + 좁다 — 수정자 없는 문자/Enter press만이고 Enter+다른 문자이거나 문자 16개 초과일 때만 paste. + 타이핑을 삼키는 오탐이 더 비싸기 때문이고, 어긋나면 순서 그대로 평소 dispatch로 되돌린다. - overlay(repo input/search)가 활성이면 leader dispatch가 금지되고 overlay가 키를 소유한다. armed 중 overlay가 열리는 경로면 prefix를 취소한다. repo 다이얼로그는 `Workspace` 소유라 `main::dispatch_key`가 per-project 핸들러보다 먼저 처리한다 — 프로젝트가 없을 때도 열려야 하기 diff --git a/src/application/event_loop.rs b/src/application/event_loop.rs index d83cc43..a82609b 100644 --- a/src/application/event_loop.rs +++ b/src/application/event_loop.rs @@ -128,27 +128,38 @@ pub(crate) fn main_loop( // OS-level wait when nothing is happening, so the higher cap doesn't // burn CPU at idle. if event::poll(Duration::from_millis(16))? { - match event::read()? { - // Ratatui's next draw will pick up the new size from - // `Frame::area()`. An explicit clear() here only adds a - // visible flash on resize without improving correctness. - Event::Resize(_, _) => {} - Event::Key(key) => { - let outcome = dispatch_key(ws, key); - if apply_outcome(terminal, ws, &mut link, outcome)? { - return Ok(()); + let first = event::read()?; + // Unix gets a real `Event::Paste` from crossterm; Windows never + // does, so a paste burst is drained and rewritten into one here + // (`input::burst`) for the same arm below to route. + #[cfg(windows)] + let events = crate::application::input::burst::coalesce_paste(first)?; + #[cfg(not(windows))] + let events = [first]; + + for event in events { + match event { + // Ratatui's next draw will pick up the new size from + // `Frame::area()`. An explicit clear() here only adds a + // visible flash on resize without improving correctness. + Event::Resize(_, _) => {} + Event::Key(key) => { + let outcome = dispatch_key(ws, key); + if apply_outcome(terminal, ws, &mut link, outcome)? { + return Ok(()); + } } - } - Event::Paste(text) => dispatch_paste(ws, &text), - Event::Mouse(mouse) => { - let screen = Rect::new(0, 0, size.width, size.height); - let outcome = - dispatch_mouse(ws, tabs, mouse, screen, &cfg.layout, cfg.mouse.enabled); - if apply_outcome(terminal, ws, &mut link, outcome)? { - return Ok(()); + Event::Paste(text) => dispatch_paste(ws, &text), + Event::Mouse(mouse) => { + let screen = Rect::new(0, 0, size.width, size.height); + let outcome = + dispatch_mouse(ws, tabs, mouse, screen, &cfg.layout, cfg.mouse.enabled); + if apply_outcome(terminal, ws, &mut link, outcome)? { + return Ok(()); + } } + _ => {} } - _ => {} } } } diff --git a/src/application/input/burst.rs b/src/application/input/burst.rs new file mode 100644 index 0000000..cdc5d0d --- /dev/null +++ b/src/application/input/burst.rs @@ -0,0 +1,96 @@ +//! Rebuild a paste out of the key burst Windows delivers instead of one. +//! +//! The Windows console has no paste input record, so a paste arrives as plain +//! key presses whose `\r`s each submit a line. A synthetic `Event::Paste` hands +//! the burst back to the normal paste path. Compiled everywhere so the rule +//! stays one body and its tests run on every platform. + +use crate::application::input::dispatch::has_command_modifier; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; +use std::io; +use std::time::{Duration, Instant}; + +/// Bounds on one burst. A split paste is a correctness bug — each fragment +/// becomes its own bracketed block — so these are generous next to a realistic +/// paste; what is left over is drained on the next tick. +const MAX_BURST_EVENTS: usize = 8192; +const MAX_BURST_TIME: Duration = Duration::from_millis(250); + +/// How long to wait for the burst to continue. The console feeds a paste in +/// incrementally, so a zero-wait poll finds the queue momentarily empty and +/// cuts the paste mid-word. Far below human cadence, so typing never merges. +const BURST_GAP: Duration = Duration::from_millis(5); + +/// No one types this many characters at a 5 ms cadence, and key repeat cannot +/// either — its events carry `KeyEventKind::Repeat`. +const MAX_TYPED_CHARS: usize = 16; + +/// Drain the rest of the burst `first` opened and classify it. +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn coalesce_paste(first: Event) -> io::Result> { + let mut events = vec![first]; + let deadline = Instant::now() + MAX_BURST_TIME; + while events.len() < MAX_BURST_EVENTS && Instant::now() < deadline && event::poll(BURST_GAP)? { + events.push(event::read()?); + } + Ok(classify(events)) +} + +pub(crate) fn classify(events: Vec) -> Vec { + match paste_text(&events) { + Some(text) => vec![Event::Paste(text)], + // Returned untouched, order included: a chord must not be reordered + // around the keys it modifies. + None => events, + } +} + +/// The payload this burst would paste, or `None` if it reads as typing. +/// +/// Narrow on purpose: a false positive submits typed keys as a block. Enter +/// hands the line off, so Enter plus more cannot be typed within one burst. +fn paste_text(events: &[Event]) -> Option { + let mut text = String::new(); + let mut enters = 0usize; + let mut chars = 0usize; + for event in events { + let Event::Key(key) = event else { + return None; + }; + // Windows reports Press/Repeat/Release per keystroke; counting the + // others would multiply every character. + if key.kind != KeyEventKind::Press { + continue; + } + match plain_input(*key)? { + '\r' => { + enters += 1; + text.push('\r'); + } + c => { + chars += 1; + text.push(c); + } + } + } + let multiline = enters > 0 && chars > 0; + if multiline || chars > MAX_TYPED_CHARS { + Some(text) + } else { + None + } +} + +/// Enter becomes `\r`, which is what a terminal puts inside a bracketed paste. +/// `\n` is dropped outright by some readers (Claude Code), collapsing the lines. +fn plain_input(key: KeyEvent) -> Option { + // Shift is excluded deliberately — it carries the uppercase half of a paste. + if has_command_modifier(key) { + return None; + } + match key.code { + KeyCode::Char(c) if !c.is_control() => Some(c), + KeyCode::Enter => Some('\r'), + _ => None, + } +} diff --git a/src/application/input/mod.rs b/src/application/input/mod.rs index f5e816b..27b36f1 100644 --- a/src/application/input/mod.rs +++ b/src/application/input/mod.rs @@ -1,5 +1,6 @@ //! Translate terminal and browser input into application actions. +pub(crate) mod burst; pub(crate) mod dispatch; mod handlers; pub(crate) mod mouse; diff --git a/src/application/input/paste.rs b/src/application/input/paste.rs index 81ac6c9..212ecbb 100644 --- a/src/application/input/paste.rs +++ b/src/application/input/paste.rs @@ -24,8 +24,9 @@ pub(crate) fn dispatch_paste(ws: &mut Workspace, text: &str) { /// Its search overlays accept the text after stripping control characters — /// the same rule the typed-key handlers enforce. The terminal pane receives /// the paste re-wrapped in `ESC [200~ ... ESC [201~` so the inner shell can -/// distinguish multi-line paste from interactive input (crossterm consumes the -/// outer markers when surfacing `Event::Paste`). +/// distinguish multi-line paste from interactive input. `text` never carries +/// the outer markers: crossterm strips them on Unix, and on Windows there are +/// none — `input::burst` synthesises the event from keys. pub(crate) fn handle_paste(app: &mut App, text: &str) { // A paste arriving while the prefix is armed would otherwise leave the // PREFIX indicator stuck and make the next key resolve as a follow-up. diff --git a/src/application/terminal_guard.rs b/src/application/terminal_guard.rs index 5c22302..32fa7af 100644 --- a/src/application/terminal_guard.rs +++ b/src/application/terminal_guard.rs @@ -47,7 +47,8 @@ impl TerminalGuard { // EnableBracketedPaste makes crossterm surface paste as // `Event::Paste(String)` instead of a flood of `Event::Key` chars — // the latter would each be filtered as control chars by the search - // handler and silently drop newlines. + // handler and silently drop newlines. Unix only — the Windows console + // has no paste record, so `input::burst` reassembles the flood. // Ratatui positions every changed cell itself. Host-side autowrap is // therefore both unnecessary and dangerous: writing the bottom-right // cell can scroll the physical screen while Ratatui's back buffer still diff --git a/src/application/tests/mod.rs b/src/application/tests/mod.rs index e12e58b..ac1f4f1 100644 --- a/src/application/tests/mod.rs +++ b/src/application/tests/mod.rs @@ -6,6 +6,7 @@ mod mouse_clicks; mod mouse_empty; mod mouse_release; mod paste; +mod paste_burst; mod prefix; mod prefix_digits; mod reload; diff --git a/src/application/tests/paste_burst.rs b/src/application/tests/paste_burst.rs new file mode 100644 index 0000000..68e6f4b --- /dev/null +++ b/src/application/tests/paste_burst.rs @@ -0,0 +1,148 @@ +//! The Windows paste-burst discriminator, tested on every platform — gating it +//! would leave the code that can swallow typed keys unverified where most +//! changes are written. + +use crate::application::input::burst::classify; +use crossterm::event::{ + Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, MouseButton, MouseEvent, + MouseEventKind, +}; + +fn key(code: KeyCode, mods: KeyModifiers) -> Event { + Event::Key(KeyEvent::new(code, mods)) +} + +fn ch(c: char) -> Event { + key(KeyCode::Char(c), KeyModifiers::NONE) +} + +fn release(c: char) -> Event { + Event::Key(KeyEvent::new_with_kind_and_state( + KeyCode::Char(c), + KeyModifiers::NONE, + KeyEventKind::Release, + KeyEventState::NONE, + )) +} + +fn pasted(events: Vec) -> Option { + match classify(events).as_slice() { + [Event::Paste(text)] => Some(text.clone()), + _ => None, + } +} + +#[test] +fn a_multi_line_burst_is_one_paste_with_carriage_returns() { + let events = vec![ + ch('h'), + ch('i'), + key(KeyCode::Enter, KeyModifiers::NONE), + ch('y'), + ch('o'), + ]; + + assert_eq!( + pasted(events).as_deref(), + Some("hi\ryo"), + "Enter inside a burst is a pasted line break, not a submitted line" + ); +} + +#[test] +fn a_long_single_line_burst_is_a_paste() { + // 17 characters: past what one burst can collect from typing. + let events: Vec = "abcdefghijklmnopq".chars().map(ch).collect(); + + assert_eq!(pasted(events).as_deref(), Some("abcdefghijklmnopq")); +} + +#[test] +fn one_typed_character_is_not_a_paste() { + let events = vec![ch('a')]; + + assert!( + pasted(events).is_none(), + "typing must keep reaching the per-key dispatch" + ); +} + +#[test] +fn a_short_typed_run_is_not_a_paste() { + let events: Vec = "abcdefgh".chars().map(ch).collect(); + + assert!(pasted(events).is_none()); +} + +#[test] +fn a_lone_enter_is_not_a_paste() { + let events = vec![key(KeyCode::Enter, KeyModifiers::NONE)]; + + assert!( + pasted(events).is_none(), + "a submitted empty line must stay an Enter keypress" + ); +} + +#[test] +fn a_ctrl_chord_in_the_burst_forces_per_key_dispatch() { + let events = vec![ + ch('h'), + key(KeyCode::Char('c'), KeyModifiers::CONTROL), + key(KeyCode::Enter, KeyModifiers::NONE), + ch('i'), + ]; + let expected = events.clone(); + + assert_eq!( + classify(events), + expected, + "an interrupt must not be swallowed into pasted text" + ); +} + +#[test] +fn a_function_key_in_the_burst_forces_per_key_dispatch() { + let events = vec![ch('h'), key(KeyCode::F(2), KeyModifiers::NONE), ch('i')]; + let expected = events.clone(); + + assert_eq!(classify(events), expected); +} + +#[test] +fn a_mouse_report_in_the_burst_forces_per_event_dispatch() { + let events = vec![ + ch('h'), + Event::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 1, + row: 1, + modifiers: KeyModifiers::NONE, + }), + key(KeyCode::Enter, KeyModifiers::NONE), + ]; + let expected = events.clone(); + + assert_eq!(classify(events), expected); +} + +#[test] +fn key_releases_do_not_count_as_pasted_characters() { + // Counting both halves would double every character and make a short typed + // run look like a paste. + let events = vec![ch('h'), release('h'), ch('i'), release('i')]; + + assert!(pasted(events).is_none()); +} + +#[test] +fn shifted_characters_stay_in_the_paste() { + let events = vec![ + key(KeyCode::Char('H'), KeyModifiers::SHIFT), + ch('i'), + key(KeyCode::Enter, KeyModifiers::NONE), + key(KeyCode::Char('Y'), KeyModifiers::SHIFT), + ]; + + assert_eq!(pasted(events).as_deref(), Some("Hi\rY")); +} diff --git a/src/cli/daemon.rs b/src/cli/daemon.rs index 083c35e..856674e 100644 --- a/src/cli/daemon.rs +++ b/src/cli/daemon.rs @@ -49,6 +49,14 @@ pub(crate) fn run_daemon( "logging initialized" ); + // Here rather than in the TUI: this process spawns the panes, `attach` only + // forwards keystrokes over the socket. Must precede the first pane, which + // `ViewerServer::start_from_config` below can open. A failure only degrades + // Ctrl-C inside panes, so it must not stop the session from starting. + if let Err(err) = crate::platform::console::inherit_ctrl_c_as_an_event() { + tracing::warn!(error = %err, "Ctrl-C in panes will not interrupt running programs"); + } + let config_path = crate::config::config_file_path()?; if let Some(password) = crate::config::ensure_web_viewer_password(&mut cfg, &config_path)? { eprintln!( diff --git a/src/platform/console.rs b/src/platform/console.rs new file mode 100644 index 0000000..80eb70f --- /dev/null +++ b/src/platform/console.rs @@ -0,0 +1,55 @@ +//! The console dispositions a process hands down to the shells it spawns. +//! +//! On Windows "ignore Ctrl-C" is a property of the process and children inherit +//! it. `daemon::detach` passes `CREATE_NEW_PROCESS_GROUP`, which sets that flag, +//! so every ConPTY pane inherits it too. +//! +//! The symptom is deceptive: conhost still delivers the `0x03` byte, so an idle +//! prompt abandons its line and looks wired up, but no `CTRL_C_EVENT` is raised +//! and a *running* program (`ping`, a Python script) is never interrupted. + +use anyhow::Result; + +#[cfg(unix)] +mod imp { + use anyhow::Result; + + /// Nothing to clear: SIGINT generation is the tty's `ISIG` bit, which every + /// PTY gets fresh from the kernel. + pub(super) fn inherit_ctrl_c_as_an_event() -> Result<()> { + Ok(()) + } +} + +#[cfg(windows)] +mod imp { + use anyhow::{Result, bail}; + use windows_sys::Win32::System::Console::SetConsoleCtrlHandler; + + pub(super) fn inherit_ctrl_c_as_an_event() -> Result<()> { + // A null routine clears the ignore flag rather than unregistering + // anyone; handlers `ctrlc` installed are separate entries and survive. + // + // SAFETY: a plain Win32 call with no memory contract to uphold. + if unsafe { SetConsoleCtrlHandler(None, 0) } == 0 { + bail!( + "clearing the inherited ignore-Ctrl-C disposition: {}", + std::io::Error::last_os_error() + ); + } + Ok(()) + } +} + +/// Make Ctrl-C reach spawned children as an interrupt, not just as a byte. +/// +/// Call once, before the first pane is spawned — the disposition is copied into +/// a child at creation, so clearing it afterwards does nothing for panes that +/// already exist. +pub(crate) fn inherit_ctrl_c_as_an_event() -> Result<()> { + imp::inherit_ctrl_c_as_an_event() +} + +#[cfg(test)] +#[path = "console_tests.rs"] +mod tests; diff --git a/src/platform/console_tests.rs b/src/platform/console_tests.rs new file mode 100644 index 0000000..912de08 --- /dev/null +++ b/src/platform/console_tests.rs @@ -0,0 +1,10 @@ +use super::inherit_ctrl_c_as_an_event; + +/// The flag has no getter and observing it needs a real ConPTY child, so the +/// seam being callable is all a test can claim. Idempotence is the contract that +/// matters: a second caller must not fail startup. +#[test] +fn clearing_the_inherited_disposition_succeeds_and_repeats() { + inherit_ctrl_c_as_an_event().expect("the disposition is cleared"); + inherit_ctrl_c_as_an_event().expect("clearing it again is not an error"); +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 43c0beb..49f546e 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -1,5 +1,6 @@ //! Operating-system-adjacent services shared by application layers. +pub(crate) mod console; pub(crate) mod logging; pub(crate) mod paths; pub(crate) mod signals;