Skip to content
Merged
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
4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
7 changes: 7 additions & 0 deletions docs/architecture/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 핸들러보다 먼저 처리한다 — 프로젝트가 없을 때도 열려야 하기
Expand Down
47 changes: 29 additions & 18 deletions src/application/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
}
}
_ => {}
}
_ => {}
}
}
}
Expand Down
96 changes: 96 additions & 0 deletions src/application/input/burst.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<Event>> {
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<Event>) -> Vec<Event> {
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<String> {
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<char> {
// 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,
}
}
1 change: 1 addition & 0 deletions src/application/input/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
5 changes: 3 additions & 2 deletions src/application/input/paste.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/application/terminal_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/application/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
148 changes: 148 additions & 0 deletions src/application/tests/paste_burst.rs
Original file line number Diff line number Diff line change
@@ -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<Event>) -> Option<String> {
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<Event> = "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<Event> = "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"));
}
8 changes: 8 additions & 0 deletions src/cli/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
Loading