feat(tui): block-based history for proper resize reflow

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.
This commit is contained in:
Yanis committed 2026-05-20 18:35:24 +09:00
1 parent 9a1910fb20
commit 6c55e260cc
1 file changed
+235 -80
+235 -80
View File
@@ -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')