diff --git a/frontends/tuiapp_v2.py b/frontends/tuiapp_v2.py index 8e37ec7f..5234e70a 100644 --- a/frontends/tuiapp_v2.py +++ b/frontends/tuiapp_v2.py @@ -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 ----------