From ec79d60fbd765a4d9242376fe6461f8c68d98161 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Thu, 19 Mar 2026 00:40:05 -0400 Subject: [PATCH] tui: fix cursor desync by scanning rendered buffer Instead of simulating ratatui's word wrapping algorithm, scan the rendered buffer to find the actual cursor position. This correctly handles word wrapping, unicode widths, and any other rendering nuances that ratatui applies. The old code computed wrapped_height() and cursor position based on simple character counting, which diverged from ratatui's WordWrapper that respects word boundaries. Now we render first, then walk the buffer counting visible characters until we reach self.cursor. This is O(area) but the input area is small (typically < 200 cells), so it's negligible. --- Cargo.lock | 137 ++++++------------------------------------ poc-agent/Cargo.toml | 1 + poc-agent/src/tui.rs | 45 +++++++++----- poc-memory/Cargo.toml | 2 +- 4 files changed, 52 insertions(+), 133 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c3b197f..0c3d5a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -339,12 +339,6 @@ dependencies = [ "capnp", ] -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - [[package]] name = "castaway" version = "0.2.4" @@ -447,20 +441,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "compact_str" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - [[package]] name = "compact_str" version = "0.9.0" @@ -1350,7 +1330,7 @@ version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ - "unicode-width 0.2.0", + "unicode-width", ] [[package]] @@ -1446,8 +1426,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -1810,15 +1788,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -2007,15 +1976,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "lru" version = "0.16.3" @@ -2648,13 +2608,14 @@ dependencies = [ "glob", "json5", "libc", - "ratatui 0.30.0", + "ratatui", "reqwest", "serde", "serde_json", "tiktoken-rs", "tokio", "tui-markdown", + "unicode-width", "walkdir", ] @@ -2701,7 +2662,7 @@ dependencies = [ "paste", "peg", "poc-agent", - "ratatui 0.29.0", + "ratatui", "rayon", "redb", "regex", @@ -3055,27 +3016,6 @@ dependencies = [ "rand 0.9.2", ] -[[package]] -name = "ratatui" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" -dependencies = [ - "bitflags 2.11.0", - "cassowary", - "compact_str 0.8.1", - "crossterm 0.28.1", - "indoc", - "instability", - "itertools 0.13.0", - "lru 0.12.5", - "paste", - "strum 0.26.3", - "unicode-segmentation", - "unicode-truncate 1.1.0", - "unicode-width 0.2.0", -] - [[package]] name = "ratatui" version = "0.30.0" @@ -3097,17 +3037,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ "bitflags 2.11.0", - "compact_str 0.9.0", + "compact_str", "hashbrown 0.16.1", "indoc", - "itertools 0.14.0", + "itertools", "kasuari", - "lru 0.16.3", - "strum 0.27.2", + "lru", + "strum", "thiserror 2.0.18", "unicode-segmentation", - "unicode-truncate 2.0.1", - "unicode-width 0.2.0", + "unicode-truncate", + "unicode-width", ] [[package]] @@ -3152,13 +3092,13 @@ dependencies = [ "hashbrown 0.16.1", "indoc", "instability", - "itertools 0.14.0", + "itertools", "line-clipping", "ratatui-core", - "strum 0.27.2", + "strum", "time", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width", ] [[package]] @@ -3751,35 +3691,13 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros 0.26.4", -] - [[package]] name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.27.2", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.117", + "strum_macros", ] [[package]] @@ -4366,7 +4284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e766339aabad4528c3fccddf4acf03bc2b7ae6642def41e43c7af1a11f183122" dependencies = [ "ansi-to-tui", - "itertools 0.14.0", + "itertools", "pretty_assertions", "pulldown-cmark", "ratatui-core", @@ -4414,39 +4332,22 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" -[[package]] -name = "unicode-truncate" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" -dependencies = [ - "itertools 0.13.0", - "unicode-segmentation", - "unicode-width 0.1.14", -] - [[package]] name = "unicode-truncate" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools 0.14.0", + "itertools", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width", ] [[package]] name = "unicode-width" -version = "0.1.14" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" diff --git a/poc-agent/Cargo.toml b/poc-agent/Cargo.toml index 12f6a22..8b4a97b 100644 --- a/poc-agent/Cargo.toml +++ b/poc-agent/Cargo.toml @@ -32,3 +32,4 @@ figment = { version = "0.10", features = ["env"] } json5 = "0.4" clap = { version = "4", features = ["derive"] } tui-markdown = "0.3" +unicode-width = "0.2.2" diff --git a/poc-agent/src/tui.rs b/poc-agent/src/tui.rs index 13861a5..4d69034 100644 --- a/poc-agent/src/tui.rs +++ b/poc-agent/src/tui.rs @@ -9,6 +9,7 @@ // Uses ratatui + crossterm. The App struct holds all TUI state and // handles rendering. Input is processed from crossterm key events. +use unicode_width::UnicodeWidthStr; use crossterm::{ event::{EnableMouseCapture, DisableMouseCapture, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, MouseButton}, terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, @@ -16,6 +17,7 @@ use crossterm::{ }; use ratatui::{ backend::CrosstermBackend, + buffer::Buffer, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, @@ -816,21 +818,36 @@ impl App { let input_para = Paragraph::new(input_lines).wrap(Wrap { trim: false }); frame.render_widget(input_para, input_area); - // Cursor position: walk through text up to cursor, tracking visual row/col - let cursor_text = format!("{}{}", prompt, &self.input[..self.cursor]); - let w = input_area.width as usize; - let cursor_lines: Vec<&str> = cursor_text.split('\n').collect(); - let n = cursor_lines.len(); - let mut visual_row = 0u16; - for line in &cursor_lines[..n - 1] { - visual_row += wrapped_height(line, w) as u16; + // Cursor position: scan the rendered buffer to find where the cursor should be. + // This matches ratatui's actual word wrapping instead of trying to simulate it. + let buffer = frame.buffer_mut(); + let mut char_count = 0usize; + let mut cursor_x = input_area.x; + let mut cursor_y = input_area.y; + + // Walk through the rendered buffer, counting characters until we reach the cursor position + for y in input_area.y..input_area.y + input_area.height { + for x in input_area.x..input_area.x + input_area.width { + if let Some(cell) = buffer.cell((x, y)) { + let symbol = cell.symbol(); + // Count visible characters (skip zero-width and empty) + if !symbol.is_empty() { + let width = symbol.width(); + if char_count + width > self.cursor { + // Found the cursor position + cursor_x = x; + cursor_y = y; + break; + } + char_count += width; + } + } + } + if cursor_x != input_area.x || cursor_y != input_area.y { + break; // Found it + } } - // Use unicode display width to match ratatui's wrapping - let last_width = ratatui::text::Line::raw(cursor_lines[n - 1]).width(); - let col = if w > 0 { last_width % w } else { last_width }; - visual_row += if w > 0 { (last_width / w) as u16 } else { 0 }; - let cursor_x = col as u16 + input_area.x; - let cursor_y = visual_row + input_area.y; + if cursor_y < input_area.y + input_area.height { frame.set_cursor_position((cursor_x, cursor_y)); } diff --git a/poc-memory/Cargo.toml b/poc-memory/Cargo.toml index 13411af..29e11f6 100644 --- a/poc-memory/Cargo.toml +++ b/poc-memory/Cargo.toml @@ -25,7 +25,7 @@ poc-agent = { path = "../poc-agent" } tokio = { version = "1", features = ["rt-multi-thread"] } redb = "2" log = "0.4" -ratatui = "0.29" +ratatui = "0.30" skillratings = "0.28" crossterm = { version = "0.28", features = ["event-stream"] }