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:
1 parent
9d1add7780
commit
e86ca723a9
1 file changed
+296
-22
+296
-22
@@ -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 ----------
|
||||
|
||||
Reference in new issue
Block a user