diff --git a/MEMORY.md b/MEMORY.md index 6303b40..4966414 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -182,3 +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에서 재현 못 함(사용자 확인 대기). 첫 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 fa7db0f..3ea4782 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(2); 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,71 @@ 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 && self.unchanged(finished, 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 + } + + /// 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)> { + 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 +635,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 +677,8 @@ impl Frontend { .iter() .map(|row| display_width(&strip_controls(row.as_bytes()))) .collect(); - stdout.flush() + self.drawn.rows = pane.rows; + Ok(()) } } @@ -1306,6 +1395,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 +1407,73 @@ 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" + ); + 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] fn resizing_preserves_drafts_queued_goals_and_pending_approval_answers() { let (lines, received) = mpsc::sync_channel(1); 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)}))