fix(tui): polish rendering, history preview, and copy behavior (#816)

* fix(tui): repaint composer after macOS input

* feat(tui): add compact conversation history

* fix(tui): repaint messages after streaming and scroll

* fix(tui): polish history focus and scrollbar styling
This commit is contained in:
nianyucatfish authored and GitHub committed 2026-09-22 23:42:24 +08:00
1 parent 9d1add7780
commit e86ca723a9
1 file changed
+296 -22
+296 -22
View File
@@ -1739,6 +1739,13 @@ Screen { background: $ga-bg; color: $ga-fg; }
/* Reserve the 1-col scrollbar gutter up front so overflowing the window
doesn't suddenly squeeze the session rows narrower. */
scrollbar-gutter: stable;
scrollbar-background: $ga-bg;
scrollbar-background-hover: $ga-bg;
scrollbar-background-active: $ga-bg;
scrollbar-color: $ga-border;
scrollbar-color-hover: $ga-border-hi;
scrollbar-color-active: $ga-dim;
scrollbar-corner-color: $ga-bg;
}
#sidebar-scroll.-hidden, #sidebar-scroll.-narrow { display: none; }
@@ -1767,6 +1774,9 @@ Screen { background: $ga-bg; color: $ga-fg; }
scrollbar-color-active: $ga-dim;
}
#history-open { display: none; width: 100%; height: 1; min-height: 1; background: $ga-bg; color: $ga-muted; content-align: center middle; text-align: center; }
#history-open.-visible { display: block; }
/* Plan/todo panel — fixed 5-row card between messages and composer.
`display: none` default so the empty post-compose frame doesn't flash;
renderer flips it on once items materialize. Fixed height (no scroll)
@@ -2769,7 +2779,7 @@ class InputArea(TextArea):
Binding("ctrl+v", "paste", "Paste", show=False),
# macOS muscle-memory alias: most terminals swallow Cmd+V (forward via bracketed
# paste → _on_paste); this only hits if the terminal forwards Cmd as a key.
Binding("cmd+v", "paste", "Paste", show=False),
Binding("cmd+v,super+v", "paste", "Paste", show=False),
# Ctrl+U: readline-style kill-line, repurposed here to clear the whole input.
Binding("ctrl+u", "clear_input", "ClearInput", show=False),
# Ctrl+S: toggle-stash the current draft. First press → stash
@@ -3457,6 +3467,133 @@ class HelpScreen(ModalScreen):
yield Static(self._content)
@dataclass
class HistoryPrompt:
text: str
def __rich_console__(self, console, options):
from rich.padding import Padding
width = max(1, options.max_width)
lines = Text(self.text).wrap(console, width, overflow="fold")
if len(lines) > 3:
lines = lines[:3]
lines[-1].truncate(width - 1, overflow="crop")
lines[-1].append("…")
# OptionList's height calculation excludes component vertical padding.
# Match the main session sidebar: equal space above and below the text.
yield Padding(Text("\n").join(lines), (1, 0, 1, 0))
class HistoryScreen(ModalScreen):
"""Read-only history: full prompt index and one lazily rendered turn."""
CSS = """
HistoryScreen { background: $ga-bg; padding: 1 2; }
#history-title { height: 2; color: $ga-muted; }
#history-columns { height: 1fr; }
#history-index {
width: 25%; min-width: 18; border: none;
border-right: solid $ga-border; padding: 0 1 0 0;
background: $ga-bg;
}
/* OptionList tints its own background on focus. The option rows cover
most of that surface, leaving the scrollbar gutter as a bright stripe. */
#history-index:focus { background-tint: transparent; }
#history-detail {
width: 1fr; padding: 0 2; background: $ga-bg;
overflow-x: hidden; overflow-y: scroll;
}
#history-index, #history-detail {
scrollbar-size: 1 1;
scrollbar-background: $ga-bg;
scrollbar-background-hover: $ga-bg;
scrollbar-background-active: $ga-bg;
scrollbar-color: $ga-border;
scrollbar-color-hover: $ga-border-hi;
scrollbar-color-active: $ga-border-hi;
}
#history-index > .option-list--option { padding: 0 2; }
#history-detail .role { margin-top: 1; margin-bottom: 1; }
#history-detail .fold-header { color: $ga-muted; }
"""
BINDINGS = [Binding("escape", "dismiss", "返回", priority=True),
Binding("ctrl+g,cmd+shift+h", "dismiss", "返回", show=False, priority=True)]
def __init__(self, session):
super().__init__()
self.session = session
self.starts = [i for i, m in enumerate(session.messages) if m.role == "user"]
if session.messages and (not self.starts or self.starts[0] != 0):
self.starts.insert(0, 0)
self.end = len(session.messages)
self.selected_turn = None
self.preview = []
self._render_version = 0
def compose(self):
yield Static("全部历史 · ↑↓ 选择 · Tab 切换栏 · Esc 返回", id="history-title")
with Horizontal(id="history-columns"):
yield OptionList(id="history-index")
yield VerticalScroll(id="history-detail")
def on_mount(self):
self.call_after_refresh(self._populate_index)
def _populate_index(self):
index = self.query_one("#history-index", OptionList)
options = []
for turn in range(len(self.starts)):
m = self.session.messages[self.starts[turn]]
label = " ".join(m.content.split()) or "(空提问)"
options.append(Option(HistoryPrompt(label), id=str(turn)))
index.add_options(options)
if options:
index.highlighted = 0
index.focus()
async def on_option_list_option_highlighted(self, event):
event.stop()
self.selected_turn = int(event.option.id)
start = self.starts[self.selected_turn]
end = (self.starts[self.selected_turn + 1]
if self.selected_turn + 1 < len(self.starts) else self.end)
# Separate models keep preview folds and widget references out of live chat.
self.preview = [ChatMessage(m.role, m.content, done=True,
image_paths=list(m.image_paths))
for m in self.session.messages[start:end]]
await self._render_preview()
def on_option_list_option_selected(self, event):
event.stop()
async def _render_preview(self, *, preserve_scroll=False):
self._render_version += 1
version = self._render_version
detail = self.query_one("#history-detail", VerticalScroll)
scroll_y = detail.scroll_y if preserve_scroll else 0
await detail.remove_children()
if version != self._render_version or not self.is_mounted:
return
for m in self.preview:
self.app._mount_message(detail, m, preview=True)
if preserve_scroll:
self.call_after_refresh(detail.scroll_to, y=scroll_y, animate=False)
else:
detail.scroll_home(animate=False)
async def on_click(self, event):
if isinstance(event.widget, FoldHeader):
event.stop()
m = event.widget.msg
m._toggled_folds.symmetric_difference_update({event.widget.fold_idx})
m._cached_body = None
await self._render_preview(preserve_scroll=True)
def on_resize(self):
if self.is_mounted and self.preview:
self.call_after_refresh(self._render_preview)
class ThemePicker(ModalScreen):
# Live-preview theme picker: highlight applies the theme so the rest of the
# UI repaints behind the modal; Enter commits + persists, Esc reverts.
@@ -3543,9 +3680,11 @@ class GenericAgentTUI(App[None]):
BINDINGS = [
Binding("ctrl+c", "handle_ctrl_c", "Stop/Quit", show=False, priority=True),
# macOS muscle-memory aliases — only fire if the terminal forwards Cmd as a key
# (Terminal.app / default iTerm2 swallow them; Ghostty / WezTerm / kitty can forward).
Binding("cmd+c", "handle_ctrl_c", "Stop/Quit", show=False, priority=True),
Binding("ctrl+g,cmd+shift+h", "show_history", "历史", show=False, priority=True),
# Ghostty forwards Cmd+C to TUI applications in its default
# configuration, so GA can copy its internal Textual selection directly.
# Terminals that intercept Cmd+C natively may require their own setup.
Binding("cmd+c,super+c", "copy_selection", "Copy", show=False, priority=True),
Binding("ctrl+n", "new_session", "New", show=False),
Binding("cmd+n", "new_session", "New", show=False),
Binding("ctrl+b", "toggle_sidebar","Sidebar", show=False),
@@ -3567,6 +3706,13 @@ class GenericAgentTUI(App[None]):
def __init__(self, agent_factory: Optional[AgentFactory] = None) -> None:
super().__init__()
self._apple_terminal_ime = (
sys.platform == "darwin"
and os.environ.get("TERM_PROGRAM") == "Apple_Terminal"
)
self._input_repaint_pending = False
self._pending_stream_renders: dict[tuple[int, int], ChatMessage] = {}
self._stream_render_timer = None
self.agent_factory: AgentFactory = agent_factory or default_agent_factory
self.sessions: dict[int, AgentSession] = {}
self.current_id: Optional[int] = None
@@ -3682,6 +3828,7 @@ class GenericAgentTUI(App[None]):
_sidebar.can_focus = False
yield _sidebar
with Vertical(id="main"):
yield Static("Ctrl+G 查看所有历史会话", id="history-open")
yield VerticalScroll(id="messages")
# Plan card: pinned header/step (#planbar-head) above a task list
# (#planbar) that scrolls inside a 4-row window (#planbar-tasks).
@@ -3719,6 +3866,9 @@ class GenericAgentTUI(App[None]):
# CSS `#planbar-scroll { display: none }` keeps it hidden by default —
# the renderer adds `-visible` once plan items materialize.
self.query_one("#input", InputArea).focus()
messages = self.query_one("#messages", VerticalScroll)
self.watch(messages, "scroll_y", self._watch_messages_scroll, init=False)
self.call_after_refresh(self._update_history_open)
self.set_interval(0.5, self._tick)
self._patch_auto_scroll_for_selection()
self._start_plan_watcher()
@@ -3737,6 +3887,18 @@ class GenericAgentTUI(App[None]):
self._last_size = size
self._apply_responsive_layout()
def _schedule_input_repaint(self) -> None:
if self._apple_terminal_ime and not self._input_repaint_pending:
self._input_repaint_pending = True
self.call_after_refresh(self._repaint_input_rows)
def _repaint_input_rows(self) -> None:
"""Repair committed input rows, including cells beyond the composer."""
self._input_repaint_pending = False
inp = self.query_one("#input", InputArea)
region = inp.region
self.screen.refresh(Region(0, region.y, self.screen.size.width, region.height))
def _patch_auto_scroll_for_selection(self) -> None:
# Make selection-drag into #input still scroll #messages: include _select_start as a
# candidate source, and trigger when the mouse leaves the scrollable above or below.
@@ -4195,9 +4357,8 @@ class GenericAgentTUI(App[None]):
return
super().copy_to_clipboard(text)
def action_handle_ctrl_c(self) -> None:
# Two-stage quit: when no task is running, first press clears input and arms;
# second press within 2s exits.
def _copy_selected_text(self) -> bool:
"""Copy application selection; an empty selection must not stop a task."""
try:
inp = self.query_one("#input", InputArea)
except Exception:
@@ -4208,7 +4369,7 @@ class GenericAgentTUI(App[None]):
try: self.copy_to_clipboard(inp.selected_text)
except Exception: pass
self._disarm_quit()
return
return True
try:
selected_text = self.screen.get_selected_text()
except Exception:
@@ -4217,7 +4378,21 @@ class GenericAgentTUI(App[None]):
try: self.copy_to_clipboard(selected_text)
except Exception: pass
self._disarm_quit()
return True
return False
def action_copy_selection(self) -> None:
self._copy_selected_text()
def action_handle_ctrl_c(self) -> None:
# Ctrl+C retains the TUI's stop/quit behavior. Cmd+C is handled by the
# copy action above when the terminal forwards it to the application.
if self._copy_selected_text():
return
try:
inp = self.query_one("#input", InputArea)
except Exception:
inp = None
sess = self.sessions.get(self.current_id)
if sess is not None and sess.status == "running":
self._cmd_stop([], "")
@@ -4260,7 +4435,7 @@ class GenericAgentTUI(App[None]):
except Exception: pass
def on_key(self, event: events.Key) -> None:
if self._quit_armed and event.key not in ("ctrl+c", "cmd+c"):
if self._quit_armed and event.key != "ctrl+c":
self._disarm_quit()
if self._rewind_armed and event.key != "escape":
self._disarm_rewind()
@@ -4466,6 +4641,7 @@ class GenericAgentTUI(App[None]):
("Esc", "取消选择 / 关闭面板 / 关闭帮助"),
("Esc Esc", "打开回退选择"),
(fmt_key("ctrl+t"), "切换主题"),
(fmt_keys("ctrl+g", "cmd+shift+h"), "查看全部历史"),
(fmt_key("ctrl+/"), "显示 / 隐藏本帮助"),
]
t = Text()
@@ -4601,7 +4777,7 @@ class GenericAgentTUI(App[None]):
m._segment_widgets = []
m._segment_sig = ()
m._spinner_widget = None
for m in self.current.messages:
for m in self._recent_messages(self.current):
self._mount_message(container, m)
if was_at_bottom:
container.scroll_end(animate=False)
@@ -4621,6 +4797,7 @@ class GenericAgentTUI(App[None]):
inp._skip_change_next = False
return
self._resize_input(inp)
self._schedule_input_repaint()
val = (inp.text or "").lstrip()
if self._suppress_palette_open:
self._suppress_palette_open = False
@@ -6348,6 +6525,10 @@ class GenericAgentTUI(App[None]):
pass
def on_unmount(self) -> None:
if self._stream_render_timer is not None:
self._stream_render_timer.stop()
self._stream_render_timer = None
self._pending_stream_renders.clear()
self._reset_terminal_title()
# Drop this run's empty signal dirs on graceful exit; the startup
# sweep mops up anything a crash leaves behind.
@@ -6569,6 +6750,8 @@ class GenericAgentTUI(App[None]):
return
def _on_stream(self, agent_id, task_id, text, done):
if done:
self._pending_stream_renders.pop((agent_id, task_id), None)
s = self.sessions.get(agent_id)
if not s: return
if s.current_task_id != task_id:
@@ -6591,16 +6774,16 @@ class GenericAgentTUI(App[None]):
except Exception: self._refresh_messages()
else:
self._refresh_messages()
if refresh_chrome:
self._refresh_sidebar()
self._refresh_topbar()
self._refresh_sidebar()
self._refresh_topbar()
self._ensure_spinner()
return
s.buffer = text
if done:
s.status = "idle"
s.current_display_queue = None
self._update_assistant(agent_id, text, task_id=task_id, done=done, refresh_chrome=True)
self._update_assistant(agent_id, text, task_id=task_id, done=done,
refresh_chrome=True, defer_render=not done)
if done:
self._rw_commit(s) # 落 checkpoint 节点(文件改动已由 tool_before 钩子追踪)
self._update_plan_state(s, text)
@@ -6772,7 +6955,26 @@ class GenericAgentTUI(App[None]):
if agent_id != prev: self.current_id = prev
return value
def _update_assistant(self, agent_id, text, *, task_id=None, done=True, refresh_chrome=False):
def _flush_stream_renders(self) -> None:
self._stream_render_timer = None
pending = self._pending_stream_renders
self._pending_stream_renders = {}
refreshed = False
for (agent_id, task_id), message in pending.items():
sess = self.sessions.get(agent_id)
if (sess is None or agent_id != self.current_id
or sess.current_task_id != task_id or message.done
or not any(m is message for m in sess.messages)):
continue
self._render_assistant_update(message)
refreshed = True
if refreshed:
self._refresh_sidebar()
self._refresh_topbar()
self._ensure_spinner()
def _update_assistant(self, agent_id, text, *, task_id=None, done=True,
refresh_chrome=False, defer_render=False):
# task_id=None matches the last assistant message; otherwise matches by task_id.
s = self.sessions.get(agent_id)
if not s: return
@@ -6785,6 +6987,20 @@ class GenericAgentTUI(App[None]):
break
if agent_id != self.current_id:
return
if defer_render and found is not None:
# Keep data current for stop/copy/session switching; coalesce only paint.
self._pending_stream_renders[(agent_id, task_id)] = found
if self._stream_render_timer is None:
self._stream_render_timer = self.set_timer(0.05, self._flush_stream_renders)
return
self._pending_stream_renders.pop((agent_id, task_id), None)
self._render_assistant_update(found)
if refresh_chrome:
self._refresh_sidebar()
self._refresh_topbar()
self._ensure_spinner()
def _render_assistant_update(self, found: Optional[ChatMessage]) -> None:
if found and found._segment_widgets:
try:
container = self.query_one("#messages", VerticalScroll)
@@ -6796,10 +7012,6 @@ class GenericAgentTUI(App[None]):
self._refresh_messages()
else:
self._refresh_messages()
if refresh_chrome:
self._refresh_sidebar()
self._refresh_topbar()
self._ensure_spinner()
# ---------------- Plan/todo panel ----------------
# State machine (graces absorb mid-stream parse misses / let final tally read):
@@ -7178,6 +7390,45 @@ class GenericAgentTUI(App[None]):
except Exception:
return True
def _watch_messages_scroll(self, _scroll_y) -> None:
self._update_history_open()
try:
messages = self.query_one("#messages", VerticalScroll)
# Scrolling changes which virtual rows are visible. In some terminal
# backends the compositor leaves stale cells until the container is
# explicitly invalidated.
messages.refresh()
except Exception:
pass
def _update_history_open(self) -> None:
"""Show the history entry only while the main conversation is at its top."""
try:
messages = self.query_one("#messages", VerticalScroll)
button = self.query_one("#history-open", Static)
except Exception:
return
starts = [m for m in self.current.messages if m.role == "user"]
truncated = len(starts) > 20
button.set_class(truncated and messages.scroll_y <= 0, "-visible")
def action_show_history(self):
if isinstance(self.screen, HistoryScreen):
self.pop_screen()
elif self.current_id is not None:
self.push_screen(HistoryScreen(self.current))
@staticmethod
def _recent_messages(sess):
# Keep complete recent turns, including all pending interaction cards.
starts = [i for i, m in enumerate(sess.messages) if m.role == "user"]
start = starts[-20] if len(starts) > 20 else 0
for i, m in enumerate(sess.messages[:start]):
if m.kind in ("choice", "multi_choice") and m.selected_label is None:
start = i
break
return sess.messages[start:]
def _refresh_messages(self):
if not self.is_mounted or self.current_id is None: return
sess = self.current
@@ -7193,7 +7444,20 @@ class GenericAgentTUI(App[None]):
m._segment_sig = ()
m._spinner_widget = None
self._last_session_id = sess.agent_id
recent = self._recent_messages(sess)
visible = {id(m) for m in recent}
for m in sess.messages:
if id(m) not in visible and m._role_widget is not None:
widgets = [m._role_widget, m._body_widget, m._hint_widget,
m._spinner_widget, *m._segment_widgets]
for w in {id(w): w for w in widgets if w is not None}.values():
w.remove()
m._role_widget = m._body_widget = m._hint_widget = m._spinner_widget = None
m._segment_widgets = []
m._segment_sig = ()
m._cached_body = None
m._seg_render_cache.clear()
for m in recent:
if m._role_widget is None:
self._mount_message(container, m)
if was_at_bottom:
@@ -7610,7 +7874,7 @@ class GenericAgentTUI(App[None]):
# (in-place .update of last widget) vs. full remount (when folds appear/expand).
return tuple((kind, idx) for kind, _, idx in segs)
def _mount_message(self, container: VerticalScroll, m: ChatMessage) -> None:
def _mount_message(self, container: VerticalScroll, m: ChatMessage, *, preview=False) -> None:
# Looked up at call time (not class init) so theme switches propagate.
color = {"user": C_PURPLE, "system": C_BLUE, "assistant": C_GREEN}.get(m.role, C_GREEN)
label = m.role.upper() if m.role != "assistant" else "AGENT"
@@ -7685,7 +7949,8 @@ class GenericAgentTUI(App[None]):
container.mount(m._body_widget)
return
if m.role == "user":
body = Text(); body.append("> ", style=C_DIM); body.append(_elide_user_display(m.content), style=C_FG)
body = Text(); body.append("> ", style=C_DIM); body.append(
m.content if preview else _elide_user_display(m.content), style=C_FG)
for path in m.image_paths:
body.append(f"\n📎 {path}", style=C_MUTED)
m._body_widget = SelectableStatic(body, classes="msg")
@@ -7696,7 +7961,8 @@ class GenericAgentTUI(App[None]):
container.mount(m._body_widget)
return
# assistant — multi-segment for per-fold click-to-expand
segs = self._assistant_segments(m, self._messages_width())
width = max(10, container.scrollable_content_region.width) if preview else self._messages_width()
segs = self._assistant_segments(m, width)
self._mount_assistant_segments(container, m, segs)
def _mount_assistant_segments(self, container, m: ChatMessage, segs: list[tuple],
@@ -7801,6 +8067,10 @@ class GenericAgentTUI(App[None]):
# progress-bar overwrites still work.
display = last_text.replace("\r\n", "\n")
last_widget.update(Text.from_ansi(display, style=C_FG))
try:
self.query_one("#messages", VerticalScroll).refresh()
except Exception:
pass
if m.done and m._spinner_widget is not None:
# Convert the live spinner into the post-turn ⠿ card in place.
self._capture_done_summary(m)
@@ -7843,6 +8113,10 @@ class GenericAgentTUI(App[None]):
m._spinner_widget = None
segs = self._assistant_segments(m, self._messages_width())
self._mount_assistant_segments(container, m, segs, after=anchor)
try:
container.refresh()
except Exception:
pass
# ---------- CLI ----------