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:
1 parent
49970b539d
commit
91fcdf51b4
3 files changed
+134
-44
No files matched your search
+27
-15
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user