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
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 검사에 포함한다.
33 changes: 31 additions & 2 deletions crates/xgen-cli/src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -517,8 +517,8 @@ impl Frontend {
}

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

Expand Down
1 change: 1 addition & 0 deletions docs/adr/0077-inline-tui-resize.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
- 기준일: 2026-10-30
- 변경 분류: 범용 터미널 표시·입력과 HTTP 의존성 갱신. protocol·journal·모델 입력 계약 변경 없음.
- 후속 결정: ADR-0074의 pane 배치와 지우기 방식
- 화면 크기 조회 방식은 [ADR-0079](0079-terminal-screen-size.md)에서 보완했다.

## 문제

Expand Down
2 changes: 2 additions & 0 deletions docs/adr/0078-folded-results.md
Original file line number Diff line number Diff line change
Expand Up @@ -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에서 실패했다.
Expand Down
60 changes: 60 additions & 0 deletions docs/adr/0079-terminal-screen-size.md
Original file line number Diff line number Diff line change
@@ -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 검사에 포함한다.
37 changes: 36 additions & 1 deletion scripts/smoke-inline-tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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-"))
Expand Down Expand Up @@ -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()
Expand Down