pmacs/src/optimistic.rs

1243 lines
52 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! T M10.10 — Frontend-side optimistic-apply infrastructure.
//!
//! Two pieces of logic live here, deliberately small and testable in
//! isolation:
//!
//! 1. **`classify_key`** — the text-input predicate. Given a keystroke,
//! decides whether to take the optimistic path (insert / delete-back /
//! delete-forward) or fall through to the v0.1 `FrontendEvent::Key`
//! round-trip. Pure function; no state.
//!
//! 2. **`apply_incoming_crdt_op`** — the echo-dedup filter for incoming
//! `InstanceMessage::CrdtOp` broadcasts. Compares the broadcast's
//! source `FrontendId` to the local frontend's id and either applies
//! the op to the `BufferMirror` (remote) or skips (own echo).
//!
//! The attach loop (`attach.rs`) is the consumer: each keystroke runs
//! through `classify_key`; each incoming `CrdtOp` runs through
//! `apply_incoming_crdt_op`. Keeping these as standalone functions in
//! a dedicated module makes them unit-testable without spinning up an
//! attach session.
//!
//! # Semantics note: keymap and the text-input predicate
//!
//! `classify_key` assumes the default keymap's "text-input → self-
//! insert" mapping. Users with Lua keymap rebindings for text characters
//! (e.g., binding 'a' to a non-insert command) will see those bindings
//! lost on replica frontends — text-input chars take the optimistic
//! path and never reach the daemon's keymap layer.
//!
//! This is a v1.0-acceptable simplification: most users don't rebind
//! text characters; users who do can fall back to v0.1 round-trip by
//! advertising `crdt_replica: false`. v0.2+ may expand the predicate
//! to consult a frontend-side keymap mirror.
use crate::buffer::BufferId;
use crate::buffer_mirror::{BufferMirror, BufferMirrorError};
use crate::protocol::{FrontendEvent, FrontendId, Key, KeyEvent, Modifiers, is_builtin_pair_char};
use crate::rope::CrdtOp;
use unicode_width::UnicodeWidthChar;
/// Result of classifying a keystroke for optimistic apply.
///
/// The frontend's keystroke handler matches on this to either take the
/// optimistic path (the concrete actions) or fall through to the v0.1
/// `FrontendEvent::Key` send.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum OptimisticAction {
/// Insert a single character at the cursor.
Insert(char),
/// Delete one byte/grapheme behind the cursor (Backspace).
DeleteBack,
/// Delete one byte/grapheme at the cursor (Delete-forward).
DeleteForward,
/// Undo this frontend's most recent edit on the active buffer
/// (M10.11 P1). Triggered by single-key undo bindings whose
/// modifier set is `Ctrl` and whose `Char` is `_` or `/` — the
/// two terminal-portable spellings of the default-keymap undo
/// binding (`builtin/keymaps/default.lua`). Multi-key undo
/// bindings like `C-x u` fall through to `RoundTrip` because
/// the optimistic layer doesn't track keymap-prefix state.
Undo,
/// No optimistic path applies; fall through to round-trip via
/// `FrontendEvent::Key`. Covers control-char modifiers (Ctrl, Alt,
/// Meta, Hyper) that aren't bound to an optimistic action,
/// function keys, navigation keys, and any keystroke whose
/// semantics aren't text-input or recognized commands.
RoundTrip,
}
/// True if the modifier set excludes all editor-control modifiers
/// (Ctrl, Alt, Meta, Hyper). Shift is allowed because capital letters
/// arrive as `Char('A')` with `SHIFT` set — Shift is part of how the
/// character was produced, not a command modifier that changes semantics.
const fn is_text_input_modifiers(mods: Modifiers) -> bool {
!mods.contains(Modifiers::CTRL)
&& !mods.contains(Modifiers::ALT)
&& !mods.contains(Modifiers::META)
&& !mods.contains(Modifiers::HYPER)
}
/// Classify a key event for the optimistic-apply path.
///
/// Returns:
/// - `Insert(c)` for a printable `Char(c)` with no editor-control modifier.
/// - `DeleteBack` for `Backspace` with no editor-control modifier.
/// - `DeleteForward` for `Delete` with no editor-control modifier.
/// - `RoundTrip` for everything else (modified text input, function
/// keys, arrows, escape, etc.).
///
/// `char::is_control()` filters out ASCII control codes (0x000x1F,
/// 0x7F) and Unicode control codes. Tab and Enter qualify as control
/// chars and therefore round-trip — they often have non-insert
/// semantics in editor keymaps (indentation, newline-with-indent).
#[must_use]
pub fn classify_key(key: Key, mods: Modifiers) -> OptimisticAction {
// M10.11 P1 — single-key undo bindings.
//
// The default keymap (`builtin/keymaps/default.lua`) binds four
// forms of undo: `C-/`, `C-_`, `C-4`, and `C-x u`. Crossterm's
// raw-terminal parser (`crossterm-0.28.1` /
// `event/sys/unix/parse.rs:106-113`) only delivers some of
// these as the literal `Char + Modifiers::CTRL` shape:
//
// - 0x01..=0x1A (Ctrl-A..Ctrl-Z) → `Char(letter)` + CTRL
// - 0x1C..=0x1F → `Char('4')..Char('7')` + CTRL (the offset-
// from-'4' convention crossterm uses for non-letter Ctrl
// bytes; *not* the "Ctrl-_" / "Ctrl-/" naming users
// intuitively expect — that mapping requires Kitty Keyboard
// Protocol enhanced mode, which pmacs doesn't currently
// negotiate).
//
// Practical consequence: when a real terminal user presses
// Ctrl-_, the byte 0x1F arrives, crossterm produces
// `Char('7')` + CTRL, *no* default-keymap binding matches.
// The deliverable undo keystrokes for raw-terminal users are
// C-4 (byte 0x1C) and C-x u (multi-key, falls through to
// daemon dispatch).
//
// We optimistically recognize `Char('4')` + CTRL as Undo
// because the default keymap binds it, AND it's the form a
// real terminal can actually deliver. `Char('/')` and
// `Char('_')` with CTRL are also recognized for symmetry —
// they'll match when Kitty enhanced mode is negotiated, or
// when a non-PTY frontend (future GUI) emits them directly.
// Multi-key bindings like `C-x u` round-trip because the
// optimistic layer doesn't track keymap-prefix state.
//
// The `mods == CTRL` exact-match (rather than `contains(CTRL)`)
// ensures combos like `C-S-_` round-trip rather than triggering
// undo unexpectedly. Lua-rebound forms similarly round-trip.
if mods == Modifiers::CTRL && matches!(key, Key::Char('/' | '_' | '4')) {
return OptimisticAction::Undo;
}
if !is_text_input_modifiers(mods) {
return OptimisticAction::RoundTrip;
}
match key {
// Auto-pairing Q#AP1: the built-in pair charset always
// round-trips so the opener and the pairing hook's closer are
// adjacent daemon-peer undo units (and dispatch-path CUA
// type-over applies). An optimistic pair char would be a
// source-peer op whose reaction closer lives on the daemon
// peer — uncleanly undoable from either frontend.
Key::Char(c) if !c.is_control() && !is_builtin_pair_char(c) => OptimisticAction::Insert(c),
Key::Backspace => OptimisticAction::DeleteBack,
Key::Delete => OptimisticAction::DeleteForward,
_ => OptimisticAction::RoundTrip,
}
}
/// Compute the `FrontendEvent` to send upstream for a keystroke,
/// applying the optimistic-apply path locally if the mirror is
/// ready **and** the action is paint-eligible.
///
/// This is the keystroke-handler orchestrator: it classifies the
/// key, consults the mirror's readiness + paint-eligibility for the
/// active buffer, and either returns a `FrontendEvent::CrdtOp`
/// (after applying the edit to the local mirror) or a
/// `FrontendEvent::Key` (round-trip path: original keystroke is
/// forwarded as before).
///
/// # Decision flow
///
/// 1. `classify_key(key, mods)` → `OptimisticAction`.
/// 2. `RoundTrip` → return `FrontendEvent::Key`.
/// 3. `Insert(c)` / `DeleteBack` / `DeleteForward`:
/// - Read `mirror.active_buffer()`. None → fall through to
/// `FrontendEvent::Key` (no active buffer known; Refinement 4
/// graceful degradation).
/// - Check `mirror.is_ready(active_buffer)`. False → fall
/// through (mirror not bootstrapped for this buffer).
/// - Check `mirror.cursor_byte_pos(active_buffer)`. None → fall
/// through (cursor position unknown).
/// - **Paint-eligibility gate (post-audit-round-3 F19):**
/// - `Insert(c)`: require `mirror.cursor_at_end_of_line` →
/// Some(true). Mid-line insert falls through to round-trip.
/// - `DeleteBack`: require
/// `mirror.cursor_at_end_of_line_safe_for_delete_back` →
/// Some(true). Mid-line, line-joining, or width-unsafe
/// delete-back falls through to round-trip.
/// - `DeleteForward`: ALWAYS round-trip (no optimistic paint
/// primitive exists for delete-forward; advancing the
/// mirror without painting de-syncs the mirror cursor from
/// the terminal cursor).
/// - Apply the action to the mirror. Errors fall through.
/// - Return `FrontendEvent::CrdtOp { source: my_fid, buffer_id,
/// op: CrdtOp { peer_id, bytes } }`.
///
/// The caller writes whatever `FrontendEvent` is returned. Non-
/// paint-eligible actions round-trip via the daemon's Key path; the
/// daemon's `apply_active_edit` emits a `DaemonKey`-origin CRDT op
/// (F16) which broadcasts to every replica including the source.
/// The source mirror updates via the broadcast, keeping mirror state
/// and terminal cursor coherent.
///
/// # F19 motivation
///
/// Pre-fix, the orchestrator advanced mirror state for non-paint
/// edits (mid-line insert, mid-line backspace, all delete-forward).
/// The terminal cursor stayed at its pre-edit position (no paint
/// primitive fires) until the daemon's `CellDelta` arrived. A fast
/// next keystroke would then run optimistic logic against the
/// already-advanced mirror — `cursor_at_end_of_line` could report
/// "yes" at the mirror's new cursor while the terminal cursor was
/// at the OLD pre-edit position, causing `paint_optimistic_insert`
/// to write at the wrong column.
#[must_use]
pub fn frontend_event_for_keystroke(
mirror: &mut BufferMirror,
my_fid: FrontendId,
pmacs_key: KeyEvent,
) -> FrontendEvent {
let action = classify_key(pmacs_key.key, pmacs_key.mods);
let round_trip = || FrontendEvent::Key(pmacs_key);
if matches!(action, OptimisticAction::RoundTrip) {
return round_trip();
}
// Need: active buffer + mirror ready for it. The cursor-position
// and cursor-freshness checks only apply to position-targeted
// actions (Insert / DeleteBack); Undo reverses the last op by
// peer regardless of cursor position, so it skips those gates.
let Some(buffer_id) = mirror.active_buffer() else {
return round_trip();
};
if !mirror.is_ready(buffer_id) {
return round_trip();
}
// M10.11 P1 — undo's optimistic path. Undo doesn't depend on
// cursor position or paint eligibility (stance α: no visual
// paint for optimistic undo; daemon's CellDelta drives
// reconciliation). The undo affects content at arbitrary
// positions; `apply_local_undo` marks the cursor stale so
// subsequent optimistic keystrokes round-trip until the daemon's
// `CursorByte` re-grounds.
if matches!(action, OptimisticAction::Undo) {
return match mirror.apply_local_undo(buffer_id) {
Ok(Some(op_bytes)) => FrontendEvent::CrdtOp {
frontend_id: my_fid,
buffer_id,
op: CrdtOp {
peer_id: mirror.peer_id(),
bytes: op_bytes,
},
},
// Nothing to undo locally (UndoManager stack empty) or
// loro error. Round-trip the Key event; the daemon's
// dispatch_key may have its own daemon-peer ops to undo
// (Lua-driven daemon-side edits), so the Key path remains
// the right fallback. If the daemon also has nothing, the
// path silently no-ops — same as v0.1.
Ok(None) | Err(_) => round_trip(),
};
}
// Position-targeted actions need authoritative cursor state
// (post-audit-round-4 F22 + F23 freshness invariant). A stale
// cursor means the mirror's cursor for this buffer hasn't been
// re-grounded by the daemon's `CursorByte` since the last
// potential desync.
if !mirror.is_cursor_fresh(buffer_id) {
return round_trip();
}
let Some(cursor) = mirror.cursor_byte_pos(buffer_id) else {
return round_trip();
};
let result = match action {
OptimisticAction::Insert(c) => {
// F19 — only optimistic-apply when paint will fire.
// Mid-line insert can't be painted by a single Print
// (cells to the right of cursor would need to shift),
// so we round-trip and let the daemon's CellDelta drive
// both visual and mirror update.
if mirror.cursor_at_end_of_line(buffer_id) != Some(true) {
return round_trip();
}
// Post-audit-round-4 F24 — `paint_optimistic_insert`
// calls `queue!(Print(c))` which writes one terminal
// column. Wide chars (width 2) would only paint the
// base column without the Continuation cell; zero-
// width chars (combining marks, ZWJ) write a column
// for what `TextView` renders as a cluster attached to
// the previous cell. Either mismatch leaves the
// terminal out of sync with what the daemon's
// eventual `CellDelta` will paint. Width-unsafe
// inserts round-trip; the daemon's `CellDelta` drives
// both visual + mirror update.
if UnicodeWidthChar::width(c) != Some(1) {
return round_trip();
}
let mut tmp = [0u8; 4];
let s: &str = c.encode_utf8(&mut tmp);
let s_len = s.len();
mirror
.apply_local_insert(buffer_id, cursor, s)
.inspect(|_bytes| {
mirror.advance_cursor(buffer_id, s_len);
})
}
OptimisticAction::DeleteBack => {
// F19 — only optimistic-apply when the strict delete-
// back predicate holds (end-of-line + prev char not '\n'
// + prev char width == 1). Round-trip otherwise.
if mirror.cursor_at_end_of_line_safe_for_delete_back(buffer_id) != Some(true) {
return round_trip();
}
match mirror.prev_char_len(buffer_id) {
Some(n) if n > 0 => {
let new_pos = cursor.saturating_sub(n);
mirror
.apply_local_delete(buffer_id, new_pos, n)
.inspect(|_bytes| {
mirror.retreat_cursor(buffer_id, n);
})
}
_ => return round_trip(),
}
}
OptimisticAction::DeleteForward => {
// F19 — no optimistic paint primitive exists for
// delete-forward. Always round-trip. Mirror state stays
// coherent via the daemon's broadcast (F16 ensures the
// source receives it).
return round_trip();
}
OptimisticAction::Undo => unreachable!("Undo handled above"),
OptimisticAction::RoundTrip => unreachable!("RoundTrip handled above"),
};
match result {
Ok(op_bytes) => FrontendEvent::CrdtOp {
frontend_id: my_fid,
buffer_id,
op: CrdtOp {
peer_id: mirror.peer_id(),
bytes: op_bytes,
},
},
Err(_) => {
// Optimistic application failed (e.g., mid-codepoint or
// out-of-range position — shouldn't happen given the
// char-aware byte counts above, but defensive). Fall
// through to v0.1 round-trip.
round_trip()
}
}
}
/// Outcome of routing an incoming `InstanceMessage::CrdtOp` through
/// the echo-dedup filter.
#[derive(Debug, Eq, PartialEq)]
pub enum IncomingCrdtOpOutcome {
/// Op was applied to the mirror (source frontend was not us).
Applied,
/// Op was filtered out as a local-edit echo (source frontend
/// was us; the mirror has already integrated this op via
/// `apply_local_insert` / `apply_local_delete`).
SkippedEcho,
}
/// Handle an incoming `CrdtOp` broadcast: filter own-echoes; apply
/// remote ops to the mirror.
///
/// **Filter rule (matches `BufferMirror::apply_remote_op` docstring):**
/// op is an echo iff `source == local_id`. The filter lives at this
/// call site (not in `BufferMirror`) because the mirror is
/// identity-ignorant by design — it operates on op bytes only.
/// Pushing the filter here keeps the mirror reusable in non-session
/// contexts (tests, future Lua bindings) and concentrates the
/// `FrontendId` comparison in one place.
///
/// # Errors
///
/// Propagates [`BufferMirrorError`] from the underlying mirror call.
/// Echo-skip never errors.
pub fn apply_incoming_crdt_op(
mirror: &mut BufferMirror,
local_id: FrontendId,
source: FrontendId,
buffer_id: BufferId,
op_bytes: &[u8],
) -> Result<IncomingCrdtOpOutcome, BufferMirrorError> {
if source == local_id {
return Ok(IncomingCrdtOpOutcome::SkippedEcho);
}
mirror.apply_remote_op(buffer_id, op_bytes)?;
Ok(IncomingCrdtOpOutcome::Applied)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crdt::CrdtState;
// -----------------------------------------------------------------
// classify_key — the text-input predicate.
// -----------------------------------------------------------------
#[test]
fn classify_ascii_char_no_modifiers_is_insert() {
assert_eq!(
classify_key(Key::Char('a'), Modifiers::NONE),
OptimisticAction::Insert('a')
);
}
#[test]
fn classify_ascii_char_with_shift_is_still_insert() {
// Capital letters arrive with SHIFT set; the char itself is
// already the shifted version.
assert_eq!(
classify_key(Key::Char('A'), Modifiers::SHIFT),
OptimisticAction::Insert('A')
);
}
#[test]
fn classify_ascii_char_with_ctrl_is_round_trip() {
// Ctrl+a is a command, not text input.
assert_eq!(
classify_key(Key::Char('a'), Modifiers::CTRL),
OptimisticAction::RoundTrip
);
}
#[test]
fn classify_ascii_char_with_alt_is_round_trip() {
assert_eq!(
classify_key(Key::Char('a'), Modifiers::ALT),
OptimisticAction::RoundTrip
);
}
#[test]
fn classify_ascii_char_with_meta_is_round_trip() {
assert_eq!(
classify_key(Key::Char('a'), Modifiers::META),
OptimisticAction::RoundTrip
);
}
#[test]
fn classify_ascii_char_with_hyper_is_round_trip() {
assert_eq!(
classify_key(Key::Char('a'), Modifiers::HYPER),
OptimisticAction::RoundTrip
);
}
#[test]
fn classify_space_no_modifiers_is_insert() {
assert_eq!(
classify_key(Key::Char(' '), Modifiers::NONE),
OptimisticAction::Insert(' ')
);
}
#[test]
fn classify_punctuation_no_modifiers_is_insert() {
for c in ['.', ',', ';', ':', '!', '?', '@', '#', '$', '%'] {
assert_eq!(
classify_key(Key::Char(c), Modifiers::NONE),
OptimisticAction::Insert(c)
);
}
}
#[test]
fn classify_builtin_pair_chars_round_trip() {
// Auto-pairing Q#AP1: the nine built-in pair chars must reach
// the daemon's dispatch so the opener and the hook's closer are
// adjacent daemon-peer undo units. Both modifier shapes real
// keyboards produce are pinned: `[`/`]`/`'`/`` ` `` arrive
// unshifted, `(`/`)`/`{`/`}`/`"` arrive with SHIFT set — a gate
// that only caught `Modifiers::NONE` would leak every shifted
// pair char back onto the optimistic path.
for c in crate::protocol::BUILTIN_PAIR_CHARS {
assert_eq!(
classify_key(Key::Char(c), Modifiers::NONE),
OptimisticAction::RoundTrip,
"unshifted {c:?} must round-trip"
);
assert_eq!(
classify_key(Key::Char(c), Modifiers::SHIFT),
OptimisticAction::RoundTrip,
"shifted {c:?} must round-trip"
);
}
}
#[test]
fn classify_unicode_char_no_modifiers_is_insert() {
// Non-ASCII printable — multi-byte UTF-8.
assert_eq!(
classify_key(Key::Char('é'), Modifiers::NONE),
OptimisticAction::Insert('é')
);
assert_eq!(
classify_key(Key::Char('中'), Modifiers::NONE),
OptimisticAction::Insert('中')
);
}
#[test]
fn classify_backspace_no_modifiers_is_delete_back() {
assert_eq!(
classify_key(Key::Backspace, Modifiers::NONE),
OptimisticAction::DeleteBack
);
}
#[test]
fn classify_backspace_with_shift_is_still_delete_back() {
// Shift+Backspace behaves the same as Backspace in default
// keymap; Shift doesn't change the semantic.
assert_eq!(
classify_key(Key::Backspace, Modifiers::SHIFT),
OptimisticAction::DeleteBack
);
}
#[test]
fn classify_backspace_with_ctrl_is_round_trip() {
// Ctrl+Backspace is often "delete previous word" — keymap
// territory, not optimistic text-input.
assert_eq!(
classify_key(Key::Backspace, Modifiers::CTRL),
OptimisticAction::RoundTrip
);
}
#[test]
fn classify_delete_no_modifiers_is_delete_forward() {
assert_eq!(
classify_key(Key::Delete, Modifiers::NONE),
OptimisticAction::DeleteForward
);
}
#[test]
fn classify_delete_with_ctrl_is_round_trip() {
assert_eq!(
classify_key(Key::Delete, Modifiers::CTRL),
OptimisticAction::RoundTrip
);
}
#[test]
fn classify_function_key_is_round_trip() {
for n in 1u8..=12 {
assert_eq!(
classify_key(Key::F(n), Modifiers::NONE),
OptimisticAction::RoundTrip
);
}
}
#[test]
fn classify_navigation_keys_are_round_trip() {
for key in [
Key::Left,
Key::Right,
Key::Up,
Key::Down,
Key::Home,
Key::End,
Key::PageUp,
Key::PageDown,
] {
assert_eq!(
classify_key(key, Modifiers::NONE),
OptimisticAction::RoundTrip
);
}
}
#[test]
fn classify_enter_and_tab_are_round_trip() {
// Enter and Tab are arguably text input but often have
// editor-specific semantics (newline-with-indent, indent
// command). Send via round-trip so keymap can dispatch.
assert_eq!(
classify_key(Key::Enter, Modifiers::NONE),
OptimisticAction::RoundTrip
);
assert_eq!(
classify_key(Key::Tab, Modifiers::NONE),
OptimisticAction::RoundTrip
);
}
#[test]
fn classify_escape_is_round_trip() {
assert_eq!(
classify_key(Key::Escape, Modifiers::NONE),
OptimisticAction::RoundTrip
);
}
#[test]
fn classify_unknown_key_is_round_trip() {
assert_eq!(
classify_key(Key::Unknown(0x1234), Modifiers::NONE),
OptimisticAction::RoundTrip
);
}
#[test]
fn classify_null_char_is_round_trip() {
// Char('\0') is control; should not optimistic-insert a NUL.
assert_eq!(
classify_key(Key::Char('\0'), Modifiers::NONE),
OptimisticAction::RoundTrip
);
}
// -----------------------------------------------------------------
// apply_incoming_crdt_op — echo-dedup composition test.
// -----------------------------------------------------------------
/// Helper: build a snapshot of a small CRDT replica seeded with
/// the given text under `peer_id`.
fn fresh_snapshot(peer_id: u64, initial: &str) -> Vec<u8> {
let state = CrdtState::new(peer_id).expect("new");
state.insert(0, initial).expect("seed");
state.export_snapshot().expect("export")
}
// -----------------------------------------------------------------
// frontend_event_for_keystroke — Day 3 step 3b orchestrator.
// -----------------------------------------------------------------
fn key_event(key: Key, mods: Modifiers, fid: FrontendId) -> KeyEvent {
KeyEvent {
frontend_id: fid,
key,
mods,
timestamp_ns: 0,
}
}
fn ready_mirror_with_cursor(initial: &str, cursor: usize) -> (BufferMirror, BufferId) {
let fid = FrontendId(2);
let mut m = BufferMirror::new(fid);
let id = BufferId::next();
m.init_from_snapshot(id, &fresh_snapshot(99, initial))
.expect("init");
m.set_cursor_byte_pos(id, cursor);
(m, id)
}
#[test]
fn keystroke_round_trips_when_mirror_has_no_active_buffer() {
let mut m = BufferMirror::new(FrontendId(2));
let fid = FrontendId(2);
let ev = key_event(Key::Char('a'), Modifiers::NONE, fid);
let result = frontend_event_for_keystroke(&mut m, fid, ev);
match result {
FrontendEvent::Key(_) => {} // round-trip
other => panic!("expected Key (graceful fallback), got {other:?}"),
}
}
#[test]
fn keystroke_round_trips_when_buffer_not_ready() {
let fid = FrontendId(2);
let mut m = BufferMirror::new(fid);
// Set cursor for a buffer that has no snapshot yet — mirror
// tracks the cursor but is_ready returns false.
let id = BufferId::next();
m.set_cursor_byte_pos(id, 5);
assert!(!m.is_ready(id));
let ev = key_event(Key::Char('a'), Modifiers::NONE, fid);
let result = frontend_event_for_keystroke(&mut m, fid, ev);
match result {
FrontendEvent::Key(_) => {} // round-trip per Refinement 4
other => panic!("expected Key (graceful fallback), got {other:?}"),
}
}
#[test]
fn keystroke_round_trips_for_non_text_input() {
// Ctrl+a — not text input; round-trip regardless of mirror
// state.
let (mut m, _id) = ready_mirror_with_cursor("hello", 5);
let fid = FrontendId(2);
let ev = key_event(Key::Char('a'), Modifiers::CTRL, fid);
let result = frontend_event_for_keystroke(&mut m, fid, ev);
match result {
FrontendEvent::Key(k) => {
assert!(k.mods.contains(Modifiers::CTRL));
}
other => panic!("expected Key for non-text-input, got {other:?}"),
}
}
#[test]
fn keystroke_optimistic_insert_produces_crdt_op_and_advances_cursor() {
let (mut m, id) = ready_mirror_with_cursor("hello", 5);
let fid = FrontendId(2);
let ev = key_event(Key::Char('!'), Modifiers::NONE, fid);
let result = frontend_event_for_keystroke(&mut m, fid, ev);
match result {
FrontendEvent::CrdtOp {
frontend_id,
buffer_id,
op,
} => {
assert_eq!(frontend_id, fid);
assert_eq!(buffer_id, id);
assert_eq!(op.peer_id, 2);
assert!(!op.bytes.is_empty());
}
other => panic!("expected CrdtOp, got {other:?}"),
}
// Mirror state advanced.
assert_eq!(m.materialize(id).as_deref(), Some("hello!"));
assert_eq!(m.cursor_byte_pos(id), Some(6));
}
#[test]
fn keystroke_optimistic_insert_with_multibyte_char_advances_by_byte_length() {
let (mut m, id) = ready_mirror_with_cursor("ab", 2);
let fid = FrontendId(2);
let ev = key_event(Key::Char('é'), Modifiers::NONE, fid); // 2 bytes
let _ = frontend_event_for_keystroke(&mut m, fid, ev);
assert_eq!(m.materialize(id).as_deref(), Some("abé"));
// Cursor advanced by 2 (UTF-8 byte length).
assert_eq!(m.cursor_byte_pos(id), Some(4));
}
#[test]
fn keystroke_optimistic_delete_back_ascii_removes_one_byte() {
let (mut m, id) = ready_mirror_with_cursor("hello", 5);
let fid = FrontendId(2);
let ev = key_event(Key::Backspace, Modifiers::NONE, fid);
let result = frontend_event_for_keystroke(&mut m, fid, ev);
match result {
FrontendEvent::CrdtOp { .. } => {}
other => panic!("expected CrdtOp, got {other:?}"),
}
assert_eq!(m.materialize(id).as_deref(), Some("hell"));
assert_eq!(m.cursor_byte_pos(id), Some(4));
}
#[test]
fn keystroke_optimistic_delete_back_multibyte_removes_full_char() {
// Char-boundary-aware: deleting back from after 'é' removes
// both UTF-8 bytes, not just one (loro rejects mid-codepoint).
let (mut m, id) = ready_mirror_with_cursor("", 3);
let fid = FrontendId(2);
let ev = key_event(Key::Backspace, Modifiers::NONE, fid);
let _ = frontend_event_for_keystroke(&mut m, fid, ev);
assert_eq!(m.materialize(id).as_deref(), Some("a"));
assert_eq!(m.cursor_byte_pos(id), Some(1));
}
#[test]
fn keystroke_optimistic_delete_back_at_position_zero_round_trips() {
let (mut m, id) = ready_mirror_with_cursor("hello", 0);
let fid = FrontendId(2);
let ev = key_event(Key::Backspace, Modifiers::NONE, fid);
let result = frontend_event_for_keystroke(&mut m, fid, ev);
match result {
FrontendEvent::Key(_) => {} // round-trip — nothing to delete
other => panic!("expected Key at pos 0, got {other:?}"),
}
// Mirror untouched.
assert_eq!(m.materialize(id).as_deref(), Some("hello"));
}
#[test]
fn keystroke_optimistic_delete_forward_at_end_round_trips() {
let (mut m, id) = ready_mirror_with_cursor("hello", 5);
let fid = FrontendId(2);
let ev = key_event(Key::Delete, Modifiers::NONE, fid);
let result = frontend_event_for_keystroke(&mut m, fid, ev);
match result {
FrontendEvent::Key(_) => {} // round-trip — nothing to delete forward
other => panic!("expected Key at end, got {other:?}"),
}
assert_eq!(m.materialize(id).as_deref(), Some("hello"));
}
/// Post-audit-round-3 F19: `OptimisticAction::DeleteForward`
/// **always** round-trips. No paint primitive exists for
/// forward-delete, so optimistically applying to the mirror
/// without painting would desync the mirror cursor from the
/// terminal cursor. The daemon's `CellDelta` + the F16 broadcast
/// keep both sides in sync via round-trip.
#[test]
fn keystroke_optimistic_delete_forward_always_round_trips_f19() {
let (mut m, id) = ready_mirror_with_cursor("hello", 1);
let fid = FrontendId(2);
let ev = key_event(Key::Delete, Modifiers::NONE, fid);
let result = frontend_event_for_keystroke(&mut m, fid, ev);
match result {
FrontendEvent::Key(_) => {}
other => panic!("F19: DeleteForward must round-trip; got {other:?}"),
}
// Mirror MUST be untouched — F19 narrowing prevents the
// mirror from advancing on round-tripped edits.
assert_eq!(m.materialize(id).as_deref(), Some("hello"));
assert_eq!(m.cursor_byte_pos(id), Some(1));
}
/// F19: `OptimisticAction::Insert` mid-line round-trips
/// (paint scope is end-of-line only; advancing the mirror
/// mid-line desyncs from the terminal cursor).
#[test]
fn keystroke_optimistic_insert_mid_line_round_trips_f19() {
let (mut m, id) = ready_mirror_with_cursor("hello", 2); // mid-line
let fid = FrontendId(2);
let ev = key_event(Key::Char('X'), Modifiers::NONE, fid);
let result = frontend_event_for_keystroke(&mut m, fid, ev);
match result {
FrontendEvent::Key(_) => {}
other => panic!("F19: mid-line Insert must round-trip; got {other:?}"),
}
assert_eq!(m.materialize(id).as_deref(), Some("hello"));
assert_eq!(m.cursor_byte_pos(id), Some(2));
}
/// F24 (post-audit-round-4) — wide char (`UnicodeWidthChar::width
/// == Some(2)`) Insert round-trips. The optimistic paint
/// `queue!(Print(c))` writes one column; the daemon's eventual
/// `CellDelta` paints two cells (base + Continuation).
#[test]
fn keystroke_optimistic_insert_wide_char_round_trips_f24() {
let (mut m, id) = ready_mirror_with_cursor("hello", 5);
let fid = FrontendId(2);
// CJK ideograph (width 2).
let ev = key_event(Key::Char('漢'), Modifiers::NONE, fid);
let result = frontend_event_for_keystroke(&mut m, fid, ev);
match result {
FrontendEvent::Key(_) => {}
other => panic!("F24: wide-char Insert must round-trip; got {other:?}"),
}
assert_eq!(m.materialize(id).as_deref(), Some("hello"));
assert_eq!(m.cursor_byte_pos(id), Some(5));
}
/// F24 — combining mark / zero-width Insert round-trips. The
/// paint writes a column for what `TextView` renders as a
/// cluster attached to the previous cell.
#[test]
fn keystroke_optimistic_insert_combining_mark_round_trips_f24() {
let (mut m, id) = ready_mirror_with_cursor("hello", 5);
let fid = FrontendId(2);
// U+0301 COMBINING ACUTE ACCENT (width 0).
let ev = key_event(Key::Char('\u{0301}'), Modifiers::NONE, fid);
let result = frontend_event_for_keystroke(&mut m, fid, ev);
match result {
FrontendEvent::Key(_) => {}
other => panic!("F24: zero-width combining-mark Insert must round-trip; got {other:?}"),
}
assert_eq!(m.materialize(id).as_deref(), Some("hello"));
}
/// F22 (post-audit-round-4) — when the mirror cursor is stale
/// (a prior `FrontendEvent::Key` may have moved the daemon's
/// cursor in ways the mirror can't predict), the orchestrator
/// must round-trip subsequent keystrokes until `CursorByte`
/// re-grounds the mirror cursor. Otherwise a fast `<left>` then
/// `x` sequence emits an Insert `CrdtOp` for the byte the cursor
/// was at BEFORE `<left>`.
#[test]
fn keystroke_round_trips_when_cursor_is_stale_f22() {
let (mut m, id) = ready_mirror_with_cursor("hello", 5);
m.mark_cursor_stale(id);
let fid = FrontendId(2);
let ev = key_event(Key::Char('x'), Modifiers::NONE, fid);
let result = frontend_event_for_keystroke(&mut m, fid, ev);
match result {
FrontendEvent::Key(_) => {}
other => panic!("F22: stale cursor must round-trip subsequent keys; got {other:?}"),
}
// Mirror untouched.
assert_eq!(m.materialize(id).as_deref(), Some("hello"));
assert_eq!(m.cursor_byte_pos(id), Some(5));
}
/// F23 (post-audit-round-4) — `apply_remote_op` marks the cursor
/// stale. A keystroke between the remote op and the next
/// `CursorByte` must round-trip.
#[test]
fn keystroke_round_trips_after_remote_op_until_cursor_byte_f23() {
let (mut m, id) = ready_mirror_with_cursor("hello", 5);
// Remote op: peer inserts "X" at position 0.
let peer = CrdtState::new(99).expect("peer");
peer.import_snapshot(&fresh_snapshot(99, "hello"))
.expect("peer init");
let v0 = peer.version();
peer.insert(0, "X").expect("peer insert");
let op_bytes = peer.export_updates_since(&v0).expect("export");
m.apply_remote_op(id, &op_bytes).expect("apply remote");
// Mirror content now "Xhello"; mirror cursor still at 5
// (no right-gravity adjustment). Without F23 the
// orchestrator would optimistically insert at byte 5 of
// "Xhello" — wrong byte for the user's intent.
let fid = FrontendId(2);
let ev = key_event(Key::Char('!'), Modifiers::NONE, fid);
let result = frontend_event_for_keystroke(&mut m, fid, ev);
match result {
FrontendEvent::Key(_) => {}
other => panic!("F23: post-remote-op cursor staleness must round-trip; got {other:?}"),
}
// After daemon's CursorByte re-grounds the mirror, optimistic
// path reopens.
m.set_cursor_byte_pos(id, 6); // post-right-gravity position
let result2 = frontend_event_for_keystroke(&mut m, fid, ev);
match result2 {
FrontendEvent::CrdtOp { .. } => {}
other => panic!("F23: CursorByte must clear staleness; got {other:?}"),
}
}
/// F19: `OptimisticAction::DeleteBack` mid-line round-trips.
#[test]
fn keystroke_optimistic_delete_back_mid_line_round_trips_f19() {
let (mut m, id) = ready_mirror_with_cursor("hello", 3); // mid-line
let fid = FrontendId(2);
let ev = key_event(Key::Backspace, Modifiers::NONE, fid);
let result = frontend_event_for_keystroke(&mut m, fid, ev);
match result {
FrontendEvent::Key(_) => {}
other => panic!("F19: mid-line DeleteBack must round-trip; got {other:?}"),
}
assert_eq!(m.materialize(id).as_deref(), Some("hello"));
assert_eq!(m.cursor_byte_pos(id), Some(3));
}
/// T M10.10 Day 4 — criterion 1 unit acceptance: keystroke-to-
/// optimistic-apply completes in sub-frame time regardless of
/// any daemon state.
///
/// Spec criterion 1: "Local edit visible in less than one frame
/// regardless of instance latency."
///
/// The load-bearing property under Path β is that the orchestrator
/// completes synchronously — it doesn't wait for the daemon, doesn't
/// poll, doesn't block on I/O. Mirror update is in-process; produced
/// `FrontendEvent` is returned by value. Latency to the daemon
/// affects when the daemon's `CellDelta` arrives back, but doesn't
/// affect the orchestrator's completion time.
///
/// This test demonstrates the property directly without involving
/// a daemon: 100 consecutive keystrokes through the orchestrator
/// must complete in well under 16ms (one frame at 60Hz). In
/// practice each call is microseconds; the upper bound is
/// generous to avoid CI flakiness.
///
/// **Path β scope**: this test exercises end-of-line typing
/// (cursor at content end after each insert). Mid-line typing
/// would round-trip and incur daemon-latency for paint per Path
/// β's documented scope.
#[test]
fn criterion_1_end_of_line_typing_completes_sub_frame_per_keystroke() {
use std::time::Instant;
let (mut m, _id) = ready_mirror_with_cursor("", 0);
let fid = FrontendId(2);
let start = Instant::now();
for c in "the quick brown fox jumps over the lazy dog".chars() {
let ev = key_event(Key::Char(c), Modifiers::NONE, fid);
let result = frontend_event_for_keystroke(&mut m, fid, ev);
// Verify each keystroke took the optimistic path —
// criterion 1 isn't met if the orchestrator falls through
// to FrontendEvent::Key (which would await daemon round-
// trip).
assert!(
matches!(result, FrontendEvent::CrdtOp { .. }),
"criterion 1: orchestrator must produce CrdtOp for end-of-line \
text input (not round-trip Key)"
);
}
let elapsed = start.elapsed();
let per_keystroke = elapsed / 43; // length of the pangram
// Upper bound per keystroke: 1ms (60× under frame budget).
// Loose because CI runners vary; tight enough to catch any
// synchronous-IO regression that would put criterion 1 at risk.
assert!(
per_keystroke < std::time::Duration::from_millis(1),
"criterion 1: per-keystroke orchestrator time {per_keystroke:?} \
exceeds 1ms (well below 16ms frame budget). Total: {elapsed:?}"
);
}
/// T M10.10 Day 3 step 6 — bootstrap-window typing test.
///
/// Narrates the bootstrap state machine that the M10.10 frontend
/// goes through on attach. The "bootstrap window" is the time
/// between session establishment and the frontend's mirror being
/// ready for optimistic apply — during this window, keystrokes
/// must gracefully degrade to v0.1 round-trip (Refinement 4).
/// After the bootstrap completes (`BufferSnapshot` + `CursorByte`
/// applied), subsequent keystrokes take the optimistic path.
///
/// This test walks the explicit transitions a real attach would
/// experience (modulo the inter-thread message delivery the wire
/// transports do):
/// 1. Pre-bootstrap: empty mirror, type 'h' → `FrontendEvent::Key`.
/// 2. `BufferSnapshot` arrives (`init_from_snapshot`): mirror has
/// state but no active buffer / cursor yet. Type 'i' → still
/// Key (no `active_buffer` until `CursorByte` arrives).
/// 3. `CursorByte` arrives (`set_cursor_byte_pos`): mirror has
/// `active_buffer` + cursor; the optimistic predicate now fires
/// for in-scope keystrokes.
/// 4. Post-bootstrap: type 'j' → `FrontendEvent::CrdtOp`.
///
/// Each transition is the boundary that step 3a's wire variants
/// (`BufferSnapshot`, `CursorByte`) and step 3b's orchestrator
/// jointly enforce. The narration matters because the bootstrap-
/// window race condition isn't deterministic at the daemon-e2e
/// level (the inter-thread message-drain ordering on the
/// receiver) — exercising the orchestrator's state machine
/// directly makes the contract observable.
#[test]
fn bootstrap_window_keystrokes_round_trip_until_mirror_ready() {
let fid = FrontendId(2);
let id = BufferId::next();
let mut m = BufferMirror::new(fid);
// -----------------------------------------------------------
// State 1: pre-bootstrap. Empty mirror; no buffers; no active
// buffer; no cursor. Refinement 4 graceful-degradation kicks
// in: keystrokes that would otherwise be optimistic must
// fall through to FrontendEvent::Key.
// -----------------------------------------------------------
assert!(m.active_buffer().is_none());
assert!(!m.is_ready(id));
let ev = key_event(Key::Char('h'), Modifiers::NONE, fid);
let result_1 = frontend_event_for_keystroke(&mut m, fid, ev);
match result_1 {
FrontendEvent::Key(_) => {} // expected — graceful fallback
other => panic!("pre-bootstrap keystroke must round-trip via Key; got {other:?}"),
}
// -----------------------------------------------------------
// State 2: BufferSnapshot processed. Mirror has CRDT state
// for `id`, but the daemon hasn't yet emitted CursorByte —
// active_buffer is still None. The orchestrator must still
// round-trip because there's no active buffer to target.
// -----------------------------------------------------------
m.init_from_snapshot(id, &fresh_snapshot(99, "abc"))
.expect("init from BufferSnapshot");
assert!(m.is_ready(id));
assert!(m.active_buffer().is_none()); // CursorByte hasn't fired yet
let ev = key_event(Key::Char('i'), Modifiers::NONE, fid);
let result_2 = frontend_event_for_keystroke(&mut m, fid, ev);
match result_2 {
FrontendEvent::Key(_) => {} // expected — no active_buffer
other => panic!(
"post-BufferSnapshot but pre-CursorByte keystroke must round-trip; \
got {other:?}"
),
}
// -----------------------------------------------------------
// State 3: CursorByte processed. Mirror has active_buffer +
// cursor byte position. The optimistic predicate can now
// fire for in-scope keystrokes. Bootstrap is complete.
// -----------------------------------------------------------
m.set_cursor_byte_pos(id, 3); // cursor at end of "abc"
assert_eq!(m.active_buffer(), Some(id));
assert_eq!(m.cursor_byte_pos(id), Some(3));
// -----------------------------------------------------------
// State 4: post-bootstrap typing. Optimistic path active;
// keystroke produces FrontendEvent::CrdtOp.
// -----------------------------------------------------------
let ev = key_event(Key::Char('j'), Modifiers::NONE, fid);
let result_3 = frontend_event_for_keystroke(&mut m, fid, ev);
match result_3 {
FrontendEvent::CrdtOp {
frontend_id,
buffer_id,
op,
} => {
assert_eq!(frontend_id, fid);
assert_eq!(buffer_id, id);
assert_eq!(op.peer_id, fid.0);
assert!(!op.bytes.is_empty());
}
other => {
panic!("post-bootstrap optimistic keystroke must produce CrdtOp; got {other:?}")
}
}
// Mirror state reflects the optimistic apply.
assert_eq!(m.materialize(id).as_deref(), Some("abcj"));
assert_eq!(m.cursor_byte_pos(id), Some(4));
}
/// The canonical Day 3 echo-dedup test: a local op echoed back
/// from the daemon (tagged with our `FrontendId`) must be skipped;
/// a remote op (tagged with a different `FrontendId`) must apply.
///
/// Symmetric coverage — single test exercises both filter
/// directions so a one-sided filter bug (filters everything OR
/// filters nothing) fails the test regardless of which way it
/// breaks.
#[test]
fn echo_dedup_skips_own_op_but_applies_remote() {
// Frontend A's FrontendId is 2; peer_id_from_frontend(2) == 2.
let local_id = FrontendId(2);
let remote_id = FrontendId(7);
let buffer_id = BufferId::next();
// Bootstrap: A's mirror initializes from a snapshot containing
// "abc" (the daemon-side initial state).
let snap = fresh_snapshot(0xABCD, "abc");
let mut mirror = BufferMirror::new(local_id);
mirror.init_from_snapshot(buffer_id, &snap).expect("init");
assert_eq!(mirror.materialize(buffer_id).as_deref(), Some("abc"));
// A types 'X' — apply_local_insert produces wire-format op
// bytes attributable to A's peer_id.
let local_op_bytes = mirror
.apply_local_insert(buffer_id, 3, "X")
.expect("local insert");
assert_eq!(mirror.materialize(buffer_id).as_deref(), Some("abcX"));
// Echo arrives: daemon broadcasts A's op back to A (tagged
// with A's FrontendId). The filter must skip it.
let outcome = apply_incoming_crdt_op(
&mut mirror,
local_id,
local_id, // source == local → echo
buffer_id,
&local_op_bytes,
)
.expect("echo filter");
assert_eq!(outcome, IncomingCrdtOpOutcome::SkippedEcho);
// After echo: mirror unchanged (op was NOT applied a second
// time). If the filter were broken and the op double-applied,
// we'd see "abcXX" here.
assert_eq!(
mirror.materialize(buffer_id).as_deref(),
Some("abcX"),
"echoed own op must not double-apply"
);
// Now a remote edit from B (a different FrontendId) arrives.
// Build a B-side replica that has integrated A's op + B's
// own edit; we'll deliver B's op-since-A to A's mirror.
let b_state = CrdtState::new(0xBEEF).expect("b new");
b_state.import_snapshot(&snap).expect("b bootstrap");
b_state
.import_updates(&local_op_bytes)
.expect("b sees A's op");
let v_before_b_edit = b_state.version();
b_state.insert(0, "Z").expect("b insert");
let remote_op_bytes = b_state
.export_updates_since(&v_before_b_edit)
.expect("b op");
let outcome = apply_incoming_crdt_op(
&mut mirror,
local_id,
remote_id, // source != local → real remote op
buffer_id,
&remote_op_bytes,
)
.expect("remote apply");
assert_eq!(outcome, IncomingCrdtOpOutcome::Applied);
// After remote apply: B's "Z" prepended to A's mirror content.
assert_eq!(
mirror.materialize(buffer_id).as_deref(),
Some("ZabcX"),
"remote op must apply when source != local"
);
}
/// **F1 gap pin.** The manual checklist originally told operators
/// to undo with `C-x u`. That is the *wrong* keystroke for the
/// per-frontend optimistic-undo path Scenario 2 tests: only the
/// single-key forms (`Ctrl-4`, and — under Kitty enhanced mode —
/// `Ctrl-/` / `Ctrl-_`) classify as `OptimisticAction::Undo`
/// (frontend per-peer undo). `C-x` is a multi-key prefix the
/// optimistic layer has no state for; it classifies `RoundTrip`
/// and the sequence `C-x u` round-trips to the *daemon's* undo,
/// which operates on the daemon's CRDT peer and cannot isolate a
/// single frontend's edits.
///
/// This pins the gap as a tested invariant rather than prose:
/// if a future change made `C-x` optimistic, or de-classified
/// `Ctrl-4`, this fails — and the checklist's `Ctrl-4`
/// instruction (F1 fix) would silently become wrong again.
#[test]
fn f1_undo_keystroke_gap_cx_u_round_trips_only_single_key_is_optimistic() {
// The keystroke the manual checklist (post-F1) and the PTY
// test both use — reaches frontend per-peer undo.
assert_eq!(
classify_key(Key::Char('4'), Modifiers::CTRL),
OptimisticAction::Undo,
"Ctrl-4 must be the frontend per-peer optimistic undo \
(raw-terminal-deliverable; what the checklist now uses)"
);
// Kitty-enhanced-mode forms — also optimistic undo (only
// delivered when Kitty negotiation lands; v0.2).
assert_eq!(
classify_key(Key::Char('/'), Modifiers::CTRL),
OptimisticAction::Undo
);
assert_eq!(
classify_key(Key::Char('_'), Modifiers::CTRL),
OptimisticAction::Undo
);
// `C-x` — the prefix of the OLD (wrong) checklist instruction
// `C-x u`. Round-trips; the optimistic layer has no multi-key
// prefix state, so `C-x u` can NEVER compose to frontend
// per-peer undo — it reaches daemon undo, which Scenario 2's
// per-frontend-isolation claim is not about.
assert_eq!(
classify_key(Key::Char('x'), Modifiers::CTRL),
OptimisticAction::RoundTrip,
"C-x must round-trip — it's the daemon-undo prefix, NOT \
frontend per-peer undo; this is why the checklist had \
to switch from C-x u to Ctrl-4 (F1)"
);
// The lone `u` after `C-x`, seen in isolation by the
// stateless optimistic layer, is just text — confirming no
// prefix-composition path to undo exists.
assert_eq!(
classify_key(Key::Char('u'), Modifiers::NONE),
OptimisticAction::Insert('u'),
"no multi-key prefix state: the 'u' in C-x u is plain \
text to the optimistic layer; C-x u cannot be \
frontend-undo by construction"
);
}
}