diff --git a/agent/context_breakdown.py b/agent/context_breakdown.py index 0e2eb772f2ff..4527c5dca220 100644 --- a/agent/context_breakdown.py +++ b/agent/context_breakdown.py @@ -154,3 +154,207 @@ def compute_session_context_breakdown( "estimated_total": estimated_total, "model": getattr(agent, "model", "") or "", } + + +# ── /context rendering (CLI + gateway) ────────────────────────────────────── +# +# Pure text renderers over the payload above. The CLI shows a glyph block-grid +# plus a category table; the gateway uses the same table without the grid +# (proportional monospace is not guaranteed on messaging platforms). + +_CATEGORY_GLYPHS = { + "system_prompt": "■", + "tool_definitions": "▣", + "rules": "▩", + "skills": "▤", + "mcp": "▥", + "subagent_definitions": "▦", + "memory": "▧", + "conversation": "▨", +} +_FREE_GLYPH = "·" +_GRID_COLUMNS = 20 +_GRID_ROWS = 5 # 100 cells → 1 cell per percent of the context window + +# Human-readable tables cap the expanded listings; nothing is dropped from +# the underlying data. +_DETAILS_TABLE_LIMIT = 15 + + +def _bytes_to_tokens(size: Optional[int]) -> Optional[int]: + if size is None: + return None + return (int(size) + 3) // 4 + + +def compute_context_details(agent: Any) -> Dict[str, Any]: + """Expanded per-skill / per-toolset cost listing for ``/context all``. + + Reuses the ``hermes prompt-size`` attribution mechanism (PR #66656): + per-skill index-line bytes parsed from the live ```` + block, and per-toolset schema bytes attributed via the tool registry's + canonical tool→toolset map. Byte figures are converted to the same + chars/4 token heuristic the categories above use. + """ + from hermes_cli.prompt_size import ( + _compute_skills_breakdown, + _compute_toolsets_breakdown, + ) + from agent.system_prompt import build_system_prompt_parts + + parts = build_system_prompt_parts(agent) + stable = parts.get("stable", "") or "" + skills_match = _SKILLS_BLOCK_RE.search(stable) + skills_block = skills_match.group(0) if skills_match else "" + + skills: List[Dict[str, Any]] = [] + if skills_block: + for entry in _compute_skills_breakdown(skills_block): + skills.append({ + "name": entry.get("name", ""), + "index_tokens": _bytes_to_tokens(entry.get("index_line_bytes")) or 0, + "skill_md_tokens": _bytes_to_tokens(entry.get("skill_md_bytes")), + }) + + toolsets: List[Dict[str, Any]] = [] + tools = list(getattr(agent, "tools", None) or []) + if tools: + for group in _compute_toolsets_breakdown(tools): + toolsets.append({ + "toolset": group.get("toolset", ""), + "tool_count": int(group.get("tool_count", 0) or 0), + "schema_tokens": _bytes_to_tokens(group.get("json_bytes")) or 0, + }) + + return {"skills": skills, "toolsets": toolsets} + + +def render_context_grid(payload: Dict[str, Any]) -> List[str]: + """Render the payload as a Claude Code-style glyph block grid. + + 100 cells (5×20), each one percent of the model context window. Categories + fill in declaration order; the remainder renders as free space. + """ + context_max = int(payload.get("context_max") or 0) + categories = payload.get("categories") or [] + total_cells = _GRID_COLUMNS * _GRID_ROWS + + cells: List[str] = [] + if context_max > 0: + for cat in categories: + tokens = int(cat.get("tokens") or 0) + n = round(tokens / context_max * total_cells) + if tokens > 0 and n == 0: + n = 1 # never render a nonzero category as invisible + glyph = _CATEGORY_GLYPHS.get(str(cat.get("id") or ""), "▪") + cells.extend([glyph] * n) + cells = cells[:total_cells] + cells.extend([_FREE_GLYPH] * (total_cells - len(cells))) + + return [ + " ".join(cells[row * _GRID_COLUMNS:(row + 1) * _GRID_COLUMNS]) + for row in range(_GRID_ROWS) + ] + + +def render_context_category_lines(payload: Dict[str, Any]) -> List[str]: + """Render the 'Estimated usage by category' table as plain-text lines.""" + categories = payload.get("categories") or [] + context_max = int(payload.get("context_max") or 0) + estimated_total = int(payload.get("estimated_total") or 0) + denom = context_max or estimated_total + + lines = ["Estimated usage by category"] + if not categories: + lines.append(" (no data yet — send a message first)") + return lines + + width = max(len(str(cat.get("label") or "")) for cat in categories) + width = max(width, len("Free space")) + for cat in categories: + tokens = int(cat.get("tokens") or 0) + glyph = _CATEGORY_GLYPHS.get(str(cat.get("id") or ""), "▪") + pct = tokens / denom * 100 if denom else 0.0 + label = str(cat.get("label") or cat.get("id") or "") + lines.append(f"{glyph} {label:<{width}} {tokens:>9,} tokens {pct:>5.1f}%") + if context_max > 0: + free = max(0, context_max - estimated_total) + pct = free / context_max * 100 + lines.append(f"{_FREE_GLYPH} {'Free space':<{width}} {free:>9,} tokens {pct:>5.1f}%") + return lines + + +def render_context_details_lines(details: Dict[str, Any]) -> List[str]: + """Render the expanded ``/context all`` per-skill / per-toolset tables.""" + lines: List[str] = [] + + toolsets = details.get("toolsets") or [] + if toolsets: + lines.append("Toolsets by schema cost (largest first)") + for group in toolsets[:_DETAILS_TABLE_LIMIT]: + lines.append( + f" {group['toolset']:<24} {group['tool_count']:>3} tools" + f" {group['schema_tokens']:>8,} tokens" + ) + remaining = len(toolsets) - _DETAILS_TABLE_LIMIT + if remaining > 0: + lines.append(f" … and {remaining} more") + + skills = details.get("skills") or [] + if skills: + if lines: + lines.append("") + lines.append("Skills by cost (index = always-on; SKILL.md = cost when loaded)") + for entry in skills[:_DETAILS_TABLE_LIMIT]: + name = str(entry.get("name") or "") + if len(name) > 28: + name = name[:27] + "…" + md = entry.get("skill_md_tokens") + md_str = f"{md:>8,}" if md is not None else f"{'n/a':>8}" + lines.append( + f" {name:<28} index {entry['index_tokens']:>6,}" + f" SKILL.md {md_str} tokens" + ) + remaining = len(skills) - _DETAILS_TABLE_LIMIT + if remaining > 0: + lines.append(f" … and {remaining} more") + + return lines + + +def render_context_breakdown_lines( + payload: Dict[str, Any], + *, + details: Optional[Dict[str, Any]] = None, + grid: bool = True, +) -> List[str]: + """Render the full /context view as plain-text lines. + + ``grid=True`` (CLI) prepends the glyph block grid; the gateway passes + ``grid=False`` and keeps its own gauge. ``details`` (from + :func:`compute_context_details`) appends the expanded listings. + """ + lines: List[str] = [] + if grid: + lines.extend(render_context_grid(payload)) + lines.append("") + lines.extend(render_context_category_lines(payload)) + + context_max = int(payload.get("context_max") or 0) + context_used = int(payload.get("context_used") or 0) + if context_max > 0: + pct = int(payload.get("context_percent") or 0) + lines.append("") + lines.append( + f"Context window: {context_used:,} / {context_max:,} tokens ({pct}%)" + ) + + if details is not None: + detail_lines = render_context_details_lines(details) + if detail_lines: + lines.append("") + lines.extend(detail_lines) + else: + lines.append("") + lines.append("Use /context all for per-skill and per-toolset costs.") + return lines diff --git a/cli.py b/cli.py index 680c38555038..07c77654371a 100644 --- a/cli.py +++ b/cli.py @@ -9628,6 +9628,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._show_gateway_status() elif canonical == "status": self._show_session_status() + elif canonical == "context": + self._show_context_breakdown(cmd_original) elif canonical == "egress": from hermes_cli.proxy_cli import format_status_text @@ -10699,6 +10701,55 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): return print(f" {result.message}") + def _show_context_breakdown(self, cmd_original: str = ""): + """`/context [all]` — visual context-window usage breakdown. + + Renders a 5×20 glyph block grid (each cell ≈ 1% of the model context + window) plus an estimated per-category table: system prompt, tool + definitions, rules, skills index, MCP, subagents, memory, and the + conversation itself — versus free space. `/context all` appends the + expanded per-skill and per-toolset cost listings. + + Read-only: same chars/4 estimation engine as the desktop context + popover (agent.context_breakdown) — no provider calls, no prompt-cache + impact. + """ + if not self.agent: + print(" (._.) No active agent -- send a message first.") + return + + args = cmd_original.split(maxsplit=1)[1].strip().lower() if " " in cmd_original else "" + expanded = args in {"all", "full", "details"} + + from agent.context_breakdown import ( + compute_context_details, + compute_session_context_breakdown, + render_context_breakdown_lines, + ) + + try: + payload = compute_session_context_breakdown( + self.agent, self.conversation_history + ) + except Exception as e: + print(f" (._.) Could not compute context breakdown: {e}") + return + + details = None + if expanded: + try: + details = compute_context_details(self.agent) + except Exception: + details = {"skills": [], "toolsets": []} + + model = payload.get("model") or self.model + print() + print(f" 🧠 Context Usage — {model}") + print() + for line in render_context_breakdown_lines(payload, details=details, grid=True): + print(f" {line}") + print() + def _show_usage(self): """Rate limits + session token usage (when a live agent exists) + Nous credits. diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 047e1003563e..be4b884ad8b1 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -726,12 +726,16 @@ class GatewaySlashCommandsMixin: (between turns), then the SessionStore/SessionDB metadata for a gauge even when no agent is resident. Falls back to a transcript estimate only as a last resort. + + ``/context all`` appends the expanded per-skill / per-toolset cost + listings (requires a resident agent). """ from gateway.run import _AGENT_PENDING_SENTINEL source = event.source session_key = self._session_key_for_source(source) session_entry = await self.async_session_store.get_or_create_session(source) + expanded = event.get_command_args().strip().lower() in {"all", "full", "details"} # Try running agent first (mid-turn), then cached agent (between turns). agent = self._running_agents.get(session_key) @@ -861,6 +865,18 @@ class GatewaySlashCommandsMixin: lines.append("") lines.append(t("gateway.context.detail_after_first")) + # Per-category estimated breakdown (+ optional expanded listings). + # Same chars/4 engine the desktop popover and /usage use; plain + # text (no glyph grid — monospace isn't guaranteed on messaging + # platforms). Fail-open: rendering errors never break /context. + if has_agent: + breakdown = await asyncio.to_thread( + self._context_breakdown_block, agent, source, expanded + ) + if breakdown: + lines.append("") + lines.extend(breakdown) + return "\n".join(lines) # Last resort: rough estimate from transcript @@ -4536,6 +4552,43 @@ class GatewaySlashCommandsMixin: lines.append("Top up and manage billing in the browser — your balance updates here after.") return "\n".join(lines) + def _context_breakdown_block(self, agent, source, expanded: bool) -> list[str]: + """Render the /context per-category block (plain text, no grid). + + Estimated (chars/4) — same engine as the desktop popover and /usage. + ``expanded`` appends the per-skill / per-toolset listings from the + prompt-size attribution mechanism. Runs in a thread (sync store reads); + returns [] and never raises so /context stays robust. + """ + try: + from agent.context_breakdown import ( + compute_context_details, + compute_session_context_breakdown, + render_context_breakdown_lines, + ) + + history: list[dict] = [] + try: + entry = self.session_store.get_or_create_session(source) + history = self.session_store.load_transcript(entry.session_id) or [] + except Exception: + history = [] + + payload = compute_session_context_breakdown(agent, history) + if not (payload.get("categories") or []): + return [] + + details = None + if expanded: + try: + details = compute_context_details(agent) + except Exception: + details = {"skills": [], "toolsets": []} + + return render_context_breakdown_lines(payload, details=details, grid=False) + except Exception: + return [] + def _context_breakdown_lines(self, agent, source) -> list[str]: """Render the per-category context breakdown for /usage. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index c1c6f37b3893..60ce9ea13aa6 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -120,8 +120,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("status", "Show session, model, token, and context info", "Session"), CommandDef("egress", "Show Docker egress proxy status", "Session", args_hint="[status]", subcommands=("status",)), - CommandDef("context", "Show detailed context window view with usage gauge, compression stats, and throughput", "Session", - aliases=("ctx",)), + CommandDef("context", "Show detailed context window view with usage gauge, category breakdown, compression stats, and throughput", "Session", + aliases=("ctx",), args_hint="[all]", subcommands=("all",)), CommandDef("whoami", "Show your slash command access (admin / user)", "Info"), CommandDef("profile", "Show active profile name and home directory", "Info"), CommandDef("sethome", "Set this chat as the home channel", "Session", @@ -1180,6 +1180,10 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg") # rare from Slack; reachable as /hermes init. Without this entry, adding # /init clamps /version off the native list and breaks Telegram parity. _SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init"}) +# - version: low-frequency info command; reachable as /hermes version on +# Slack. Demoted when /context claimed a native slot (context is a +# recurring inspection surface; version is a one-off lookup). +_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "version"}) def _sanitize_slack_name(raw: str) -> str: diff --git a/tests/agent/test_context_breakdown.py b/tests/agent/test_context_breakdown.py index d8a8c2fc2bb0..2b49f6449c8a 100644 --- a/tests/agent/test_context_breakdown.py +++ b/tests/agent/test_context_breakdown.py @@ -58,3 +58,147 @@ def test_breakdown_uses_measured_context_when_available(): assert data["context_used"] == 42_000 assert data["context_percent"] == 21 + +# ── /context renderers (pure functions over the payload) ──────────────────── + +from agent.context_breakdown import ( # noqa: E402 + compute_context_details, + render_context_breakdown_lines, + render_context_category_lines, + render_context_details_lines, + render_context_grid, +) + + +def _payload(**overrides): + base = { + "categories": [ + {"id": "system_prompt", "label": "System prompt", "tokens": 10_000}, + {"id": "tool_definitions", "label": "Tool definitions", "tokens": 20_000}, + {"id": "skills", "label": "Skills", "tokens": 5_000}, + {"id": "conversation", "label": "Conversation", "tokens": 15_000}, + ], + "context_max": 200_000, + "context_percent": 25, + "context_used": 50_000, + "estimated_total": 50_000, + "model": "openai/gpt-test", + } + base.update(overrides) + return base + + +def test_grid_is_5x20_and_mostly_free(): + rows = render_context_grid(_payload()) + assert len(rows) == 5 + cells = " ".join(rows).split(" ") + assert len(cells) == 100 + # 50k / 200k → 25 used cells, 75 free + assert cells.count("·") == 75 + # Category glyphs proportional: 10k→5, 20k→10, 5k→2-3, 15k→7-8 cells + assert cells.count("■") == 5 + assert cells.count("▣") == 10 + + +def test_grid_nonzero_category_never_invisible(): + payload = _payload( + categories=[{"id": "memory", "label": "Memory", "tokens": 10}], + estimated_total=10, + context_used=10, + ) + rows = render_context_grid(payload) + assert "▧" in " ".join(rows) + + +def test_grid_without_context_max_is_all_free(): + rows = render_context_grid(_payload(context_max=0)) + cells = " ".join(rows).split(" ") + assert set(cells) == {"·"} + + +def test_category_lines_include_tokens_percent_and_free_space(): + lines = render_context_category_lines(_payload()) + text = "\n".join(lines) + assert "Estimated usage by category" in text + assert "System prompt" in text and "10,000 tokens" in text + assert "5.0%" in text # 10k / 200k + assert "Free space" in text and "150,000 tokens" in text + + +def test_category_lines_no_categories(): + lines = render_context_category_lines(_payload(categories=[])) + assert any("no data yet" in line for line in lines) + + +def test_breakdown_lines_grid_toggle(): + with_grid = render_context_breakdown_lines(_payload(), grid=True) + without = render_context_breakdown_lines(_payload(), grid=False) + assert any("·" in line for line in with_grid[:5]) + assert not any("·" in line for line in without[:2]) + # Both include the window summary and the expand hint + for lines in (with_grid, without): + text = "\n".join(lines) + assert "Context window: 50,000 / 200,000 tokens (25%)" in text + assert "/context all" in text + + +def test_breakdown_lines_with_details_omits_hint(): + details = { + "skills": [ + {"name": "alpha", "index_tokens": 25, "skill_md_tokens": 800}, + {"name": "beta", "index_tokens": 30, "skill_md_tokens": None}, + ], + "toolsets": [ + {"toolset": "terminal", "tool_count": 3, "schema_tokens": 4_000}, + ], + } + lines = render_context_breakdown_lines(_payload(), details=details, grid=False) + text = "\n".join(lines) + assert "Toolsets by schema cost" in text + assert "terminal" in text and "4,000 tokens" in text + assert "Skills by cost" in text + assert "alpha" in text and "beta" in text + assert "n/a" in text # unmapped SKILL.md renders n/a, not a crash + assert "Use /context all" not in text + + +def test_details_lines_caps_listing(): + details = { + "skills": [ + {"name": f"skill-{i}", "index_tokens": 10, "skill_md_tokens": 100} + for i in range(20) + ], + "toolsets": [], + } + lines = render_context_details_lines(details) + assert any("… and 5 more" in line for line in lines) + + +def test_compute_context_details_maps_bytes_to_tokens(): + agent, parts = _make_agent( + stable=( + "base\n\n demo:\n" + " - hello: a demo skill\n" + ), + ) + fake_skills = [{ + "name": "hello", + "index_line_bytes": 40, + "index_line_total_bytes": 40, + "index_line_shared_bytes": 0, + "index_line_skill_count": 1, + "skill_md_bytes": 401, + "path": "/tmp/hello/SKILL.md", + }] + fake_toolsets = [{"toolset": "terminal", "tool_count": 2, "json_bytes": 399}] + with patch("agent.system_prompt.build_system_prompt_parts", return_value=parts), \ + patch("hermes_cli.prompt_size._compute_skills_breakdown", return_value=fake_skills), \ + patch("hermes_cli.prompt_size._compute_toolsets_breakdown", return_value=fake_toolsets): + details = compute_context_details(agent) + + assert details["skills"] == [ + {"name": "hello", "index_tokens": 10, "skill_md_tokens": 101}, + ] + assert details["toolsets"] == [ + {"toolset": "terminal", "tool_count": 2, "schema_tokens": 100}, + ] diff --git a/tests/gateway/test_status_command.py b/tests/gateway/test_status_command.py index a4e864985a18..d02c0c440323 100644 --- a/tests/gateway/test_status_command.py +++ b/tests/gateway/test_status_command.py @@ -969,3 +969,124 @@ async def test_context_command_no_data(): result = await runner._handle_context_command(_make_event("/context")) assert "No context data available yet" in result + + +@pytest.mark.asyncio +async def test_context_command_includes_category_breakdown(): + """/context with a live agent appends the per-category estimated + breakdown (plain text, no glyph grid) and the /context all hint.""" + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-5", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + ) + runner = _make_runner(session_entry) + agent = _stub_agent() + runner._running_agents[session_entry.session_key] = agent + + fake_payload = { + "categories": [ + {"id": "system_prompt", "label": "System prompt", "tokens": 9_000}, + {"id": "tool_definitions", "label": "Tool definitions", "tokens": 21_000}, + ], + "context_max": 200_000, + "context_percent": 24, + "context_used": 47_231, + "estimated_total": 30_000, + "model": "openai/gpt-test", + } + from unittest.mock import patch as _patch + with _patch( + "agent.context_breakdown.compute_session_context_breakdown", + return_value=fake_payload, + ): + result = await runner._handle_context_command(_make_event("/context")) + + assert "Estimated usage by category" in result + assert "System prompt" in result + assert "9,000 tokens" in result + assert "Tool definitions" in result + assert "Use /context all" in result + # No glyph grid on the gateway (plain-text variant) + assert "· · ·" not in result + + +@pytest.mark.asyncio +async def test_context_all_appends_expanded_listings(): + """/context all appends per-toolset and per-skill cost listings.""" + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-6", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + ) + runner = _make_runner(session_entry) + agent = _stub_agent() + runner._running_agents[session_entry.session_key] = agent + + fake_payload = { + "categories": [ + {"id": "skills", "label": "Skills", "tokens": 2_000}, + ], + "context_max": 200_000, + "context_percent": 24, + "context_used": 47_231, + "estimated_total": 2_000, + "model": "openai/gpt-test", + } + fake_details = { + "skills": [ + {"name": "hermes-agent", "index_tokens": 30, "skill_md_tokens": 2_500}, + ], + "toolsets": [ + {"toolset": "terminal", "tool_count": 4, "schema_tokens": 5_100}, + ], + } + from unittest.mock import patch as _patch + with _patch( + "agent.context_breakdown.compute_session_context_breakdown", + return_value=fake_payload, + ), _patch( + "agent.context_breakdown.compute_context_details", + return_value=fake_details, + ): + result = await runner._handle_context_command(_make_event("/context all")) + + assert "Toolsets by schema cost" in result + assert "terminal" in result and "5,100 tokens" in result + assert "Skills by cost" in result + assert "hermes-agent" in result + # Expanded view drops the hint + assert "Use /context all" not in result + + +@pytest.mark.asyncio +async def test_context_breakdown_failure_never_breaks_command(): + """A breakdown engine crash degrades gracefully — the gauge still renders.""" + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-7", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + ) + runner = _make_runner(session_entry) + agent = _stub_agent() + runner._running_agents[session_entry.session_key] = agent + + from unittest.mock import patch as _patch + with _patch( + "agent.context_breakdown.compute_session_context_breakdown", + side_effect=RuntimeError("boom"), + ): + result = await runner._handle_context_command(_make_event("/context")) + + assert "🧠 **Context Window**" in result + assert "In use: 47,231 / 200,000 (24%)" in result + assert "Estimated usage by category" not in result diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index d6587d503b86..b962f3fbdf74 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -120,6 +120,16 @@ class TestResolveCommand: assert topic.name == "topic" assert "topic" in GATEWAY_KNOWN_COMMANDS + def test_context_command_registered_with_ctx_alias(self): + ctx = resolve_command("context") + assert ctx is not None + assert ctx.name == "context" + assert resolve_command("ctx").name == "context" + assert "all" in (ctx.subcommands or ()) + # Available on both CLI and gateway surfaces + assert not ctx.cli_only and not ctx.gateway_only + assert "context" in GATEWAY_KNOWN_COMMANDS + def test_leading_slash_stripped(self): assert resolve_command("/help").name == "help" assert resolve_command("/bg").name == "background" diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 38622d4e5078..e196adefaabe 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -58,6 +58,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/egress [status]` | Show Docker egress proxy status — enabled/configured/running state, credential source, token mappings, uncovered providers, and next remediation step. Works in CLI, TUI, Desktop chat, and messaging gateway. | | `/redraw` | Force a full UI repaint (recovers from terminal drift after tmux resize, mouse selection artifacts, etc.) | | `/status` | Show session info — model, provider, profile, session ID, working directory, title, created/updated timestamps, token totals, agent-running state — followed by a local **Session recap** block (recent user/assistant turn counts, tool result count, top tools used, last few files touched, the latest user prompt, and the latest assistant reply). The recap is computed locally from the in-memory conversation; no LLM call, no prompt-cache impact. | +| `/context [all]` (alias: `/ctx`) | Visual context-window breakdown. On the CLI/TUI: a 5×20 glyph block grid (each cell ≈ 1% of the model window) plus an estimated per-category table — system prompt, tool definitions, rules, skills index, MCP, subagents, memory, conversation — versus free space. On messaging platforms: a usage gauge with auto-compression threshold/headroom, compression stats, cumulative throughput, and the same category table in plain text. `/context all` appends per-skill and per-toolset cost listings (index cost vs SKILL.md load cost; schema tokens per toolset). Read-only and computed locally — no LLM call, no prompt-cache impact. | | `/agents` (alias: `/tasks`) | Show active agents and running tasks across the current session. | | `/background ` (alias: `/bg`, `/btw`) | Run a prompt in a separate background session. The agent processes your prompt independently — your current session stays free for other work. Results appear as a panel when the task finishes. See [CLI Background Sessions](/user-guide/cli#background-sessions). | | `/branch [name]` (alias: `/fork`) | Branch the current session (explore a different path) | diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index ca10b145847d..aa479f03c919 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -135,6 +135,7 @@ Common examples: | `/reasoning high` | Increase reasoning effort | | `/title My Session` | Name the current session | | `/status` | Show session info — model/profile/tokens/duration — followed by a local **Session recap** block (recent turn counts, top tools used, files touched, latest user prompt + assistant reply). Pure local compute; no LLM call. | +| `/context [all]` | Visual context-usage breakdown — glyph block grid + per-category token table (system prompt / tools / skills / memory / conversation / free space). `/context all` adds per-skill and per-toolset costs. | | `/sessions` | Open an interactive session picker right inside the classic CLI (same surface the TUI uses). Type to filter, arrow keys to navigate, Enter to resume. | For the full built-in CLI and messaging lists, see [Slash Commands Reference](../reference/slash-commands.md).