diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c729113..6c705df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,8 @@ jobs: sudo apt-get install --yes tmux fi python3 scripts/smoke-inline-tui.py - python3 scripts/smoke-inline-tui.py --results + python3 scripts/smoke-inline-tui.py --rapid-resize + python3 scripts/smoke-inline-tui.py --results --rapid-resize - name: Test Python scripts without API calls run: python3 -m unittest discover -s scripts/tests diff --git a/MEMORY.md b/MEMORY.md index d0681ef..1a73bde 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -151,3 +151,6 @@ - 긴 결과 접기(2026-10-30, ADR-0078, 구현·로컬 검증 완료): 일반 TUI의 답변·Changes가 12행을 넘으면 앞4/뒤2행을 남기고 접는다. Ctrl+O 전체 보기, 좌우 result 선택, Home/End·페이지 이동, Esc 복귀를 제공한다. typed section 전달로 모델 문자열을 UI marker로 해석하지 않는다. 전체 보기에서 Enter·paste는 승인/goal에 전달하지 않고 background Run·새 승인·출력·입력을 보존한다. 최근20개/원문·행 배열8MiB 보관, 초과 section은 원문 출력, pipe/debug 기존 제한 유지다. Rust 773개(4 ignored), Python 130개(37 skipped), Clippy·fmt·protocol·공개 문서 검사와 실제 tmux 기본/결과 smoke(HTTP 3/5회)를 통과했다. 실제 유료 모델·다른 OS 터미널 실측은 하지 않았다. 빠른 연속 resize stress에서는 viewer를 열지 않은 draft·실행 중 queue에서도 이전 테두리 한 줄이 남았다. #134의 정상 resize 검증과 구분하며, clamp·margin·absolute-row 가설은 효과가 없어 버렸다. 변경 전 main `ad2a898`의 별도 archive 빌드에서도 40×12 → 100×30 stress 실패를 재현했다. `scripts/smoke-inline-tui.py --rapid-resize`로 후속 재현하며 이 문제 해결은 아직 아니다. - 긴 결과 접기 변경 추적: [PR #135](https://github.com/PlateerLab/xgen-cli/pull/135), 코드 `45c4c0b`, [브랜치 전체 CI](https://github.com/PlateerLab/xgen-cli/actions/runs/41055760380). Linux PR 검사는 통과했고 전체 CI는 해당 run 결과를 참조한다. + +- 빠른 TUI resize 잔상(2026-10-30, ADR-0079, 구현·로컬 검증 완료): tmux가 screen을 먼저 바꾸고 PTY 크기 통지를 늦추는 동안 xgen의 periodic redraw가 옛 크기로 다시 그렸다. cursor report 캐시 문제가 아니었다. Unix draw·정리는 cursor 위치를 보존하고 화면 밖 CUP→CPR clipping으로 실제 화면 크기를 조회하며, Windows console size API는 유지한다. sleep·tmux 감지·전체 화면/scrollback 삭제는 넣지 않았다. draft·실행 중 queue의 rapid smoke 5회와 별도 크기 12회 burst, 결과 viewer·승인·입력·history·종료 복원을 통과했다(모델 HTTP 3/5회, 유료 LLM 없음). Rust 773 passed·4 ignored와 Clippy·fmt·문서·배포 workflow 검사를 통과했다. PTY만 73×19·화면은 100×30으로 고정한 fixture에서도 새 입력 redraw를 확인해 지연 상황을 timing과 무관하게 검증했다. 이 강제 불일치 검사는 변경 전 main `6215e93`의 별도 archive 빌드에서 실패하고 수정 빌드에서 통과했다. Unix redraw마다 CPR 왕복 하나가 추가되어 원격 터미널 latency 비용은 남는다. 소유자는 xgen TUI이며 다른 도구 변경은 없다. +- 빠른 resize 변경 추적: [PR #136](https://github.com/PlateerLab/xgen-cli/pull/136), 코드 `f1171ba`, [전체 CI](https://github.com/PlateerLab/xgen-cli/actions/runs/41071470733). 강제 PTY 지연 fixture는 후속 PR 검사에 포함한다. diff --git a/crates/xgen-cli/src/tui.rs b/crates/xgen-cli/src/tui.rs index 3dcf8f8..fa7db0f 100644 --- a/crates/xgen-cli/src/tui.rs +++ b/crates/xgen-cli/src/tui.rs @@ -517,8 +517,8 @@ impl Frontend { } fn draw(&mut self, finished: &[Vec], with_pane: bool) -> io::Result<()> { - let (width, height) = terminal::size().unwrap_or((80, 24)); 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(); } @@ -593,9 +593,38 @@ impl Frontend { } } +/// A PTY's ioctl size can lag behind its terminal's resized screen (for example tmux). +/// Ask the screen itself by moving to a clipped coordinate, then restore the caret. +fn screen_size(output: &mut impl Write, cursor_visible: bool) -> io::Result<(u16, u16)> { + #[cfg(unix)] + { + queue!( + output, + cursor::Hide, + cursor::SavePosition, + cursor::MoveTo(u16::MAX - 1, u16::MAX - 1) + )?; + output.flush()?; + let size = cursor::position().map(|(column, row)| (column + 1, row + 1)); + // Restore even when the cursor report times out. + queue!(output, cursor::RestorePosition)?; + if cursor_visible { + queue!(output, cursor::Show)?; + } + output.flush()?; + size + } + #[cfg(windows)] + { + let _ = (output, cursor_visible); + Ok(terminal::size().unwrap_or((80, 24))) + } +} + fn clear_pane(drawn: &DrawnPane) -> io::Result<()> { let mut stdout = io::stdout(); - erase_pane(&mut stdout, drawn, terminal::size().unwrap_or((80, 24)))?; + let size = screen_size(&mut stdout, true)?; + erase_pane(&mut stdout, drawn, size)?; stdout.flush() } diff --git a/docs/adr/0077-inline-tui-resize.md b/docs/adr/0077-inline-tui-resize.md index fdbbf3b..25f215c 100644 --- a/docs/adr/0077-inline-tui-resize.md +++ b/docs/adr/0077-inline-tui-resize.md @@ -4,6 +4,7 @@ - 기준일: 2026-10-30 - 변경 분류: 범용 터미널 표시·입력과 HTTP 의존성 갱신. protocol·journal·모델 입력 계약 변경 없음. - 후속 결정: ADR-0074의 pane 배치와 지우기 방식 +- 화면 크기 조회 방식은 [ADR-0079](0079-terminal-screen-size.md)에서 보완했다. ## 문제 diff --git a/docs/adr/0078-folded-results.md b/docs/adr/0078-folded-results.md index 123623c..41b13d9 100644 --- a/docs/adr/0078-folded-results.md +++ b/docs/adr/0078-folded-results.md @@ -50,6 +50,8 @@ ## 제한과 후속 과제 +빠른 resize 잔상은 이후 [ADR-0079](0079-terminal-screen-size.md)에서 수정했다. 아래는 발견 당시 기록이다. + - 연속 resize stress(`--rapid-resize`)에서는 축소 직후 확대하면 inline pane의 이전 테두리 한 줄이 남는 경우를 발견했다. viewer가 닫힌 draft·실행 중 queue 두 상태에서 재현했다. 변경 전 main `ad2a898`을 별도 archive에서 빌드한 바이너리도 같은 40×12 → 100×30 stress에서 실패했다. diff --git a/docs/adr/0079-terminal-screen-size.md b/docs/adr/0079-terminal-screen-size.md new file mode 100644 index 0000000..11b8580 --- /dev/null +++ b/docs/adr/0079-terminal-screen-size.md @@ -0,0 +1,60 @@ +# ADR-0079: inline pane은 PTY 크기 대신 실제 터미널 화면 크기로 배치한다 + +- 상태: Accepted +- 기준일: 2026-10-30 +- 분류: 범용 TUI 표시 수정. 모델 입력·journal·protocol·승인 계약 변경 없음. +- 후속 결정: ADR-0077의 화면 크기 조회, ADR-0078에서 남긴 빠른 resize 잔상. + +## 원인 + +40×12로 줄인 뒤 100×30으로 키우면 이전 좁은 top border가 새 pane 위에 남았다. +입력 중 draft와 실행 중 queue에서 각각 재현했고, 변경 전 main `ad2a898`에서도 확인했다. + +터미널 화면과 PTY의 `TIOCGWINSZ` 크기가 동시에 바뀐다는 가정이 잘못됐다. +tmux는 screen을 즉시 resize하지만 PTY resize 통지는 queue·timer로 늦출 수 있다. +그 사이 periodic draw가 이전 PTY 크기를 읽어 이미 reflow된 화면에 다시 그렸다. +나중에 SIGWINCH가 오면 caret은 중간 draw가 옮긴 위치라, 기존 cursor 기반 복구로는 남은 border를 찾지 못했다. + +근거는 격리된 tmux/loopback fixture의 syscall trace와 process를 멈춘 직후의 screen/cursor capture다. +화면은 100×30·cursor row 27인데 PTY 크기는 잠시 40×12였고, 그 사이 draw 이후 cursor row가 9로 바뀌었다. +이는 cursor 응답이 캐시됐다는 초기 추측과 달랐다. tmux 3.4의 +[screen resize](https://github.com/tmux/tmux/blob/3.4/window.c#L1006)와 +[PTY resize queue/timer](https://github.com/tmux/tmux/blob/3.4/server-client.c#L2099)도 이 순서를 설명한다. + +## 결정 + +1. Unix의 draw·pane 정리는 화면 자체에서 폭·높이를 조회한다. cursor를 숨기고 위치를 저장한 뒤, + 화면 밖의 충분히 큰 좌표로 이동한다. 터미널이 화면 마지막 셀로 제한한 실제 cursor 위치를 CPR로 읽고 + 폭·높이로 바꾼다. 원래 위치와 frontend의 cursor 가시성을 복원한 뒤 기존 지우기·배치를 수행한다. + CPR이 실패해도 원래 위치 복원을 시도하고 기존 오류 종료 경로를 따른다. +2. Windows는 기존 console size API를 유지한다. WinAPI의 화면 밖 좌표 이동은 Unix 터미널처럼 + clipping을 보장하지 않으므로 Unix probe를 적용하지 않는다. +3. 고정 sleep·tmux 환경 감지·size별 보정값·전체 화면/scrollback 삭제를 추가하지 않는다. + 기존 origin·hard-line 폭 기반 pane 지우기와 terminal scrollback 보존을 유지한다. +4. Unix는 redraw마다 CPR 왕복이 하나 늘어난다. 원격 터미널의 latency 비용은 남아 있다. + 실제 화면 크기와 PTY 크기가 일치한다고 가정해 조회를 생략하면 같은 결함이 돌아오므로, + 후속 최적화는 별도 측정과 회귀 검증을 요구한다. 원래 TUI도 시작·출력·resize에 CPR을 사용했다. + +## 검증 + +- 설계: 입력 중 draft, 모델 응답 대기 중 queue의 빠른 축소·확대에서 border 한 세트·시작 출력 한 번을 검사한다. + 수정 전에는 100×30 복원에서 실패했고 수정 후 연속 5회 통과했다. 모델 요청은 실행당 3회다. +- 별도 검증: 53×17·91×26·28×9·80×24를 세 번씩 중간 frame 대기 없이 변경한 뒤 복원한다. + 설계와 다른 크기의 burst에서도 scrollback·입력·승인·완료·history·종료 후 icanon 복원을 확인했다. +- timing과 무관한 회귀 fixture로 화면은 100×30에 둔 채 `TIOCSWINSZ`로 PTY만 73×19로 바꾼다. + 입력 문자열을 새로 그린 화면에서 border 100열·한 세트와 PTY 73×19 유지 상태를 함께 검사한 뒤 + 원래 PTY 크기를 복원한다. 이 검사는 tmux timer가 우연히 빨리 끝나도 지연 상황을 보장한다. + 변경 전 main `6215e93`의 별도 archive 빌드는 이 assertion에서 실패했고 수정 빌드는 통과했다. +- 결과 viewer를 포함한 smoke도 빠른 resize로 통과했다. 전체 보기 이동·승인 Enter 격리·보류 출력·입력 복원을 + 유지하며 모델 요청은 5회다. 새 유료 모델 호출이나 provider 재요청은 없다. +- Fast/Linux CI는 기존 정상 속도 smoke에 기본·결과 viewer의 `--rapid-resize` smoke를 추가한다. +- Rust 전체 773 passed·4 ignored, Clippy·fmt·공개 문서·배포 workflow 검사 통과. + 실제 화면 관찰은 Linux/tmux이며 다른 플랫폼의 build/unit 검사와 구분한다. + +## 책임 + +PTY와 screen의 갱신 순서는 tmux의 구현 특성이다. xgen이 두 크기를 같다고 가정하고 그린 것이 표시 결함의 원인이다. +수정 책임은 xgen TUI에 있으며 tmux 자체 변경이나 다른 도구 저장소의 patch는 필요하지 않다. + +변경 추적: [PR #136](https://github.com/PlateerLab/xgen-cli/pull/136), 코드 `f1171ba`, +[전체 CI](https://github.com/PlateerLab/xgen-cli/actions/runs/41071470733). 강제 PTY 지연 fixture는 후속 PR 검사에 포함한다. diff --git a/scripts/smoke-inline-tui.py b/scripts/smoke-inline-tui.py index da50ecb..6b628f5 100644 --- a/scripts/smoke-inline-tui.py +++ b/scripts/smoke-inline-tui.py @@ -2,14 +2,17 @@ """Exercise the real inline TUI in isolated tmux with a loopback model; no API key needed.""" import argparse +import fcntl import http.server import json import os from pathlib import Path import shlex import subprocess +import struct import tempfile import threading +import termios import time import unicodedata @@ -20,7 +23,7 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--binary", type=Path, default=Path("target/debug/xgen")) parser.add_argument("--results", action="store_true", help="Also check folding and the full-result viewer") - parser.add_argument("--rapid-resize", action="store_true", help="Stress consecutive resizes without settling; known inline-pane edge case") + parser.add_argument("--rapid-resize", action="store_true", help="Stress consecutive resizes without settling") args = parser.parse_args() binary = args.binary.resolve(strict=True) root = Path(tempfile.mkdtemp(prefix="xgen-inline-tui-")) @@ -178,8 +181,40 @@ def ready(): tmux("set-option", "-t", "fixture", "remain-on-exit", "on") wait_for(lambda: "Enter send" in capture(), "initial pane") send("한글 draft that wraps across the terminal and remains editable") + if args.rapid_resize: + # Guarantee a PTY/screen mismatch independently of tmux's timer scheduling. + pane_tty = tmux("display-message", "-p", "-t", "fixture", "#{pane_tty}").strip() + with open(pane_tty, "rb", buffering=0) as tty: + def pty_size(): + return struct.unpack("HHHH", fcntl.ioctl(tty, termios.TIOCGWINSZ, bytes(8)))[:2] + + wait_for(lambda: pty_size() == (30, 100), "initial PTY geometry") + original = fcntl.ioctl(tty, termios.TIOCGWINSZ, bytes(8)) + try: + fcntl.ioctl(tty, termios.TIOCSWINSZ, struct.pack("HHHH", 19, 73, 0, 0)) + assert pty_size() == (19, 73), "fixture did not create geometry lag" + send(" lag-probe") + + def lagged_input_updated(): + text = capture(history=True) + return ("lag-probe" in text and text.count("╭") == 1 + and text.count("╰") == 1 and "╭" + "─" * 98 + "╮" in text) + + wait_for(lagged_input_updated, "input redraw with stale PTY size") + assert pty_size() == (19, 73), "PTY lag ended before redraw assertion" + mark("lagged-pty-redraw-verified") + (root / "lagged-pty.txt").write_text(capture(history=True)) + finally: + fcntl.ioctl(tty, termios.TIOCSWINSZ, original) + tmux("send-keys", "-t", "fixture", *("BSpace" for _ in " lag-probe")) for width, height in [(40, 12), (100, 30), (100, 12), (80, 24)]: resize(width, height, f"draft-{width}x{height}") + if args.rapid_resize: + # Held-out sizes: send a burst without waiting for an intermediate frame. + mark("heldout-resize-burst") + for burst_width, burst_height in [(53, 17), (91, 26), (28, 9), (80, 24)] * 3: + tmux("resize-window", "-t", "fixture", "-x", str(burst_width), "-y", str(burst_height)) + resize(80, 24, "heldout-burst-restored") tmux("send-keys", "-t", "fixture", "C-c") send("Inspect fixture") enter()