From 70a4dbcc82fade0ff0cbe4dc3e4be872add8325e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=84=B1=EC=A4=80?= Date: Tue, 3 Nov 2026 19:53:34 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EB=8C=80=EA=B8=B0=20=EC=A4=91=EC=9D=B8=20T?= =?UTF-8?q?UI=EA=B0=80=20100ms=EB=A7=88=EB=8B=A4=20=EC=A7=80=EC=9A=B0?= =?UTF-8?q?=EA=B3=A0=20=EB=8B=A4=EC=8B=9C=20=EA=B7=B8=EB=A6=AC=EB=8D=98=20?= =?UTF-8?q?=EA=B9=9C=EB=B9=A1=EC=9E=84=EC=9D=84=20=EA=B3=A0=EC=B3=A4?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 입력을 기다리는 동안에도 100ms마다 커서를 화면 구석으로 보내 크기를 묻고 (ESC[6n) 입력창을 지웠다 다시 그렸다. iTerm2에서는 깜빡임으로 보이고, 한글 IME 조합 글자가 입력줄이 아닌 곳에 그려졌다(tmux 실측 대기 5초 61KB). - 시간 재그리기는 spinner가 돌 때와 resize 뒤 1초 동안만 한다. - 화면 크기 조회는 PTY 보고 크기가 바뀌었거나 resize 직후일 때만 한다. PTY 지연 대응(f1171ba)은 그대로 유지된다. - 같은 프레임은 쓰지 않고, 나머지는 동기화 출력(?2026)으로 감싼다. - REPL 메시지가 오면 항상 다시 그려 상태 변화를 놓치지 않는다. 대기 5초 출력 0바이트, smoke-inline-tui 3종(rapid-resize·PTY 지연 포함) 통과. Co-Authored-By: Claude Opus 5.5 --- MEMORY.md | 3 + crates/xgen-cli/src/tui.rs | 191 +++++++++++++++++++++++++++++++------ 2 files changed, 166 insertions(+), 28 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index 6303b40..dbb2354 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -182,3 +182,6 @@ - OpenAI 연결(사용자 요청, 2026-11-03): rc.6 설치 후 OpenAI preset으로 어떤 model도 setup을 통과하지 못했다. 원인 3가지를 실제 api.openai.com 요청으로 확정했다. (1) 추론 model은 `max_tokens`·`temperature: 0` 거절 (2) preset의 `json_schema`는 strict 모드가 모든 schema node에 `type`을 요구해 Step `arguments`에서 모든 model이 400 (3) 별칭 model은 응답 `model`이 날짜 붙은 snapshot이라 identity 검사에서 닫힘. - OpenAI parameter style(ADR-0086, 구현·실제 OpenAI 검증 완료): `--parameter-style compatible|openai`, `openai`는 `max_completion_tokens`·temperature 생략. OpenAI preset은 `json_object`+`openai`. compatible은 digest·profile 파일 바이트가 그대로다. 별칭 identity 검사는 완화하지 않고 snapshot ID 선택을 안내한다. setup PASS gpt-6-luna·gpt-5.5-2026-04-23·gpt-4.1-2025-04-14, run 파일 수정 3/3, 테스트 생성+README 수정 2/2(pytest 4 passed), 출력 한도 1024에서 잘림 0. - 다음(제안, 미구현): setup/run의 `request_rejected`·`chat_completions_incompatible`에 provider 거절 이유와 응답 model ID를 보여 준다. 지금은 code 한 줄뿐이라 사용자가 원인을 알 수 없다. +- TUI 깜빡임(사용자 보고, 2026-11-03, iTerm2·한글 IME): 대기 중에도 100ms마다 커서를 구석으로 보내 크기를 묻고(`ESC[6n`) 입력창 4줄을 지웠다 다시 그렸다. tmux 실측 대기 5초 61KB. IME 조합 글자가 입력줄 아래에 그려졌다. 수정(구현·tmux 검증): 시간 재그리기는 spinner가 돌 때와 resize 뒤 1초만, 크기 조회는 PTY 보고 크기가 바뀌었거나 resize 직후만, 같은 프레임은 건너뛰고 `?2026` 동기화 출력으로 감쌌다. 대기 5초 0바이트, 스모크 3종(rapid-resize·PTY 지연 포함) 통과. 입력창 아래에 시작 안내 줄이 다시 보이던 증상은 iTerm2에서 재현 못 함(사용자 확인 대기). +- TUI 사용성(사용자 요청, 2026-11-03, 미구현): Claude Code `/model`처럼 화살표로 고르는 model 선택기. opencode는 TypeScript/Go라 그대로 못 가져온다. 같은 Rust인 OpenAI Codex CLI TUI(ratatui, Apache-2.0)가 참고·이식 후보 — 셀 단위 diff 렌더링이라 지우고 다시 그리는 깜빡임 구조 자체가 없다. 한 단계씩: 깜빡임 수정 → `/model` 선택기 → 렌더러 교체 여부는 ADR로 따로 판단. +- 18초 응답(사용자 보고, 2026-11-03): 시간 전부가 한 번의 HTTP 호출 안이었다(usage elapsedMillis 18324, 출력 52 token). 같은 경로를 tmux TUI·`xgen run`으로 다시 재면 2~5초, 직접 호출 1.5~2초라 일회성 provider 지연으로 본다(원인 확정 아님). diff --git a/crates/xgen-cli/src/tui.rs b/crates/xgen-cli/src/tui.rs index fa7db0f..2c040a5 100644 --- a/crates/xgen-cli/src/tui.rs +++ b/crates/xgen-cli/src/tui.rs @@ -36,7 +36,10 @@ const MAX_EVENTS_PER_TICK: usize = 256; /// Input rows the box shows before it scrolls with the caret. const MAX_INPUT_ROWS: usize = 8; const SPINNER: [char; 4] = ['|', '/', '-', '\\']; +/// Timed repaints only animate the spinner or settle a resize; an idle pane is never repainted. const REDRAW_INTERVAL: Duration = Duration::from_millis(100); +/// After a resize, keep asking the screen for its size while a lagging PTY catches up. +const RESIZE_SETTLE: Duration = Duration::from_secs(1); const MAX_DEFERRED_BYTES: usize = 8 * 1024 * 1024; static ALTERNATE_ACTIVE: AtomicBool = AtomicBool::new(false); @@ -178,10 +181,15 @@ struct Frontend { deferred: Vec>, deferred_bytes: usize, viewer_frame: Option<((u16, u16), Vec)>, + /// The PTY-reported size at the last screen probe, and the screen size that probe found. + probed: Option<((u16, u16), (u16, u16))>, + last_resize: Option, } #[derive(Default)] struct DrawnPane { + /// The rows as last written, so an unchanged frame is not erased and written again. + rows: Vec, /// Row of the cursor inside the pane, counted from the pane's first row. caret_row: u16, caret_column: u16, @@ -206,6 +214,13 @@ impl DrawnPane { u16::try_from(above + usize::from(self.caret_column) / width).unwrap_or(u16::MAX) } + fn shows(&self, pane: &Pane, size: (u16, u16)) -> bool { + self.size == Some(size) + && self.rows == pane.rows + && usize::from(self.caret_row) == pane.caret_row + && usize::from(self.caret_column) == pane.caret_column + } + fn physical_rows(&self, width: u16) -> usize { let width = usize::from(width.max(1)); self.line_widths @@ -246,6 +261,8 @@ impl Frontend { deferred: Vec::new(), deferred_bytes: 0, viewer_frame: None, + probed: None, + last_resize: None, } } @@ -253,9 +270,13 @@ impl Frontend { let mut finished = Vec::new(); let mut last_draw = Instant::now(); loop { + // Any message can change the pane (the prompt, the progress line, the Run state). + let mut dirty = false; // Drain everything the REPL produced before looking at the keyboard. loop { - match receiver.try_recv() { + let message = receiver.try_recv(); + dirty |= message.is_ok(); + match message { Ok(Message::Output(bytes)) => finished.extend(self.screen.push(&bytes)), Ok(Message::Section(kind, bytes)) => { let lines = self.screen.push(&bytes); @@ -278,7 +299,7 @@ impl Frontend { Err(mpsc::TryRecvError::Empty) => break, } } - let mut dirty = !finished.is_empty(); + dirty |= !finished.is_empty(); let event = event::poll(Duration::from_millis(25)) .and_then(|ready| ready.then(event::read).transpose()); let Ok(event) = event else { @@ -296,7 +317,16 @@ impl Frontend { dirty |= self.on_event(event, &mut finished); } } - if dirty || last_draw.elapsed() >= REDRAW_INTERVAL { + // A cancelled Run is over once the REPL asks for input again. + if self.requested && self.cancelling { + self.cancelling = false; + dirty = true; + } + // Repainting an idle pane erases and rewrites it, which terminals show as flicker + // and which moves the caret under an IME composition. + let timed = (self.animating() || self.settling_resize()) + && last_draw.elapsed() >= REDRAW_INTERVAL; + if dirty || timed { if self.draw(&finished, true).is_err() { let _ = self.lines.send(InputMessage::Unavailable); return; @@ -304,13 +334,19 @@ impl Frontend { finished.clear(); last_draw = Instant::now(); } - // A cancelled Run is over once the REPL asks for input again. - if self.requested { - self.cancelling = false; - } } } + /// The pane shows a spinner only while a Run is in flight or being cancelled. + fn animating(&self) -> bool { + self.viewer.is_none() && (self.cancelling || (!self.requested && !self.prompt().is_empty())) + } + + fn settling_resize(&self) -> bool { + self.last_resize + .is_some_and(|resized| resized.elapsed() < RESIZE_SETTLE) + } + fn prompt(&self) -> String { strip_controls(self.screen.tail()) } @@ -345,7 +381,10 @@ impl Frontend { .insert_str(&text.replace("\r\n", "\n").replace('\r', "\n")); true } - Event::Resize(..) => true, + Event::Resize(..) => { + self.last_resize = Some(Instant::now()); + true + } _ => false, } } @@ -518,9 +557,65 @@ impl Frontend { fn draw(&mut self, finished: &[Vec], with_pane: bool) -> io::Result<()> { let mut stdout = io::stdout().lock(); - let (width, height) = screen_size(&mut stdout, !self.alternate)?; - if with_pane && self.draw_result_viewer(&mut stdout, finished, (width, height))? { - return stdout.flush(); + let size = self.size(&mut stdout)?; + if with_pane + && finished.is_empty() + && self.deferred.is_empty() + && self.viewer.is_none() + && !self.alternate + && self.drawn.shows(&self.pane(size), size) + { + return Ok(()); + } + // Terminals that support synchronized output show the erased and rewritten pane at once. + queue!(stdout, terminal::BeginSynchronizedUpdate)?; + let painted = self.paint(&mut stdout, finished, with_pane, size); + queue!(stdout, terminal::EndSynchronizedUpdate)?; + stdout.flush()?; + painted + } + + /// The screen size, probed only when the PTY size moved or a resize may still be settling. + /// Probing parks the caret in the corner for a round trip, which an IME composition shows. + fn size(&mut self, output: &mut impl Write) -> io::Result<(u16, u16)> { + let reported = terminal::size().ok(); + if !self.settling_resize() + && let Some((probed_report, screen)) = self.probed + && reported == Some(probed_report) + { + return Ok(screen); + } + let screen = screen_size(output, !self.alternate)?; + self.probed = reported.map(|reported| (reported, screen)); + Ok(screen) + } + + fn pane(&self, (width, height): (u16, u16)) -> Pane { + let view = PaneView { + width: usize::from(width), + height: usize::from(height), + prompt: self.prompt(), + requested: self.requested, + cancelling: self.cancelling, + queue: &self.queue, + footer: &self.footer, + has_results: self.results.latest().is_some(), + spinner: SPINNER[usize::try_from(self.started.elapsed().as_millis() / 250) + .unwrap_or(0) + % SPINNER.len()], + }; + layout(&view, &self.editor) + } + + fn paint( + &mut self, + stdout: &mut impl Write, + finished: &[Vec], + with_pane: bool, + (width, height): (u16, u16), + ) -> io::Result<()> { + if with_pane && self.draw_result_viewer(stdout, finished, (width, height))? { + return Ok(()); } self.viewer = None; if self.alternate { @@ -534,29 +629,16 @@ impl Frontend { self.deferred_bytes = 0; lines.extend_from_slice(finished); let finished = &lines; - let origin = erase_pane(&mut stdout, &self.drawn, (width, height))?; + let origin = erase_pane(stdout, &self.drawn, (width, height))?; for line in finished { stdout.write_all(line)?; stdout.write_all(b"\x1b[0m\r\n")?; } if !with_pane { self.drawn = DrawnPane::default(); - return stdout.flush(); + return Ok(()); } - let view = PaneView { - width: usize::from(width), - height: usize::from(height), - prompt: self.prompt(), - requested: self.requested, - cancelling: self.cancelling, - queue: &self.queue, - footer: &self.footer, - has_results: self.results.latest().is_some(), - spinner: SPINNER[usize::try_from(self.started.elapsed().as_millis() / 250) - .unwrap_or(0) - % SPINNER.len()], - }; - let pane = layout(&view, &self.editor); + let pane = self.pane((width, height)); let row = if finished.is_empty() && let Some(origin) = origin { @@ -589,7 +671,8 @@ impl Frontend { .iter() .map(|row| display_width(&strip_controls(row.as_bytes()))) .collect(); - stdout.flush() + self.drawn.rows = pane.rows; + Ok(()) } } @@ -1306,6 +1389,7 @@ mod tests { caret_row: 1, caret_column: 9, line_widths: vec![80, 80, 80, 79], + ..DrawnPane::default() }; let mut output = Vec::new(); assert_eq!(erase_pane(&mut output, &drawn, (80, 24)).unwrap(), Some(20)); @@ -1317,6 +1401,57 @@ mod tests { assert_eq!(drawn.physical_rows(40), 8); } + #[test] + fn only_a_running_or_resizing_pane_is_repainted_on_a_timer() { + let (lines, _received) = mpsc::sync_channel(1); + let mut frontend = + Frontend::new(Cancellation::default(), None, "fixture".to_owned(), lines); + let mut finished = Vec::new(); + frontend.screen.push(GOAL_PROMPT.as_bytes()); + frontend.on_request(&mut finished); + assert!( + !frontend.animating(), + "an idle goal prompt must not repaint" + ); + assert!(!frontend.settling_resize()); + + frontend.on_event(Event::Resize(80, 24), &mut finished); + assert!( + frontend.settling_resize(), + "a resize keeps probing while the PTY settles" + ); + + frontend.editor.insert_str("goal"); + frontend.on_key( + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + &mut finished, + ); + frontend.screen.push(b"Reading... 1s"); + assert!( + frontend.animating(), + "the spinner turns while a Run is in flight" + ); + + let pane = frontend.pane((80, 24)); + let mut drawn = DrawnPane { + size: Some((80, 24)), + rows: pane.rows.clone(), + caret_row: u16::try_from(pane.caret_row).unwrap(), + caret_column: u16::try_from(pane.caret_column).unwrap(), + ..DrawnPane::default() + }; + assert!(drawn.shows(&pane, (80, 24))); + assert!( + !drawn.shows(&pane, (100, 24)), + "a new size is always repainted" + ); + drawn.caret_column += 1; + assert!( + !drawn.shows(&pane, (80, 24)), + "a moved caret is always repainted" + ); + } + #[test] fn resizing_preserves_drafts_queued_goals_and_pending_approval_answers() { let (lines, received) = mpsc::sync_channel(1); From 30b7a2d71e61fffa324dd03324850cea6b71f1ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=84=B1=EC=A4=80?= Date: Tue, 3 Nov 2026 20:18:46 +0900 Subject: [PATCH 2/2] =?UTF-8?q?resize=20=EC=A7=81=ED=9B=84=EC=97=90?= =?UTF-8?q?=EB=8A=94=20=EA=B0=99=EC=9D=80=20=ED=94=84=EB=A0=88=EC=9E=84=20?= =?UTF-8?q?=EA=B1=B4=EB=84=88=EB=9B=B0=EA=B8=B0=EB=A5=BC=20=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tmux는 resize 중에 입력창 뒤 화면을 reflow·잘라내므로, 마지막으로 그린 프레임과 같다는 것이 화면이 그대로라는 증거가 되지 않는다. 건너뛰기 때문에 rapid-resize 스모크의 heldout-burst-restored가 CI에서 실패했다. resize 뒤 2초 동안은 항상 다시 그린다. 스모크의 stty.txt 대기는 셸이 파일을 만든 직후 빈 내용을 읽어 "raw mode not restored"로 가끔 실패했다. mode 목록이 써질 때까지 기다린다. 맥 로컬 rapid-resize: 수정본 8/8 통과, main 7/8(같은 heldout-burst-restored 조각 잔상은 기존에도 가끔 난다, MEMORY.md 기록). Co-Authored-By: Claude Opus 5.5 --- MEMORY.md | 3 ++- crates/xgen-cli/src/tui.rs | 38 +++++++++++++++++++++++++++++-------- scripts/smoke-inline-tui.py | 4 +++- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index dbb2354..4966414 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -182,6 +182,7 @@ - OpenAI 연결(사용자 요청, 2026-11-03): rc.6 설치 후 OpenAI preset으로 어떤 model도 setup을 통과하지 못했다. 원인 3가지를 실제 api.openai.com 요청으로 확정했다. (1) 추론 model은 `max_tokens`·`temperature: 0` 거절 (2) preset의 `json_schema`는 strict 모드가 모든 schema node에 `type`을 요구해 Step `arguments`에서 모든 model이 400 (3) 별칭 model은 응답 `model`이 날짜 붙은 snapshot이라 identity 검사에서 닫힘. - OpenAI parameter style(ADR-0086, 구현·실제 OpenAI 검증 완료): `--parameter-style compatible|openai`, `openai`는 `max_completion_tokens`·temperature 생략. OpenAI preset은 `json_object`+`openai`. compatible은 digest·profile 파일 바이트가 그대로다. 별칭 identity 검사는 완화하지 않고 snapshot ID 선택을 안내한다. setup PASS gpt-6-luna·gpt-5.5-2026-04-23·gpt-4.1-2025-04-14, run 파일 수정 3/3, 테스트 생성+README 수정 2/2(pytest 4 passed), 출력 한도 1024에서 잘림 0. - 다음(제안, 미구현): setup/run의 `request_rejected`·`chat_completions_incompatible`에 provider 거절 이유와 응답 model ID를 보여 준다. 지금은 code 한 줄뿐이라 사용자가 원인을 알 수 없다. -- TUI 깜빡임(사용자 보고, 2026-11-03, iTerm2·한글 IME): 대기 중에도 100ms마다 커서를 구석으로 보내 크기를 묻고(`ESC[6n`) 입력창 4줄을 지웠다 다시 그렸다. tmux 실측 대기 5초 61KB. IME 조합 글자가 입력줄 아래에 그려졌다. 수정(구현·tmux 검증): 시간 재그리기는 spinner가 돌 때와 resize 뒤 1초만, 크기 조회는 PTY 보고 크기가 바뀌었거나 resize 직후만, 같은 프레임은 건너뛰고 `?2026` 동기화 출력으로 감쌌다. 대기 5초 0바이트, 스모크 3종(rapid-resize·PTY 지연 포함) 통과. 입력창 아래에 시작 안내 줄이 다시 보이던 증상은 iTerm2에서 재현 못 함(사용자 확인 대기). +- TUI 깜빡임(사용자 보고, 2026-11-03, iTerm2·한글 IME): 대기 중에도 100ms마다 커서를 구석으로 보내 크기를 묻고(`ESC[6n`) 입력창 4줄을 지웠다 다시 그렸다. tmux 실측 대기 5초 61KB. IME 조합 글자가 입력줄 아래에 그려졌다. 수정(구현·tmux 검증): 시간 재그리기는 spinner가 돌 때와 resize 뒤 1초만, 크기 조회는 PTY 보고 크기가 바뀌었거나 resize 직후만, 같은 프레임은 건너뛰고 `?2026` 동기화 출력으로 감쌌다. 대기 5초 0바이트, 스모크 3종(rapid-resize·PTY 지연 포함) 통과. 입력창 아래에 시작 안내 줄이 다시 보이던 증상은 iTerm2에서 재현 못 함(사용자 확인 대기). 첫 CI 실패는 resize 직후 "같은 프레임 건너뛰기"가 tmux reflow로 바뀐 화면을 못 본 탓이라, resize 뒤 2초는 건너뛰지 않게 고쳤다. +- 알려진 흔들림(기존 main에도 있음): `smoke-inline-tui.py --rapid-resize`의 `heldout-burst-restored`가 burst 중간 크기의 입력창 조각이 화면에 남아 가끔 시간 초과한다. 맥 로컬 main 8회 중 1회, 수정본 14회 중 1회. 원인은 연속 reflow를 프런트엔드가 다 따라가지 못해 지울 위치를 잘못 잡는 것(미해결). 같은 스크립트의 `stty.txt` 읽기 경합(파일 생성 직후 빈 내용 읽음)은 고쳤다. - TUI 사용성(사용자 요청, 2026-11-03, 미구현): Claude Code `/model`처럼 화살표로 고르는 model 선택기. opencode는 TypeScript/Go라 그대로 못 가져온다. 같은 Rust인 OpenAI Codex CLI TUI(ratatui, Apache-2.0)가 참고·이식 후보 — 셀 단위 diff 렌더링이라 지우고 다시 그리는 깜빡임 구조 자체가 없다. 한 단계씩: 깜빡임 수정 → `/model` 선택기 → 렌더러 교체 여부는 ADR로 따로 판단. - 18초 응답(사용자 보고, 2026-11-03): 시간 전부가 한 번의 HTTP 호출 안이었다(usage elapsedMillis 18324, 출력 52 token). 같은 경로를 tmux TUI·`xgen run`으로 다시 재면 2~5초, 직접 호출 1.5~2초라 일회성 provider 지연으로 본다(원인 확정 아님). diff --git a/crates/xgen-cli/src/tui.rs b/crates/xgen-cli/src/tui.rs index 2c040a5..3ea4782 100644 --- a/crates/xgen-cli/src/tui.rs +++ b/crates/xgen-cli/src/tui.rs @@ -39,7 +39,7 @@ const SPINNER: [char; 4] = ['|', '/', '-', '\\']; /// Timed repaints only animate the spinner or settle a resize; an idle pane is never repainted. const REDRAW_INTERVAL: Duration = Duration::from_millis(100); /// After a resize, keep asking the screen for its size while a lagging PTY catches up. -const RESIZE_SETTLE: Duration = Duration::from_secs(1); +const RESIZE_SETTLE: Duration = Duration::from_secs(2); const MAX_DEFERRED_BYTES: usize = 8 * 1024 * 1024; static ALTERNATE_ACTIVE: AtomicBool = AtomicBool::new(false); @@ -558,13 +558,7 @@ impl Frontend { fn draw(&mut self, finished: &[Vec], with_pane: bool) -> io::Result<()> { let mut stdout = io::stdout().lock(); let size = self.size(&mut stdout)?; - if with_pane - && finished.is_empty() - && self.deferred.is_empty() - && self.viewer.is_none() - && !self.alternate - && self.drawn.shows(&self.pane(size), size) - { + if with_pane && self.unchanged(finished, size) { return Ok(()); } // Terminals that support synchronized output show the erased and rewritten pane at once. @@ -575,6 +569,18 @@ impl Frontend { painted } + /// Whether the screen still shows exactly the frame that would be written now. + fn unchanged(&self, finished: &[Vec], size: (u16, u16)) -> bool { + finished.is_empty() + && self.deferred.is_empty() + && self.viewer.is_none() + && !self.alternate + // The terminal reflows and clips the screen behind the pane while it resizes, + // so a matching last frame does not prove the screen still shows it. + && !self.settling_resize() + && self.drawn.shows(&self.pane(size), size) + } + /// The screen size, probed only when the PTY size moved or a resize may still be settling. /// Probing parks the caret in the corner for a round trip, which an IME composition shows. fn size(&mut self, output: &mut impl Write) -> io::Result<(u16, u16)> { @@ -1450,6 +1456,22 @@ mod tests { !drawn.shows(&pane, (80, 24)), "a moved caret is always repainted" ); + drawn.caret_column -= 1; + + // Spinner frames change every 250ms; restart the clock so both panes are the same frame. + frontend.started = Instant::now(); + frontend.drawn = DrawnPane { + rows: frontend.pane((80, 24)).rows, + ..drawn + }; + frontend.last_resize = Some(Instant::now()); + assert!( + !frontend.unchanged(&[], (80, 24)), + "a resize reflows the screen, so a matching last frame is repainted" + ); + frontend.last_resize = None; + assert!(frontend.unchanged(&[], (80, 24))); + assert!(!frontend.unchanged(&[b"output".to_vec()], (80, 24))); } #[test] diff --git a/scripts/smoke-inline-tui.py b/scripts/smoke-inline-tui.py index 6b628f5..9c2f6cd 100644 --- a/scripts/smoke-inline-tui.py +++ b/scripts/smoke-inline-tui.py @@ -298,7 +298,9 @@ def lagged_input_updated(): recalled = "│ > Inspect viewer background" if args.results else "│ > queued after resize" wait_for(lambda: recalled in capture(), "history after resize") tmux("send-keys", "-t", "fixture", "C-c", "C-d") - wait_for(lambda: (root / "stty.txt").exists(), "terminal restoration") + # The shell creates stty.txt before stty writes it; wait for the mode list itself. + wait_for(lambda: (root / "stty.txt").exists() + and "icanon" in (root / "stty.txt").read_text(), "terminal restoration") modes = (root / "stty.txt").read_text().split() assert "icanon" in modes and "-icanon" not in modes, "raw mode not restored" print(json.dumps({"status": "passed", "results": args.results, "modelCalls": len(requests), "artifacts": str(root)}))