已关闭
fix(tuix): inflight 重绘改走 cell-diff, 消除光标闪烁 #1104
郑育强创建于 9月21日关闭于 6 天前
fix(tuix): inflight 重绘改走 cell-diff, 消除光标闪烁 #1104
已关闭
共 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 at | 2238 | // In-place rewrite: the prior render's terminal rows are at |
| 2239 | - // (bottom - n + 1 ..= bottom). Update model state by | 2239 | + // (bottom - n + 1 ..= bottom). Update model state by swapping |
| 2240 | - // swapping the trailing slice; then walk each terminal row | 2240 | + // 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 emit | 2269 | // DO NOT erase the footer here. This used to emit |
| 2263 | // `\x1b[{bottom+1};1H\x1b[0J` (ED 0) to wipe everything below | 2270 | // `\x1b[{bottom+1};1H\x1b[0J` (ED 0) to wipe everything below |
| 2264 | // the inflight strip — i.e. the whole input box — on EVERY | 2271 | // 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 rows | 15400 | /// Regression: during a Task fan-out the live subagent panel + child rows |
| 15333 | /// repaint continuously, stranding the (visible) hardware caret on a child | 15401 | /// repaint continuously, stranding the (visible) hardware caret on a child |
| 15334 | /// row — the user saw a caret blinking mid-panel. Fix: suppress the caret | 15402 | /// row — the user saw a caret blinking mid-panel. Fix: suppress the caret |