Skip to content
Open
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
9 changes: 9 additions & 0 deletions crates/fresh-editor/plugins/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"auto_recovery_save_interval_secs": 2,
"auto_revert_poll_interval_ms": 2000,
"keyboard_disambiguate_escape_codes": true,
"keyboard_escape_time_ms": 50,
"keyboard_report_event_types": false,
"keyboard_report_alternate_keys": true,
"keyboard_report_all_keys_as_escape_codes": false,
Expand Down Expand Up @@ -887,6 +888,14 @@
"default": true,
"x-section": "Keyboard"
},
"keyboard_escape_time_ms": {
"description": "How long a lone Escape byte waits for a continuation before it is treated\nas the Escape key, in milliseconds.\nIn the legacy encoding `ESC` is both the Escape key and the prefix of every\nescape sequence, so this is a guess on a timer: too low and a mouse report\nsplit by a slow ssh/mosh link is torn apart mid-sequence, too high and the\nEscape key feels sluggish. Raise it on high-latency connections\n(tmux's equivalent, `escape-time`, defaults to 500).\nIgnored entirely on terminals that confirm the kitty keyboard protocol's\n\"disambiguate escape codes\" mode, where Escape is unambiguous and no\nguessing is needed.\nDefault: 50ms",
"type": "integer",
"format": "uint64",
"minimum": 0,
"default": 50,
"x-section": "Keyboard"
},
"keyboard_report_event_types": {
"description": "Enable keyboard enhancement: report key event types (repeat/release).\nAdds extra events when keys are autorepeated or released.\nRequires terminal support (kitty keyboard protocol).\nDefault: false",
"type": "boolean",
Expand Down
25 changes: 25 additions & 0 deletions crates/fresh-editor/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1685,6 +1685,21 @@ pub struct EditorConfig {
#[schemars(extend("x-section" = "Keyboard"))]
pub keyboard_disambiguate_escape_codes: bool,

/// How long a lone Escape byte waits for a continuation before it is treated
/// as the Escape key, in milliseconds.
/// In the legacy encoding `ESC` is both the Escape key and the prefix of every
/// escape sequence, so this is a guess on a timer: too low and a mouse report
/// split by a slow ssh/mosh link is torn apart mid-sequence, too high and the
/// Escape key feels sluggish. Raise it on high-latency connections
/// (tmux's equivalent, `escape-time`, defaults to 500).
/// Ignored entirely on terminals that confirm the kitty keyboard protocol's
/// "disambiguate escape codes" mode, where Escape is unambiguous and no
/// guessing is needed.
/// Default: 50ms
#[serde(default = "default_keyboard_escape_time")]
#[schemars(extend("x-section" = "Keyboard"))]
pub keyboard_escape_time_ms: u64,

/// Enable keyboard enhancement: report key event types (repeat/release).
/// Adds extra events when keys are autorepeated or released.
/// Requires terminal support (kitty keyboard protocol).
Expand Down Expand Up @@ -1815,6 +1830,15 @@ fn default_quick_suggestions_delay() -> u64 {
150 // 150ms — fast enough to feel responsive, slow enough to not interrupt typing
}

fn default_keyboard_escape_time() -> u64 {
// 50ms, matching Neovim's `ttimeoutlen` and libtermkey's waittime — the
// established floor for reassembling sequences split across reads. The
// previous 15ms was the most aggressive of any comparable tool (tmux's
// `escape-time` defaults to 500ms) and left normal ssh jitter inside the
// window where a split mouse report is torn apart (sinelaw/fresh#2793).
50
}

fn default_scroll_offset() -> usize {
3
}
Expand Down Expand Up @@ -1917,6 +1941,7 @@ impl Default for EditorConfig {
rainbow_brackets: true,
cursor_style: CursorStyle::default(),
keyboard_disambiguate_escape_codes: true,
keyboard_escape_time_ms: default_keyboard_escape_time(),
keyboard_report_event_types: false,
keyboard_report_alternate_keys: true,
keyboard_report_all_keys_as_escape_codes: false,
Expand Down
8 changes: 6 additions & 2 deletions crates/fresh-editor/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4736,7 +4736,9 @@ fn run_event_loop(
// Host input is read raw and parsed by fresh's own state machine (see
// `services::tty_input`), not crossterm — this is what prevents mouse
// reports from leaking into focused terminals (#2745).
let mut reader = fresh::services::tty_input::TtyReader::new();
let mut reader = fresh::services::tty_input::TtyReader::with_escape_grace(
std::time::Duration::from_millis(editor.config().editor.keyboard_escape_time_ms),
);
run_event_loop_common(
editor,
terminal,
Expand Down Expand Up @@ -4859,7 +4861,9 @@ fn run_event_loop(
// Host input is read raw and parsed by fresh's own state machine (see
// `services::tty_input`), not crossterm — this is what prevents mouse
// reports from leaking into focused terminals (#2745).
let mut reader = fresh::services::tty_input::TtyReader::new();
let mut reader = fresh::services::tty_input::TtyReader::with_escape_grace(
std::time::Duration::from_millis(editor.config().editor.keyboard_escape_time_ms),
);
run_event_loop_common(
editor,
terminal,
Expand Down
7 changes: 7 additions & 0 deletions crates/fresh-editor/src/partial_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ pub struct PartialEditorConfig {
pub rainbow_brackets: Option<bool>,
pub cursor_style: Option<CursorStyle>,
pub keyboard_disambiguate_escape_codes: Option<bool>,
pub keyboard_escape_time_ms: Option<u64>,
pub keyboard_report_event_types: Option<bool>,
pub keyboard_report_alternate_keys: Option<bool>,
pub keyboard_report_all_keys_as_escape_codes: Option<bool>,
Expand Down Expand Up @@ -310,6 +311,8 @@ impl Merge for PartialEditorConfig {
self.cursor_style.merge_from(&other.cursor_style);
self.keyboard_disambiguate_escape_codes
.merge_from(&other.keyboard_disambiguate_escape_codes);
self.keyboard_escape_time_ms
.merge_from(&other.keyboard_escape_time_ms);
self.keyboard_report_event_types
.merge_from(&other.keyboard_report_event_types);
self.keyboard_report_alternate_keys
Expand Down Expand Up @@ -654,6 +657,7 @@ impl From<&crate::config::EditorConfig> for PartialEditorConfig {
rainbow_brackets: Some(cfg.rainbow_brackets),
cursor_style: Some(cfg.cursor_style),
keyboard_disambiguate_escape_codes: Some(cfg.keyboard_disambiguate_escape_codes),
keyboard_escape_time_ms: Some(cfg.keyboard_escape_time_ms),
keyboard_report_event_types: Some(cfg.keyboard_report_event_types),
keyboard_report_alternate_keys: Some(cfg.keyboard_report_alternate_keys),
keyboard_report_all_keys_as_escape_codes: Some(
Expand Down Expand Up @@ -808,6 +812,9 @@ impl PartialEditorConfig {
keyboard_disambiguate_escape_codes: self
.keyboard_disambiguate_escape_codes
.unwrap_or(defaults.keyboard_disambiguate_escape_codes),
keyboard_escape_time_ms: self
.keyboard_escape_time_ms
.unwrap_or(defaults.keyboard_escape_time_ms),
keyboard_report_event_types: self
.keyboard_report_event_types
.unwrap_or(defaults.keyboard_report_event_types),
Expand Down
8 changes: 7 additions & 1 deletion crates/fresh-editor/src/server/editor_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1124,7 +1124,13 @@ impl EditorServer {
term_size: hello.term_size,
env: hello.env,
id: client_id,
input_parser: ClientInputParser::new(),
// Same escape grace as the direct tty path (#2793): the session
// path used to flush a lone `ESC` after a single 15ms window while
// the tty path allowed two, so `fresh -a` tore split sequences
// apart twice as readily.
input_parser: ClientInputParser::with_escape_grace(std::time::Duration::from_millis(
self.config.editor_config.editor.keyboard_escape_time_ms,
)),
needs_full_render: true,
wait_id: None,
cmd_token: hello.cmd_token,
Expand Down
33 changes: 24 additions & 9 deletions crates/fresh-editor/src/server/input_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,16 @@ use crossterm::event::Event;

pub use fresh_input_parser::InputParser;

/// How long a lone `ESC` stays buffered before it is resolved as the Escape
/// key. Mirrors the tty reader's grace window: long enough that a control
/// sequence split across two socket reads still arrives as one sequence, short
/// enough that Escape feels immediate. Two keystrokes can never land this close
/// together, so nothing a user types is coalesced by it.
const ESC_GRACE: Duration = Duration::from_millis(15);
/// Default grace for a lone `ESC` on the session path, used when no configured
/// value is supplied.
///
/// Mirrors the tty reader's default (and `editor.keyboard_escape_time_ms`): long
/// enough that a control sequence split across two socket reads still arrives as
/// one sequence, short enough that Escape feels immediate. The two paths must
/// agree — this window was 15ms while the tty path effectively allowed ~30ms,
/// which made `fresh -a` twice as easy to tear a split mouse report apart on
/// (sinelaw/fresh#2793).
pub const DEFAULT_ESC_GRACE: Duration = Duration::from_millis(50);

/// [`InputParser`] plus the idle-flush rule the session path needs.
///
Expand All @@ -39,13 +43,22 @@ pub struct ClientInputParser {
/// When the currently-buffered lone `ESC` was first observed. `None`
/// whenever the parser is not sitting on one.
escape_pending_since: Option<Instant>,
/// How long that `ESC` may stay buffered before resolving to the Escape key.
esc_grace: Duration,
}

impl ClientInputParser {
pub fn new() -> Self {
Self::with_escape_grace(DEFAULT_ESC_GRACE)
}

/// As [`ClientInputParser::new`], with the grace from
/// `editor.keyboard_escape_time_ms`.
pub fn with_escape_grace(esc_grace: Duration) -> Self {
Self {
parser: InputParser::new(),
escape_pending_since: None,
esc_grace,
}
}

Expand All @@ -58,15 +71,15 @@ impl ClientInputParser {
}

/// Resolve a buffered lone `ESC` as the Escape key once it has been pending
/// for [`ESC_GRACE`] without a continuation. Returns the events to inject
/// for the escape grace without a continuation. Returns the events to inject
/// (empty when nothing is pending or the grace window has not elapsed).
///
/// `now` is a parameter so tests can drive the window without sleeping.
pub fn flush_idle(&mut self, now: Instant) -> Vec<Event> {
let Some(since) = self.escape_pending_since else {
return Vec::new();
};
if now.duration_since(since) < ESC_GRACE {
if now.duration_since(since) < self.esc_grace {
return Vec::new();
}
let events = self.parser.flush();
Expand All @@ -78,7 +91,9 @@ impl ClientInputParser {
/// the parser has moved on (the continuation arrived, or the escape was
/// already flushed).
fn sync_escape_timer(&mut self, now: Instant) {
if self.parser.escape_pending() {
// A terminal that confirmed unambiguous Escape encoding never needs the
// timer: a lone `ESC` there is only ever the head of a sequence.
if self.parser.escape_pending() && !self.parser.escape_unambiguous() {
self.escape_pending_since.get_or_insert(now);
} else {
self.escape_pending_since = None;
Expand Down
17 changes: 17 additions & 0 deletions crates/fresh-editor/src/services/terminal_modes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,23 @@ impl TerminalModes {
"Pushed keyboard enhancement flags optimistically: {:?}",
flags
);
// Ask what actually took effect. Pushing optimistically leaves us
// unable to tell a terminal that honoured the flags from one that
// ignored them, and that distinction decides whether a lone `ESC`
// can ever be the Escape key: with "disambiguate escape codes"
// active it arrives as `CSI 27 u`, so a bare `0x1b` is always the
// head of a sequence and must never be resolved on a timer
// (sinelaw/fresh#2793).
//
// Unlike `crossterm::supports_keyboard_enhancement` this costs
// nothing on terminals that don't implement the protocol: we do
// not wait for the answer. `CSI ? u` is silently ignored by them,
// while terminals that do implement it reply on stdin, where the
// input parser picks the reply up as part of normal reading
// (`InputParser::take_keyboard_flags_reply`).
if let Err(e) = write!(stdout(), "\x1b[?u").and_then(|()| stdout().flush()) {
tracing::info!("Failed to query keyboard flags: {}", e);
}
}
} else {
tracing::debug!("Keyboard enhancement disabled by config");
Expand Down
Loading