From 3e2f3ef03b848940d177263faa4309735a4d9691 Mon Sep 17 00:00:00 2001 From: qbit-mirror-bot Date: Thu, 2 Jul 2026 15:57:09 +0000 Subject: [PATCH] fix(tui): resolve /compact alias collision between compress alias and TUI display toggle (#57070) --- tui_gateway/server.py | 66 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 2c057155b587..34a875c34a16 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -284,6 +284,14 @@ def __init__(self, session_key: str, model: str): self._closed = False from hermes_cli._subprocess_compat import windows_hide_flags + # start_new_session=True detaches the slash worker into its own + # process group / session. Without this, the worker inherits the + # gateway's pgid (= TUI parent PID). When mcp_tool's + # _kill_orphaned_mcp_children races with slash_worker spawn and sweeps + # the gateway's child set, it captures the worker PID, records the + # inherited pgid, and killpg() then kills the TUI parent itself. + # See agent/lsp/client.py for the symmetric LSP server fix and + # tools/mcp_tool.py _filter_mcp_children for defense-in-depth. self.proc = subprocess.Popen( argv, stdin=subprocess.PIPE, @@ -296,6 +304,7 @@ def __init__(self, session_key: str, model: str): # Tier-1 secrets (gateway/GitHub/infra) are still stripped (#29157). env=hermes_subprocess_env(inherit_credentials=True), creationflags=windows_hide_flags(), + start_new_session=True, ) threading.Thread(target=self._drain_stdout, daemon=True).start() threading.Thread(target=self._drain_stderr, daemon=True).start() @@ -2964,12 +2973,31 @@ def _get_usage(agent) -> dict: } comp = getattr(agent, "context_compressor", None) if comp: - ctx_used = getattr(comp, "last_prompt_tokens", 0) or usage["total"] or 0 + # context_used is the *current-window* occupancy. Do NOT fall back to + # usage["total"] (cumulative lifetime session_total_tokens): for an + # external context engine that doesn't report last_prompt_tokens that + # substitution showed lifetime totals as the live context fill, yielding + # impossible readings such as 1.9m/120k clamped to 100% (#50421). + # + # Per the issue, populate context_used/percent only from a *real* + # current-occupancy value and "leave it unknown otherwise" — so a falsy + # last_prompt_tokens (0 or missing, i.e. an engine that doesn't track + # per-window occupancy) intentionally emits no gauge rather than a + # fabricated 0% or the old cumulative reading. The built-in compressor + # always reports a real last_prompt_tokens once a turn runs, so it is + # unaffected. + # Clamp the -1 "compression just ran, awaiting real usage" sentinel + # (conversation_compression.py) to 0 so the transitional turn reads as + # unknown (no gauge) instead of leaking context_used=-1. Matches the + # CLI status-bar path (cli.py _get_status_bar_snapshot). + last_prompt = getattr(comp, "last_prompt_tokens", 0) or 0 + if last_prompt < 0: + last_prompt = 0 ctx_max = getattr(comp, "context_length", 0) or 0 - if ctx_max: - usage["context_used"] = ctx_used + if ctx_max and last_prompt: + usage["context_used"] = last_prompt usage["context_max"] = ctx_max - usage["context_percent"] = max(0, min(100, round(ctx_used / ctx_max * 100))) + usage["context_percent"] = max(0, min(100, round(last_prompt / ctx_max * 100))) usage["compressions"] = getattr(comp, "compression_count", 0) or 0 # Live count of background/async subagents still running (delegate_task # batches + background single delegations). Mirrors the classic CLI status @@ -8121,7 +8149,12 @@ def _(rid, params: dict) -> dict: return _err(rid, 4004, "truncate_before_user_ordinal must be an integer") history = session.get("history", []) user_indices = [i for i, m in enumerate(history) if m.get("role") == "user"] - if ordinal >= len(user_indices): + # Reject out-of-range ordinals on BOTH ends. A negative value would + # otherwise sail past the upper-bound check and hit Python's negative + # indexing below (user_indices[-1] -> the LAST user turn), silently + # truncating history to everything before it and persisting that loss + # via replace_messages — an unrecoverable overwrite of the session DB. + if ordinal < 0 or ordinal >= len(user_indices): return _err(rid, 4018, "target user message is no longer in session history") truncated = history[: user_indices[ordinal]] session["history"] = truncated @@ -10134,7 +10167,7 @@ def _resolve_toggle(current: bool) -> bool: ) return _ok(rid, {"key": key, "value": nv}) - if key == "compact": + if key in ("compact", "compact-ui"): raw = str(value or "").strip().lower() cfg0 = _load_cfg() d0 = cfg0.get("display") if isinstance(cfg0.get("display"), dict) else {} @@ -10800,7 +10833,7 @@ def _(rid, params: dict) -> dict: ) nv = "full" if dm == "expanded" else "collapsed" return _ok(rid, {"value": nv}) - if key == "compact": + if key in ("compact", "compact-ui"): on = bool((_load_cfg().get("display") or {}).get("tui_compact", False)) return _ok(rid, {"value": "on" if on else "off"}) if key == "statusbar": @@ -11089,7 +11122,7 @@ def _(rid, params: dict) -> dict: ) _TUI_EXTRA: list[tuple[str, str, str]] = [ - ("/compact", "Toggle compact display mode", "TUI"), + ("/compact-ui", "Toggle compact display mode", "TUI"), ("/logs", "Show recent gateway log lines", "TUI"), ( "/mouse", @@ -11156,6 +11189,10 @@ def _(rid, params: dict) -> dict: cat_map[cat].append([c, desc]) for name, desc, cat in _TUI_EXTRA: + # Skip TUI-only entries whose name collides with a registered + # command or alias (e.g. /compact is now an alias of /compress). + if name.lower() in canon: + continue all_pairs.append([name, desc]) if cat not in cat_map: cat_map[cat] = [] @@ -11310,6 +11347,11 @@ def _(rid, params: dict) -> dict: if name in qcmds: qc = qcmds[name] if qc.get("type") == "exec": + # Sanitize env to prevent credential leakage — + # quick commands run in the TUI server process which + # has all API keys in os.environ. + from tools.environments.local import _sanitize_subprocess_env + sanitized_env = _sanitize_subprocess_env(os.environ.copy()) r = subprocess.run( qc.get("command", ""), shell=True, @@ -11317,12 +11359,16 @@ def _(rid, params: dict) -> dict: text=True, timeout=30, stdin=subprocess.DEVNULL, + env=sanitized_env, ) output = ( (r.stdout or "") + ("\n" if r.stdout and r.stderr else "") + (r.stderr or "") ).strip()[:4000] + if output: + from agent.redact import redact_sensitive_text + output = redact_sensitive_text(output) if r.returncode != 0: return _err( rid, @@ -12158,8 +12204,8 @@ def _(rid, params: dict) -> dict: text_lower = text.lower() extras = [ { - "text": "/compact", - "display": "/compact", + "text": "/compact-ui", + "display": "/compact-ui", "meta": "Toggle compact display mode", }, {