Merge PR #513: fix(tui): v2+v3 bug fixes, pending input queue, ! shell magic, /resume

Merged pull request #513 from shenhao-stu:tui-v2-v3-fixes-pending-shell
This commit is contained in:
LJQ authored and GitHub committed 2026-05-28 09:01:47 +08:00
commit 11a0dfef55
3 files changed
+1058 -165

No files matched your search

+13 -10
View File
@@ -343,18 +343,20 @@ def start_service(name: str) -> tuple[bool, str]:
def _match_service(cmdline: list[str], svc: dict) -> bool:
"""Does this OS process belong to `svc`? Match on the trailing script
arg (`reflect/foo.py` for reflect tasks, `frontends/bar.py` for apps),
which is invariant across `python` vs `pythonw` vs venv shims."""
which is invariant across `python` vs `pythonw` vs venv shims.
Reflect detection used to require BOTH `agentmain.py` AND the reflect
path in cmdline. That rejected tasks launched directly (`python
reflect/scheduler.py`) by launch.pyw, dev scripts, or by an earlier
TUI run that used a different launcher — they showed unticked in
/scheduler even when alive. Path-only match handles both styles; the
Python-process pre-filter in `running_services` keeps false positives
(greps, editors with the file open) from sneaking in."""
if not cmdline:
return False
rel = svc["name"] # 'reflect/foo.py' | 'frontends/bar.py'
if svc["kind"] == "reflect":
# agentmain.py --reflect reflect/foo.py
has_main = any("agentmain.py" in (a or "") for a in cmdline)
has_rel = any(rel.replace("/", os.sep) in (a or "") or rel in (a or "")
for a in cmdline)
return has_main and has_rel
# frontend: either `python frontends/foo.py` or `python -m streamlit run frontends/stapp.py …`
return any(rel.replace("/", os.sep) in (a or "") or rel in (a or "")
rel_norm = rel.replace("/", os.sep)
return any(rel_norm in (a or "") or rel in (a or "")
for a in cmdline)
@@ -503,7 +505,8 @@ PALETTE_ENTRIES: list[tuple[str, str, str]] = [
("/goal", "[goal]", "进入 Goal 模式(需 condition 约束)"),
("/hive", "[target]", "进入 Hive 多 worker 协作模式"),
("/conductor", "[task]", "调用 frontends/conductor.py 多 subagent 编排"),
("/scheduler", "", "多选启动 reflect 任务 / 查看 cron"),
("/scheduler", "", "多选启动/停止 reflect 任务(cron 由 reflect/scheduler.py 驱动)"),
("/resume", "", "列出最近会话并恢复其中一个(GA 端展开 prompt)"),
]
+634 -91
View File
File diff suppressed because it is too large. Load diff
+411 -64
View File
@@ -21,6 +21,10 @@ import sys
import tempfile
import threading
import time
# Local: cross-platform shortcut-label formatter (Win/Linux "Ctrl+B" vs mac "⌃B").
# Imported early because _TIPS at module load time uses fmt_key().
from keysym import fmt_key, fmt_keys # noqa: E402
from dataclasses import dataclass, field
from itertools import count
from typing import Any, Callable, Optional
@@ -55,6 +59,7 @@ try:
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.message import Message
from textual.screen import ModalScreen
from textual.widget import Widget
from textual.widgets import Input, OptionList, SelectionList, Static, TextArea
from textual.widgets.option_list import Option
from textual.widgets.selection_list import Selection
@@ -96,10 +101,13 @@ _ANSI_CONTROL_RE = re.compile(
# (e.g. mapping narrow rendered output to source positions for selection).
_ANSI_SGR_RE = re.compile(r"\x1b\[[0-9;]*m")
# Strip the leading `**LLM Running (Turn N) ...**` marker that agent_loop yields per turn.
# Strip the leading turn marker that agent_loop yields per turn — covers
# both the default `**LLM Running (Turn N) ...**` and the task-mode short
# `**Turn N ...**` (agent_loop.py:52 switches when handler.parent.task_dir
# is set; v2 now sets task_dir for the _intervene injection hook).
# fold_turns still needs the marker in source content to split turns, so we only strip at
# render time. Applies to the live (last) text segment, since folded turns don't include it.
_TURN_MARKER_RE = re.compile(r"^\s*\**LLM Running \(Turn \d+\) \.\.\.\**\s*", re.MULTILINE)
_TURN_MARKER_RE = re.compile(r"^\s*\**(?:LLM Running \()?Turn \d+\)?[^\n]*\**\s*", re.MULTILINE)
# Commonmark task-list patterns: `- [ ] foo` / `* [x] foo` / `+ [X] foo`.
# Group 1 keeps the bullet + leading space so we can substitute the [ ] / [x]
@@ -134,10 +142,10 @@ _TIPS = (
"Tip: /cost 查看 token 用量;/cost all 列出所有会话的累计。",
"Tip: /continue 列出最近 20 个历史会话,按 Enter 进入。",
"Tip: /btw <问题> 让 side-agent 回答而不打断主任务。",
"Tip: Ctrl+B 折叠侧栏;Ctrl+O 切换长输出折叠;Ctrl+/ 查看快捷键。",
"Tip: Ctrl+N 新建会话;Ctrl+↑/↓ 在多个会话间切换。",
f"Tip: {fmt_key('ctrl+b')} 折叠侧栏;{fmt_key('ctrl+o')} 切换长输出折叠;{fmt_key('ctrl+/')} 查看快捷键。",
f"Tip: {fmt_key('ctrl+n')} 新建会话;{fmt_keys('ctrl+up','ctrl+down')} 在多个会话间切换。",
"Tip: 粘贴图片 / 文件后会自动折叠成 [Image #N] / [File #N] 占位符。",
"Tip: 多行输入用 Ctrl+J 换行;Enter 直接发送。",
f"Tip: 多行输入用 {fmt_key('ctrl+j')} 换行;Enter 直接发送。",
"Tip: /rewind <n> 回退最近 n 轮对话;/stop 中止当前任务。",
"Tip: /export clip 把上一条回复复制到剪贴板;/export all 给出完整日志路径。",
"Tip: /branch [name] 从当前历史分裂出新会话,互不污染。",
@@ -150,7 +158,7 @@ _TIPS = (
"Tip: /conductor <任务> 直接交给 frontends/conductor.py 做多 subagent 编排。",
"Tip: /update 是双分支 upstream 同步 —— 先 diff 预演,再分别快进。",
"Tip: /scheduler 里再点一下已勾选的任务可以 stop —— 取消勾选 = 停止。",
"Tip: Ctrl+S 把当前输入 stash 起来,下次 / 打开 picker 时还在。",
f"Tip: {fmt_key('ctrl+s')} 把当前输入 stash 起来,下次 / 打开 picker 时还在。",
)
@@ -253,7 +261,7 @@ def fold_turns(text: str) -> list[dict]:
# Line-anchored so backticks embedded in tool output (e.g. `N|\`\`\`\``
# gutter from file_read) don't pair with later real fences.
safe = re.sub(r"^`{4,}.*?^`{4,}\n?", stash, text, flags=re.DOTALL | re.MULTILINE)
parts = re.split(r"(\**LLM Running \(Turn \d+\) \.\.\.\**)", safe)
parts = re.split(r"(\**(?:LLM Running \()?Turn \d+\)? \.\.\.\**)", safe)
parts = [re.sub(r"\x00PH(\d+)\x00", lambda m: placeholders[int(m.group(1))], p) for p in parts]
if len(parts) < 4:
return [{"type": "text", "content": text}]
@@ -1182,6 +1190,12 @@ class AgentSession:
# Boundary between restored history (≤ idx) and this run (> idx);
# `/continue` bumps to `len(messages)` so old plan cards don't resurrect.
plan_scan_baseline: int = 0
# Pending user inputs queued while this session was running (codex-style).
# Drained 300 ms after the current turn ends (`_pending_cooldown`). The
# cooldown gives the user a chance to Esc-cancel or amend the most recent
# entry via the Up arrow (popped back into the input box).
pending: list[str] = field(default_factory=list)
pending_drain_at: float = 0.0 # epoch deadline; 0 = no drain pending
def default_agent_factory() -> Any:
@@ -1215,8 +1229,9 @@ COMMANDS = [
("/goal", "[goal]", "进入 Goal 模式(需 condition 约束)"),
("/hive", "[target]", "进入 Hive 多 worker 协作模式"),
("/conductor", "[task]", "调用 frontends/conductor.py 多 subagent 编排"),
("/scheduler", "", "多选启动 reflect 任务 / 查看 cron"),
("/scheduler", "", "多选启动/停止 reflect 任务(cron 由 reflect/scheduler.py 驱动)"),
("/continue", "[n|name]", "列出 / 恢复历史会话"),
("/resume", "", "列出最近会话并恢复其中一个"),
("/cost", "[all]", "显示当前会话 token 用量(all = 所有会话)"),
("/export", "clip|<file>|all", "导出最后回复"),
("/restore", "", "恢复上次模型响应日志"),
@@ -1238,6 +1253,12 @@ EDIT_ANSWER_CHOICE = "\x00__edit_answer__"
class ChoiceList(OptionList):
BINDINGS = [*OptionList.BINDINGS,
Binding("right", "select", "Select", show=False),
# `left` mirrors Esc — pickers spawned with an on_cancel
# (e.g. /scheduler's submit-confirm card → rollback to
# picker) get a directional way to back out without
# reaching for Esc. Choices without an on_cancel just
# dismiss, same as Esc.
Binding("left", "cancel", "Back", show=False),
Binding("escape", "cancel", "Cancel", show=False)]
def __init__(self, msg: "ChatMessage", *options, **kwargs):
@@ -1634,6 +1655,14 @@ class MultiChoiceList(SelectionList):
class SelectableStatic(Static):
# PR #461: a SelectableStatic that gets removed from the DOM but whose
# reference still lingers (e.g. cached in a closure) was firing mouse
# selection on stale screen coordinates. has_valid_selection_parent
# is the cheap "am I still in the tree?" probe used by the screen-
# level mouse-event filter (`_is_stale_selectable_mouse_event`).
def has_valid_selection_parent(self) -> bool:
return isinstance(self.parent, Widget)
# Widget.get_selection returns None for non-Text/Content visuals; fall back to render_line.
def get_selection(self, selection):
render = getattr(self, "_ga_render", None)
@@ -1795,42 +1824,66 @@ class InputArea(TextArea):
def action_stash(self) -> None:
"""Stash/restore the input draft.
- Non-empty input → save into `_draft_stash`, then `reset()` the
TextArea. `reset()` is O(1)-ish vs the multi-second relayout
that `self.text = ""` triggers on large drafts.
- Empty input + a previous stash → put the saved text back so the
user can keep typing where they left off.
- Empty input + no stash → no-op (accidental presses harmless).
Long-session note (2026-05-28, 2nd pass): the previous fix tried to
keep `reset()` synchronous and only deferred the palette/resize
cleanup, but `reset()` itself still queues a Changed event behind
the streaming reactive cycle. When the queue is saturated with
per-chunk repaints the visible clear never lands and the user sees
a frozen input. Now we defer the reset itself: only the buffer
snapshot happens on the keystroke; the visible clear and layout
work both happen on the next event-loop tick via
`call_after_refresh`. Trades a 1-frame delay for guaranteed
keystroke responsiveness.
"""
current = self.text
if current:
self._draft_stash = current
self.reset()
self._history_index = -1
self._history_stash = ""
try: self.app._hide_palette()
except Exception: pass
try: self.app._resize_input(self)
except Exception: pass
try:
self.app.call_after_refresh(self._stash_cleanup_clear)
except Exception:
# Last-resort synchronous fallback (re-introduces the freeze
# window but at least keeps the function correct).
self._stash_cleanup_clear()
elif self._draft_stash:
stashed = self._draft_stash
self._draft_stash = ""
self._history_index = -1
self._history_stash = ""
# Suppress palette popup during the bulk insert — restoring
# a slash-command draft should not re-trigger the command picker.
try: self._suppress_palette_next_change()
except Exception: pass
self.text = stashed
try:
# `cursor_location = document.end` is multi-line safe;
# `move_cursor((0, len(stashed)))` clamps to row 0 and
# would mis-position the caret on multi-line drafts.
self.cursor_location = self.document.end
self.app.call_after_refresh(self._stash_cleanup_restore, stashed)
except Exception:
pass
try: self.app._resize_input(self)
except Exception: pass
self._stash_cleanup_restore(stashed)
def _stash_cleanup_clear(self) -> None:
"""Deferred companion to action_stash (clear path).
Runs OUTSIDE the keystroke event so the Textual layout cascade
triggered by reset() doesn't block the user from typing more.
`_skip_change_next` still short-circuits on_input_area_changed
so the cascade hits once (from `reset()` itself), not twice."""
self._skip_change_next = True
try:
self.reset()
finally:
self._skip_change_next = False
try: self.app._hide_palette()
except Exception: pass
try: self.app._resize_input(self)
except Exception: pass
def _stash_cleanup_restore(self, stashed: str) -> None:
"""Deferred companion to action_stash (restore path)."""
try: self._suppress_palette_next_change()
except Exception: pass
self.text = stashed
try:
self.cursor_location = self.document.end
except Exception:
pass
try: self.app._resize_input(self)
except Exception: pass
def action_clear_input(self) -> None:
self.reset()
@@ -1946,6 +1999,12 @@ class InputArea(TextArea):
# Ctrl+S scratch draft (PR#479 semantics). Distinct from
# `_history_stash`, which is the Up/Down-arrow working buffer.
self._draft_stash: str = ""
# Set by `action_stash` to make on_input_area_changed bail out on
# the synchronous Changed event from `reset()` — the layout work
# is rescheduled via `call_after_refresh` so the keystroke handler
# returns immediately even when streaming has the reactive queue
# saturated. Cleared by `_stash_cleanup_clear`.
self._skip_change_next: bool = False
self._HISTORY_MAX = 200
def expand_placeholders(self, text: str) -> str:
@@ -2067,6 +2126,29 @@ class InputArea(TextArea):
# 3) history browse: only at (0,0) for up / end-of-text for down, so in-line
# cursor movement is preserved.
if event.key == "up" and self.cursor_location == (0, 0):
# Pending-queue recall: Up on an empty input pops the most-
# recent queued message back into the composer for amendment
# (LIFO). Re-submitting it appends at the tail again; Esc
# clears the whole queue. Takes priority over history when
# there's nothing typed AND something is queued.
if not self.text:
try:
sess = self.app.current
except Exception:
sess = None
if sess is not None and sess.pending:
self._suppress_palette_next_change()
self.text = sess.pending.pop()
if not sess.pending:
sess.pending_drain_at = 0.0
if sess.agent_id == self.app.current_id:
try: self.app._refresh_bottombar()
except Exception: pass
try:
self.cursor_location = self.document.end
except Exception:
pass
event.stop(); event.prevent_default(); return
if self._history_up():
event.stop(); event.prevent_default(); return
if event.key == "down":
@@ -2226,16 +2308,17 @@ def render_bottombar(quit_armed: bool = False, rewind_armed: bool = False) -> Ta
t.add_column(justify="left")
left = Text()
if quit_armed:
left.append("再按 Ctrl+C 退出", style=f"bold {C_GREEN}")
left.append(f"再按 {fmt_key('ctrl+c')} 退出", style=f"bold {C_GREEN}")
elif rewind_armed:
left.append("再按 Esc 回退", style=f"bold {C_GREEN}")
else:
pairs = [("Enter", "发送"), ("Ctrl+N", "新会话"),
("Ctrl+B", "侧栏"), ("Ctrl+C", "停止/退出"),
("/", "命令面板"), ("Ctrl+/", "快捷键帮助")]
for i, (k, d) in enumerate(pairs):
pairs = [("enter", "发送"), ("ctrl+n", "新会话"),
("ctrl+b", "侧栏"), ("ctrl+c", "停止/退出"),
("/", "命令面板"), ("ctrl+/", "快捷键帮助")]
for i, (combo, d) in enumerate(pairs):
if i: left.append(" ")
left.append(k, style=C_GREEN if k in ("/", "Ctrl+/") else C_FG)
k = "/" if combo == "/" else fmt_key(combo)
left.append(k, style=C_GREEN if combo in ("/", "ctrl+/") else C_FG)
left.append(" ")
left.append(d, style=C_MUTED)
t.add_row(left)
@@ -2540,6 +2623,38 @@ class GenericAgentTUI(App[None]):
except Exception:
pass
# PR #461 (upstream 08f21e8): suppress mouse events that target a
# SelectableStatic whose parent is no longer in the widget tree.
# Such widgets persist as cached references (e.g. the ChatMessage
# picker collapse path stashes the previous body widget) but they're
# no longer mounted, so any MouseDown/MouseMove on their old screen
# rect would fire selection callbacks on detached objects → crash.
def _is_stale_selectable_mouse_event(self, event) -> bool:
if not isinstance(event, (events.MouseDown, events.MouseMove)):
return False
try:
select_widget, select_offset = self.screen.get_widget_and_offset_at(
event.x, event.y
)
except Exception:
return False
return (
select_offset is not None
and isinstance(select_widget, SelectableStatic)
and not select_widget.has_valid_selection_parent()
)
async def on_event(self, event) -> None:
if self._is_stale_selectable_mouse_event(event):
if isinstance(event, events.MouseDown):
try:
self.screen.clear_selection()
except Exception:
pass
event.stop()
return
await super().on_event(event)
def compose(self) -> ComposeResult:
yield Static("", id="topbar")
with Horizontal(id="body"):
@@ -2555,7 +2670,7 @@ class GenericAgentTUI(App[None]):
show_line_numbers=False,
compact=True,
highlight_cursor_line=False,
placeholder="输入指令或问题... (Enter 发送, Ctrl+J 换行, / 唤起命令面板)",
placeholder=f"输入指令或问题... (Enter 发送, {fmt_key('ctrl+j')} 换行, / 唤起命令面板)",
)
# Tip line sits inside #main so it doesn't compete for height
# with #body's 1fr. Content set at compose so the first frame
@@ -2565,13 +2680,15 @@ class GenericAgentTUI(App[None]):
def on_mount(self) -> None:
self.add_session("main")
self._system("Welcome to GenericAgent TUI. 按 / 唤起命令面板,Ctrl+N 新建会话。")
self._system(f"Welcome to GenericAgent TUI. 按 / 唤起命令面板,{fmt_key('ctrl+n')} 新建会话。")
# CSS `#planbar { display: none }` keeps it hidden by default —
# the renderer flips it on once items materialize.
self.query_one("#input", InputArea).focus()
self.set_interval(0.5, self._tick)
self._patch_auto_scroll_for_selection()
self._start_plan_watcher()
self._start_pending_watcher()
self._start_tip_rotator()
self._apply_responsive_layout()
# Disable alternate scroll mode (?1007). Textual enables ?1006 SGR mouse but doesn't
@@ -2664,6 +2781,17 @@ class GenericAgentTUI(App[None]):
agent = self.agent_factory()
try: agent.inc_out = True
except Exception: pass
# Give the agent a per-session task_dir so ga.turn_end_callback's
# `_intervene` file hook (ga.py:576) can fire — that's how pending
# user messages slip into the current turn's next LLM call instead
# of waiting for the whole run to end. Dedicated PID+session dir
# so concurrent sessions don't share an intervene file.
try:
agent.task_dir = os.path.join(FRONTENDS_DIR, '..', 'temp',
f'_tui_v2_{os.getpid()}_{agent_id}')
os.makedirs(agent.task_dir, exist_ok=True)
except Exception:
pass
sess = AgentSession(agent_id=agent_id, name=name or f"agent-{agent_id}", agent=agent)
thread = threading.Thread(target=agent.run, name=f"ga-tui-agent-{agent_id}", daemon=True)
thread.start()
@@ -2861,6 +2989,25 @@ class GenericAgentTUI(App[None]):
self.query_one("#input", InputArea).focus()
self._disarm_rewind()
return
# Pending-queue cancel: Esc with the input empty and entries queued
# drops the lot. Runs before quit-arm so a single Esc clears the
# queue (no second-press needed).
try:
sess = self.current
except Exception:
sess = None
if sess is not None and sess.pending:
try:
inp_empty = not (self.query_one("#input", InputArea).text or "")
except Exception:
inp_empty = True
if inp_empty:
n = len(sess.pending)
sess.pending = []
sess.pending_drain_at = 0.0
self._system(f"已清空 {n} 条待发送消息")
self._disarm_rewind()
return
if self._quit_armed:
self._disarm_quit()
return
@@ -2960,30 +3107,30 @@ class GenericAgentTUI(App[None]):
def _render_help(self) -> Text:
rows = [
("Enter", "发送"),
("Ctrl+J / Ctrl+Enter", "换行(Shift+Enter 同义)"),
("Ctrl+C", "停止任务 / 空闲时连按两次退出"),
("Ctrl+N", "新建会话"),
("Ctrl+B", "切换侧栏"),
("Ctrl+↑ / Ctrl+↓", "切换会话"),
("Ctrl+D", "侧栏移除会话"),
("Ctrl+O", "折叠 / 展开已完成的轮次"),
("Ctrl+U", "清空输入框"),
("Ctrl+V", "粘贴(图片优先)"),
("↑ / ↓", "输入框:浏览发送历史 / 面板内:移动"),
("/", "唤起命令面板"),
("Tab", "命令面板可见时补全"),
("Esc", "取消选择 / 关闭面板 / 关闭帮助"),
("Esc Esc", "打开回退选择"),
("Ctrl+T", "切换主题"),
("Ctrl+/", "显示 / 隐藏本帮助"),
("Enter", "发送"),
(fmt_keys("ctrl+j", "ctrl+enter"), "换行(Shift+Enter 同义)"),
(fmt_key("ctrl+c"), "停止任务 / 空闲时连按两次退出"),
(fmt_key("ctrl+n"), "新建会话"),
(fmt_key("ctrl+b"), "切换侧栏"),
(fmt_keys("ctrl+up", "ctrl+down"), "切换会话"),
(fmt_key("ctrl+d"), "侧栏移除会话"),
(fmt_key("ctrl+o"), "折叠 / 展开已完成的轮次"),
(fmt_key("ctrl+u"), "清空输入框"),
(fmt_key("ctrl+v"), "粘贴(图片优先)"),
("↑ / ↓", "输入框:浏览发送历史 / 面板内:移动"),
("/", "唤起命令面板"),
("Tab", "命令面板可见时补全"),
("Esc", "取消选择 / 关闭面板 / 关闭帮助"),
("Esc Esc", "打开回退选择"),
(fmt_key("ctrl+t"), "切换主题"),
(fmt_key("ctrl+/"), "显示 / 隐藏本帮助"),
]
t = Text()
t.append("快捷键帮助\n\n", style=f"bold {C_GREEN}")
for k, d in rows:
t.append(f" {k:<22}", style=C_FG)
t.append(f"{d}\n", style=C_MUTED)
t.append("\n按 Esc 或 Ctrl+/ 关闭", style=C_DIM)
t.append(f"\n按 Esc 或 {fmt_key('ctrl+/')} 关闭", style=C_DIM)
return t
def action_complete_command(self) -> None:
@@ -3118,6 +3265,14 @@ class GenericAgentTUI(App[None]):
if event.text_area.id != "input":
return
inp = event.text_area
# action_stash flips this flag right before `reset()`/text assign
# so the synchronous Changed event won't trigger the heavy
# _resize_input + palette-query on the keystroke hot path. The
# deferred `_stash_cleanup_*` callbacks (call_after_refresh) own
# all the layout work for that path.
if getattr(inp, "_skip_change_next", False):
inp._skip_change_next = False
return
self._resize_input(inp)
val = (inp.text or "").lstrip()
if self._suppress_palette_open:
@@ -3174,6 +3329,17 @@ class GenericAgentTUI(App[None]):
sess.agent.load_llm_sessions()
except Exception:
pass
if text.startswith("!"):
# Shell-mode magic: run the rest as a host shell command, echo
# the command + output into scrollback, and append the pair to
# the agent's LLM history so a follow-up question can reference
# it (parity with v3 _run_shell).
self._run_shell(text[1:].strip())
try:
self.query_one("#messages", VerticalScroll).scroll_end(animate=False)
except Exception:
pass
return
if text.startswith("/"):
parts = text.split(maxsplit=1)
cmd = parts[0][1:].lower()
@@ -3185,6 +3351,11 @@ class GenericAgentTUI(App[None]):
except Exception:
pass
return
if cmd == "resume":
# GA's _handle_slash_cmd expands `/resume` at agent side —
# forward the literal so the agent recovers context.
self.submit_user_message(text)
return
self.submit_user_message(text, images=images)
def _show_palette(self) -> None:
@@ -3353,6 +3524,16 @@ class GenericAgentTUI(App[None]):
result_text = msg.on_select(value)
except Exception as e:
result_text = f"❌ 失败: {type(e).__name__}: {e}"
# PR #466 (upstream 8ae3645): if on_select rebuilt the message
# container (e.g. /rewind picker → _do_rewind → _remount_current_session
# detaches every widget under #messages), the captured anchors are
# now stale. Bail out early so we don't try to mount(after=...)
# against a detached widget — that'd raise NoWidget and crash.
anchor_guard = msg._hint_widget or msg._body_widget
if (anchor_guard is not None
and hasattr(anchor_guard, 'is_mounted')
and not anchor_guard.is_mounted):
return
display = (result_text or label).strip() or label
msg.selected_label = display
msg.content = display
@@ -4121,15 +4302,33 @@ class GenericAgentTUI(App[None]):
for kind in ("reflect", "frontend"):
for s in (svc for svc in services if svc["kind"] == kind):
doc = f" — {s['doc']}" if s["doc"] else ""
tag = " ● running" if s["name"] in running else ""
if s["name"] in running:
is_running = s["name"] in running
tag = " · running" if is_running else ""
if is_running:
preselected.append(len(choices))
choices.append((f"{s['name']}{doc}{tag}", s["name"]))
label = f"{s['name']}{doc}{tag}"
# Functional green for already-running rows so they're
# distinguishable even when the [x] tick is small or the
# row scrolls off the visible window.
if is_running:
from rich.text import Text as _T
rich_label = _T(label, style="green")
choices.append((rich_label, s["name"]))
else:
choices.append((label, s["name"]))
sess = self.current
hint = ("📋 选择要启动的服务(与 hub.pyw 一致:reflect 任务 + frontend 应用)"
hint = ("选择要启动的服务(与 hub.pyw 一致:reflect 任务 + frontend 应用)"
" Space 勾选 · Enter 提交 · Esc 取消 — 提交后还需二次确认")
if running:
hint += f"\n ● = 正在运行(已勾选);取消勾选即停止该服务(共 {len(running)} 个在运行)"
hint += f"\n 绿色 = 正在运行(已勾选);取消勾选即停止该服务(共 {len(running)} 个在运行)"
try:
cron_n = len(slash_cmds.list_scheduler_tasks())
except Exception:
cron_n = 0
if cron_n:
sch_running = 'reflect/scheduler.py' in running
cron_state = "已激活" if sch_running else "未激活(启动 reflect/scheduler.py 来调度)"
hint += f"\n cron:sche_tasks/*.json 共 {cron_n} 个任务 · {cron_state}"
msg = ChatMessage(
role="system",
content=hint,
@@ -4236,7 +4435,7 @@ class GenericAgentTUI(App[None]):
from frontends import slash_cmds
if not names:
self._system("(未选择任何服务)"); return
lines = [f"🚀 批量启动 {len(names)} 个服务:"]
lines = [f"批量启动 {len(names)} 个服务:"]
for n in names:
ok, msg = slash_cmds.start_service(n)
lines.append((" ✅ " if ok else " ❌ ") + msg)
@@ -4254,14 +4453,111 @@ class GenericAgentTUI(App[None]):
def on_unmount(self) -> None:
self._reset_terminal_title()
def _run_shell(self, cmd: str) -> None:
"""`!cmd` magic: run `cmd` in the host shell, echo command + output
into the current session's scrollback, and append a `[!shell]` pair
to backend.history so the agent can reference it on the next turn.
Output capture is utf-8 / replace; 30 s timeout — anything longer
belongs in /conductor or a proper tool call, not a one-liner."""
if not cmd:
return
sess = self.current
sess.messages.append(ChatMessage("system",
f"! {cmd}",
kind="system"))
import subprocess
out = ''; rc = 0
try:
r = subprocess.run(
cmd, shell=True, capture_output=True,
timeout=30, encoding='utf-8', errors='replace',
)
out = (r.stdout or '') + (r.stderr or '')
rc = r.returncode
except subprocess.TimeoutExpired:
out = '[shell: timeout 30s]'; rc = -1
except Exception as e:
out = f'[shell error: {type(e).__name__}: {e}]'; rc = -1
body = (out.rstrip('\n') or '(no output)').split('\n')
formatted = '\n'.join((' └ ' + ln if i == 0 else ' ' + ln)
for i, ln in enumerate(body))
sess.messages.append(ChatMessage("system", formatted, kind="system"))
if sess.agent_id == self.current_id:
self._refresh_messages()
# Splice the exchange into LLM history.
try:
be = getattr(sess.agent, 'llmclient', None)
be = getattr(be, 'backend', None) if be is not None else None
if be is not None and hasattr(be, 'history'):
txt = f"[!shell] {cmd}\n```\n{out.rstrip()}\n```\n(exit {rc})"
be.history.append({"role": "user",
"content": [{"type": "text", "text": txt}]})
except Exception:
pass
# ---------------- agent task + stream ----------------
# 5 s cooldown after the LAST user submit (cooldown resets per entry).
# Drain path:
# running → write to <task_dir>/_intervene; ga.turn_end_callback
# (ga.py:576) prepends it to next_prompt at the next
# turn boundary — message lands mid-run.
# idle → fall back to put_task for a fresh turn.
_PENDING_COOLDOWN_SEC = 5.0
def _session_intervene_path(self, sess: AgentSession) -> Optional[str]:
td = getattr(sess.agent, 'task_dir', None)
if not td:
return None
try:
os.makedirs(td, exist_ok=True)
except Exception:
return None
return os.path.join(td, '_intervene')
def _inject_intervene(self, sess: AgentSession, text: str) -> bool:
"""Append `text` to `<task_dir>/_intervene` (append-mode so a
TOCTOU between our read-then-write and the agent's
`consume_file` (read+delete) can't duplicate already-consumed
content into the NEXT turn)."""
if sess.status != "running":
return False
fp = self._session_intervene_path(sess)
if not fp:
return False
try:
sep = ''
try:
if os.path.getsize(fp) > 0:
sep = '\n\n'
except OSError:
pass # file consumed mid-call → write into a fresh one
with open(fp, 'a', encoding='utf-8') as f:
f.write(sep + text)
return True
except Exception:
return False
def submit_user_message(self, text: str, images: Optional[list[str]] = None, display_text: Optional[str] = None) -> int:
sess = self.current
# Free-text ask_user answers go through a 2-step submit-confirm card.
if self._maybe_intercept_free_text(sess, text):
return -1
if sess.status == "running":
self._system(f"#{sess.agent_id} 正在跑,/stop 后再发。")
# Codex-style: don't reject, queue. Cooldown resets per entry
# so a burst of edits coalesces into one inject. Drain fires
# `_inject_intervene` (mid-turn) or `put_task` (idle).
visible = text if display_text is None else display_text
sess.pending.append(text)
sess.pending_drain_at = time.time() + self._PENDING_COOLDOWN_SEC
sess.messages.append(ChatMessage(
"system",
f"[queued #{len(sess.pending)}] {visible}",
kind="system",
))
if sess.agent_id == self.current_id:
self._refresh_messages()
self._refresh_bottombar()
return -1
sess.task_seq += 1
tid = sess.task_seq
@@ -4313,12 +4609,38 @@ class GenericAgentTUI(App[None]):
if done:
s.status = "idle"
s.current_display_queue = None
# NOTE: cooldown timer is armed on each user submit (see
# submit_user_message), NOT here on turn end. This matches
# "5 s since LAST keystroke" semantics: if turn ends within
# the cooldown the timer keeps counting, drain fires whenever
# the deadline lapses (mid-turn → intervene; idle → put_task).
self._update_assistant(agent_id, text, task_id=task_id, done=done, refresh_chrome=True)
# End-of-turn re-parse only; mid-stream `[...]` fragments would flash.
if done:
self._update_plan_state(s, text)
self._drain_ask_user_events(s)
def _drain_pending(self, sess: AgentSession) -> None:
"""Flush queued user messages. Two paths:
- sess.status == 'running' → write to <task_dir>/_intervene; the
agent's turn_end_callback (ga.py:576) consumes it and prepends
to next_prompt, so the message lands inside the current run.
- idle → fall back to put_task for a fresh turn.
Combined into one payload because users usually queue clarifying
lines for a single logical follow-up; N separate turns would
force pointless handoffs."""
if not sess.pending:
return
combined = "\n\n".join(sess.pending)
sess.pending = []
sess.pending_drain_at = 0.0
if self._inject_intervene(sess, combined):
return
# Echo nothing — the [queued #N] system lines already landed when
# each entry was submitted; this just hands the combined text to
# the agent without an extra visual.
self.submit_user_message(combined, display_text=combined)
# Phrasing-based opt-in for multi-select picker (no core schema change).
_MULTI_RE = re.compile(r"\[?(?:多选|multi(?:[-_ ]?select)?|select all)\]?", re.IGNORECASE)
@@ -4635,6 +4957,31 @@ class GenericAgentTUI(App[None]):
try: self._plan_timer = self.set_interval(1.0, self._poll_plan_files)
except Exception: pass
def _start_pending_watcher(self) -> None:
"""Pending-input drain poller — fires every 100 ms. Walks all
sessions, fires `_drain_pending(sess)` when the post-turn cooldown
deadline lapses. Idle on sessions with nothing queued."""
if getattr(self, "_pending_timer", None) is not None: return
try: self._pending_timer = self.set_interval(0.1, self._poll_pending)
except Exception: pass
def _poll_pending(self) -> None:
now = time.time()
for sess in list(self.sessions.values()):
# Drain regardless of running state — _drain_pending picks the
# right transport (intervene for mid-turn, put_task for idle).
if (sess.pending and sess.pending_drain_at > 0
and now >= sess.pending_drain_at):
try:
self._drain_pending(sess)
except Exception:
# Swallow so a bad pending entry can't kill the timer.
sess.pending = []
sess.pending_drain_at = 0.0
# Repaint bottombar so the cooldown countdown ticks visibly.
if any(s.pending for s in self.sessions.values()):
self._refresh_bottombar()
def _poll_plan_files(self) -> None:
# Poll only the visible session — background sessions don't paint planbar.
import plan_state