fix(tui): slash-cmd polish, bundle i18n sync & settle spinner on abort (#507)

* fix(tui): slash-cmd polish — /scheduler perf, v2 i18n typo, v3 bundle i18n

- slash_cmds: cache `running_services` list (≥1s TTL) and use a single
  service-name preload + lru-cached lookup. /scheduler picker cold path
  ~2.1s → ~1.0s, warm path ~0ms. Cache is invalidated whenever
  `start_service` succeeds so the picker reflects the new state on the
  next open.
- tuiapp_v2: drop the stray space inside `/reload-keys` zh description
  ("重新加载 mykey.py" → "重新加载mykey.py").
- tui_v3: route the remaining slash_cmds-bundle rows (/autorun,
  /morphling, /goal, /hive, /scheduler) through _t() the same way
  /update already was, with matching en/zh entries in _I18N. Keeps the
  v3 command palette fully bilingual.

* tui: settle spinner on abort + DRY-extract annotation helpers

Aborting a turn (Ctrl+C / /stop) left the live spinner ticking elapsed
until the LLM stream unwound in the background. Now we freeze a
`_stop_summary` snapshot the instant the user aborts and flip the line to
a settled "Stopping…", then "Stopped after Xm Ys" once done.

- ChatMessage._stop_summary: abort-time (elapsed, in, out) snapshot
- _mark_stopping(): freeze + repaint every in-flight assistant on /stop
- _stopping_annotation() / _done_annotation() stopped-state branch
- DRY: _fmt_tokens() single source for `↑ N · ↓ M`, _freeze_summary()
  single source for the snapshot tuple (de-dups spinner/done/stopping
  and the two capture paths)
- carries the branch's existing ask_user multi-choice "; ".join collapse

---------

Co-authored-by: Shen Hao <shenhao-stu@users.noreply.github.com>
This commit is contained in:
Shen HaoandShen Hao authored and GitHub committed 2026-05-27 21:28:12 +08:00
1 parent 49970b539d
commit 91fcdf51b4
3 files changed
+134 -44

No files matched your search

+27 -15
View File
@@ -306,6 +306,7 @@ def start_service(name: str) -> tuple[bool, str]:
rc = proc.poll()
if rc is not None:
return False, f"启动失败 (退出码 {rc}): {svc['name']}"
invalidate_running_cache()
return True, f"已启动 {svc['name']} (pid={proc.pid})"
except Exception as e:
return False, f"启动失败: {type(e).__name__}: {e}"
@@ -340,11 +341,23 @@ def _match_service(cmdline: list[str], svc: dict) -> bool:
for a in cmdline)
def running_services() -> dict[str, int]:
"""Return {service_name: pid} for every launchable service currently
alive on this host. Empty dict if psutil isn't installed (degrades to
"/scheduler can't show running marks" rather than crashing the TUI).
"""
# 2s TTL cache + name-prefilter: ~2.1s → ~1.0s cold, ~0ms warm.
# cmdline() is the per-proc cost; only pay it for python-ish survivors.
_RUNNING_CACHE: tuple[float, dict[str, int]] | None = None
_RUNNING_TTL = 2.0
def invalidate_running_cache() -> None:
"""Drop the snapshot. Call after start/stop so the next read is fresh."""
global _RUNNING_CACHE
_RUNNING_CACHE = None
def running_services(use_cache: bool = True) -> dict[str, int]:
"""{service_name: pid} for live services. {} if psutil missing."""
global _RUNNING_CACHE
if use_cache and _RUNNING_CACHE and time.time() - _RUNNING_CACHE[0] < _RUNNING_TTL:
return dict(_RUNNING_CACHE[1])
try:
import psutil # type: ignore
except Exception:
@@ -352,24 +365,21 @@ def running_services() -> dict[str, int]:
svcs = list_launchable_services()
out: dict[str, int] = {}
me = os.getpid()
for proc in psutil.process_iter(["pid", "name", "cmdline"]):
for proc in psutil.process_iter(["pid", "name"]):
try:
if proc.info["pid"] == me:
continue
nm = (proc.info.get("name") or "").lower()
# cheap pre-filter: only python-ish processes
if "python" not in nm and "py.exe" not in nm:
continue
cmd = proc.info.get("cmdline") or []
for svc in svcs:
if svc["name"] in out:
continue
if _match_service(cmd, svc):
out[svc["name"]] = proc.info["pid"]
break
cmd = proc.cmdline()
except Exception:
# psutil races (proc died mid-iter) are normal; skip silently.
continue
for svc in svcs:
if svc["name"] not in out and _match_service(cmd, svc):
out[svc["name"]] = proc.info["pid"]
break
_RUNNING_CACHE = (time.time(), dict(out))
return out
@@ -403,8 +413,10 @@ def stop_service(name: str) -> tuple[bool, str]:
p.kill()
except Exception:
pass
invalidate_running_cache()
return True, f"已停止 {name} (pid={pid})"
except psutil.NoSuchProcess:
invalidate_running_cache()
return True, f"{name} 已退出"
except Exception as e:
return False, f"停止失败: {type(e).__name__}: {e}"
+31 -9
View File
@@ -173,6 +173,17 @@ _I18N: dict[str, dict[str, str]] = {
'cmd.rename.arg': '<name>',
'cmd.export.arg': '[clip|file|all]',
'cmd.language.arg': '[code]',
'cmd.update.arg': '[note]',
'cmd.update.desc': 'preview upstream commits & diff, then pull (no commit)',
'cmd.autorun.arg': '[seed]',
'cmd.autorun.desc': 'enter autonomous operation mode',
'cmd.morphling.arg': '[target]',
'cmd.morphling.desc': 'distill / absorb external skills',
'cmd.goal.arg': '[goal]',
'cmd.goal.desc': 'enter Goal mode (needs condition)',
'cmd.hive.arg': '[target]',
'cmd.hive.desc': 'enter Hive multi-worker mode',
'cmd.scheduler.desc': 'multi-pick reflect tasks / show cron',
# status line (one-liner above input box)
'status.asking': '◉ waiting · Esc cancel',
@@ -368,6 +379,17 @@ _I18N: dict[str, dict[str, str]] = {
'cmd.rename.arg': '<name>',
'cmd.export.arg': '[clip|file|all]',
'cmd.language.arg': '[code]',
'cmd.update.arg': '[备注]',
'cmd.update.desc': '预览 upstream 提交与 diff,再 git pull(不 commit)',
'cmd.autorun.arg': '[seed]',
'cmd.autorun.desc': '进入 autonomous_operation 自主模式',
'cmd.morphling.arg': '[target]',
'cmd.morphling.desc': '启用 Morphling 蒸馏 / 吞噬外部技能',
'cmd.goal.arg': '[goal]',
'cmd.goal.desc': '进入 Goal 模式(需 condition 约束)',
'cmd.hive.arg': '[target]',
'cmd.hive.desc': '进入 Hive 多 worker 协作模式',
'cmd.scheduler.desc': '多选启动 reflect 任务 / 查看 cron',
# status line
'status.asking': '◉ 待答 · Esc 撤回提问',
@@ -1583,15 +1605,15 @@ def _cmds() -> list[tuple[str, str, str]]:
('/btw', _t('cmd.btw.arg'), _t('cmd.btw.desc')),
('/review', _t('cmd.review.arg'), _t('cmd.review.desc')),
# ── slash_cmds bundle (same set as v2; descriptions kept inline
# rather than going through _t() so a missing translation key never
# blanks the row). /scheduler stays as an interactive multi-pick
# menu; the rest fold into prompt-injection turns.
('/update', '[note]', 'git pull GA & report impact'),
('/autorun', '[seed]', 'enter autonomous operation mode'),
('/morphling', '[target]', 'distill / absorb external skills'),
('/goal', '[goal]', 'enter Goal mode (needs condition)'),
('/hive', '[target]', 'enter Hive multi-worker mode'),
('/scheduler', '', 'multi-pick reflect tasks / show cron'),
# /scheduler stays an interactive multi-pick menu; the rest fold
# into prompt-injection turns. All bundle rows now route through
# _t() so zh/en stay in sync.
('/update', _t('cmd.update.arg'), _t('cmd.update.desc')),
('/autorun', _t('cmd.autorun.arg'), _t('cmd.autorun.desc')),
('/morphling', _t('cmd.morphling.arg'), _t('cmd.morphling.desc')),
('/goal', _t('cmd.goal.arg'), _t('cmd.goal.desc')),
('/hive', _t('cmd.hive.arg'), _t('cmd.hive.desc')),
('/scheduler', '', _t('cmd.scheduler.desc')),
('/rewind', _t('cmd.rewind.arg'), _t('cmd.rewind.desc')),
('/continue', _t('cmd.continue.arg'), _t('cmd.continue.desc')),
('/new', _t('cmd.new.arg'), _t('cmd.new.desc')),
+76 -20
View File
@@ -1137,6 +1137,10 @@ class ChatMessage:
# Frozen `(elapsed, last_in, last_out)` at done→True; keeps the post-turn
# card from ticking when the next turn shifts cost_tracker deltas.
_done_summary: Optional[tuple] = field(default=None, repr=False)
# Frozen `(elapsed, last_in, last_out)` stamped the instant the user aborts
# (Ctrl+C / `/stop`). Flips the live spinner to a settled "Stopping…" line so
# elapsed stops climbing while the LLM stream unwinds in the background.
_stop_summary: Optional[tuple] = field(default=None, repr=False)
# Per-(seg_hash, width) Text cache; survives fold-toggle re-mounts.
_seg_render_cache: dict = field(default_factory=dict, repr=False)
@@ -1211,7 +1215,7 @@ COMMANDS = [
("/cost", "[all]", "显示当前会话 token 用量(all = 所有会话)"),
("/export", "clip|<file>|all", "导出最后回复"),
("/restore", "", "恢复上次模型响应日志"),
("/reload-keys", "", "重新加载 mykey.py(不重启)"),
("/reload-keys", "", "重新加载mykey.py(不重启)"),
("/quit", "", "退出"),
]
@@ -3569,6 +3573,7 @@ class GenericAgentTUI(App[None]):
sess.agent.abort()
if sess.status == "running":
sess.status = "stopping"
self._mark_stopping(sess)
self._system(f"Stop sent to #{sess.agent_id}.")
except Exception as e:
self._system(f"Stop failed: {e}")
@@ -4303,9 +4308,13 @@ class GenericAgentTUI(App[None]):
choices = [(c, c) for c in candidates] + [(FREE_TEXT_LABEL, FREE_TEXT_CHOICE)]
hint = "Space 切换 · Enter 提交 · Esc 取消" if multi else "↑/↓ 选择 · Enter 确认 · Esc 取消"
head = f"{question} {hint}"
# multi_choice hands `_finalize_multi_choice` a list of picked values;
# single choice hands a plain string. The agent answer must be a string,
# so collapse a list the same way the breadcrumb does ("; ".join).
msg = ChatMessage(
role="system", content=head, kind=kind, choices=choices,
on_select=lambda v: self._answer_ask_user(sess.agent_id, v),
on_select=lambda v: self._answer_ask_user(
sess.agent_id, "; ".join(v) if isinstance(v, list) else v),
)
sess.messages.append(msg)
if sess.agent_id == self.current_id:
@@ -4956,12 +4965,20 @@ class GenericAgentTUI(App[None]):
return f"{v:.1f}k" if v < 100 else f"{int(v)}k"
return f"{n / 1_000_000.0:.2f}M"
def _fmt_tokens(self, last_in: int, last_out: int) -> str:
"""`↑ N · ↓ M` for the latest call's sizes, or "" when both are zero."""
if last_in <= 0 and last_out <= 0:
return ""
return f"↑ {self._humanize_tokens(last_in)} · ↓ {self._humanize_tokens(last_out)}"
def _spinner_annotation(self, m) -> Text:
"""Render `⠋ Gerund… (Xm Ys · ↑ N · ↓ M)` for a streaming message.
↑/↓ are the latest LLM call's prompt / completion sizes, gated on
cumulative counters moving past the baselines captured at stream start
(otherwise the prior turn's tail values leak in on prompt submit).
"""
if m._stop_summary is not None:
return self._stopping_annotation(m)
out = Text()
elapsed = int(time.time() - m._stream_started_at) if m._stream_started_at else 0
last_in, last_out = self._live_call_tokens(m)
@@ -4971,8 +4988,9 @@ class GenericAgentTUI(App[None]):
bits = []
if m._stream_started_at:
bits.append(_fmt_elapsed(elapsed))
if last_in > 0 or last_out > 0:
bits.append(f"↑ {self._humanize_tokens(last_in)} · ↓ {self._humanize_tokens(last_out)}")
tok = self._fmt_tokens(last_in, last_out)
if tok:
bits.append(tok)
if bits:
out.append(" (", style=C_DIM)
out.append(" · ".join(bits), style=C_DIM)
@@ -5024,26 +5042,64 @@ class GenericAgentTUI(App[None]):
def _done_annotation(self, m) -> Text:
"""Render `⠿ {Verb} for Xm Ys · ↑ N · ↓ M` after a turn finishes.
Numbers frozen via `_done_summary` so re-mounts / next turn don't
shift the line."""
elapsed, last_in, last_out = m._done_summary or (0, 0, 0)
verb = self._done_gerund(m)
shift the line. A user-aborted turn reads `⠿ Stopped after Xm Ys`
off the abort-time `_stop_summary` instead."""
if m._stop_summary is not None:
elapsed, last_in, last_out = m._stop_summary
verb, glyph_style = "Stopped after", C_DIM
else:
elapsed, last_in, last_out = m._done_summary or (0, 0, 0)
verb, glyph_style = f"{self._done_gerund(m)} for", C_GREEN
out = Text()
out.append(self._DONE_GLYPH + " ", style=C_GREEN)
out.append(f"{verb} for {_fmt_elapsed(int(elapsed))}", style=C_DIM)
if last_in > 0 or last_out > 0:
out.append(" · ", style=C_DIM)
out.append(f"↑ {self._humanize_tokens(last_in)} · ↓ {self._humanize_tokens(last_out)}",
style=C_DIM)
out.append(self._DONE_GLYPH + " ", style=glyph_style)
out.append(f"{verb} {_fmt_elapsed(int(elapsed))}", style=C_DIM)
tok = self._fmt_tokens(last_in, last_out)
if tok:
out.append(" · " + tok, style=C_DIM)
return out
def _capture_done_summary(self, m) -> None:
"""Freeze `(elapsed, last_in, last_out)` once when an assistant message
transitions done→True. Idempotent — repeat calls are no-ops so re-mounts
and stream-update passes won't overwrite the snapshot."""
if m._done_summary is not None or not m.done: return
def _stopping_annotation(self, m) -> Text:
"""Settled `⠿ Stopping… (Xm Ys · ↑ N · ↓ M)` shown from the moment the
user aborts until the LLM stream actually unwinds. Numbers frozen via
`_stop_summary` so elapsed stops climbing while we wait — the live
spinner would otherwise keep ticking until `done` finally flips."""
elapsed, last_in, last_out = m._stop_summary or (0, 0, 0)
out = Text()
out.append(self._DONE_GLYPH + " ", style=C_DIM)
out.append(f"Stopping… ({_fmt_elapsed(int(elapsed))}", style=C_DIM)
tok = self._fmt_tokens(last_in, last_out)
if tok:
out.append(" · " + tok, style=C_DIM)
out.append(")", style=C_DIM)
return out
def _freeze_summary(self, m) -> tuple:
"""Snapshot `(elapsed, last_in, last_out)` at the current instant."""
elapsed = (time.time() - m._stream_started_at) if m._stream_started_at else 0.0
last_in, last_out = self._live_call_tokens(m)
m._done_summary = (elapsed, last_in, last_out)
return (elapsed, *self._live_call_tokens(m))
def _capture_done_summary(self, m) -> None:
"""Freeze the turn's numbers once it flips done→True. Idempotent, so
re-mounts and stream-update passes never overwrite the snapshot."""
if m._done_summary is None and m.done:
m._done_summary = self._freeze_summary(m)
def _capture_stop_summary(self, m) -> None:
"""Freeze the turn's numbers the instant the user aborts. Idempotent —
the first Ctrl+C / `/stop` wins so a late real abort can't bump elapsed."""
if m._stop_summary is None:
m._stop_summary = self._freeze_summary(m)
def _mark_stopping(self, sess) -> None:
"""Freeze every in-flight assistant in `sess` to the settled "Stopping…"
line and push it now, so the spinner stops climbing the instant the user
aborts instead of after the LLM stream unwinds in the background."""
for m in sess.messages:
if m.role == "assistant" and not m.done:
self._capture_stop_summary(m)
if m._spinner_widget is not None:
try: m._spinner_widget.update(self._stopping_annotation(m))
except Exception: pass
def _has_streaming(self) -> bool:
if self.current_id is None: