Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 지연으로 본다(원인 확정 아님).
213 changes: 185 additions & 28 deletions crates/xgen-cli/src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -178,10 +181,15 @@ struct Frontend {
deferred: Vec<Vec<u8>>,
deferred_bytes: usize,
viewer_frame: Option<((u16, u16), Vec<String>)>,
/// 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<Instant>,
}

#[derive(Default)]
struct DrawnPane {
/// The rows as last written, so an unchanged frame is not erased and written again.
rows: Vec<String>,
/// Row of the cursor inside the pane, counted from the pane's first row.
caret_row: u16,
caret_column: u16,
Expand All @@ -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
Expand Down Expand Up @@ -246,16 +261,22 @@ impl Frontend {
deferred: Vec::new(),
deferred_bytes: 0,
viewer_frame: None,
probed: None,
last_resize: None,
}
}

fn run(&mut self, receiver: &Receiver<Message>) {
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);
Expand All @@ -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 {
Expand All @@ -296,21 +317,36 @@ 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;
}
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())
}
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -518,9 +557,71 @@ impl Frontend {

fn draw(&mut self, finished: &[Vec<u8>], 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<u8>], 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<u8>],
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 {
Expand All @@ -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
{
Expand Down Expand Up @@ -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(())
}
}

Expand Down Expand Up @@ -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));
Expand All @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion scripts/smoke-inline-tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}))
Expand Down