diff --git a/frontends/slash_cmds.py b/frontends/slash_cmds.py index 555bcc5e..1831e2fe 100644 --- a/frontends/slash_cmds.py +++ b/frontends/slash_cmds.py @@ -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}" diff --git a/frontends/tui_v3.py b/frontends/tui_v3.py index 90429eb5..339790bf 100644 --- a/frontends/tui_v3.py +++ b/frontends/tui_v3.py @@ -173,6 +173,17 @@ _I18N: dict[str, dict[str, str]] = { 'cmd.rename.arg': '', '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': '', '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')), diff --git a/frontends/tuiapp_v2.py b/frontends/tuiapp_v2.py index eead5663..e139ca90 100644 --- a/frontends/tuiapp_v2.py +++ b/frontends/tuiapp_v2.py @@ -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||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: