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:
commit
11a0dfef55
3 files changed
+1058
-165
No files matched your search
+13
-10
@@ -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
File diff suppressed because it is too large.
Load diff
+411
-64
@@ -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
|
||||
|
||||
Reference in new issue
Block a user