From 6c55e260cc4b98ba548f02160ca7dcce31fc3755 Mon Sep 17 00:00:00 2001 From: Yanis <1610599258@qq.com> Date: Wed, 20 May 2026 18:35:24 +0900 Subject: [PATCH] feat(tui): block-based history for proper resize reflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace _history_lines (pre-rendered) with _blocks (source + kind) so _repaint_screen can re-render every chip, banner, user panel at the current width. \x1b[3J wipes scrollback on resize/Ctrl+L so the entire conversation reflows, not just the visible viewport. SIGWINCH delivery via self-pipe + select() — PEP 475 auto-retry on os.read makes signal-driven InterruptedError unreliable under iTerm split panes, so a dedicated pipe wakes the loop deterministically. Streaming tracks a single _streaming_block whose source grows monotonically through safe-position commits; mid-stream resize re-renders both the committed source and the volatile live_tail. --- frontends/tui_v3.py | 315 +++++++++++++++++++++++++++++++++----------- 1 file changed, 235 insertions(+), 80 deletions(-) diff --git a/frontends/tui_v3.py b/frontends/tui_v3.py index c2edb54a..127b53c4 100644 --- a/frontends/tui_v3.py +++ b/frontends/tui_v3.py @@ -244,6 +244,19 @@ class FoldSegment: is_last: bool = False +@dataclass +class Block: + """A unit of finalized scrollback history, stored as SOURCE (not rendered + lines). Resize replays each block through its renderer at the new width, + so width-baked structures (chip boxes, banner box) reflow correctly. The + actual scrollback bytes above the viewport stay frozen at the old width — + that's a terminal physics constraint — but the viewport and everything + new flows correctly.""" + kind: str # 'user' | 'assistant' | 'plain' | 'banner' + source: str # source text (or '' for banner — regenerated on demand) + tool_n: int = 0 # tool count cached at last render (for stable tids) + + def sanitize_ansi(text: str) -> str: """Strip non-SGR ANSI escapes and incomplete sequences from streaming chunks.""" text = _ANSI_DEC_PRIVATE_RE.sub('', text) @@ -1054,7 +1067,20 @@ class SB: self._pstore: dict[int, str] = {}; self._imgs: list[str | None] = []; self._pc = 0 self._t0 = 0.0; self._spin = 0 self._painted: list[str] = [] - self._history_lines: list[str] = [] + self._blocks: list[Block] = [] # block-based scrollback history; + self._streaming_block: Block | None = None # the in-flight assistant block + # Self-pipe for SIGWINCH delivery. PEP 475 makes Python auto-retry + # os.read on EINTR even with siginterrupt(SIGWINCH, True) — especially + # under iTerm split panes where the signal can be delivered while the + # read is mid-syscall and silently dropped. The signal handler writes + # a byte to this pipe; the main select() polls both stdin and the + # pipe, so a resize always wakes the loop within select's timeout. + sr, sw = os.pipe() + import fcntl as _fcntl + for fd in (sr, sw): + fl = _fcntl.fcntl(fd, _fcntl.F_GETFL) + _fcntl.fcntl(fd, _fcntl.F_SETFL, fl | os.O_NONBLOCK) + self._sig_r, self._sig_w = sr, sw self._last_render = 0.0 self._asking: AskUserEvent | None = None self._quit = False @@ -1395,41 +1421,160 @@ class SB: _w('\r' + (f'\x1b[{up}A' if up > 0 else '') + f'\x1b[{col}G') self._parked_up = up - def commit(self, lines: list[str]) -> None: - self._history_lines.extend(lines) - if len(self._history_lines) > 12000: - self._history_lines = self._history_lines[-8000:] + def commit(self, content) -> None: + """Append finalized history. Accepts either a Block (preferred — keeps + the source so resize can re-render at the new width) or a pre-rendered + list[str] (legacy: wrapped as a 'plain' block, which can reflow only + through _fit_rows — no un-rendering of width-baked structures). For + assistant blocks, _tool_base is anchored to the cumulative tool count + of prior blocks so chip tids continue the global sequence and stay + stable across resize repaints.""" w = _term()[0] + if isinstance(content, Block): + blk = content + if blk.kind == 'assistant': + self._tool_base = sum(b.tool_n for b in self._blocks) + self._blocks.append(blk) + self._cap_blocks() + lines = self._render_block(blk, w) + if blk.kind == 'assistant': + self._tool_base += blk.tool_n # advance for next message + else: + blk = Block('plain', '\n'.join(content)) + self._blocks.append(blk) + self._cap_blocks() + lines = list(content) + self._emit_lines(lines, w) + + def _cap_blocks(self) -> None: + if len(self._blocks) > 4000: + self._blocks = self._blocks[-3000:] + # streaming block (if any) is the last element by construction — + # the cap preserves it. Pre-streaming tool_base recompute will pick + # up the surviving blocks correctly. + + def _emit_lines(self, lines: list[str], w: int) -> None: + """Write lines to scrollback, wiping any live region currently below.""" + if not lines: + self._render_live(); return emit: list[str] = [] for ln in lines: emit.extend(_fit_rows(ln, w)) self._goto_top() - _w('\x1b[J') # wipe live region (infrequent) + _w('\x1b[J') _w('\r\n'.join(emit) + '\r\n') self._painted = []; self._live_rows = 0 self._render_live() - def _repaint_screen(self) -> None: - """Rebuild the visible page from remembered transcript rows. + def _render_block(self, b: Block, w: int) -> list[str]: + """Render one block at width w. Mutates self._last_tool_n for + assistant blocks; callers needing stable tids must set _tool_base + before calling (see _render_all_blocks / _flow).""" + if b.kind == 'user': + parts = b.source.split('\n') + raw = [_MARK + ' ' + parts[0]] + [CONT + p for p in parts[1:]] + lines = [_tile(' ' + x, _TILE_U) for x in raw] + lines.append('') + return lines + if b.kind == 'assistant': + body = self._render_assistant(b.source, max(1, w - 2)) + b.tool_n = self._last_tool_n # cache for tool_base recompute + return _indent_rows(body, w) + [''] + if b.kind == 'banner': + return self._make_banner_lines(w) + # 'plain': pre-formatted ANSI (system messages, dividers, /help text…) + out: list[str] = [] + for ln in b.source.split('\n'): + out.extend(_fit_rows(ln, w)) + return out - Native terminal scrollback cannot reflow old rows after resize. This - redraw keeps the current viewport coherent by replaying remembered rows - through the current width, then painting the live input region below. - """ + def _render_all_blocks(self, w: int) -> list[str]: + """Re-render every finalized block at width w with stable tool ids. + Tool ids are assigned per-block from a running tool_base, so a chip's + tid stays the same across resize repaints (otherwise /verbose would + de-sync from what the user sees).""" + out: list[str] = [] + saved_base = self._tool_base + tool_base = 0 + for blk in self._blocks: + if blk.kind == 'assistant': + self._tool_base = tool_base + out.extend(self._render_block(blk, w)) + tool_base += blk.tool_n + else: + out.extend(self._render_block(blk, w)) + self._tool_base = saved_base + return out + + def _pre_streaming_tool_base(self) -> int: + """Cumulative tool count from blocks before the streaming block.""" + total = 0 + for blk in self._blocks: + if blk is self._streaming_block: + break + total += blk.tool_n + return total + + def _make_banner_lines(self, w: int) -> list[str]: + """Regenerate the startup banner at the given width — called once at + run-start AND again on every resize via the 'banner' block kind.""" + d, r = _DIM, _RST + cwd = os.getcwd().replace(os.path.expanduser('~'), '~') + name = self._bridge.llm_name if self._bridge else '?' + rows = [(_ACCENT + '>_' + _RST + ' GenericAgent', '>_ GenericAgent'), + ('', ''), + (f'{d}model:{r} {name} {d}/llm 切换{r}', + f'model: {name} /llm 切换'), + (f'{d}directory:{r} {cwd}', f'directory: {cwd}'), + (f'{d}session:{r} 单会话 · scrollback', 'session: 单会话 · scrollback')] + top = _border('╭', '╮', w) + bot = _border('╰', '╯', w) + lines = ['', top] + lines += [self._boxln(p, c, w) for c, p in rows] + lines += [bot, '', + f' {d}Tip: Enter 发送 · Shift+Enter/Ctrl+J 换行 · ↑↓ 历史 · ' + f'cmd+⌫ 清行 · /help 全部命令{r}', ''] + if self._bridge and not self._bridge._healthy: + lines.append(f' {d}⚠ {self._bridge._init_error}{r}'); lines.append('') + return lines + + def _repaint_screen(self) -> None: + """Wipe BOTH viewport and scrollback, then replay ALL blocks at the + current width. Lines that overflow the viewport scroll naturally into + the fresh scrollback at the new width — so the entire history reflows, + not just what's visible. \\x1b[3J (clear scrollback) is iTerm/xterm + specific but widely supported; on terminals that ignore it, viewport + still re-renders correctly and old scrollback simply stays put. + + The in-flight stream's open portion (after the streaming block's safe + boundary) re-renders into _live_tail too so mid-stream resize snaps + to the new width.""" w, h = _term() + if self._stream and self._streaming_block is not None: + safe_src = self._streaming_block.source + open_src = sanitize_ansi(self._stream)[len(safe_src):] + saved_base = self._tool_base + self._tool_base = self._pre_streaming_tool_base() + self._streaming_block.tool_n + open_body = self._render_assistant(open_src, max(1, w - 2)) if open_src.strip() else [] + self._tool_base = saved_base + self._live_tail = _indent_rows(open_body[-8:], w) live = self._live_lines() - hist_budget = max(0, h - len(live)) - hist = _tail_fit_rows(self._history_lines, w, hist_budget) - frame = hist + live - _w('\x1b[2J\x1b[H') - last = len(frame) - 1 - for i, ln in enumerate(frame): + hist_all = self._render_all_blocks(w) + # Fold each rendered line to terminal width — long ANSI lines (e.g. + # plain blocks) reflow via _fit_rows so no soft-wrap drift. + hist_fit: list[str] = [] + for ln in hist_all: + hist_fit.extend(_fit_rows(ln, w)) + _w('\x1b[3J\x1b[2J\x1b[H') # wipe scrollback + viewport + for ln in hist_fit: # write all history; overflow + _w('\x1b[2K' + ln + '\r\n') # naturally scrolls to new scrollback + last = len(live) - 1 + for i, ln in enumerate(live): _w('\x1b[2K' + ln + ('\r\n' if i != last else '')) self._painted = list(live) self._live_rows = len(live) - hist_rows = len(hist) row, col = self._cur - up = (len(frame) - 1) - (hist_rows + row) + up = (len(live) - 1) - row _w('\r' + (f'\x1b[{up}A' if up > 0 else '') + f'\x1b[{col}G') self._parked_up = up @@ -1638,7 +1783,8 @@ class SB: from frontends import continue_cmd continue_cmd.reset_conversation(ag) _w('\x1b[2J\x1b[H'); self._painted = []; self._live_rows = 0 - self._history_lines = [] + self._blocks = []; self._streaming_block = None; self._sent = 0 + self._tool_base = 0; self._tools = {} self.commit([_DIM + '🆕 新对话 · 上下文已清空' + _RST]) elif name == 'rewind': n = int(arg) if arg.isdigit() else 1 @@ -1662,7 +1808,8 @@ class SB: self._submit(prompt, []) else: try: - self.commit(_render(dq.get_nowait().get('done', ''), _term()[0], markdown=True)) + text = dq.get_nowait().get('done', '') + self.commit(Block('assistant', text)) # markdown re-renders on resize except queue.Empty: self.commit(['(review 无输出)']) elif name == 'llm': @@ -1719,7 +1866,7 @@ class SB: finally: self._running = False with self._lk: - self.commit(_render(ans or '(无回答)', _term()[0], markdown=True)) + self.commit(Block('assistant', ans or '(无回答)')) # markdown re-renders on resize def _verbose_view(self) -> None: """Tool-call audit on a TEMP alt-screen — main scrollback is never @@ -1853,11 +2000,7 @@ class SB: self._render_live() def _commit_user(self, text: str) -> None: - parts = text.split('\n') - raw = [_MARK + ' ' + parts[0]] + [CONT + p for p in parts[1:]] - lines = [_tile(' ' + x, _TILE_U) for x in raw] # user panel, near-black - lines.append('') # bare gap = the divider - self.commit(lines) + self.commit(Block('user', text)) def _compress(self, t: str) -> str: """Capture each tool call as a structured ToolRecord (ids fixed at @@ -1918,12 +2061,7 @@ class SB: return out def _commit_assistant(self, text: str) -> None: - w = _term()[0] - body = self._render_assistant(text, max(1, w - 2)) - lines = _indent_rows(body, w) # AI = plain white, no panel, no bar - lines.append('') # bare gap = the divider - self._tool_base += self._last_tool_n # message done → ids advance - self.commit(lines) + self.commit(Block('assistant', text)) # commit() handles _tool_base def _safe_pos(self, stream: str) -> int: """Position up to which the stream is STRUCTURALLY stable — past this @@ -1961,12 +2099,26 @@ class SB: w = _term()[0] stream = sanitize_ansi(self._stream) + if self._streaming_block is None and (stream.strip() or final): + self._streaming_block = Block('assistant', '') + self._blocks.append(self._streaming_block) + self._cap_blocks() + self._sent = 0 + if final: + if self._streaming_block is not None: + self._streaming_block.source = stream + base = self._pre_streaming_tool_base() + saved_base = self._tool_base; self._tool_base = base body = self._render_assistant(stream, max(1, w - 2)) - new = _indent_rows(body[self._sent:], w) + [''] + if self._streaming_block is not None: + self._streaming_block.tool_n = self._last_tool_n + self._tool_base = saved_base + self._last_tool_n # legacy tracker advance + full_lines = _indent_rows(body, w) + [''] + new = full_lines[self._sent:] self._live_tail = [] - self._tool_base += self._last_tool_n - self.commit(new) + self._emit_lines(new, w) + self._streaming_block = None self._stream = ''; self._sent = 0; self._live_tail = [] return @@ -1974,19 +2126,27 @@ class SB: closed_text = stream[:safe] open_text = stream[safe:] + if self._streaming_block is not None: + self._streaming_block.source = closed_text # block tracks committed src + + base = self._pre_streaming_tool_base() + saved_base, saved_n = self._tool_base, self._last_tool_n + self._tool_base = base closed_body = self._render_assistant(closed_text, max(1, w - 2)) if closed_text.strip() else [] closed_n = self._last_tool_n + if self._streaming_block is not None: + self._streaming_block.tool_n = closed_n if open_text.strip(): - saved_base, saved_n = self._tool_base, self._last_tool_n - self._tool_base = saved_base + closed_n # open tids don't collide + self._tool_base = base + closed_n # open tids don't collide open_body = self._render_assistant(open_text, max(1, w - 2)) - self._tool_base = saved_base - self._last_tool_n = saved_n # finalize uses closed_n else: open_body = [] + self._tool_base = saved_base + self._last_tool_n = saved_n # finalize uses closed_n - new = closed_body[self._sent:] - self._sent = len(closed_body) + closed_lines = _indent_rows(closed_body, w) + new = closed_lines[self._sent:] + self._sent = len(closed_lines) self._live_tail = _indent_rows(open_body[-8:], w) # cap volatile region — # any larger and a growing # live region scrolls past @@ -1994,7 +2154,7 @@ class SB: # paints into scrollback as # un-erasable "duplicates" if new: - self.commit(_indent_rows(new, w)) + self._emit_lines(new, w) else: self._render_live() @@ -2159,6 +2319,10 @@ class SB: def _on_resize(self, *_a) -> None: self._resized = True + try: + os.write(self._sig_w, b'r') # wake the select() in run() + except (BlockingIOError, OSError): + pass # pipe full = pending wake already queued def run(self) -> None: self._bridge = AgentBridge() @@ -2167,24 +2331,6 @@ class SB: cost_tracker.install() # without this _trackers stays empty → no cost except Exception: pass - d, r = _DIM, _RST - w = _term()[0] - cwd = os.getcwd().replace(os.path.expanduser('~'), '~') - rows = [(_ACCENT + '>_' + _RST + ' GenericAgent', '>_ GenericAgent'), - ('', ''), - (f'{d}model:{r} {self._bridge.llm_name} {d}/llm 切换{r}', - f'model: {self._bridge.llm_name} /llm 切换'), - (f'{d}directory:{r} {cwd}', f'directory: {cwd}'), - (f'{d}session:{r} 单会话 · scrollback', 'session: 单会话 · scrollback')] - top = _border('╭', '╮', w) - bot = _border('╰', '╯', w) - banner = ['', top] - banner += [self._boxln(p, c, w) for c, p in rows] - banner += [bot, '', - f' {d}Tip: Enter 发送 · Shift+Enter/Ctrl+J 换行 · ↑↓ 历史 · ' - f'cmd+⌫ 清行 · /help 全部命令{r}', ''] - if not self._bridge._healthy: - banner.append(f' {d}⚠ {self._bridge._init_error}{r}'); banner.append('') self._old = termios.tcgetattr(self._fd) signal.signal(signal.SIGWINCH, self._on_resize) signal.siginterrupt(signal.SIGWINCH, True) # don't auto-retry os.read — let @@ -2200,25 +2346,34 @@ class SB: _w('\x1b[?2004h') # bracketed paste: multi-line paste won't pre-submit _w('\x1b[>4;1m') # modifyOtherKeys: Shift+Enter becomes distinguishable _w('\x1b[2J\x1b[H') # one-time fresh page (NOT alt-screen; scrollback intact) - self.commit(banner) + self.commit(Block('banner', '')) while True: - # 40ms gate: triggers when ANYTHING is pending (a held Esc, an - # _ingest holdback that contains a lone \x1b, or a deferred - # resize). On timeout _flush_esc drains them all in order. - if self._epend or self._resized or (self._rb and not self._bp): - r, _, _ = select.select([self._fd], [], [], 0.04) - if not r: - self._flush_esc(); continue - try: - data = os.read(self._fd, 4096) - except InterruptedError: # SIGWINCH interrupted the read; - if self._resized: # repaint at the new size - with self._lk: # right now, don't wait for a - self._resized = False # keystroke - self._redraw() - continue - if not data or not self._feed(data): - break + # Always select on BOTH stdin and the SIGWINCH self-pipe — under + # iTerm split panes, signal-driven InterruptedError on os.read + # is unreliable (PEP 475 auto-retries; on some macOS builds the + # signal can be coalesced/dropped during the read syscall). The + # self-pipe trick wakes select() deterministically whenever + # SIGWINCH fires, so a resize ALWAYS repaints within ~40ms even + # if the user never touches a key. + gated = self._epend or (self._rb and not self._bp) + timeout = 0.04 if gated else None + r, _, _ = select.select([self._fd, self._sig_r], [], [], timeout) + if self._sig_r in r: + try: os.read(self._sig_r, 4096) + except OSError: pass # drain pending wake bytes + if self._resized: + with self._lk: + self._resized = False + self._redraw() + if not r: + self._flush_esc(); continue + if self._fd in r: + try: + data = os.read(self._fd, 4096) + except InterruptedError: + continue + if not data or not self._feed(data): + break finally: _w('\x1b[>4;0m') # restore default key reporting _w('\x1b[?2004l')