已关闭
fix(tuix): inflight 重绘改走 cell-diff, 消除光标闪烁 #1104
郑育强创建于 9月21日关闭于 6 天前
fix(tuix): inflight 重绘改走 cell-diff, 消除光标闪烁 #1104
已关闭
郑育强创建于 9月21日关闭于 6 天前
共 1 个文件变更+78-10
@@ -2236,9 +2236,23 @@ impl<W: Write + Send> RetainedRenderer<W> {
2236 let inplace_ok = prev_rows > 0 && n == prev_rows && bottom >= n as u16;2236 let inplace_ok = prev_rows > 0 && n == prev_rows && bottom >= n as u16;
2237 if inplace_ok {2237 if inplace_ok {
2238 // In-place rewrite: the prior render's terminal rows are at2238 // In-place rewrite: the prior render's terminal rows are at
2239- // (bottom - n + 1 ..= bottom). Update model state by2239+ // (bottom - n + 1 ..= bottom). Update model state by swapping
2240- // swapping the trailing slice; then walk each terminal row2240+ // the trailing slice, then invalidate the cell model and let
2241- // with a position + erase + write triple.2241+ // the next `flush_deferred` repaint through the cell-diff —
2242+ // inside `render_diff`'s DECSET 2026 envelope, same as the
2243+ // live-spinner path (`push_or_update_live_spinner`).
2244+ //
2245+ // This used to ALSO write each row directly to the terminal
2246+ // (`\x1b[{r};1H\x1b[2K` + serialize_row, bypassing the screen
2247+ // model). That raw write ran outside the 2026 envelope and
2248+ // left the physical caret at end of the Running liveness row
2249+ // until the next coalesced frame re-parked it — invisible on
2250+ // a local tty (~5ms), but over a high-RTT SSH link the raw
2251+ // write and the re-park land in different TCP segments, so a
2252+ // visible caret ping-ponged between the Running row and the
2253+ // input box at ~10Hz. The sentinel invalidate below already
2254+ // forces a patch on every cell of the strip, so dropping the
2255+ // raw write loses nothing but the stray caret.
2242 let keep = self.body_lines.len().saturating_sub(prev_rows);2256 let keep = self.body_lines.len().saturating_sub(prev_rows);
2243 crate::tuix_trace!(2257 crate::tuix_trace!(
2244 "BPOP",2258 "BPOP",
@@ -2252,13 +2266,6 @@ impl<W: Write + Send> RetainedRenderer<W> {
2252 for row in &new_rows {2266 for row in &new_rows {
2253 self.body_lines.push(row.clone());2267 self.body_lines.push(row.clone());
2254 }2268 }
2255- for (i, row) in new_rows.iter().enumerate() {
2256- let r = first + i as u16;
2257- let seq = format!("\x1b[{};1H\x1b[2K", r);
2258- let _ = self.out.write_all(seq.as_bytes());
2259- let bytes = serialize_row(row);
2260- let _ = self.out.write_all(&bytes);
2261- }
2262 // DO NOT erase the footer here. This used to emit2269 // DO NOT erase the footer here. This used to emit
2263 // `\x1b[{bottom+1};1H\x1b[0J` (ED 0) to wipe everything below2270 // `\x1b[{bottom+1};1H\x1b[0J` (ED 0) to wipe everything below
2264 // the inflight strip — i.e. the whole input box — on EVERY2271 // the inflight strip — i.e. the whole input box — on EVERY
@@ -15329,6 +15336,67 @@ mod tests {
15329 );15336 );
15330 }15337 }
15331 15338 
15339+ /// Regression (SSH high-RTT caret flicker at the `Running` row): the
15340+ /// in-place branch of `render_inflight_tool` emitted raw
15341+ /// `\x1b[{r};1H\x1b[2K` + row bytes DIRECTLY to the sink — outside
15342+ /// `render_diff`'s DECSET 2026 envelope, without `?25l`, and leaving
15343+ /// the physical caret at end of the last rewritten row (the Running
15344+ /// liveness row) until the next coalesced frame re-parked it. Over a
15345+ /// high-RTT link the raw write and the re-park land in different TCP
15346+ /// segments, so a visible caret ping-pongs between the Running row
15347+ /// and the input box at ~10Hz.
15348+ ///
15349+ /// Fix: the in-place branch only updates `body_lines` and invalidates
15350+ /// the cell model; the repaint goes through the cell-diff inside the
15351+ /// synchronized envelope — same approach the live spinner already uses
15352+ /// (`push_or_update_live_spinner`). `\x1b[2K` is unique to the raw
15353+ /// rewrite, so its absence proves the tick stayed inside the envelope.
15354+ #[test]
15355+ fn retained_inflight_inplace_rewrite_stays_inside_sync_envelope() {
15356+ let (mut r, buf) = new_capturing(80, 24);
15357+ 
15358+ r.render(UiLine::InputPrompt {
15359+ buf: String::new(),
15360+ cursor_byte: 0,
15361+ menu: None,
15362+ status: status_basic(),
15363+ attachments: Vec::new(),
15364+ });
15365+ r.render(UiLine::ToolCallInFlight {
15366+ id: "call_1".into(),
15367+ name: "Bash".into(),
15368+ detail: "sleep 60".into(),
15369+ hint: None,
15370+ });
15371+ r.flush_deferred();
15372+ // Drain the setup frame; the tick below must produce its own bytes.
15373+ let mut vterm = crate::test_term::VirtualTerminal::new(80, 24);
15374+ drain_into_vterm(&buf, &mut vterm);
15375+ 
15376+ // Spinner tick → exercises the in-place rewrite branch.
15377+ r.render(UiLine::Spinner {
15378+ frame: "⠙".into(),
15379+ label: "Running (1s)".into(),
15380+ });
15381+ r.flush_deferred();
15382+ let tick = std::mem::take(&mut *buf.lock().unwrap());
15383+ 
15384+ assert!(
15385+ !tick.windows(4).any(|w| w == b"\x1b[2K"),
15386+ "inflight tick must not emit raw full-line erases outside the \
15387+ DECSET 2026 envelope — the in-place rewrite goes through the \
15388+ cell-diff so the frame stays atomic (raw writes strand the \
15389+ visible caret at the Running row over high-RTT links)"
15390+ );
15391+ 
15392+ // The repaint still happened: the Running liveness row is on screen.
15393+ vterm.feed(&tick);
15394+ assert!(
15395+ vterm.dump().contains("Running"),
15396+ "the Running liveness row must still repaint on tick"
15397+ );
15398+ }
15399+ 
15332 /// Regression: during a Task fan-out the live subagent panel + child rows15400 /// Regression: during a Task fan-out the live subagent panel + child rows
15333 /// repaint continuously, stranding the (visible) hardware caret on a child15401 /// repaint continuously, stranding the (visible) hardware caret on a child
15334 /// row — the user saw a caret blinking mid-panel. Fix: suppress the caret15402 /// row — the user saw a caret blinking mid-panel. Fix: suppress the caret