chore: import upstream snapshot with attribution
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:45 +08:00
commit 4b6817381b
3933 changed files with 525247 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from platform.terminal.theme import (
ANSI_DIM,
ANSI_RESET,
BG,
BOLD_BRAND,
DEVICE_CODE,
DEVICE_CODE_ANSI,
DIM,
DIM_COUNTER_ANSI,
ERROR,
HIGHLIGHT,
MARKDOWN_THEME,
PROMPT_ACCENT_ANSI,
PROMPT_FRAME_ANSI,
SECONDARY,
TEXT,
WARNING,
)
from surfaces.interactive_shell.ui.banner import render_banner, render_ready_box
from surfaces.interactive_shell.ui.components import (
print_valid_choice_list,
repl_choose_one,
repl_section_break,
repl_tty_interactive,
)
from surfaces.interactive_shell.ui.components.rendering import (
print_repl_json,
print_repl_table,
refresh_welcome_poster,
repl_print,
repl_table,
)
if TYPE_CHECKING:
from surfaces.interactive_shell.ui.agents.agents_view import (
_build_agents_table,
render_agents_table,
)
from surfaces.interactive_shell.ui.streaming import (
STREAM_LABEL_ANSWER,
STREAM_LABEL_ASSISTANT,
stream_to_console,
)
from surfaces.interactive_shell.ui.tables import (
MCP_INTEGRATION_SERVICES,
ColumnDef,
print_command_output,
render_integrations_table,
render_mcp_table,
render_models_table,
render_table,
render_tools_table,
resolve_provider_models,
)
# Heavy re-exports resolved lazily so importing ``ui`` (done on every REPL boot
# via the prompt/completion path) does not force the table + streaming stack.
_LAZY_SUBMODULE_EXPORTS: dict[str, str] = {
"_build_agents_table": "surfaces.interactive_shell.ui.agents",
"render_agents_table": "surfaces.interactive_shell.ui.agents",
"STREAM_LABEL_ANSWER": "surfaces.interactive_shell.ui.streaming",
"STREAM_LABEL_ASSISTANT": "surfaces.interactive_shell.ui.streaming",
"stream_to_console": "surfaces.interactive_shell.ui.streaming",
"MCP_INTEGRATION_SERVICES": "surfaces.interactive_shell.ui.tables",
"ColumnDef": "surfaces.interactive_shell.ui.tables",
"print_command_output": "surfaces.interactive_shell.ui.tables",
"render_integrations_table": "surfaces.interactive_shell.ui.tables",
"render_mcp_table": "surfaces.interactive_shell.ui.tables",
"render_models_table": "surfaces.interactive_shell.ui.tables",
"render_table": "surfaces.interactive_shell.ui.tables",
"render_tools_table": "surfaces.interactive_shell.ui.tables",
"resolve_provider_models": "surfaces.interactive_shell.ui.tables",
}
def __getattr__(name: str) -> Any:
module_path = _LAZY_SUBMODULE_EXPORTS.get(name)
if module_path is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
return getattr(importlib.import_module(module_path), name)
__all__ = [
"ANSI_DIM",
"ANSI_RESET",
"BG",
"BOLD_BRAND",
"ColumnDef",
"DEVICE_CODE",
"DEVICE_CODE_ANSI",
"DIM",
"DIM_COUNTER_ANSI",
"ERROR",
"HIGHLIGHT",
"MCP_INTEGRATION_SERVICES",
"MARKDOWN_THEME",
"PROMPT_ACCENT_ANSI",
"PROMPT_FRAME_ANSI",
"SECONDARY",
"STREAM_LABEL_ANSWER",
"STREAM_LABEL_ASSISTANT",
"TEXT",
"WARNING",
"_build_agents_table",
"print_valid_choice_list",
"print_command_output",
"print_repl_json",
"print_repl_table",
"render_agents_table",
"refresh_welcome_poster",
"render_banner",
"render_ready_box",
"render_integrations_table",
"render_mcp_table",
"render_models_table",
"render_table",
"render_tools_table",
"repl_choose_one",
"repl_print",
"repl_section_break",
"repl_table",
"repl_tty_interactive",
"resolve_provider_models",
"stream_to_console",
]
@@ -0,0 +1,94 @@
"""Rendering for the shell tool-calling turn.
This module owns the terminal-facing action observer. Planner tool calls are
internal state by default: the observer records them for history/storage while
the concrete action executors render user-facing command output. The execution
orchestration that drives it lives in
:func:`interactive_shell.runtime.action_turn.run_action_tool_turn`.
Keeping rendering here means the shell turn-entry adapter stays focused on
binding core ports while terminal formatting stays in ``ui/``.
"""
from __future__ import annotations
import contextlib
import json
from typing import Any
from rich.console import Console
from core.agent_harness.turns.action_driver import SELF_RECORDING_ACTION_TOOL_NAMES
from surfaces.interactive_shell.runtime import Session
# Tools whose preview is just ``(label, single-arg)``. The display content is the
# stripped string value of that single argument. Anything that needs to combine
# multiple arguments (``slash_invoke``, ``synthetic_run``) keeps a custom branch
# in :func:`tool_call_display`.
_SIMPLE_TOOL_LABELS: dict[str, tuple[str, str]] = {
"llm_set_provider": ("LLM provider", "target"),
"alert_sample": ("sample alert", "template"),
"investigation_start": ("investigation", "alert_text"),
"task_cancel": ("cancel task", "target"),
"cli_exec": ("opensre", "payload"),
"code_implement": ("implementation", "task"),
"shell_run": ("shell", "command"),
}
def tool_call_display(tool_name: str, args: dict[str, Any]) -> tuple[str, str]:
"""Return a ``(label, content)`` pair describing a planned tool call."""
if tool_name == "slash_invoke":
command = str(args.get("command", "")).strip()
raw_args = args.get("args")
parsed_args = [str(item).strip() for item in raw_args] if isinstance(raw_args, list) else []
return "command", " ".join([command, *parsed_args]).strip()
if tool_name == "synthetic_run":
suite = str(args.get("suite", "")).strip()
scenario = str(args.get("scenario", "")).strip()
return "synthetic test", f"{suite}:{scenario}" if scenario else suite
simple = _SIMPLE_TOOL_LABELS.get(tool_name)
if simple is not None:
label, arg_key = simple
return label, str(args.get(arg_key, "")).strip()
return tool_name, json.dumps(args, default=str, sort_keys=True)
class ActionRenderObserver:
"""Agent event observer that records planner turns not owned by action tools.
Self-recording tools (``slash_invoke``, ``shell_run``, etc.) append their own
history row; chat turns are recorded later by turn accounting when the
assistant runs.
"""
def __init__(self, *, session: Session, console: Console, message: str) -> None:
self.session = session
self.console = console
self.message = message
self.planned_count = 0
def __call__(self, kind: str, data: dict[str, Any]) -> None:
if kind == "tool_update":
with contextlib.suppress(Exception):
self.session.storage.append_tool_update(
self.session.session_id,
tool=str(data.get("name") or "tool"),
update=data.get("update"),
tool_call_id=str(data.get("id") or "") or None,
)
return
if kind != "tool_start":
return
name = str(data.get("name", "")).strip()
if not name or name == "assistant_handoff":
return
if self.planned_count == 0 and name not in SELF_RECORDING_ACTION_TOOL_NAMES:
self.session.record("cli_agent", self.message)
self.planned_count += 1
__all__ = [
"ActionRenderObserver",
"tool_call_display",
]
@@ -0,0 +1,8 @@
"""Fleet and background-agent dashboard rendering."""
from surfaces.interactive_shell.ui.agents.agents_view import (
_build_agents_table,
render_agents_table,
)
__all__ = ["_build_agents_table", "render_agents_table"]
@@ -0,0 +1,144 @@
"""Rich-table rendering for the ``/fleet`` slash-command dashboard.
Columns: ``agent``, ``pid``, ``uptime``, ``cpu%``, ``tokens/min``,
``$/hr``, ``status``. Every metric cell falls back to ``-`` when its
sampler accessor returns ``None``. ``0`` versus ``-`` is meaningful
in ``tokens/min``: ``0`` is observed-but-idle, ``-`` is unobservable
(no meter for this provider, or the JSONL is unreadable, or the
sampler task is not running — e.g. non-interactive
``opensre fleet list``).
This module lives outside ``tools/system/fleet_monitoring/`` so collectors don't pull
in Rich.
"""
from __future__ import annotations
from collections.abc import Iterable
from datetime import UTC, datetime, timedelta
from rich.console import Console, JustifyMethod
from rich.markup import escape
from rich.table import Table
from platform.terminal.theme import BOLD_BRAND
from surfaces.interactive_shell.ui.components.rendering import print_repl_table, repl_table
from tools.system.fleet_monitoring.registry import AgentRecord
from tools.system.fleet_monitoring.sampler import get_snapshot, get_tokens_per_min, get_usd_per_hour
from tools.system.fleet_monitoring.status import Status, compute_status
_UNFILLED = "-"
# Re-using Rich's own ``JustifyMethod`` so column-justify options
# stay in lockstep with the library.
_COLUMNS: tuple[tuple[str, JustifyMethod], ...] = (
("agent", "left"),
("pid", "right"),
("uptime", "right"),
("cpu%", "right"),
("tokens/min", "right"),
("$/hr", "right"),
("status", "left"),
)
_STATUS_COLORS: dict[Status, str] = {
Status.ACTIVE: "green",
Status.IDLE: "yellow",
Status.STUCK: "red",
}
def _format_uptime(delta: timedelta) -> str:
total_seconds = int(delta.total_seconds())
if total_seconds < 60:
return f"{total_seconds}s"
if total_seconds < 3600:
return f"{total_seconds // 60}m"
hours = total_seconds // 3600
if hours < 24:
minutes = (total_seconds % 3600) // 60
return f"{hours}h{minutes}m"
days = hours // 24
remaining_hours = hours % 24
return f"{days}d{remaining_hours}h"
def _format_tokens_per_min(value: float | None) -> str:
if value is None:
return _UNFILLED
# Round-then-compare so ``999.6`` doesn't render as 4-digit ``"1000"``
# next to its 3-digit neighbors.
rounded = int(round(value))
if rounded < 1000:
return f"{rounded}"
return f"{value / 1000:.1f}k"
def _format_usd_per_hour(value: float | None) -> str:
if value is None:
return _UNFILLED
return f"${value:.2f}"
def _format_status(status: Status, msg: str = "") -> str:
"""Return a Rich-markup-colorized status cell for the /fleet table."""
color = _STATUS_COLORS.get(status, "default")
label = f"{status.value} ({msg})" if msg else status.value
return f"[{color}]{label}[/{color}]"
def _build_agents_table(records: Iterable[AgentRecord]) -> Table:
"""Build and return the agents dashboard Table without printing it."""
materialized = list(records)
table = repl_table(
title="agents",
title_style=BOLD_BRAND,
caption="no agents discovered or registered yet" if not materialized else None,
)
for header, justify in _COLUMNS:
table.add_column(header, justify=justify)
now = datetime.now(UTC)
for record in materialized:
snapshot = get_snapshot(record.pid)
if snapshot is not None:
# Use output freshness when available; otherwise the status
# heuristic falls back to the process start time.
status = compute_status(
snapshot,
now,
last_output_at=snapshot.last_output_at,
idle_after_s=120,
stuck_after_s=480,
)
status_msg = ""
if status is Status.STUCK:
anchor = snapshot.last_output_at or snapshot.started_at
status_msg = f"{_format_uptime(now - anchor)} no progress"
uptime_cell = _format_uptime(now - snapshot.started_at)
cpu_cell = f"{snapshot.cpu_percent:.1f}"
status_cell = _format_status(status, status_msg)
else:
uptime_cell = _UNFILLED
cpu_cell = _UNFILLED
status_cell = _UNFILLED
tokens_cell = _format_tokens_per_min(get_tokens_per_min(record.pid))
hourly_cell = _format_usd_per_hour(get_usd_per_hour(record.pid))
table.add_row(
escape(record.name),
str(record.pid),
uptime_cell,
cpu_cell,
tokens_cell,
hourly_cell,
status_cell,
)
return table
def render_agents_table(console: Console, records: Iterable[AgentRecord]) -> None:
"""Print the agents dashboard table to the REPL console with TTY-safe width."""
print_repl_table(console, _build_agents_table(records))
__all__ = ["_build_agents_table", "render_agents_table"]
@@ -0,0 +1,110 @@
"""Rich rendering for incoming alerts surfaced in the REPL loop.
The alert receiver, queue, and listener lifecycle live in
``core.domain.alerts.inbox``.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from rich.console import Console
from rich.markup import escape
from rich.panel import Panel
from core.domain.alerts.inbox import IncomingAlert
from platform.terminal.theme import (
DIM,
INCOMING_ALERT_ACCENT,
TEXT,
)
if TYPE_CHECKING:
from rich.console import RenderableType
from core.domain.alerts.inbox import AlertInbox
from surfaces.interactive_shell.runtime import Session
def time_ago(then: datetime | None) -> str:
"""Format a relative time string like '5 seconds ago', '1 minute ago', etc."""
if then is None:
return "unknown"
now = datetime.now(UTC)
delta = now - then
seconds = int(delta.total_seconds())
if seconds < 60:
return f"{seconds}s ago" if seconds != 1 else "1s ago"
elif seconds < 3600:
minutes = seconds // 60
return f"{minutes}m ago" if minutes != 1 else "1m ago"
elif seconds < 86400:
hours = seconds // 3600
return f"{hours}h ago" if hours != 1 else "1h ago"
else:
days = seconds // 86400
return f"{days}d ago" if days != 1 else "1d ago"
def format_incoming_alert(alert: IncomingAlert) -> RenderableType:
"""Format an incoming alert as a Rich renderable with distinct styling.
Returns a Panel with:
- Header showing incoming alert label, source, and severity (if present)
- Relative received time
- Alert text body
"""
# Build the header line: source and severity
header_parts: list[str] = ["incoming alert"]
if alert.source:
header_parts.append(f"from {escape(alert.source)}")
if alert.severity:
# Escape the whole `[severity]` fragment so Rich cannot treat `[bold ...]` etc. as tags.
header_parts.append(escape(f"[{alert.severity}]"))
header = " | ".join(header_parts)
# Format the alert body with timestamp
timestamp_str = time_ago(alert.received_at)
body_lines = [
f"[{DIM}]received {timestamp_str}[/]",
"",
f"[{TEXT}]{escape(alert.text)}[/]",
]
body = "\n".join(body_lines)
# Create a panel with the distinct accent
panel = Panel(
body,
title=f"[{INCOMING_ALERT_ACCENT}]⚠ {header}[/]",
expand=False,
border_style=INCOMING_ALERT_ACCENT,
)
return panel
def drain_and_render_incoming(
session: Session,
console: Console,
inbox: AlertInbox,
) -> int:
"""Pop all queued alerts, render each one, and record them in session.
Returns the number of alerts rendered.
"""
alerts = inbox.iter_pending()
count = 0
for alert in alerts:
console.print(format_incoming_alert(alert), end="\n")
session.record_incoming_alert(alert)
count += 1
return count
__all__ = ["format_incoming_alert", "drain_and_render_incoming", "time_ago"]
@@ -0,0 +1,17 @@
"""Startup splash and REPL welcome banner."""
from surfaces.interactive_shell.ui.banner.banner import (
build_ready_panel,
render_banner,
render_ready_box,
render_splash,
)
from surfaces.interactive_shell.ui.banner.banner_state import integration_display_name
__all__ = [
"build_ready_panel",
"integration_display_name",
"render_banner",
"render_ready_box",
"render_splash",
]
@@ -0,0 +1,411 @@
"""Splash screen, agent ready-state box, and REPL launch banner.
Three exported entry points
---------------------------
render_splash(console, first_run=False)
Full branded startup screen with ASCII art and optional security gate.
Called once when the CLI starts.
render_ready_box(console, session=None)
DIM-bordered two-column welcome panel:
left → ◉ OpenSRE · provider · model · mode · cwd
right → "Tips for getting started" + "What's new"
Called after the splash and on /clear, /welcome, and greeting aliases.
render_banner(console)
Backward-compatible shim: render_splash + render_ready_box in one call.
Existing callers continue to work unchanged.
Rendered output legend (colour roles)
--------------------------------------
# [HIGHLIGHT] ASCII art lines · ◉ glyph · OpenSRE brand name
# [BRAND] version string · model name · section headers
# [SECONDARY] "opensre" product name label · cwd · tip / note body
# [DIM] subtitle description · rule lines · box chrome · dividers
# [TEXT] provider/model values · greeting
# [WARNING] read-only or trust-mode notice · incomplete-integration marker
"""
from __future__ import annotations
import getpass
import math
import os
import sys
from rich import box
from rich.console import Console, Group
from rich.panel import Panel
from rich.rule import Rule
from rich.table import Table
from rich.text import Text
from config.repl_config import WHATS_NEW
from config.version import get_opensre_version
from platform.terminal.theme import (
BRAND,
DIM,
HIGHLIGHT,
SECONDARY,
TEXT,
WARNING,
_parse_hex_color,
get_active_theme,
)
from surfaces.interactive_shell.ui.banner.banner_state import _build_ambient_right_column
from surfaces.interactive_shell.ui.components.banner_art import _render_art
from surfaces.interactive_shell.ui.tables.provider import detect_provider_model
def _is_first_run() -> bool:
"""True when the wizard has never been completed on this machine."""
try:
from surfaces.cli.wizard.store import get_store_path
return not get_store_path().exists()
except Exception:
return False
# ── Splash screen ─────────────────────────────────────────────────────────────
def _interpolate_hex_color(start: str, end: str, t: float) -> str:
"""Return a hex color linearly interpolated between ``start`` and ``end``."""
start_rgb = _parse_hex_color(start)
end_rgb = _parse_hex_color(end)
clamped = max(0.0, min(1.0, t))
channels = tuple(
int(round(start_rgb[idx] + (end_rgb[idx] - start_rgb[idx]) * clamped)) for idx in range(3)
)
return f"#{channels[0]:02X}{channels[1]:02X}{channels[2]:02X}"
def _splash_block_style(block_index: int, block_total: int) -> str:
"""Return the Rich style for one splash block character."""
theme = get_active_theme()
start = theme.SPLASH_GRADIENT_START
end = theme.SPLASH_GRADIENT_END
if not start or not end:
return f"bold {HIGHLIGHT}"
if block_total <= 1:
return f"bold {_interpolate_hex_color(start, end, 0.0)}"
ratio = block_index / (block_total - 1)
return f"bold {_interpolate_hex_color(start, end, ratio)}"
def render_splash(console: Console | None = None, *, first_run: bool | None = None) -> None:
"""Print the branded startup splash.
Rendered output (with colour roles):
┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ [DIM divider]
╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋ [HIGHLIGHT art]
...
opensre [SECONDARY] · v<version> [BRAND]
open-source SRE agent for automated incident
investigation and root cause analysis [DIM]
┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ [DIM divider]
If first_run (or not set and wizard has never run):
⚠ This tool runs AI-powered commands … [WARNING]
Press Enter to continue… [SECONDARY]
"""
console = console or Console(
highlight=False,
force_terminal=True,
color_system="truecolor",
legacy_windows=False,
)
if first_run is None:
first_run = _is_first_run()
version = get_opensre_version()
art = _render_art(console.width)
console.print()
console.print(Rule(style=DIM))
console.print()
for line in art.splitlines():
t = Text()
t.append(" ")
block_total = line.count("")
block_index = 0
for ch in line:
if ch == "":
t.append(ch, style=_splash_block_style(block_index, block_total))
block_index += 1
else:
t.append(ch, style=f"bold {BRAND}")
console.print(t)
console.print()
subtitle = Text()
subtitle.append(" ")
subtitle.append("opensre", style=SECONDARY)
subtitle.append(" · ", style=DIM)
subtitle.append(f"v{version}", style=BRAND)
console.print(subtitle)
desc = Text()
desc.append(
" open-source SRE agent for automated incident investigation and root cause analysis",
style=DIM,
)
console.print(desc)
console.print()
console.print(Rule(style=DIM))
if first_run:
console.print()
notice = Text()
notice.append(" ")
notice.append("", style=f"bold {WARNING}")
notice.append(
"This tool executes AI-powered commands against your infrastructure.\n"
" Review the documentation before connecting production systems.\n"
" Source: https://github.com/opensre-dev/opensre",
style=SECONDARY,
)
console.print(notice)
console.print()
if sys.stdin.isatty():
try:
console.print(f" [{SECONDARY}]Press Enter to continue…[/]", end="")
sys.stdin.readline()
except (EOFError, KeyboardInterrupt, OSError):
# Non-interactive stdin or user abort — skip blocking and continue startup.
pass
console.print()
# ── Agent ready-state box ─────────────────────────────────────────────────────
# Static copy for the right column (first-run only). Keep entries terse.
_TIPS: tuple[str, ...] = (
"Paste alert JSON or describe an incident",
"Type /help to list slash commands",
"Run /doctor for environment diagnostics",
"Use /investigate for runnable demos/templates",
)
# Panel geometry. The body switches to a stacked layout on narrow terminals,
# and otherwise expands to fill the full console width while keeping the left
# identity column readable and the right notes column roomy.
_MIN_LEFT_COL_WIDTH = 34
_MAX_LEFT_COL_WIDTH = 48
_MIN_RIGHT_COL_WIDTH = 40
_DIVIDER_WIDTH = 3
_PANEL_PADDING_X = 2
_PANEL_FRAME_WIDTH = 2 + (_PANEL_PADDING_X * 2)
_MIN_TWO_COLUMN_CONTENT_WIDTH = _MIN_LEFT_COL_WIDTH + _DIVIDER_WIDTH + _MIN_RIGHT_COL_WIDTH
# OpenSRE brand mark — single "O" from oh-my-logo tiny font (half-block chars).
_LOGO_MARK_ROWS: tuple[tuple[str, str], ...] = (
("█▀█", ""),
("█▄█", ""),
)
def _github_username() -> str:
"""Return the saved GitHub login for the configured GitHub integration, or "".
Best-effort and never raises: the welcome greeting must render even when the
integration store is unreadable or GitHub is not configured.
"""
try:
from integrations.github.identity import saved_github_username
return saved_github_username()
except Exception:
return ""
def _get_username() -> str:
# Prefer the authenticated GitHub handle once it is known, so the greeting
# reflects the user's GitHub identity rather than the local system account.
github = _github_username()
if github:
return github
try:
return getpass.getuser()
except Exception:
return "there"
def _build_logo_mark() -> Text:
"""Return the brand mark left-aligned (flush with the column's 2-space indent)."""
logo = Text(no_wrap=True)
for index, (body, _echo) in enumerate(_LOGO_MARK_ROWS):
if index:
logo.append("\n")
logo.append(body, style=f"bold {HIGHLIGHT}")
return logo
def _format_cwd(path: str) -> str:
"""Collapse the user's home directory to ~ for a tidier identity line."""
home = os.path.expanduser("~")
if home and (path == home or path.startswith(home + os.sep)):
return "~" + path[len(home) :]
return path
def _build_identity_block(provider: str, model: str, *, trust_mode: bool) -> Text:
"""Left column: mascot · blank · greeting · blank · identity line (all left-aligned)."""
logo = _build_logo_mark()
greeting = Text()
greeting.append(f"Welcome back {_get_username()}!", style=f"bold {TEXT}")
# Single flowing line: model · tier · workspace
cwd = _format_cwd(os.getcwd())
tier = "trust mode" if trust_mode else provider
identity = Text(overflow="fold")
identity.append(model, style=f"bold {BRAND}")
identity.append(" · ", style=DIM)
if trust_mode:
identity.append(tier, style=f"bold {WARNING}")
identity.append(" · ", style=DIM)
else:
identity.append(tier, style=SECONDARY)
identity.append(" · ", style=DIM)
identity.append(cwd, style=SECONDARY)
return Text("\n").join([logo, Text(), Text(), greeting, Text(), Text(), identity])
def _build_notes_block(header_text: str, items: tuple[str, ...]) -> Text:
"""Right column section: bold header followed by dim list items."""
parts: list[Text] = [Text(header_text, style=f"bold {BRAND}")]
for item in items:
parts.append(Text(item, style=SECONDARY, overflow="fold"))
return Text("\n").join(parts)
def _visual_line_count(block: Text, width: int) -> int:
"""Estimate how many terminal lines a Text block will occupy at ``width``."""
safe_width = max(width, 1)
total = 0
for raw_line in block.plain.split("\n"):
total += max(1, math.ceil(max(len(raw_line), 1) / safe_width))
return total
def _vertical_divider(height: int) -> Text:
"""Build a padded vertical rule with ``height`` lines."""
return Text("\n".join("" for _ in range(max(height, 1))), style=DIM, no_wrap=True)
def _two_column_widths(console_width: int) -> tuple[int, int]:
"""Return responsive left/right widths for the ready panel body."""
content_width = max(console_width - _PANEL_FRAME_WIDTH, _MIN_TWO_COLUMN_CONTENT_WIDTH)
left_width = int((content_width - _DIVIDER_WIDTH) * 0.42)
left_width = max(_MIN_LEFT_COL_WIDTH, min(left_width, _MAX_LEFT_COL_WIDTH))
right_width = content_width - _DIVIDER_WIDTH - left_width
if right_width < _MIN_RIGHT_COL_WIDTH:
right_width = _MIN_RIGHT_COL_WIDTH
left_width = content_width - _DIVIDER_WIDTH - right_width
return left_width, right_width
def build_ready_panel(
console: Console | None = None,
*,
session: object = None,
) -> Panel:
"""Build the responsive welcome panel shared by startup and CLI help."""
console = console or Console(
highlight=False,
force_terminal=True,
color_system="truecolor",
legacy_windows=False,
)
provider, model = detect_provider_model()
version = get_opensre_version()
trust_mode: bool = bool(getattr(session, "trust_mode", False))
panel_title = Text()
panel_title.append(" OpenSRE", style=f"bold {HIGHLIGHT}")
panel_title.append(" · ", style=DIM)
panel_title.append(f"v{version} ", style=BRAND)
left = _build_identity_block(provider, model, trust_mode=trust_mode)
if _is_first_run():
right = Text("\n").join(
[
_build_notes_block("Tips for getting started", _TIPS),
Text("───", style=DIM),
_build_notes_block("What's new", WHATS_NEW),
]
)
else:
right = _build_ambient_right_column(session=session)
body: Group | Table
if console.width - _PANEL_FRAME_WIDTH >= _MIN_TWO_COLUMN_CONTENT_WIDTH:
left_width, right_width = _two_column_widths(console.width)
height = max(
_visual_line_count(left, left_width),
_visual_line_count(right, right_width),
)
divider = _vertical_divider(height)
grid = Table.grid(padding=0, expand=False)
grid.add_column(justify="left", vertical="top", width=left_width)
grid.add_column(justify="center", vertical="top", width=_DIVIDER_WIDTH)
grid.add_column(justify="left", vertical="top", width=right_width)
grid.add_row(left, divider, right)
body = grid
else:
body = Group(
left,
Rule(style=DIM),
right,
)
return Panel(
body,
title=panel_title,
title_align="left",
border_style=DIM,
padding=(1, _PANEL_PADDING_X),
expand=True,
box=box.ROUNDED,
)
def render_ready_box(
console: Console | None = None,
*,
session: object = None,
) -> None:
"""Print the two-column welcome panel with an embedded title bar."""
console = console or Console(
highlight=False,
force_terminal=True,
color_system="truecolor",
legacy_windows=False,
)
console.print()
console.print(build_ready_panel(console, session=session))
console.print()
# ── Backward-compatible shim ──────────────────────────────────────────────────
def render_banner(console: Console | None = None) -> None:
"""Render splash + ready-state box in one call (legacy entry point).
Existing callers (main.run_repl) continue to work unchanged.
"""
_console = console or Console(
highlight=False,
force_terminal=True,
color_system="truecolor",
legacy_windows=False,
)
render_splash(_console)
render_ready_box(_console)
@@ -0,0 +1,147 @@
"""Live system state helpers for the agent ready-state banner.
Queries integration health and alert-listener config without making
network calls — results are cached-once per banner render and used by
:func:`interactive_shell.ui.banner.banner._build_ambient_right_column`.
"""
from __future__ import annotations
from rich.text import Text
from platform.terminal.theme import BRAND, DIM, HIGHLIGHT, SECONDARY, WARNING
# Display-name overrides for known integration service slugs.
_SERVICE_DISPLAY_NAMES: dict[str, str] = {
"grafana": "Grafana",
"datadog": "Datadog",
"honeycomb": "Honeycomb",
"coralogix": "Coralogix",
"aws": "AWS",
"github": "GitHub",
"sentry": "Sentry",
"prometheus": "Prometheus",
"loki": "Loki",
"elasticsearch": "Elasticsearch",
"bigquery": "BigQuery",
"pagerduty": "PagerDuty",
"slack": "Slack",
"telegram": "Telegram",
"signoz": "SigNoz",
"jira": "Jira",
"gitlab": "GitLab",
"vercel": "Vercel",
"mongodb": "MongoDB",
"postgresql": "PostgreSQL",
"mysql": "MySQL",
"redis": "Redis",
"kafka": "Kafka",
"rabbitmq": "RabbitMQ",
"clickhouse": "ClickHouse",
"mariadb": "MariaDB",
"kubernetes": "Kubernetes",
"betterstack": "Better Stack",
"snowflake": "Snowflake",
"newrelic": "New Relic",
"opsgenie": "OpsGenie",
"linear": "Linear",
"supabase": "Supabase",
}
def integration_display_name(service: str) -> str:
"""Human-readable label for an integration service slug."""
return _SERVICE_DISPLAY_NAMES.get(service, service.replace("_", " ").title())
def _load_integration_health() -> list[tuple[str, str]]:
"""Return ``(display_name, status)`` for each configured integration.
``status`` is ``"ok"`` or ``"incomplete"`` (e.g. a hosted MCP record saved
without an API token). Offline and best-effort: never raises and never makes
network calls, so the banner reflects health without slowing startup.
"""
try:
from integrations.catalog import ( # lazy — avoids circular deps
configured_integration_health,
)
return [
(integration_display_name(service), status)
for service, status in configured_integration_health()
]
except Exception:
return []
def _is_alert_listener_active() -> bool:
"""Return True if the alert listener is enabled in config. Never raises."""
try:
from config.repl_config import ReplConfig
return ReplConfig.load(apply_active_theme=False).alert_listener_enabled
except Exception:
return False
def _build_ambient_right_column(session: object = None) -> Text:
"""Right column for returning users: live integration status and alert listener state."""
parts: list[Text] = []
# Integrations — annotate by offline health so the banner never implies a
# half-configured integration (e.g. a hosted MCP record with no API token)
# is connected. A "⚠" + dim name marks an integration missing credentials.
parts.append(Text("Integrations", style=f"bold {BRAND}"))
entries = _load_integration_health()
if entries:
_MAX_SHOWN = 6
shown = entries[:_MAX_SHOWN]
overflow = len(entries) - len(shown)
name_line = Text(overflow="fold")
for idx, (name, status) in enumerate(shown):
if idx:
name_line.append(" · ", style=DIM)
if status == "incomplete":
name_line.append(f"{name}", style=DIM)
else:
name_line.append(name, style=SECONDARY)
if overflow:
name_line.append(f" +{overflow}", style=DIM)
parts.append(name_line)
if any(status == "incomplete" for _name, status in entries):
parts.append(Text("⚠ incomplete — run /integrations verify", style=WARNING))
else:
parts.append(Text("run /onboard to connect tools", style=DIM))
parts.append(Text("───", style=DIM))
# Alert listener
parts.append(Text("Alert listener", style=f"bold {BRAND}"))
if _is_alert_listener_active():
listener_line = Text()
listener_line.append("", style=f"bold {HIGHLIGHT}")
listener_line.append("active", style=SECONDARY)
parts.append(listener_line)
else:
parts.append(Text("○ not configured", style=DIM))
# Session summary — only shown when /clear is used mid-session with history
if session is not None:
history: list[object] = getattr(session, "history", [])
if history:
parts.append(Text("───", style=DIM))
parts.append(Text("This session", style=f"bold {BRAND}"))
count = len(history)
noun = "interaction" if count == 1 else "interactions"
parts.append(Text(f"{count} {noun}", style=SECONDARY))
return Text("\n").join(parts)
__all__ = [
"_SERVICE_DISPLAY_NAMES",
"integration_display_name",
"_build_ambient_right_column",
"_is_alert_listener_active",
"_load_integration_health",
]
@@ -0,0 +1,42 @@
"""Reusable terminal UI primitives (menus, TTY rendering, formatting)."""
from surfaces.interactive_shell.ui.components.choice_menu import (
print_valid_choice_list,
repl_choose_one,
repl_section_break,
repl_tty_interactive,
)
from surfaces.interactive_shell.ui.components.loaders import DEFAULT_LOADER_LABEL, llm_loader
from surfaces.interactive_shell.ui.components.rendering import (
print_repl_json,
print_repl_table,
refresh_welcome_poster,
repl_print,
repl_table,
)
from surfaces.interactive_shell.ui.components.time_format import (
format_repl_duration,
format_repl_timestamp,
)
from surfaces.interactive_shell.ui.components.token_format import (
_CHARS_PER_TOKEN,
format_token_count_short,
)
__all__ = [
"DEFAULT_LOADER_LABEL",
"_CHARS_PER_TOKEN",
"format_repl_duration",
"format_repl_timestamp",
"format_token_count_short",
"llm_loader",
"print_repl_json",
"print_repl_table",
"print_valid_choice_list",
"refresh_welcome_poster",
"repl_choose_one",
"repl_print",
"repl_section_break",
"repl_table",
"repl_tty_interactive",
]
@@ -0,0 +1,74 @@
"""ASCII splash art and art-selection logic for the startup screen.
Exported
--------
SPLASH_ART block font, 59 cols, solid ██ fills
SPLASH_ART_NARROW simpleBlock font, 72 cols, pure ASCII fallback
_FALLBACK_ART minimal art, 44 cols, last resort
_render_art(width) return the best-fit art string for a given terminal width
"""
from __future__ import annotations
import os
from platform.observability.render.figlet import render_figlet
# Pre-rendered during development and checked into this module as a static string.
# Colour codes are stripped; HIGHLIGHT is re-applied at render time.
SPLASH_ART = """\
██████╗ ██████╗ ███████╗███╗ ██╗███████╗██████╗ ███████╗
██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔════╝██╔══██╗██╔════╝
██║ ██║██████╔╝█████╗ ██╔██╗ ██║███████╗██████╔╝█████╗
██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║╚════██║██╔══██╗██╔══╝
╚██████╔╝██║ ███████╗██║ ╚████║███████║██║ ██║███████╗
╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝╚══════╝"""
SPLASH_ART_NARROW = """\
_|_| _|_|_| _|_|_|_| _| _| _|_|_| _|_|_| _|_|_|_|
_| _| _| _| _| _|_| _| _| _| _| _|
_| _| _|_|_| _|_|_| _| _| _| _|_| _|_|_| _|_|_|
_| _| _| _| _| _|_| _| _| _| _|
_|_| _| _|_|_|_| _| _| _|_|_| _| _| _|_|_|_|"""
_FALLBACK_ART = """\
___ ____ ____ _____
/ _ \\ _ __ ___ _ __ / ___|| _ \\| ____|
| | | | '_ \\ / _ \\ '_ \\ \\___ \\| |_) | _|
| |_| | |_) | __/ | | | ___) | _ <| |___
\\___/| .__/ \\___|_| |_||____/|_| \\_\\_____|
|_|"""
def _render_art(console_width: int = 80) -> str:
"""Return the splash art string for the given terminal width.
Priority: SPLASH_ART (grid, 34 cols) → SPLASH_ART_NARROW (simpleBlock, 72 cols)
→ _FALLBACK_ART (minimal, 44 cols). OPENSRE_FIGLET_FONT overrides the default
when pyfiglet is installed.
"""
custom_font = os.getenv("OPENSRE_FIGLET_FONT")
if custom_font:
rendered = render_figlet("OpenSRE", font=custom_font, max_line_width=console_width - 2)
if rendered:
return rendered
art_width = max(len(ln) for ln in SPLASH_ART.splitlines())
narrow_width = max(len(ln) for ln in SPLASH_ART_NARROW.splitlines())
fallback_width = max(len(ln) for ln in _FALLBACK_ART.splitlines())
if console_width >= art_width + 4:
return SPLASH_ART
if console_width >= narrow_width + 4:
return SPLASH_ART_NARROW
if console_width >= fallback_width + 4:
return _FALLBACK_ART
return _FALLBACK_ART
__all__ = [
"SPLASH_ART",
"SPLASH_ART_NARROW",
"_FALLBACK_ART",
"_render_art",
]
@@ -0,0 +1,285 @@
"""Interactive choice helpers for TTY-first REPL flows.
Inline menus render in the terminal scrollback (below the submitted command),
not as a separate prompt-toolkit full-screen dialog — important when the REPL
already runs under asyncio.
Each menu erases itself on exit (selection or Esc) so nested menus never
pile up — only the result output and the next level appear on screen.
"""
from __future__ import annotations
import os
import shutil
import sys
from typing import Literal
from rich.console import Console
from rich.markup import escape
import platform.terminal.theme as ui_theme
from surfaces.interactive_shell.ui.components.key_reader import read_key_unix, read_key_windows
_HINT = "↑↓/j/k/Tab Enter/Space Esc/q"
CRUMB_SEP = " "
# Blank line after the submitted slash line before the menu header (all pickers).
_MENU_LEADING_LINES = 1
_TERMINAL_NEWLINE = "\r\n"
MenuAction = Literal["up", "down", "enter", "cancel", "eof", "ignore"]
def repl_tty_interactive() -> bool:
"""Return True when stdin/stdout support an interactive picker UI."""
return bool(sys.stdin.isatty() and sys.stdout.isatty())
def ensure_tty_column_zero() -> None:
"""Reset the cursor column before Rich output when a TTY is active."""
if repl_tty_interactive():
reset_tty_column()
def prepare_repl_output_line() -> None:
"""Begin Rich output on a new line after inline menu I/O."""
if repl_tty_interactive():
sys.stdout.write(_TERMINAL_NEWLINE)
reset_tty_column()
def repl_section_break(console: Console) -> None:
"""Blank line + dim rule between an inline menu step and Rich output."""
prepare_repl_output_line()
console.print()
console.rule(characters="", style=str(ui_theme.DIM))
console.print()
# ── raw key reader ───────────────────────────────────────────────────────────
def _read_action() -> MenuAction:
"""Map a raw keypress to a menu action.
Delegates terminal I/O to :mod:`key_reader` and applies
choice_menu-specific overrides: Tab → ``"down"``,
right-arrow → ``"enter"``, left-arrow → ``"ignore"``.
"""
key = read_key_windows() if os.name == "nt" else read_key_unix()
if key == "tab":
return "down"
if key == "right":
return "enter"
if key == "left":
return "ignore"
return key # type: ignore[return-value]
def read_menu_action() -> MenuAction:
"""Read one normalized inline-menu action from stdin."""
return _read_action()
# ── rendering helpers ────────────────────────────────────────────────────────
def _cols() -> int:
return max(40, shutil.get_terminal_size(fallback=(80, 24)).columns)
def menu_columns() -> int:
"""Return the current terminal width floor used by inline menus."""
return _cols()
def _rule(width: int) -> str:
return "" * width
def _pad(sym: str, label: str, width: int) -> str:
content = f" {sym} {label}"
pad = width - len(content)
return content + (" " * pad if pad > 0 else "")
def _menu_height(crumb: str, labels: list[str]) -> int:
# leading, title, [crumb], rule, blank, choices, blank, hint
return _MENU_LEADING_LINES + 1 + (1 if crumb else 0) + 1 + 1 + len(labels) + 1 + 1
def write_menu_line(text: str = "") -> None:
"""Write one inline-menu line at column zero even while the terminal is in raw mode."""
if text:
sys.stdout.write(f"\r{text}{_TERMINAL_NEWLINE}")
return
sys.stdout.write(_TERMINAL_NEWLINE)
def _erase_menu_block(height: int) -> None:
if height:
sys.stdout.write(f"\r\x1b[{height}A\r\x1b[J")
reset_tty_column()
def reset_tty_column() -> None:
"""Return the cursor to column zero after inline menu I/O.
Menu rows are padded to the terminal width, so the cursor often ends on a
high column. Rich output that follows must start at column zero or tables
render as a diagonal block of leading whitespace.
"""
sys.stdout.write("\r")
sys.stdout.flush()
def erase_menu_lines(height: int) -> None:
"""Erase a previously-rendered inline menu block."""
_erase_menu_block(height)
def _draw_menu(
*,
title: str,
crumb: str,
labels: list[str],
index: int,
erase_lines: int,
) -> None:
out = sys.stdout
w = _cols()
if erase_lines:
_erase_menu_block(erase_lines)
for _ in range(_MENU_LEADING_LINES):
write_menu_line()
# title
write_menu_line(f"{ui_theme.PROMPT_ACCENT_ANSI}{title}{ui_theme.ANSI_RESET}")
# breadcrumb path
if crumb:
write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{crumb}{ui_theme.ANSI_RESET}")
# separator below header
write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{_rule(w)}{ui_theme.ANSI_RESET}")
write_menu_line()
# choices
for i, label in enumerate(labels):
here = i == index
sym = ">" if here else " "
padded = _pad(sym, label, w)
if here:
write_menu_line(f"{ui_theme.MENU_SELECTION_ROW_ANSI}{padded}{ui_theme.ANSI_RESET}")
else:
write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{padded}{ui_theme.ANSI_RESET}")
write_menu_line()
write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{_HINT}{ui_theme.ANSI_RESET}")
out.flush()
def _erase_menu(crumb: str, labels: list[str]) -> None:
"""Move cursor up to the start of this menu block and wipe it."""
height = _menu_height(crumb, labels)
_erase_menu_block(height)
sys.stdout.flush()
# ── picker loop ──────────────────────────────────────────────────────────────
def _pick(
*,
title: str,
crumb: str,
labels: list[str],
initial_index: int = 0,
) -> int | None:
"""Draw an inline menu, let user navigate, erase on exit. Returns index or None."""
if not labels:
return None
idx = initial_index % len(labels)
height = _menu_height(crumb, labels)
first = True
while True:
_draw_menu(
title=title,
crumb=crumb,
labels=labels,
index=idx,
erase_lines=0 if first else height,
)
first = False
action = _read_action()
if action == "enter":
_erase_menu(crumb, labels)
return idx
if action in ("cancel", "eof"):
_erase_menu(crumb, labels)
return None
if action == "ignore":
continue
if action == "up":
idx = (idx - 1) % len(labels)
elif action == "down":
idx = (idx + 1) % len(labels)
# ── public API ───────────────────────────────────────────────────────────────
def repl_choose_one(
*,
title: str,
choices: list[tuple[str, str]],
breadcrumb: str = "",
initial_value: str | None = None,
) -> str | None:
"""Show an inline erasing arrow-key menu; return selected value or None on Esc.
``breadcrumb`` is a slash-separated path shown dimly below the title, e.g.
``/model set``. Only call when :func:`repl_tty_interactive` is True.
"""
from surfaces.interactive_shell.ui.components.cpr_stdin import drain_stale_cpr_bytes
if not choices or not repl_tty_interactive():
return None
drain_stale_cpr_bytes()
crumb = breadcrumb
labels = [label for _value, label in choices]
initial_index = 0
if initial_value is not None:
for index, (value, _label) in enumerate(choices):
if value == initial_value:
initial_index = index
break
picked = _pick(title=title, crumb=crumb, labels=labels, initial_index=initial_index)
if picked is None:
return None
value = choices[picked][0]
return value if isinstance(value, str) else None
def print_valid_choice_list(
console: Console,
*,
title: str,
choices: list[str],
) -> None:
"""Print one choice per line for scan-friendly fallback/error messaging."""
if not choices:
return
console.print(f"[{ui_theme.SECONDARY}]{title}[/]")
for choice in choices:
console.print(f"[{ui_theme.SECONDARY}] - {escape(choice)}[/]")
__all__ = [
"CRUMB_SEP",
"erase_menu_lines",
"menu_columns",
"print_valid_choice_list",
"read_menu_action",
"repl_choose_one",
"ensure_tty_column_zero",
"prepare_repl_output_line",
"repl_section_break",
"repl_tty_interactive",
"reset_tty_column",
"write_menu_line",
]
@@ -0,0 +1,70 @@
"""CPR (cursor position report) stdin hygiene for the interactive REPL loop."""
from __future__ import annotations
import os
import re
import select
import sys
# A leaked cursor-position reply is ``ESC[row;colR`` (8-bit CSI ``\x9b`` too); when it
# leaks into the input stream the ESC and/or ``[`` introducer can be lost. The
# introducer-less branches below are constrained so they only fire on genuine CPR
# context. Without that constraint they can silently strip legitimate input such as
# ``5R3``, ``12;34R okay`` or ``12;34R5 nodes``.
_CPR_SEQUENCE_RE = re.compile(
r"(?:\x1b\[|\x9b)\d{1,4};\d{1,4}R" # ESC [ row ; col R (introducer present)
r"|\[\d{1,4};\d{1,4}R" # [row;colR without ESC (leaked into input)
r"|\d{1,4};\d{1,4}R(?=[\[\x1b\x9b]|\d{1,4};\d{1,4}R)" # bare row;colR before another fragment
r"|\d{1,4}R(?=\[|\x1b|\x9b|\d{1,4};\d{1,4}R)" # bare rowR before another fragment
r"|\d{1,4};\d{1,4}R$" # bare row;colR alone at end of the line
)
_CPR_ESCAPED_SEQUENCE_RE = re.compile(r"(?:\x1b\[|\x9b)\d{1,4};\d{1,4}R")
def drain_stale_cpr_bytes() -> None:
"""Discard CPR escape-sequence bytes left in stdin after prompt teardown.
When ``prompt_async`` returns, prompt_toolkit tears down its input-reader
thread. CPR responses (``ESC[row;colR``) that the bottom-toolbar refresh
sent but that arrived just after the reader stopped sit in the OS stdin
buffer and appear as literal keystrokes in the next prompt. This function
non-blockingly drains stdin between ``prompt_async`` calls on POSIX TTYs.
"""
if os.name == "nt" or not sys.stdin.isatty():
return
try:
fd = sys.stdin.fileno()
while select.select([fd], [], [], 0)[0]:
chunk = os.read(fd, 256)
if not chunk:
break
except OSError:
# Draining stdin is best-effort; ignore when the fd is not readable.
pass
def strip_cpr_sequences(text: str | None) -> str:
"""Remove terminal cursor-position replies that leaked into submitted text."""
if not text:
return ""
return _CPR_SEQUENCE_RE.sub("", text)
def strip_cpr_escape_sequences(text: str | None) -> str:
"""Remove only canonical escaped CPR sequences from text."""
if not text:
return ""
return _CPR_ESCAPED_SEQUENCE_RE.sub("", text)
def contains_cpr_sequence(text: str | None) -> bool:
return bool(text and _CPR_SEQUENCE_RE.search(text))
__all__ = [
"contains_cpr_sequence",
"drain_stale_cpr_bytes",
"strip_cpr_escape_sequences",
"strip_cpr_sequences",
]
@@ -0,0 +1,152 @@
"""Low-level terminal key reader for TTY-first interactive menus.
Shared between :mod:`choice_menu` (REPL inline picker) and
:mod:`feedback` (post-investigation rating prompt) so the raw-mode
terminal I/O lives in one place.
Return values from :func:`read_key_unix` / :func:`read_key_windows`:
``"up"``, ``"down"``, ``"enter"``, ``"cancel"``, ``"tab"``,
``"right"``, ``"left"``, ``"eof"``, ``"ignore"``.
"""
from __future__ import annotations
import contextlib
import os
import sys
def flush_stdin_unix() -> None:
"""Discard pending stdin bytes before raw-mode reading."""
with contextlib.suppress(Exception):
import termios
termios.tcflush(sys.stdin.fileno(), termios.TCIFLUSH) # type: ignore[attr-defined]
def restore_stdin_terminal() -> None:
"""Return stdin to canonical echo mode after Live/raw investigation UI.
Investigation progress uses a background Ctrl+O watcher that puts stdin in
non-canonical mode without echo. If nested watchers restore the wrong
snapshot, the shell prompt appears to accept input but characters are not
echoed. Call this after investigation UI teardown and before line prompts.
"""
if os.name == "nt" or not sys.stdin.isatty():
return
import termios
with contextlib.suppress(Exception):
fd = sys.stdin.fileno()
attrs = termios.tcgetattr(fd) # type: ignore[attr-defined]
# Restore cooked-mode flags a raw menu clears: ICRNL so Enter (CR) submits,
# OPOST for output newlines, ICANON/ECHO/ISIG for line editing and signals.
attrs[0] |= termios.BRKINT | termios.ICRNL | termios.IXON # type: ignore[attr-defined]
attrs[1] |= termios.OPOST # type: ignore[attr-defined]
attrs[3] |= termios.ICANON | termios.ECHO | termios.ISIG # type: ignore[attr-defined]
if hasattr(termios, "IEXTEN"):
attrs[3] |= termios.IEXTEN # type: ignore[attr-defined]
termios.tcsetattr(fd, termios.TCSADRAIN, attrs) # type: ignore[attr-defined]
termios.tcflush(fd, termios.TCIFLUSH) # type: ignore[attr-defined]
def read_key_unix(*, also_cancel: tuple[bytes, ...] = ()) -> str:
"""Read one logical keypress in raw mode; return a normalised key name.
Possible return values: ``"up"``, ``"down"``, ``"enter"``,
``"cancel"``, ``"tab"``, ``"right"``, ``"left"``, ``"eof"``,
``"ignore"``.
``also_cancel`` treats additional single-byte keys as ``"cancel"`` (e.g.
``(b"s", b"S")`` for an explicit skip shortcut).
"""
import select as _sel
import termios
import tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd) # type: ignore[attr-defined]
try:
tty.setraw(fd) # type: ignore[attr-defined]
ch = os.read(fd, 1)
if not ch:
return "eof"
b = ch[0]
if b in (3, 4) or ch in also_cancel: # Ctrl-C / Ctrl-D / caller shortcuts
return "cancel"
if b in (10, 13, 32): # LF / CR / Space
return "enter"
if b == 9: # Tab
return "tab"
if ch in (b"j", b"J"):
return "down"
if ch in (b"k", b"K"):
return "up"
if ch in (b"q", b"Q"):
return "cancel"
if b == 27: # ESC or arrow-key prefix
if _sel.select([fd], [], [], 0.1)[0]:
nxt = os.read(fd, 1)
if nxt == b"[" and _sel.select([fd], [], [], 0.1)[0]:
arr = os.read(fd, 1)
if arr == b"A":
return "up"
if arr == b"B":
return "down"
if arr == b"C":
return "right"
if arr == b"D":
return "left"
# Not an arrow key — drain the rest of the CSI sequence so
# bytes like "0;1R" from a CPR (ESC[row;colR) don't leak into
# the next read or the prompt buffer as literal characters.
# The VT/xterm spec defines 0x400x7E as valid CSI final bytes.
while arr and not (0x40 <= arr[0] <= 0x7E):
if not _sel.select([fd], [], [], 0)[0]:
break
arr = os.read(fd, 1)
return "cancel"
return "ignore"
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old) # type: ignore[attr-defined]
def read_key_windows(*, also_cancel: tuple[bytes, ...] = ()) -> str:
"""Read one logical keypress on Windows; return a normalised key name.
Possible return values: ``"up"``, ``"down"``, ``"enter"``,
``"cancel"``, ``"tab"``, ``"right"``, ``"left"``, ``"eof"``,
``"ignore"``.
``also_cancel`` treats additional single-byte keys as ``"cancel"``.
"""
import msvcrt # type: ignore[import,attr-defined]
ch = msvcrt.getch() # type: ignore[attr-defined]
if ch in (b"\x03", b"\x1b") or ch in also_cancel:
return "cancel"
if ch in (b"\r", b"\n", b" "):
return "enter"
if ch == b"\t":
return "tab"
if ch in (b"j", b"J"):
return "down"
if ch in (b"k", b"K"):
return "up"
if ch in (b"q", b"Q"):
return "cancel"
if ch in (b"\xe0", b"\x00"):
ch2 = msvcrt.getch() # type: ignore[attr-defined]
if ch2 == b"H":
return "up"
if ch2 == b"P":
return "down"
if ch2 == b"M":
return "right"
if ch2 == b"K":
return "left"
return "ignore"
return "ignore"
__all__ = ["flush_stdin_unix", "read_key_unix", "read_key_windows", "restore_stdin_terminal"]
@@ -0,0 +1,41 @@
"""Shared Rich loaders for interactive-shell LLM calls.
A quiet, dim spinner shows that an LLM call is in flight. Centralised so
every LLM-backed surface in the interactive shell (``cli_agent``,
``follow_up``) shares the same look.
"""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from rich.console import Console
from platform.terminal.theme import SECONDARY
# Quiet, secondary-colour spinner — less visual noise than a bright accent.
_LOADER_COLOR = SECONDARY
_LOADER_SPINNER = "dots"
DEFAULT_LOADER_LABEL = "thinking"
@contextmanager
def llm_loader(console: Console, label: str = DEFAULT_LOADER_LABEL) -> Iterator[None]:
"""Show a dim spinner while an LLM call is in flight.
On non-terminal consoles (CI, captured output, piped stdout), the spinner is
skipped so captured logs stay clean — the wrapped call still runs unchanged.
"""
if not console.is_terminal:
yield
return
console.print()
text = f"[{_LOADER_COLOR}]{label}…[/{_LOADER_COLOR}]"
with console.status(text, spinner=_LOADER_SPINNER, spinner_style=_LOADER_COLOR):
yield
__all__ = ["DEFAULT_LOADER_LABEL", "llm_loader"]
@@ -0,0 +1,256 @@
"""REPL TTY plumbing: buffered print helpers and table factory.
Keeps cursor at column zero and normalises line endings under prompt_toolkit's
patch_stdout so Rich tables and JSON don't render as diagonal blocks.
Domain-specific table renderers live in :mod:`tables`.
"""
from __future__ import annotations
import io
import shutil
import sys
from collections.abc import Callable
from contextvars import ContextVar
from typing import Any
from rich import box
from rich.console import Console
from rich.markup import escape
from rich.table import Table
import platform.terminal.theme as ui_theme
_REPL_OUTPUT_PREPARED = ContextVar("_REPL_OUTPUT_PREPARED", default=False)
def _repl_output_already_prepared() -> bool:
"""Whether current call stack already prepared the TTY for Rich output."""
return _REPL_OUTPUT_PREPARED.get()
def _console_print_prepared(console: Console, *objects: Any, **kwargs: Any) -> None:
token = _REPL_OUTPUT_PREPARED.set(True)
try:
console.print(*objects, **kwargs)
finally:
_REPL_OUTPUT_PREPARED.reset(token)
def _repl_table_width(console: Console) -> int:
"""Best-effort terminal width for Rich tables after inline menu I/O."""
term_cols = shutil.get_terminal_size(fallback=(80, 24)).columns
# Keep one safety column to avoid right-edge auto-wrap artifacts in some
# terminals (first-char clipping / duplicate right border when a row lands
# exactly on the terminal width).
return max(40, min(console.width, term_cols) - 1)
def _prepare_tty_for_rich(console: Console) -> int:
"""Return the width Rich should render at.
prepare_repl_output_line() (which writes \\r\\n) is intentionally NOT called
here. Under patch_stdout(raw=True), that extra newline causes the bottom
toolbar text to flush into the output stream before the table renders. Slash
commands start after the user presses Enter, so the cursor is already on a
fresh line; no extra line-feed is needed.
"""
return _repl_table_width(console)
def _normalize_repl_line_endings(text: str) -> str:
"""Convert Rich output to ``\\r\\n`` so each line starts at column zero."""
return text.replace("\r\n", "\n").replace("\n", "\r\n")
def _write_repl_tty_buffered(
*,
width: int,
leading_blank: bool,
render_to_buffer: Callable[[Console], None],
) -> None:
"""Render Rich output to a buffer and write it in one TTY-safe stdout call."""
buf = io.StringIO()
buf_console = Console(
file=buf,
force_terminal=True,
highlight=False,
width=width,
)
render_to_buffer(buf_console)
rendered = _normalize_repl_line_endings(buf.getvalue())
if leading_blank:
rendered = "\r\n" + rendered
token = _REPL_OUTPUT_PREPARED.set(True)
try:
sys.stdout.write(rendered)
sys.stdout.flush()
finally:
_REPL_OUTPUT_PREPARED.reset(token)
def print_repl_table(console: Console, table: Table, *, width: int | None = None) -> None:
"""Print a Rich table using REPL-safe TTY width.
When the console writes to sys.stdout (the real REPL path), tables are
rendered into a string buffer first and written in a single sys.stdout.write
call with explicit \\r\\n line endings. This prevents the diagonal-render
artifact that occurs under prompt_toolkit's patch_stdout: each table row is
a separate Rich write, and if the terminal or proxy does not convert \\n to
\\r\\n, every row starts where the previous one ended instead of column zero.
When the console writes to a non-TTY stdout (piped output) or to a
different file (e.g. a StringIO in tests), the normal console.print path
is used — preserving the caller's color_system and avoiding ANSI pollution
in piped output.
"""
leading_blank = width is None
width = width if width is not None else _prepare_tty_for_rich(console)
if console.file is sys.stdout and sys.stdout.isatty():
_write_repl_tty_buffered(
width=width,
leading_blank=leading_blank,
render_to_buffer=lambda buf_console: buf_console.print(table),
)
else:
if leading_blank:
_console_print_prepared(console)
_console_print_prepared(console, table, width=width)
def print_repl_json(console: Console, json_str: str) -> None:
"""Print JSON via Rich using REPL-safe \\r\\n line endings.
Mirrors the buffered-write approach in :func:`print_repl_table` to prevent
the diagonal-render artifact under prompt_toolkit's patch_stdout: bare
``\\n`` from Rich does not imply a carriage-return, so each JSON line would
start at the column where the previous one ended. Rendering to a buffer
and normalising to ``\\r\\n`` ensures every line begins at column zero.
The leading blank is included in the same write call to avoid a stale CPR
sequence being left in stdin by a prompt_toolkit toolbar flush.
"""
width = _prepare_tty_for_rich(console)
if console.file is sys.stdout and sys.stdout.isatty():
_write_repl_tty_buffered(
width=width,
leading_blank=True,
render_to_buffer=lambda buf_console: buf_console.print_json(json_str),
)
else:
token = _REPL_OUTPUT_PREPARED.set(True)
try:
console.print_json(json_str)
finally:
_REPL_OUTPUT_PREPARED.reset(token)
def repl_print(console: Console, *objects: Any, **kwargs: Any) -> None:
"""Print via Rich after resetting the TTY column (inline-menu safe)."""
from surfaces.interactive_shell.ui.components.choice_menu import prepare_repl_output_line
prepare_repl_output_line()
_console_print_prepared(console, *objects, **kwargs)
def _repl_write_buffer(rendered: str) -> None:
"""Flush pre-rendered Rich output with CRLF line endings (patch_stdout safe)."""
from surfaces.interactive_shell.ui.components.cpr_stdin import strip_cpr_escape_sequences
normalized = strip_cpr_escape_sequences(rendered.replace("\r\n", "\n").replace("\n", "\r\n"))
token = _REPL_OUTPUT_PREPARED.set(True)
try:
sys.stdout.write(normalized)
sys.stdout.flush()
finally:
_REPL_OUTPUT_PREPARED.reset(token)
def repl_clear_screen() -> None:
"""Clear the terminal scrollback when the REPL runs under patch_stdout."""
if not sys.stdout.isatty():
return
sys.stdout.write("\x1b[2J\x1b[H")
sys.stdout.flush()
def _theme_notice_line(theme_notice: str) -> str:
"""REPL-safe ``theme set: <name>`` using the active palette (not stale imports)."""
return (
f"{ui_theme.HIGHLIGHT_ANSI}theme set: {escape(theme_notice)}{ui_theme.ANSI_RESET}\r\n\r\n"
)
def repl_render_launch_poster(
console: Console,
*,
session: object = None,
theme_notice: str | None = None,
) -> None:
"""Render splash + welcome panel using REPL-safe CRLF writes."""
from surfaces.interactive_shell.ui import banner as banner_module
if console.file is sys.stdout and sys.stdout.isatty():
width = _prepare_tty_for_rich(console)
buf = io.StringIO()
buf_console = Console(
file=buf,
force_terminal=True,
highlight=False,
color_system="truecolor",
legacy_windows=False,
width=width,
)
banner_module.render_splash(buf_console, first_run=False)
banner_module.render_ready_box(buf_console, session=session)
prefix = _theme_notice_line(theme_notice) if theme_notice else ""
_repl_write_buffer(prefix + buf.getvalue())
return
if theme_notice:
_console_print_prepared(
console,
f"[{ui_theme.HIGHLIGHT}]theme set:[/] {escape(theme_notice)}",
)
banner_module.render_splash(console, first_run=False)
banner_module.render_ready_box(console, session=session)
def refresh_welcome_poster(
console: Console,
*,
session: object = None,
theme_notice: str | None = None,
) -> None:
"""Clear scrollback and redraw splash art + welcome panel with the active theme."""
from surfaces.interactive_shell.ui.components.cpr_stdin import drain_stale_cpr_bytes
repl_clear_screen()
# ``repl_clear_screen`` can trigger a toolbar DSR/CPR exchange; drain before writing.
drain_stale_cpr_bytes()
repl_render_launch_poster(console, session=session, theme_notice=theme_notice)
def repl_table(**kwargs: Any) -> Table:
"""Minimal outer borders — closer to Claude Code than full ASCII grids."""
opts: dict[str, Any] = {
"box": box.MINIMAL_HEAVY_HEAD,
"show_edge": False,
"pad_edge": False,
"title_justify": "left",
}
opts.update(kwargs)
return Table(**opts)
__all__ = [
"_repl_output_already_prepared",
"_repl_table_width",
"print_repl_json",
"print_repl_table",
"refresh_welcome_poster",
"repl_clear_screen",
"repl_print",
"repl_render_launch_poster",
"repl_table",
]
@@ -0,0 +1,68 @@
"""Shared timestamp and duration formatting for REPL tables and menus."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Literal
def _fmt_timing(elapsed_ms: int) -> str:
"""Format a millisecond count as a short elapsed string: ``42ms`` or ``1.3s``."""
return f"{elapsed_ms / 1000:.1f}s" if elapsed_ms >= 1000 else f"{elapsed_ms}ms"
def _elapsed_hms(seconds: float) -> str:
"""Format seconds as ``M:SS`` for live-display rows."""
m = int(seconds // 60)
s = int(seconds % 60)
return f"{m}:{s:02d}"
ReplTimestampStyle = Literal["table", "compact", "utc"]
def format_repl_duration(duration_secs: int | None) -> str:
"""Format a duration in seconds for REPL session/task tables."""
if duration_secs is None:
return ""
if duration_secs < 60:
return f"{duration_secs}s"
if duration_secs < 3600:
return f"{duration_secs // 60}m {duration_secs % 60}s"
hours = duration_secs // 3600
minutes = (duration_secs % 3600) // 60
return f"{hours}h {minutes}m"
def format_repl_timestamp(
value: str | datetime | int | float | None,
*,
style: ReplTimestampStyle = "table",
fallback: str = "",
) -> str:
"""Format ISO strings, datetimes, or unix timestamps for REPL display."""
if value is None:
return fallback
if isinstance(value, datetime):
dt = value
elif isinstance(value, (int, float)):
dt = datetime.fromtimestamp(float(value), tz=UTC)
else:
raw = value.strip()
if not raw:
return fallback
try:
dt = datetime.fromisoformat(raw)
except ValueError:
return raw[:16] if raw else fallback
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
if style == "utc":
return dt.astimezone(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
local = dt.astimezone()
if style == "compact":
return local.strftime("%m-%d %H:%M")
return local.strftime("%Y-%m-%d %H:%M")
@@ -0,0 +1,22 @@
"""Token-count constants and short-form formatter.
Shared between the streaming renderer (ui/streaming.py) and the spinner state
(runtime/state.py) so both display the same ``1.2k`` format and the same
chars-per-token heuristic.
"""
from __future__ import annotations
# Approximate characters per token used to estimate token counts from byte
# lengths without waiting for the API to return exact usage.
_CHARS_PER_TOKEN = 4
def format_token_count_short(token_count: int) -> str:
"""Format a token count as a short string — ``42`` / ``1.2k`` / ``5.2k``."""
if token_count >= 1000:
return f"{token_count / 1000:.1f}k"
return str(token_count)
__all__ = ["_CHARS_PER_TOKEN", "format_token_count_short"]
@@ -0,0 +1,165 @@
"""Interaction layer for the REPL execution policy.
This module owns the *user-facing* half of the execution gate: it renders the
policy decision (``Action blocked``, the non-TTY warning, the ``Proceed? [Y/n]``
prompt), reads the user's confirmation, and emits analytics. The pure decision
itself is computed by
:func:`tools.interactive_shell.shared.resolve_confirmation`,
which has no console, ``input``, or analytics dependency.
Keeping interaction here (rather than in ``execution_policy``) means the policy
module stays pure and easy to test, while callers that need the confirmation UX
import :func:`execution_allowed` from this UI module.
"""
from __future__ import annotations
import sys
from collections.abc import Callable
from typing import TYPE_CHECKING
from rich.console import Console
from rich.markup import escape
from core.agent_harness.session.terminal_access import trust_mode_enabled
from platform.analytics.cli import capture_repl_execution_policy_decision
from platform.analytics.provider import Properties
from platform.terminal.theme import DIM, WARNING
from tools.interactive_shell.shared import (
ConfirmationOutcome,
ExecutionPolicyResult,
ExecutionVerdict,
resolve_confirmation,
)
if TYPE_CHECKING:
from surfaces.interactive_shell.runtime import Session
def _default_confirm_fn(prompt: str) -> str:
return input(prompt)
DEFAULT_CONFIRM_FN: Callable[[str], str] = _default_confirm_fn
def _emit_decision(
*,
tool_type: str,
policy_verdict: ExecutionVerdict,
outcome: str,
trust_mode: bool,
reason: str | None,
user_prompted: bool = False,
) -> None:
props: Properties = {
"tool_type": tool_type,
"policy_verdict": policy_verdict,
"outcome": outcome,
"trust_mode": trust_mode,
}
if reason:
props["reason"] = reason[:240]
if user_prompted:
props["user_prompted"] = True
capture_repl_execution_policy_decision(props)
def execution_allowed(
result: ExecutionPolicyResult,
*,
session: Session,
console: Console,
action_summary: str,
confirm_fn: Callable[[str], str] | None = None,
is_tty: bool | None = None,
action_already_listed: bool = False,
) -> bool:
"""Print policy UX, emit analytics, and return whether execution should proceed.
When ``action_already_listed`` is True (e.g. assistant printed a numbered action plan),
the TTY prompt omits repeating ``action_summary`` and shows only the policy reason.
"""
trust_mode = trust_mode_enabled(session)
tty = sys.stdin.isatty() if is_tty is None else is_tty
confirm = confirm_fn or DEFAULT_CONFIRM_FN
plan = resolve_confirmation(result, trust_mode=trust_mode, is_tty=tty)
if plan.outcome == ConfirmationOutcome.DENY:
_emit_decision(
tool_type=result.tool_type,
policy_verdict=result.verdict,
outcome=plan.analytics_outcome or "blocked",
trust_mode=trust_mode,
reason=plan.analytics_reason,
)
console.print(f"[{WARNING}]Action blocked:[/] {escape(result.reason or 'not allowed')}")
if result.hint:
console.print(f"[{DIM}]{escape(result.hint)}[/]")
return False
if plan.outcome == ConfirmationOutcome.ALLOW:
_emit_decision(
tool_type=result.tool_type,
policy_verdict=result.verdict,
outcome=plan.analytics_outcome or "allowed",
trust_mode=trust_mode,
reason=plan.analytics_reason,
)
return True
if plan.outcome == ConfirmationOutcome.BLOCK_NON_TTY:
_emit_decision(
tool_type=result.tool_type,
policy_verdict=result.verdict,
outcome=plan.analytics_outcome or "blocked",
trust_mode=trust_mode,
reason=plan.analytics_reason,
)
console.print(
f"[{WARNING}]confirmation required but stdin is not a TTY; "
f"enable trust mode with[/] [bold]/trust[/bold] [{WARNING}]or rerun in a terminal.[/]"
)
console.print(f"[{DIM}]{escape(action_summary)}[/]")
return False
# NEEDS_CONFIRMATION
reason = (result.reason or "this action").strip()
summary = action_summary.strip()
if action_already_listed:
console.print(f"[{WARNING}]Confirm:[/] [{DIM}]{escape(reason)}[/]")
elif summary:
console.print(
f"[{WARNING}]Confirm[/] [bold]{escape(summary)}[/bold] [{DIM}]— {escape(reason)}[/]"
)
else:
console.print(f"[{WARNING}]Confirm:[/] [{DIM}]{escape(reason)}[/]")
answer = confirm("Proceed? [Y/n] ").strip().lower()
if answer not in {"", "y", "yes"}:
_emit_decision(
tool_type=result.tool_type,
policy_verdict=result.verdict,
outcome="aborted",
trust_mode=trust_mode,
reason="user_declined",
user_prompted=True,
)
console.print(f"[{DIM}]cancelled.[/]")
return False
_emit_decision(
tool_type=result.tool_type,
policy_verdict=result.verdict,
outcome="allowed",
trust_mode=trust_mode,
reason="user_confirmed",
user_prompted=True,
)
return True
__all__ = [
"DEFAULT_CONFIRM_FN",
"execution_allowed",
]
@@ -0,0 +1,485 @@
"""Post-investigation accuracy feedback prompt.
Shown after every investigation when stdin/stdout is a TTY.
Silently skipped when: not a TTY, the user has opted out via prefs, or any
exception occurs — feedback must never disrupt the CLI.
Why a custom select menu instead of repl_choose_one() on the CLI path:
Rich's Live renderer leaves the cursor at an indeterminate row.
choice_menu._erase_menu_block() assumes a fixed cursor position and can
redraw in the wrong place after streaming output ends.
The local :func:`_run_select` erases line-by-line with ``\\x1b[2K`` and is
robust to any cursor state. Call :func:`restore_stdin_terminal` before
entering the menu so investigation progress UI (Ctrl+O watcher) does not
leave stdin in no-echo mode. The REPL path keeps :func:`repl_choose_one`
inside prompt_toolkit's stdout patch context.
"""
from __future__ import annotations
import contextlib
import json
import os
import sys
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from surfaces.interactive_shell.ui.components.key_reader import (
flush_stdin_unix,
read_key_unix,
read_key_windows,
restore_stdin_terminal,
)
if TYPE_CHECKING:
from rich.console import Console
# Labels mirror the Slack feedback block in utils/slack_delivery.py.
_CHOICES: list[tuple[str, str]] = [
("accurate", "Accurate — root cause identified correctly"),
("partial", "Partially accurate — missed some issues"),
("inaccurate", "Inaccurate — wrong root cause"),
("skip", "Skip for now"),
("never", "Never ask again"),
]
_NEVER_AGAIN_KEY = "feedback_disabled"
_SKIP_KEYS = (b"s", b"S")
# ANSI helpers (theme colours inlined to avoid import at module level)
_H = "\x1b[1;38;2;185;237;175m" # HIGHLIGHT bold (#B9EDAF)
_D = "\x1b[2m" # dim
_R = "\x1b[0m" # reset
_HINT = f" {_D}↑↓ / j k · Enter · Esc / s to skip{_R}"
def _write_raw(text: str) -> None:
"""Write the console-less (CLI/REPL) feedback text in one TTY-safe call.
Normalises bare ``\\n`` to ``\\r\\n`` when stdout is a TTY so the context,
header, note and confirmation lines do not staircase under the REPL's
``patch_stdout(raw=True)`` proxy, which passes raw-mode output through
verbatim. ``_run_select`` already emits ``\\r\\n`` for the menu rows; this
applies the same rule to the surrounding text. For non-TTY stdout
(piped/captured/tests) the text is written as-is.
"""
if sys.stdout.isatty():
text = text.replace("\r\n", "\n").replace("\n", "\r\n")
sys.stdout.write(text)
sys.stdout.flush()
# ── persistence ───────────────────────────────────────────────────────────────
def _config_dir() -> Path:
from config.constants import OPENSRE_HOME_DIR
return OPENSRE_HOME_DIR
def _feedback_path() -> Path:
return _config_dir() / "feedback.jsonl"
def _prefs_path() -> Path:
return _config_dir() / "prefs.json"
def _is_disabled() -> bool:
with contextlib.suppress(Exception):
path = _prefs_path()
if path.exists():
data = json.loads(path.read_text(encoding="utf-8"))
return bool(data.get(_NEVER_AGAIN_KEY, False))
return False
def _set_disabled() -> None:
with contextlib.suppress(Exception):
path = _prefs_path()
path.parent.mkdir(parents=True, exist_ok=True)
data: dict[str, Any] = {}
if path.exists():
with contextlib.suppress(Exception):
data = json.loads(path.read_text(encoding="utf-8"))
data[_NEVER_AGAIN_KEY] = True
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
def _store(record: dict[str, Any]) -> None:
path = _feedback_path()
with contextlib.suppress(OSError):
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
# ── analytics ─────────────────────────────────────────────────────────────────
def _emit_analytics(record: dict[str, Any]) -> None:
from platform.analytics.events import Event
from platform.analytics.provider import get_analytics
with contextlib.suppress(Exception):
props: dict[str, Any] = {
"feedback_id": record["feedback_id"],
"rating": record["rating"],
"has_note": bool(record.get("note")),
"is_noise": bool(record.get("is_noise", False)),
}
for key in ("run_id", "alert_name", "root_cause_category", "investigation_loop_count"):
if record.get(key):
props[key] = record[key]
for key in ("user_id", "user_email", "org_id"):
if record.get(key):
props[key] = record[key]
if record.get("validity_score") is not None:
props["validity_score"] = str(record["validity_score"])
get_analytics().capture(Event.INVESTIGATION_FEEDBACK_SUBMITTED, props)
def _emit_miss_classified(miss_record: dict[str, Any]) -> None:
"""Emit a follow-up event so PostHog dashboards can chart category trends."""
from platform.analytics.events import Event
from platform.analytics.provider import get_analytics
with contextlib.suppress(Exception):
props: dict[str, Any] = {
"miss_id": miss_record.get("miss_id", ""),
"feedback_id": miss_record.get("feedback_id", ""),
"taxonomy": miss_record.get("taxonomy", ""),
"rating": miss_record.get("rating", ""),
"has_detail": bool(miss_record.get("taxonomy_detail")),
}
for key in ("run_id", "alert_name", "root_cause_category", "pipeline_name"):
if miss_record.get(key):
props[key] = miss_record[key]
for key in ("user_id", "org_id"):
if miss_record.get(key):
props[key] = miss_record[key]
get_analytics().capture(Event.INVESTIGATION_MISS_CLASSIFIED, props)
# ── context display ───────────────────────────────────────────────────────────
def _format_root_cause_lines(root: str, *, cols: int) -> list[str]:
"""Wrap root-cause text to terminal width with a hanging ``Root cause:`` prefix."""
import textwrap
prefix = "Root cause: "
content_width = max(20, cols - len(prefix))
wrapped = textwrap.wrap(root, width=content_width)
if not wrapped:
return []
lines = [prefix + wrapped[0]]
indent = " " * len(prefix)
lines.extend(indent + line for line in wrapped[1:])
return lines
def _root_cause_width(*, console: Console | None) -> int:
"""Best-effort terminal width for root-cause display (matches REPL tables)."""
import shutil
from surfaces.interactive_shell.ui.components.rendering import _repl_table_width
if console is not None:
return _repl_table_width(console)
return max(40, shutil.get_terminal_size(fallback=(80, 24)).columns)
def _print_context(final_state: dict[str, Any], *, console: Console | None) -> None:
"""Print the root-cause summary above the rating prompt."""
root = (final_state.get("root_cause") or "").strip()
if not root:
return
cols = _root_cause_width(console=console)
from rich.markup import escape
from platform.terminal.theme import BRAND, DIM, SECONDARY
if console is not None:
console.print()
console.rule(characters="", style=DIM)
console.print(
f"[{SECONDARY}]Root cause:[/] [{BRAND}]{escape(root)}[/]",
soft_wrap=True,
width=cols,
)
else:
rule = "" * cols
body = "\n".join(_format_root_cause_lines(root, cols=cols))
_write_raw(f"\n{rule}\n{body}\n{rule}\n")
# ── self-contained select (CLI path) ─────────────────────────────────────────
def _run_select(choices: list[tuple[str, str]]) -> str | None:
"""Arrow-key select menu after streaming output.
Uses per-line ``\\x1b[2K`` (erase line) instead of a block cursor-position
assumption. ``restore_stdin_terminal()`` must run before this so the menu
starts from canonical echo mode rather than the investigation watcher state.
Returns the selected key string, or None on Esc / Ctrl-C / s.
"""
restore_stdin_terminal()
labels = [label for _, label in choices]
n = len(labels)
total_lines = n + 1 # n choice lines + 1 hint line
idx = 0
is_unix = os.name != "nt"
if is_unix:
flush_stdin_unix()
def _out(s: str) -> None:
sys.stdout.write(s)
sys.stdout.flush()
def _draw(redraw: bool) -> None:
if redraw:
_out(f"\x1b[{total_lines}A")
for i, label in enumerate(labels):
if i == idx:
_out(f"\r\x1b[2K{_H} > {label}{_R}\r\n")
else:
_out(f"\r\x1b[2K{_D} {label}{_R}\r\n")
_out(f"\r\x1b[2K{_HINT}\r\n")
_draw(False)
while True:
key = (
read_key_unix(also_cancel=_SKIP_KEYS)
if is_unix
else read_key_windows(also_cancel=_SKIP_KEYS)
)
if key == "enter":
_out(f"\x1b[{total_lines}A\r\x1b[J")
return choices[idx][0]
if key in ("cancel", "eof"):
_out(f"\x1b[{total_lines}A\r\x1b[J")
return None
if key == "up":
idx = (idx - 1) % n
_draw(True)
elif key == "down":
idx = (idx + 1) % n
_draw(True)
# ── note reader ───────────────────────────────────────────────────────────────
def _read_note(*, console: Console | None) -> str:
from platform.terminal.theme import DIM, SECONDARY
restore_stdin_terminal()
if console is not None:
console.print(
f"[{SECONDARY}]What was wrong or missing? [{DIM}](Enter to skip)[/]:[/] ", end=""
)
else:
_write_raw("\nWhat was wrong or missing? (Enter to skip): ")
with contextlib.suppress(EOFError, KeyboardInterrupt):
return input().strip()
return ""
# ── core ──────────────────────────────────────────────────────────────────────
def _pick_rating(*, console: Console | None) -> str | None:
"""Show the rating prompt; returns key or None on cancel/skip."""
if console is not None:
from surfaces.interactive_shell.ui.components.choice_menu import (
repl_choose_one,
repl_tty_interactive,
)
if not repl_tty_interactive():
return None
return repl_choose_one(title="Was this RCA accurate?", choices=_CHOICES)
if not (sys.stdin.isatty() and sys.stdout.isatty()):
return None
return _run_select(_CHOICES)
def _pick_taxonomy(*, console: Console | None) -> str | None:
"""Show the miss-taxonomy picker after a partial/inaccurate rating."""
from core.domain.feedback import taxonomy_choices
choices = taxonomy_choices()
if console is not None:
from surfaces.interactive_shell.ui.components.choice_menu import (
repl_choose_one,
repl_tty_interactive,
)
if not repl_tty_interactive():
return None
return repl_choose_one(title="Where did this miss come from?", choices=choices)
if not (sys.stdin.isatty() and sys.stdout.isatty()):
return None
return _run_select(choices)
def _collect(final_state: dict[str, Any], *, console: Console | None) -> None:
if not (sys.stdin.isatty() and sys.stdout.isatty()):
return
if _is_disabled():
return
_print_context(final_state, console=console)
from platform.terminal.theme import BRAND, DIM
if console is not None:
console.print(
f"\n[{BRAND}]Was this RCA accurate?[/] [{DIM}]↑↓ · Enter · Esc or s to skip[/]"
)
else:
_write_raw(f"\n{_H}Was this RCA accurate?{_R} {_D}↑↓ · Enter · Esc or s to skip{_R}\n\n")
rating = _pick_rating(console=console)
if not rating or rating == "skip":
return
if rating == "never":
_set_disabled()
msg = (
f"Feedback prompts disabled. "
f"To re-enable, remove {_NEVER_AGAIN_KEY!r} from {_prefs_path()}"
)
if console is not None:
console.print(f"[{DIM}]{msg}[/]")
else:
_write_raw(f"\n{_D}{msg}{_R}\n")
return
note = ""
if rating in ("partial", "inaccurate"):
note = _read_note(console=console)
record: dict[str, Any] = {
"feedback_id": str(uuid.uuid4()),
"timestamp": datetime.now(UTC).isoformat(),
"run_id": final_state.get("run_id", ""),
"alert_name": final_state.get("alert_name", ""),
"root_cause": (final_state.get("root_cause") or "")[:500],
"root_cause_category": final_state.get("root_cause_category", ""),
"validity_score": final_state.get("validity_score"),
"is_noise": final_state.get("is_noise", False),
"investigation_loop_count": final_state.get("investigation_loop_count"),
"user_id": final_state.get("user_id", ""),
"user_email": final_state.get("user_email", ""),
"org_id": final_state.get("org_id", ""),
"rating": rating,
"note": note,
}
_store(record)
_emit_analytics(record)
# Closed-loop learning: classify partial/inaccurate outcomes so they can be
# tracked over time and replayed as benchmark regressions.
miss_record: dict[str, Any] | None = None
if rating in ("partial", "inaccurate"):
miss_record = _classify_miss(record, final_state=final_state, console=console)
if console is not None:
console.print(f"[{BRAND}]✓ Feedback saved.[/] [{DIM}]{_feedback_path()}[/]")
if miss_record is not None:
from core.domain.feedback import misses_path
console.print(f"[{DIM}] Miss recorded → {misses_path()}[/]")
else:
message = f"\n{_H}✓ Feedback saved.{_R} {_D}{_feedback_path()}{_R}\n"
if miss_record is not None:
from core.domain.feedback import misses_path
message += f" {_D}Miss recorded → {misses_path()}{_R}\n"
_write_raw(f"{message}\n")
def _classify_miss(
record: dict[str, Any],
*,
final_state: dict[str, Any],
console: Console | None,
) -> dict[str, Any] | None:
"""Prompt for taxonomy classification and persist a miss record.
Returns the miss record on success, ``None`` if the user cancels the
taxonomy picker (the rating + note are still kept in feedback.jsonl).
"""
from core.domain.feedback import MissTaxonomy, record_miss
from platform.terminal.theme import BRAND, DIM
if console is not None:
console.print(
f"\n[{BRAND}]Where did this miss come from?[/] [{DIM}]↑↓ · Enter · Esc to skip[/]"
)
else:
sys.stdout.write(
f"\n{_H}Where did this miss come from?{_R} {_D}↑↓ · Enter · Esc to skip{_R}\n\n"
)
sys.stdout.flush()
taxonomy_key = _pick_taxonomy(console=console)
if not taxonomy_key:
return None
try:
taxonomy = MissTaxonomy(taxonomy_key)
except ValueError:
taxonomy = MissTaxonomy.UNKNOWN
persisted = record_miss(
record,
taxonomy=taxonomy,
taxonomy_detail=record.get("note", ""),
final_state=final_state,
)
if persisted is None:
# record_miss already surfaced the OSError to stderr; suppress the
# "saved" confirmation and analytics so the user is not misled.
return None
miss_record: dict[str, Any] = dict(persisted)
_emit_miss_classified(miss_record)
return miss_record
def prompt_investigation_feedback(
final_state: dict[str, Any],
*,
console: Console | None = None,
) -> None:
"""Prompt for RCA accuracy feedback; never raises.
Stores each response to ``~/.opensre/feedback.jsonl`` and emits
``investigation_feedback_submitted`` to PostHog with investigation
provenance (run_id, alert_name, validity_score, root_cause_category, …)
and user context (user_id, user_email, org_id when available on
the hosted/JWT path).
"""
with contextlib.suppress(Exception):
try:
_collect(final_state, console=console)
finally:
restore_stdin_terminal()
@@ -0,0 +1,196 @@
"""Shared foreground investigation task lifecycle for REPL entry points."""
from __future__ import annotations
import time
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from rich.console import Console
from rich.markup import escape
from core.agent_harness.session.terminal_access import session_terminal
from core.llm.shared.llm_retry import CREDIT_EXHAUSTED_MARKER
from platform.common.errors import OpenSREError
from platform.common.task_types import TaskKind, TaskRecord
from platform.observability.trace.spans import mark_span_outcome, traced_session
from platform.terminal.theme import DIM, ERROR, WARNING
from surfaces.interactive_shell.ui.investigation_outcome import (
InvestigationOutcome,
classify_investigation_failure,
failure_detail_from_exception,
normalize_investigation_target,
user_facing_error_message,
)
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
from surfaces.interactive_shell.utils.telemetry.investigation_llm_usage import (
InvestigationLlmUsage,
observe_investigation_llm_usage,
resolve_configured_llm_identity,
)
if TYPE_CHECKING:
from surfaces.interactive_shell.session import Session
def _render_credit_exhausted_recovery_hint(console: Console, message: str) -> None:
if CREDIT_EXHAUSTED_MARKER not in message:
return
console.print(f"[{DIM}]Run /model to switch to another provider.[/]")
console.print(
f"[{DIM}]Or run /auth login <provider> to re-authenticate or add a different provider.[/]"
)
def _contains_auth_login_hint(message: str | None) -> bool:
if not message:
return False
return "auth login" in message
def _llm_fields(usage: InvestigationLlmUsage, started: float) -> dict[str, Any]:
"""LLM identity, token, and timing fields shared by every outcome shape."""
provider, configured_model = resolve_configured_llm_identity()
return {
"llm_model": usage.model or configured_model,
"llm_provider": provider,
"llm_input_tokens": usage.input_tokens,
"llm_output_tokens": usage.output_tokens,
"duration_ms": int((time.monotonic() - started) * 1000),
}
def run_foreground_investigation(
*,
session: Session,
console: Console,
task_command: str,
run: Callable[[TaskRecord], dict[str, Any]],
exception_context: str,
target: str = "",
) -> InvestigationOutcome:
"""Run one foreground investigation with shared task and error handling."""
normalized_target = normalize_investigation_target(target)
session.last_investigation_id = ""
task = session.task_registry.create(TaskKind.INVESTIGATION, command=task_command)
task.mark_running()
started = time.monotonic()
session_id = str(getattr(session, "session_id", "") or "") or None
with traced_session(
session_id,
component="investigation",
attributes={"target": normalized_target, "command": task_command},
) as attrs:
outcome = _run_foreground_investigation_body(
session=session,
console=console,
task_command=task_command,
run=run,
exception_context=exception_context,
normalized_target=normalized_target,
task=task,
started=started,
)
mark_span_outcome(
attrs,
outcome.status,
error=bool(outcome.failure_category),
investigation_id=outcome.investigation_id or None,
failure_category=outcome.failure_category or None,
)
return outcome
def _run_foreground_investigation_body(
*,
session: Session,
console: Console,
task_command: str,
run: Callable[[TaskRecord], dict[str, Any]],
exception_context: str,
normalized_target: str,
task: TaskRecord,
started: float,
) -> InvestigationOutcome:
try:
with observe_investigation_llm_usage() as usage:
final_state = run(task)
except KeyboardInterrupt:
task.mark_cancelled()
console.print(f"[{WARNING}]investigation cancelled.[/]")
return InvestigationOutcome(
status="cancelled",
target=normalized_target,
investigation_id=str(getattr(session, "last_investigation_id", "") or ""),
failure_category="user_cancelled",
**_llm_fields(usage, started),
)
except OpenSREError as exc:
task.mark_failed(str(exc))
message = str(exc)
console.print(f"[{ERROR}]investigation failed:[/] {escape(message)}")
if not _contains_auth_login_hint(exc.suggestion):
_render_credit_exhausted_recovery_hint(console, message)
if exc.suggestion:
console.print(f"[{WARNING}]suggestion:[/] {escape(exc.suggestion)}")
category, integration, integration_detail = classify_investigation_failure(exc)
return InvestigationOutcome(
status="failed",
target=normalized_target,
investigation_id=str(getattr(session, "last_investigation_id", "") or ""),
error_message=user_facing_error_message(exc),
error_detail=failure_detail_from_exception(exc),
failure_category=category,
integration_involved=integration,
integration_failure_message=integration_detail,
**_llm_fields(usage, started),
)
except Exception as exc:
task.mark_failed(str(exc))
report_exception(exc, context=exception_context)
message = str(exc)
console.print(f"[{ERROR}]investigation failed:[/] {escape(message)}")
_render_credit_exhausted_recovery_hint(console, message)
category, integration, integration_detail = classify_investigation_failure(exc)
return InvestigationOutcome(
status="failed",
target=normalized_target,
investigation_id=str(getattr(session, "last_investigation_id", "") or ""),
error_message=user_facing_error_message(exc),
error_detail=failure_detail_from_exception(exc),
failure_category=category,
integration_involved=integration,
integration_failure_message=integration_detail,
**_llm_fields(usage, started),
)
root = final_state.get("root_cause")
task.mark_completed(result=str(root) if root is not None else "")
session.apply_investigation_result(final_state, trigger=task_command)
from surfaces.interactive_shell.ui.components.choice_menu import repl_tty_interactive
from surfaces.interactive_shell.ui.components.key_reader import restore_stdin_terminal
from surfaces.interactive_shell.ui.feedback import prompt_investigation_feedback
# Skip feedback while the prompt-toolkit app is running: its cursor-position
# queries would race the raw feedback menu and leak bytes into the next prompt.
terminal = session_terminal(session)
prompt_app_running = False
if terminal is not None:
prompt_app = terminal.prompt_app
prompt_app_running = prompt_app is not None and getattr(prompt_app, "is_running", False)
# RCA feedback is REPL-only: gateway/headless sessions have no terminal facet and
# must not block on a raw-stdin picker (e.g. gateway running under tmux with TTY).
if terminal is not None and not prompt_app_running and repl_tty_interactive():
restore_stdin_terminal()
prompt_investigation_feedback(final_state)
return InvestigationOutcome(
status="completed",
target=normalized_target,
investigation_id=str(getattr(session, "last_investigation_id", "") or ""),
final_state=final_state,
**_llm_fields(usage, started),
)
__all__ = ["run_foreground_investigation"]
@@ -0,0 +1,172 @@
"""Rendering helpers for the ``opensre health`` command."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import click
from rich import box
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from platform.terminal.theme import (
BOLD_BRAND,
BRAND,
ERROR,
HIGHLIGHT,
SECONDARY,
WARNING,
)
def status_badge(status: str) -> Text:
normalized = status.strip().lower()
if normalized in {"passed", "pass", "ok", "healthy"}:
return Text("PASSED", style=f"bold {HIGHLIGHT}")
if normalized in {"warn", "warning", "degraded", "outdated"}:
return Text("WARN", style=f"bold {WARNING}")
if normalized == "missing":
return Text("MISSING", style=f"bold {WARNING}")
if normalized in {"failed", "fail", "error", "unhealthy"}:
return Text("FAILED", style=f"bold {ERROR}")
return Text(normalized.upper() or "UNKNOWN", style="bold")
_STATUS_BUCKETS: dict[str, str] = {
"passed": "passed",
"pass": "passed",
"ok": "passed",
"healthy": "passed",
"missing": "missing",
"failed": "failed",
"fail": "failed",
"error": "failed",
"unhealthy": "failed",
}
def _summary_counts(results: list[dict[str, str]]) -> dict[str, int]:
counts = {"passed": 0, "missing": 0, "failed": 0, "other": 0}
for result in results:
status = str(result.get("status", "")).strip().lower()
bucket = _STATUS_BUCKETS.get(status, "other")
counts[bucket] += 1
return counts
def render_health_report(
*,
console: Console,
environment: str,
integration_store_path: str | Path,
results: list[dict[str, Any]],
) -> None:
"""Render a polished health report with summary and actionable hints."""
store_path_text = str(integration_store_path)
normalized_results: list[dict[str, str]] = [
{
"service": str(item.get("service", "")),
"source": str(item.get("source", "")),
"status": str(item.get("status", "")),
"detail": str(item.get("detail", "")),
}
for item in results
]
counts = _summary_counts(normalized_results)
console.print()
console.print(Panel.fit(f"[{BOLD_BRAND}]OpenSRE Health[/]", border_style=BRAND))
from platform.guardrails.rules import get_default_rules_path, load_rules
rules_path = get_default_rules_path()
if rules_path.exists():
rules = load_rules(rules_path)
enabled = [r for r in rules if r.enabled]
guardrails_status = f"{len(enabled)} rules active ({rules_path})"
else:
guardrails_status = "not configured"
meta = Table.grid(padding=(0, 1))
meta.add_row("[bold]Environment[/bold]", environment)
meta.add_row("[bold]Integration store[/bold]", store_path_text)
meta.add_row("[bold]Guardrails[/bold]", guardrails_status)
console.print(meta)
summary = Text.assemble(
("Summary: ", "bold"),
(f"{counts['passed']} passed", HIGHLIGHT),
(" | ", SECONDARY),
(f"{counts['missing']} missing", WARNING),
(" | ", SECONDARY),
(f"{counts['failed']} failed", ERROR),
)
if counts["other"]:
summary.append(" | ", style=SECONDARY)
summary.append(f"{counts['other']} unknown")
console.print(summary)
console.print()
table = Table(title="Integration Checks", box=box.SIMPLE_HEAVY, show_lines=False)
table.add_column("Service", style=BOLD_BRAND)
table.add_column("Source", style=SECONDARY)
table.add_column("Status")
table.add_column("Detail")
for result in normalized_results:
table.add_row(
result["service"] or "-",
result["source"] or "-",
status_badge(result["status"]),
result["detail"] or "-",
)
console.print(table)
console.print()
if counts["failed"] > 0:
console.print(
f"[bold {ERROR}]Action:[/] Fix failed integrations, then rerun [bold]opensre health[/bold]."
)
elif counts["missing"] > 0:
console.print(
f"[bold {WARNING}]Action:[/] Configure missing integrations with "
"[bold]opensre integrations setup <service>[/bold]."
)
else:
console.print(f"[bold {HIGHLIGHT}]All configured integrations look healthy.[/]")
def render_health_json(
*,
environment: str,
integration_store_path: str | Path,
results: list[dict[str, Any]],
) -> None:
"""Render the health report as machine-readable JSON."""
normalized = [
{
"service": str(item.get("service", "")),
"source": str(item.get("source", "")),
"status": str(item.get("status", "")),
"detail": str(item.get("detail", "")),
}
for item in results
]
counts = _summary_counts(normalized)
click.echo(
json.dumps(
{
"environment": environment,
"integration_store": str(integration_store_path),
"summary": counts,
"results": normalized,
},
indent=2,
)
)
@@ -0,0 +1,17 @@
"""Interactive help slash-command menus."""
from surfaces.interactive_shell.ui.help.help_menu import (
choose_help_command,
has_help_details,
render_command_detail,
render_help_index,
render_section_detail,
)
__all__ = [
"choose_help_command",
"has_help_details",
"render_command_detail",
"render_help_index",
"render_section_detail",
]
@@ -0,0 +1,490 @@
"""Help renderers for the interactive shell slash-command catalog."""
from __future__ import annotations
import sys
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Literal
from rich.console import Console
from rich.markup import escape
from rich.table import Table
from platform.terminal import theme as ui_theme
from surfaces.interactive_shell.command_registry.types import SlashCommand
from surfaces.interactive_shell.ui.components.choice_menu import (
erase_menu_lines,
menu_columns,
read_menu_action,
write_menu_line,
)
from surfaces.interactive_shell.ui.components.rendering import (
print_repl_table,
repl_print,
repl_table,
)
HelpSection = tuple[str, Sequence[SlashCommand]]
_HELP_VIEW_ROWS = 21
_HELP_HINT = "↑↓/j/k navigate · Enter run command · Space toggle details · Esc/q close"
@dataclass(frozen=True)
class HelpRow:
section: str | None = None
command: SlashCommand | None = None
separator: bool = False
@property
def selectable(self) -> bool:
return self.command is not None
@dataclass(frozen=True)
class HelpDetailLine:
text: str
role: Literal["label", "value"]
@dataclass(frozen=True)
class HelpDisplayRow:
source_index: int | None = None
section: str | None = None
command: SlashCommand | None = None
detail: HelpDetailLine | None = None
separator: bool = False
def render_help_index(console: Console, sections: Sequence[HelpSection]) -> None:
"""Render the compact non-interactive help index."""
table = repl_table(
title="Slash commands",
title_style=str(ui_theme.BOLD_BRAND),
show_header=False,
)
table.add_column("command", no_wrap=True, min_width=18)
table.add_column("description", style=str(ui_theme.DIM))
for section_name, commands in sections:
if not commands:
continue
table.add_row(f"[{ui_theme.BOLD_BRAND}]{escape(section_name)}[/]", "")
for index, command in enumerate(commands):
table.add_row(
f" [{ui_theme.HIGHLIGHT}]{escape(command.name)}[/]",
escape(command.description),
end_section=(index == len(commands) - 1),
)
print_repl_table(console, table)
repl_print(
console,
f"[{ui_theme.DIM}]Use[/] [bold]/help <command>[/bold] [{ui_theme.DIM}]for usage.[/]",
)
def render_section_detail(
console: Console,
section_name: str,
commands: Sequence[SlashCommand],
) -> None:
"""Render one category using the same compact description-only style."""
table = repl_table(
title=f"{section_name} commands",
title_style=str(ui_theme.BOLD_BRAND),
show_header=False,
)
table.add_column("command", no_wrap=True, min_width=18)
table.add_column("description", style=str(ui_theme.DIM))
for command in commands:
table.add_row(
f"[{ui_theme.HIGHLIGHT}]{escape(command.name)}[/]",
escape(command.description),
)
print_repl_table(console, table)
repl_print(
console,
f"[{ui_theme.DIM}]Use[/] [bold]/help <command>[/bold] [{ui_theme.DIM}]for usage.[/]",
)
def render_command_detail(console: Console, command: SlashCommand) -> None:
"""Render detailed help for one slash command."""
table = Table(
title=command.name,
title_style=str(ui_theme.BOLD_BRAND),
show_header=False,
box=None,
)
table.add_column("label", style="bold", no_wrap=True)
table.add_column("value")
table.add_row("description", escape(command.description))
if command.usage:
table.add_row("usage", "\n".join(escape(item) for item in command.usage))
if command.examples:
table.add_row("examples", "\n".join(escape(item) for item in command.examples))
if command.notes:
table.add_row("notes", "\n".join(escape(item) for item in command.notes))
print_repl_table(console, table)
def has_help_details(command: SlashCommand) -> bool:
"""True when a command has expandable usage, examples, or notes."""
return bool(command.usage or command.examples or command.notes)
def _flatten_help_rows(sections: Sequence[HelpSection]) -> list[HelpRow]:
rows: list[HelpRow] = []
for section_name, commands in sections:
if not commands:
continue
if rows:
rows.append(HelpRow(separator=True))
rows.append(HelpRow(section=section_name))
rows.extend(HelpRow(command=command) for command in commands)
return rows
def _first_selectable_index(rows: Sequence[HelpRow]) -> int | None:
for index, row in enumerate(rows):
if row.selectable:
return index
return None
def _next_selectable_index(rows: Sequence[HelpRow], current: int, delta: int) -> int:
if not rows:
return current
index = current
for _ in range(len(rows)):
index = (index + delta) % len(rows)
if rows[index].selectable:
return index
return current
def _expanded_detail_lines(command: SlashCommand) -> list[HelpDetailLine]:
lines: list[HelpDetailLine] = []
if command.usage:
lines.append(HelpDetailLine("usage:", "label"))
lines.extend(HelpDetailLine(f" {item}", "value") for item in command.usage)
if command.examples:
lines.append(HelpDetailLine("examples:", "label"))
lines.extend(HelpDetailLine(f" {item}", "value") for item in command.examples)
if command.notes:
lines.append(HelpDetailLine("notes:", "label"))
lines.extend(HelpDetailLine(f" {item}", "value") for item in command.notes)
return lines
def _display_rows(rows: Sequence[HelpRow], expanded: int | None) -> list[HelpDisplayRow]:
display: list[HelpDisplayRow] = []
for index, row in enumerate(rows):
display.append(
HelpDisplayRow(
source_index=index,
section=row.section,
command=row.command,
separator=row.separator,
)
)
if expanded == index and row.command is not None:
display.extend(
HelpDisplayRow(detail=line) for line in _expanded_detail_lines(row.command)
)
return display
def _display_index_for_source(display_rows: Sequence[HelpDisplayRow], source_index: int) -> int:
for index, row in enumerate(display_rows):
if row.source_index == source_index:
return index
return 0
def _detail_end_index(rows: Sequence[HelpDisplayRow], selected: int) -> int:
end = selected + 1
while end < len(rows) and rows[end].detail is not None:
end += 1
return end
def _expanded_viewport_height(
rows: Sequence[HelpDisplayRow],
selected: int,
base_height: int,
) -> int:
detail_end = _detail_end_index(rows, selected)
expanded_block_height = detail_end - selected
if expanded_block_height <= 1:
return base_height
return max(base_height, expanded_block_height + 2)
def _viewport_bounds(
rows: Sequence[HelpDisplayRow],
selected: int,
height: int,
) -> tuple[int, int]:
if len(rows) <= height:
return 0, len(rows)
before = max(0, height // 3)
start = max(0, selected - before)
end = start + height
if end > len(rows):
end = len(rows)
start = max(0, end - height)
detail_end = _detail_end_index(rows, selected)
if detail_end > selected + 1 and detail_end - selected <= height:
if selected < start:
start = selected
end = min(len(rows), start + height)
if detail_end > end:
end = detail_end
start = max(0, end - height)
# Keep the selected command's category header visible when it fits in the viewport.
section_start = selected
while section_start > 0 and rows[section_start].section is None:
section_start -= 1
if rows[section_start].section is not None and section_start < start:
detail_end = _detail_end_index(rows, selected)
distance = detail_end - section_start
if distance <= height:
start = section_start
end = min(len(rows), start + height)
return start, end
def _visible_width(text: str) -> int:
return len(text)
def _clip(text: str, width: int) -> str:
if width <= 0:
return ""
if _visible_width(text) <= width:
return text
return text[: max(0, width - 1)] + ""
def _pad(text: str, width: int) -> str:
clipped = _clip(text, width)
return clipped + (" " * max(0, width - _visible_width(clipped)))
def _center(text: str, width: int) -> str:
if _visible_width(text) >= width:
return text
return text.center(width)
def _command_name_width(width: int) -> int:
return min(18, max(12, width // 3))
def _left_column_width(width: int) -> int:
return 6 + _command_name_width(width)
def _right_column_width(width: int) -> int:
return max(0, width - _left_column_width(width) - 2)
def _render_grid_row(left: str, right: str, width: int) -> str:
left_column = _pad(left, _left_column_width(width))
right_column = _clip(right, _right_column_width(width))
return _pad(f"{left_column}{right_column}", width)
def _render_section_row(section: str, width: int) -> str:
left_column = _pad(section, _left_column_width(width))
right_column = " " * _right_column_width(width)
return (
f"{ui_theme.BOLD_BRAND_ANSI}{left_column}{ui_theme.ANSI_RESET}"
f"{ui_theme.DIM_COUNTER_ANSI}{right_column}{ui_theme.ANSI_RESET}"
)
def _render_detail_row(detail: HelpDetailLine, width: int) -> str:
left_column = " " * _left_column_width(width)
right_column = _clip(detail.text, _right_column_width(width))
right_padding = " " * max(0, _right_column_width(width) - _visible_width(right_column))
detail_ansi = ui_theme.TEXT_ANSI if detail.role == "label" else ui_theme.DIM_COUNTER_ANSI
return (
f"{ui_theme.DIM_COUNTER_ANSI}{left_column}{ui_theme.ANSI_RESET}"
f"{detail_ansi}{right_column}{right_padding}{ui_theme.ANSI_RESET}"
)
def _separator_rule(width: int) -> str:
column = _left_column_width(width)
if column >= width:
return "" * width
return f"{'' * column}{'' * max(0, width - column - 1)}"
def _render_command_row(
command: SlashCommand,
*,
selected: bool,
expanded: bool,
width: int,
) -> str:
marker = ">" if selected else " "
affordance = "" if expanded else "" if has_help_details(command) else " "
left = f" {marker} {affordance} {command.name}"
padded = _render_grid_row(left, command.description, width)
if selected:
return f"{ui_theme.MENU_SELECTION_ROW_ANSI}{padded}{ui_theme.ANSI_RESET}"
left_width = _left_column_width(width)
right_width = _right_column_width(width)
prefix = f" {marker} {affordance} "
command_width = max(0, left_width - _visible_width(prefix))
command_name = _clip(command.name, command_width)
command_padding = " " * max(0, command_width - _visible_width(command_name))
right_column = _clip(command.description, right_width)
right_padding = " " * max(0, right_width - _visible_width(right_column))
return (
f"{ui_theme.DIM_COUNTER_ANSI}{prefix}{ui_theme.ANSI_RESET}"
f"{ui_theme.HIGHLIGHT_ANSI}{command_name}{ui_theme.ANSI_RESET}"
f"{ui_theme.DIM_COUNTER_ANSI}{command_padding}{right_column}{right_padding}"
f"{ui_theme.ANSI_RESET}"
)
def _render_help_row(row: HelpRow, *, selected: bool, expanded: bool, width: int) -> str:
if row.separator:
return f"{ui_theme.DIM_COUNTER_ANSI}{_separator_rule(width)}{ui_theme.ANSI_RESET}"
if row.section is not None:
return _render_section_row(row.section, width)
if row.command is None:
return ""
return _render_command_row(row.command, selected=selected, expanded=expanded, width=width)
def _render_display_row(
row: HelpDisplayRow,
*,
selected: bool,
expanded: bool,
width: int,
) -> str:
if row.detail is not None:
return _render_detail_row(row.detail, width)
return _render_help_row(
HelpRow(section=row.section, command=row.command, separator=row.separator),
selected=selected,
expanded=expanded,
width=width,
)
def _help_menu_height(viewport_height: int) -> int:
# leading blank, title, counter, rule, rows, blank, hint
return 5 + viewport_height + 1
def _draw_help_menu(
rows: Sequence[HelpRow],
*,
selected: int,
expanded: int | None,
erase_lines: int,
viewport_height: int = _HELP_VIEW_ROWS,
) -> int:
width = menu_columns()
display = _display_rows(rows, expanded)
display_selected = _display_index_for_source(display, selected)
effective_viewport_height = _expanded_viewport_height(
display,
display_selected,
viewport_height,
)
start, end = _viewport_bounds(display, display_selected, effective_viewport_height)
visible = display[start:end]
height = _help_menu_height(effective_viewport_height)
if erase_lines:
erase_menu_lines(erase_lines)
selected_count = sum(1 for row in rows[: selected + 1] if row.selectable)
total_count = sum(1 for row in rows if row.selectable)
write_menu_line()
write_menu_line(
f"{ui_theme.PROMPT_ACCENT_ANSI}{_center('Slash commands', width)}{ui_theme.ANSI_RESET}"
)
write_menu_line(
f"{ui_theme.DIM_COUNTER_ANSI}{selected_count}/{total_count}{ui_theme.ANSI_RESET}"
)
write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{_separator_rule(width)}{ui_theme.ANSI_RESET}")
for offset, row in enumerate(visible, start=start):
write_menu_line(
_render_display_row(
row,
selected=(offset == display_selected),
expanded=(row.source_index == expanded),
width=width,
)
)
for _ in range(max(0, effective_viewport_height - len(visible))):
write_menu_line()
write_menu_line()
write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{_HELP_HINT}{ui_theme.ANSI_RESET}")
sys.stdout.flush()
return height
def choose_help_command(sections: Sequence[HelpSection]) -> str | None:
"""Let a TTY user browse command details from a grouped viewport."""
rows = _flatten_help_rows(sections)
selected = _first_selectable_index(rows)
if selected is None:
return None
erase_lines = 0
expanded: int | None = None
while True:
erase_lines = _draw_help_menu(
rows,
selected=selected,
expanded=expanded,
erase_lines=erase_lines,
)
action = read_menu_action()
if action == "space":
command = rows[selected].command
if command is not None and has_help_details(command):
expanded = None if expanded == selected else selected
continue
if action == "enter":
command = rows[selected].command
erase_menu_lines(erase_lines)
return command.name if command is not None else None
if action in ("cancel", "eof"):
erase_menu_lines(erase_lines)
return None
if action == "ignore":
continue
if action == "up":
selected = _next_selectable_index(rows, selected, -1)
expanded = None
elif action == "down":
selected = _next_selectable_index(rows, selected, 1)
expanded = None
__all__ = [
"HelpSection",
"HelpRow",
"choose_help_command",
"render_command_detail",
"render_help_index",
"render_section_detail",
]
@@ -0,0 +1,48 @@
"""PromptSession assembly for the interactive shell."""
from __future__ import annotations
from prompt_toolkit import PromptSession
from surfaces.interactive_shell.prompt_history import load_prompt_history
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui.input_prompt.completion import ShellCompleter
from surfaces.interactive_shell.ui.input_prompt.key_bindings import _build_prompt_key_bindings
from surfaces.interactive_shell.ui.input_prompt.lexer import ReplInputLexer
from surfaces.interactive_shell.ui.input_prompt.rendering import (
_DEFAULT_PLACEHOLDER_ANSI,
resolve_prompt_placeholder,
)
from surfaces.interactive_shell.ui.input_prompt.style import _build_prompt_style
def _install_prompt_frame(session: PromptSession[str]) -> PromptSession[str]:
return session
def build_prompt_session(session: Session | None = None) -> PromptSession[str]:
placeholder = (
(lambda: resolve_prompt_placeholder(session))
if session is not None
else _DEFAULT_PLACEHOLDER_ANSI
)
return _install_prompt_frame(
PromptSession(
completer=ShellCompleter(),
complete_while_typing=True,
multiline=True,
reserve_space_for_menu=8,
history=load_prompt_history(),
lexer=ReplInputLexer(),
key_bindings=_build_prompt_key_bindings(),
style=_build_prompt_style(),
erase_when_done=True,
placeholder=placeholder,
)
)
__all__ = [
"_install_prompt_frame",
"build_prompt_session",
]
@@ -0,0 +1,186 @@
"""Slash-command, alias, and file-path completion for the REPL prompt."""
from __future__ import annotations
from collections.abc import Iterable
from prompt_toolkit.application.current import get_app_or_none
from prompt_toolkit.completion import CompleteEvent, Completer, Completion, PathCompleter
from prompt_toolkit.document import Document
from platform.terminal import theme as ui_theme
from surfaces.interactive_shell.command_registry import SLASH_COMMANDS
from surfaces.interactive_shell.command_registry.help import QUICK_ACCESS_COMMANDS
from surfaces.interactive_shell.command_registry.types import SlashCommand
from surfaces.interactive_shell.ui.components.choice_menu import repl_tty_interactive
from surfaces.interactive_shell.ui.input_prompt.layout import (
_DEFAULT_TERMINAL_COLUMNS,
_clip_text,
_short_meta,
_terminal_columns,
)
_COMPLETION_PREVIEW_SEP = ""
def _slash_command_name(completion: Completion) -> str | None:
for candidate in (completion.text, completion.display_text or ""):
if candidate.startswith("/"):
return candidate
return None
def _resolve_completion_preview(
completion: Completion,
*,
buffer_text: str,
) -> tuple[str, str] | None:
cmd_name = _slash_command_name(completion)
if cmd_name is not None:
entry = SLASH_COMMANDS.get(cmd_name)
if entry is not None:
return cmd_name, entry.description
meta = completion.display_meta_text
if not meta:
return None
display = completion.display_text or completion.text
if cmd_name is not None:
label = display
else:
parts = buffer_text.split()
label = f"{parts[0]} {display}" if parts and parts[0].startswith("/") else display
return label, meta
def completion_preview_hint_ansi() -> str:
"""Full description for the highlighted completion menu item."""
app = get_app_or_none()
if app is None:
return ""
buffer = app.current_buffer
complete_state = buffer.complete_state
if complete_state is None or not complete_state.completions:
return ""
completion = complete_state.current_completion or complete_state.completions[0]
preview = _resolve_completion_preview(completion, buffer_text=buffer.text)
if preview is None:
return ""
label, description = preview
try:
cols = app.output.get_size().columns
except Exception:
cols = _DEFAULT_TERMINAL_COLUMNS
line = _clip_text(f"{label}{_COMPLETION_PREVIEW_SEP}{description}", cols)
return f"{ui_theme.ANSI_DIM}{line}{ui_theme.ANSI_RESET}"
# Precomputed at import time so bare-`/` completions never rebuild it per keystroke.
_QUICK_ACCESS_SET: frozenset[str] = frozenset(QUICK_ACCESS_COMMANDS)
def _slash_completion(cmd: SlashCommand, start_position: int, *, cols: int) -> Completion:
return Completion(
cmd.name,
start_position=start_position,
display=cmd.name,
display_meta=_short_meta(cmd.description, command_name=cmd.name, cols=cols),
)
class ShellCompleter(Completer):
"""Tab-completion for slash commands, subcommands, and file paths."""
def get_completions(
self,
document: Document,
complete_event: CompleteEvent,
) -> Iterable[Completion]:
text = document.text_before_cursor
if not text:
return
if not text.startswith("/"):
return
parts = text.split()
trailing_space = text != text.rstrip(" ")
if len(parts) == 1 and not trailing_space:
needle = parts[0].lower()
cols = _terminal_columns()
if needle == "/":
# Bare `/`: show most important commands first, then the rest.
for name in QUICK_ACCESS_COMMANDS:
cmd = SLASH_COMMANDS.get(name)
if cmd is not None:
yield _slash_completion(cmd, -1, cols=cols)
for cmd in SLASH_COMMANDS.values():
if cmd.name not in _QUICK_ACCESS_SET:
yield _slash_completion(cmd, -1, cols=cols)
else:
for cmd in SLASH_COMMANDS.values():
if cmd.name.lower().startswith(needle):
yield _slash_completion(cmd, -len(parts[0]), cols=cols)
return
if len(parts) <= 2:
cmd_name = parts[0].lower()
raw_arg = "" if trailing_space or len(parts) < 2 else parts[1]
if _suppress_empty_arg_completions_for_inline_picker(cmd_name, raw_arg):
return
if cmd_name in ("/investigate", "/save"):
if cmd_name == "/investigate":
entry = SLASH_COMMANDS.get(cmd_name)
hints = entry.first_arg_completions if entry is not None else ()
sub_prefix = raw_arg.lower()
for sub, meta in hints:
if sub.startswith(sub_prefix):
yield Completion(
sub,
start_position=-len(raw_arg),
display=sub,
display_meta=meta,
)
yield from PathCompleter(expanduser=True).get_completions(
Document(raw_arg, len(raw_arg)),
complete_event,
)
return
entry = SLASH_COMMANDS.get(cmd_name)
hints = entry.first_arg_completions if entry is not None else ()
sub_prefix = raw_arg.lower()
for sub, meta in hints:
if sub.startswith(sub_prefix):
yield Completion(
sub,
start_position=-len(raw_arg),
display=sub,
display_meta=meta,
)
# Commands where bare invocation opens an inline picker in TTY mode.
_INLINE_PICKER_COMMANDS: frozenset[str] = frozenset(
{
"/history",
"/integrations",
"/investigate",
"/mcp",
"/model",
"/template",
"/tests",
"/trust",
"/verbose",
}
)
def _suppress_empty_arg_completions_for_inline_picker(cmd_name: str, raw_arg: str) -> bool:
"""Hide first-arg autocomplete when bare slash command opens inline picker."""
return repl_tty_interactive() and not raw_arg and cmd_name in _INLINE_PICKER_COMMANDS
@@ -0,0 +1,104 @@
"""Prompt-toolkit key bindings for the REPL prompt."""
from __future__ import annotations
from typing import Protocol
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.completion import CompleteEvent
from prompt_toolkit.filters import has_completions
from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
class _DispatchCancelState(Protocol):
def is_dispatch_running(self) -> bool:
raise NotImplementedError
def cancel_current_dispatch(self) -> None:
raise NotImplementedError
# Keystroke escape (xterm modifyOtherKeys for Shift+Enter), not a colour code.
_SHIFT_ENTER_SEQUENCE = "\x1b[27;2;13~"
def _tab_expand_or_menu(buffer: Buffer) -> None:
"""Apply the current completion or open the menu when several choices exist."""
if buffer.complete_state:
state = buffer.complete_state
completion = state.current_completion
if completion is None and state.completions:
completion = state.completions[0]
if completion is not None:
buffer.apply_completion(completion)
return
if buffer.completer is None:
return
completions = list(
buffer.completer.get_completions(
buffer.document,
CompleteEvent(completion_requested=True),
)
)
if len(completions) == 1:
buffer.apply_completion(completions[0])
else:
buffer.start_completion(select_first=True)
def _build_prompt_key_bindings() -> KeyBindings:
bindings = KeyBindings()
@bindings.add("c-m")
def _accept_turn(event: object) -> None:
if event.data == _SHIFT_ENTER_SEQUENCE: # type: ignore[attr-defined]
event.current_buffer.newline(copy_margin=False) # type: ignore[attr-defined]
return
event.current_buffer.validate_and_handle() # type: ignore[attr-defined]
@bindings.add("tab")
def _tab_complete(event: object) -> None:
_tab_expand_or_menu(event.current_buffer) # type: ignore[attr-defined]
@bindings.add("s-tab")
def _shift_tab_complete(event: object) -> None:
buff = event.current_buffer # type: ignore[attr-defined]
if buff.complete_state:
buff.complete_previous()
else:
buff.start_completion(select_first=False)
@bindings.add("down", filter=has_completions)
def _next_completion(event: object) -> None:
event.current_buffer.complete_next() # type: ignore[attr-defined]
@bindings.add("up", filter=has_completions)
def _previous_completion(event: object) -> None:
event.current_buffer.complete_previous() # type: ignore[attr-defined]
return bindings
def build_cancel_key_bindings(state: _DispatchCancelState) -> KeyBindings:
kb = KeyBindings()
@kb.add("escape", eager=True)
def _on_escape(event: KeyPressEvent) -> None:
if state.is_dispatch_running():
state.cancel_current_dispatch()
return
if event.current_buffer.text:
event.current_buffer.reset()
@kb.add("c-l")
def _on_ctrl_l(event: KeyPressEvent) -> None:
event.app.renderer.clear()
return kb
def install_session_key_bindings(pt_session: object, extra_kb: KeyBindings) -> None:
existing = getattr(pt_session, "key_bindings", None)
merged = merge_key_bindings([existing, extra_kb]) if existing is not None else extra_kb
pt_session.key_bindings = merged # type: ignore[attr-defined]
@@ -0,0 +1,46 @@
"""Shared sizing and clipping helpers for prompt UI text."""
from __future__ import annotations
from prompt_toolkit.application.current import get_app_or_none
_DEFAULT_TERMINAL_COLUMNS = 80
_COMPLETION_META_PADDING = 6
_COMPLETION_META_MIN_WIDTH = 24
def _terminal_columns() -> int:
app = get_app_or_none()
if app is None:
return _DEFAULT_TERMINAL_COLUMNS
try:
return app.output.get_size().columns
except Exception:
return _DEFAULT_TERMINAL_COLUMNS
def _clip_text(text: str, max_len: int) -> str:
if max_len <= 0:
return ""
if len(text) <= max_len:
return text
return text[: max_len - 1] + ""
def _completion_meta_width(command_name: str, cols: int) -> int:
return max(_COMPLETION_META_MIN_WIDTH, cols - len(command_name) - _COMPLETION_META_PADDING)
def _short_meta(
text: str,
*,
command_name: str = "",
max_len: int | None = None,
cols: int | None = None,
) -> str:
if max_len is None:
if command_name:
max_len = _completion_meta_width(command_name, cols or _terminal_columns())
else:
max_len = 54
return _clip_text(text, max_len)
@@ -0,0 +1,47 @@
"""Lexer for styling REPL input while the user types."""
from __future__ import annotations
from collections.abc import Callable
from prompt_toolkit.document import Document
from prompt_toolkit.formatted_text import StyleAndTextTuples
from prompt_toolkit.lexers import Lexer
class ReplInputLexer(Lexer):
"""Style the leading slash-command token like Claude Code."""
_CMD_STYLE = "class:repl-slash-command"
def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]:
lines = document.lines
def get_line(lineno: int) -> StyleAndTextTuples:
try:
line = lines[lineno]
except IndexError:
return []
if not line:
return [("", line)]
leading = len(line) - len(line.lstrip(" \t"))
lead, stripped = line[:leading], line[leading:]
if not stripped:
return [("", line)]
if stripped.startswith("/"):
i = 0
while i < len(stripped) and not stripped[i].isspace():
i += 1
cmd, rest = stripped[:i], stripped[i:]
out: StyleAndTextTuples = []
if lead:
out.append(("", lead))
out.append((self._CMD_STYLE, cmd))
if rest:
out.append(("", rest))
return out
return [("", line)]
return get_line
@@ -0,0 +1,54 @@
"""Prompt redraw and pending-input prefill wiring."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from typing import Any
from surfaces.interactive_shell.runtime import Session
def wire_prompt_refresh(
session: Session,
pt_app: Any,
loop: asyncio.AbstractEventLoop,
) -> Callable[[], None]:
"""Register session hook to prefill pending text and redraw the active prompt."""
def invalidate_prompt() -> None:
loop.call_soon_threadsafe(pt_app.invalidate)
def refresh_active_prompt() -> None:
def _apply() -> None:
pending = session.terminal.pending_prompt_default
buffer = pt_app.current_buffer
# Never clobber text the user is actively typing.
if not pending or buffer.text:
invalidate_prompt()
return
if session.terminal.pending_prompt_autosubmit:
# Auto-submit an agent-queued interactive command so it dispatches
# through the normal exclusive-stdin path (the only place an
# interactive child process gets clean stdin). Note: pt_app.is_running
# under-reports while prompt_async awaits during a dispatch, so we do
# not gate on it; validate_and_handle works regardless. If the app is
# genuinely not accepting input, leave the prefill in place so the
# next prompt iteration picks it up via the before-prompt path.
session.terminal.pending_prompt_default = None
session.terminal.pop_pending_autosubmit()
buffer.text = pending
try:
buffer.validate_and_handle()
except Exception: # noqa: BLE001
session.terminal.pending_prompt_default = pending
session.terminal.pending_prompt_autosubmit = True
elif pt_app.is_running:
session.terminal.pending_prompt_default = None
buffer.text = pending
invalidate_prompt()
loop.call_soon_threadsafe(_apply)
session.terminal.prompt_refresh_fn = refresh_active_prompt
return invalidate_prompt
@@ -0,0 +1,114 @@
"""Prompt text, hint, placeholder, and submitted-turn rendering."""
from __future__ import annotations
from prompt_toolkit.application.current import get_app_or_none
from prompt_toolkit.formatted_text import ANSI
from rich.console import Console
from rich.text import Text
from platform.terminal import theme as ui_theme
from surfaces.interactive_shell.runtime import Session
from surfaces.interactive_shell.ui.banner.banner_state import integration_display_name
from surfaces.interactive_shell.ui.input_prompt.completion import completion_preview_hint_ansi
from surfaces.interactive_shell.ui.input_prompt.layout import _short_meta, _terminal_columns
_PROMPT_RULE_CHAR = ""
_DEFAULT_PLACEHOLDER_TEXT = "Type a message, /command, or paste an alert"
_DEFAULT_PLACEHOLDER_ANSI = ANSI(
f"{ui_theme.ANSI_DIM}{_DEFAULT_PLACEHOLDER_TEXT}{ui_theme.ANSI_RESET}"
)
def _prompt_rule_line(width: int) -> str:
return _PROMPT_RULE_CHAR * max(width, 1)
def _prompt_rule_ansi() -> str:
return (
f"{ui_theme.PROMPT_FRAME_ANSI}{_prompt_rule_line(_terminal_columns())}{ui_theme.ANSI_RESET}"
)
def _prompt_turn_number(session: Session) -> int:
"""1-based index for the turn about to be entered or just submitted."""
return len(session.history) + 1
def _prompt_counter_text(session: Session) -> str:
return f"[{_prompt_turn_number(session)}] "
def _prompt_prefix_text(session: Session) -> str:
return f"{_prompt_counter_text(session)} "
def _prompt_line_ansi(session: Session) -> ANSI:
counter = _prompt_counter_text(session)
prefix = f"{ui_theme.DIM_COUNTER_ANSI}{counter}{ui_theme.ANSI_RESET}"
return ANSI(f"{prefix}{ui_theme.PROMPT_ACCENT_ANSI}{ui_theme.ANSI_RESET} ")
def _prompt_message(session: Session) -> ANSI:
"""Top border rule plus cursor line: the top two rows of the input box."""
return ANSI(f"{_prompt_rule_ansi()}\n{_prompt_line_ansi(session).value}")
def render_submitted_prompt(console: Console, session: Session, text: str) -> None:
"""Render the submitted user turn above the streamed assistant response."""
lines = text.splitlines() or [""]
continuation_prefix = " " * len(_prompt_prefix_text(session))
rendered = Text()
counter = _prompt_counter_text(session)
# Rich's Style.parse() reads the bare str value of a _LazyRichStyle (""),
# so resolve to a concrete string at the call site to keep palette colors.
rendered.append(counter, style=str(ui_theme.DIM))
rendered.append(" ", style=f"bold {ui_theme.HIGHLIGHT}")
rendered.append(lines[0], style=str(ui_theme.TEXT))
for line in lines[1:]:
rendered.append("\n")
rendered.append(continuation_prefix, style=str(ui_theme.DIM))
rendered.append(line, style=str(ui_theme.TEXT))
console.print(rendered)
def resolve_prompt_prefix_ansi(*, inline_spinner: str, idle_hint: str) -> str:
"""Choose the prompt's top context line: spinner, completion preview, or idle hint."""
if inline_spinner:
return inline_spinner
preview = completion_preview_hint_ansi()
return preview or idle_hint
def resolve_idle_hint_ansi(session: Session) -> str:
"""Dim hint line above the prompt rule: shortcuts plus connected integrations."""
parts = ["/ for commands", "↑↓ history"]
if session.configured_integrations_known and session.configured_integrations:
max_shown = 4
names = [integration_display_name(name) for name in sorted(session.configured_integrations)]
shown = names[:max_shown]
overflow = len(names) - len(shown)
integration_segment = " · ".join(shown)
if overflow:
integration_segment += f" +{overflow}"
parts.append(integration_segment)
app = get_app_or_none()
if app is not None and app.current_buffer.text:
parts.append("esc to clear")
hint = " · ".join(parts)
return f"{ui_theme.DIM_ANSI}{hint}{ui_theme.ANSI_RESET}"
def resolve_prompt_placeholder(session: Session) -> ANSI:
"""Contextual ghost text when the input buffer is empty."""
parts: list[str] = []
if session.terminal.trust_mode:
parts.append("trust on")
running = session.task_registry.running_count()
if running:
parts.append(f"{running} task{'s' if running != 1 else ''} running")
if session.resumed_from_name:
parts.append(f"resumed: {_short_meta(session.resumed_from_name, max_len=32)}")
if parts:
return ANSI(f"{ui_theme.ANSI_DIM}{' · '.join(parts)}{ui_theme.ANSI_RESET}")
return _DEFAULT_PLACEHOLDER_ANSI
@@ -0,0 +1,55 @@
"""prompt-toolkit style construction and live theme refresh."""
from __future__ import annotations
from contextlib import suppress
from prompt_toolkit.styles import Style
from platform.terminal import theme as ui_theme
from surfaces.interactive_shell.runtime import Session
def _build_prompt_style() -> Style:
theme = ui_theme.get_active_theme()
text_fg = f"fg:{theme.TEXT}"
return Style.from_dict(
{
"prompt-frame-line": f"bold {theme.HIGHLIGHT}",
"": text_fg,
"default": text_fg,
"repl-slash-command": f"bold {theme.HIGHLIGHT} bg:{theme.BG}",
"completion-menu": f"bg:{theme.BG}",
"completion-menu.completion": f"{theme.TEXT} bg:{theme.BG}",
"completion-menu.completion.current": f"bold {theme.HIGHLIGHT} bg:{theme.BG}",
"completion-menu.meta.completion": f"{theme.DIM} bg:{theme.BG}",
"completion-menu.meta.completion.current": f"{theme.HIGHLIGHT} bg:{theme.BG}",
"completion-menu.border": theme.DIM,
"scrollbar.background": f"bg:{theme.BG}",
"scrollbar.button": f"bg:{theme.DIM}",
# prompt_toolkit defaults the ``bottom-toolbar`` style to
# ``reverse:noinherit``, which paints the toolbar as a dark
# highlighted band across the terminal. Clear the reverse
# so the spinner + hint sit on the regular terminal bg
# (Claude Code-style flat layout).
"bottom-toolbar": "noreverse",
"bottom-toolbar.text": "noreverse",
}
)
def refresh_prompt_theme(session: Session) -> None:
"""Apply the active palette to the running prompt (input text + placeholder)."""
app = session.terminal.prompt_app
if app is None:
return
app.style = _build_prompt_style()
# Between prompt_async turns the Application is not running; invalidate() then
# triggers ESC[6n CPR queries whose responses leak as literal text on the
# next idle-hint line (e.g. ``^[[1;1R/ for commands``).
if not app.is_running:
return
if app.renderer is not None:
with suppress(Exception):
app.renderer.clear()
app.invalidate()
@@ -0,0 +1,160 @@
"""Structured investigation run outcomes for terminal UX and PostHog analytics."""
from __future__ import annotations
import re
import traceback
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
from platform.common.errors import OpenSREError
InvestigationStatus = Literal["completed", "failed", "cancelled"]
FailureCategory = Literal[
"config",
"integration",
"timeout",
"user_cancelled",
"k8s_api",
"llm",
"unknown",
]
_INTEGRATION_KEYWORDS: tuple[tuple[str, str], ...] = (
("grafana", "grafana"),
("loki", "grafana"),
("mimir", "grafana"),
("tempo", "grafana"),
("datadog", "datadog"),
("sentry", "sentry"),
("jenkins", "jenkins"),
("kubernetes", "k8s"),
("k8s", "k8s"),
("kubectl", "k8s"),
("splunk", "splunk"),
("honeycomb", "honeycomb"),
("coralogix", "coralogix"),
("posthog", "posthog"),
("github", "github"),
("argocd", "argocd"),
("pagerduty", "pagerduty"),
)
@dataclass(frozen=True, slots=True)
class InvestigationOutcome:
"""Facts from one foreground investigation run."""
status: InvestigationStatus
target: str = ""
investigation_id: str = ""
final_state: dict[str, Any] | None = None
error_message: str = ""
error_detail: str = ""
failure_category: FailureCategory = "unknown"
integration_involved: str = ""
integration_failure_message: str = ""
llm_model: str = ""
llm_provider: str = ""
llm_input_tokens: int = 0
llm_output_tokens: int = 0
duration_ms: int = 0
def normalize_investigation_target(raw_target: str, *, path: Path | None = None) -> str:
"""Return a stable analytics slug for an investigation target."""
if path is not None:
return path.name or path.stem or str(path)
stripped = raw_target.strip()
if not stripped:
return "investigation"
lowered = stripped.lower()
for prefix in ("sample:", "template:"):
if lowered.startswith(prefix):
return lowered[len(prefix) :].strip() or "investigation"
if "/" in stripped or "\\" in stripped:
return Path(stripped).name or stripped
collapsed = re.sub(r"\s+", " ", stripped)
if len(collapsed) > 80:
return f"{collapsed[:77]}"
return collapsed
def _integration_from_message(message: str) -> tuple[str, str]:
lowered = message.lower()
for keyword, service in _INTEGRATION_KEYWORDS:
if keyword in lowered:
detail = message.strip()
if len(detail) > 200:
detail = f"{detail[:197]}"
return service, detail
return "", ""
def classify_investigation_failure(
exc: BaseException,
) -> tuple[FailureCategory, str, str]:
"""Map an exception to category, integration service, and integration detail."""
message = str(exc).strip()
if isinstance(exc, TimeoutError):
return "timeout", "", ""
if isinstance(exc, KeyboardInterrupt):
return "user_cancelled", "", ""
integration, integration_detail = _integration_from_message(message)
lowered = message.lower()
if integration:
return "integration", integration, integration_detail
if any(token in lowered for token in ("kubernetes", "k8s", "kubectl", "pod ", "deployment ")):
return "k8s_api", "k8s", message[:200]
if any(
token in lowered
for token in ("context length", "token limit", "llm", "model", "anthropic", "openai")
):
return "llm", "", message[:200]
if any(token in lowered for token in ("config", "credential", "not configured", "missing api")):
return "config", "", message[:200]
if isinstance(exc, OpenSREError):
return "config", integration, integration_detail or message[:200]
return "unknown", integration, integration_detail
def failure_detail_from_exception(exc: BaseException) -> str:
"""Best-effort truncated detail for analytics (not user-facing)."""
return truncate_failure_detail("".join(traceback.format_exception_only(exc)).strip())
def truncate_failure_detail(text: str, *, max_chars: int = 500) -> str:
cleaned = text.strip()
if len(cleaned) <= max_chars:
return cleaned
return f"{cleaned[: max_chars - 20].rstrip()}… [truncated]"
def user_facing_error_message(exc: BaseException, *, max_lines: int = 3) -> str:
"""Compact user-facing error text for analytics payloads."""
if isinstance(exc, OpenSREError):
parts = [exc.message.strip()]
if exc.suggestion:
parts.append(f"Suggestion: {exc.suggestion.strip()}")
text = "\n".join(part for part in parts if part)
else:
text = str(exc).strip()
lines = [line.strip() for line in text.splitlines() if line.strip()]
if not lines:
return type(exc).__name__
return "\n".join(lines[:max_lines])
__all__ = [
"FailureCategory",
"InvestigationOutcome",
"InvestigationStatus",
"classify_investigation_failure",
"failure_detail_from_exception",
"normalize_investigation_target",
"truncate_failure_detail",
"user_facing_error_message",
]
@@ -0,0 +1,136 @@
"""Rich landing and help renderers for the OpenSRE CLI."""
from __future__ import annotations
from collections.abc import Sequence
import click
from rich.console import Console
from rich.text import Text
from platform.terminal.theme import BRAND, DIM, TEXT
from surfaces.interactive_shell.ui.banner import build_ready_panel
_LANDING_EXAMPLES: tuple[tuple[str, str], ...] = (
(
'opensre "investigate high latency in checkout-api"',
"Start the interactive agent with a prompt",
),
("opensre onboard", "Configure LLM provider and integrations"),
("opensre investigate -i alert.json", "Run RCA against an alert payload"),
("opensre investigate --service <name>", "Run RCA on a deployed remote service"),
("opensre remote --url <ip> health", "Check a remote deployed agent"),
("opensre remote ops status", "Inspect hosted service status (Railway)"),
("opensre tests", "Browse and run inventoried tests"),
("opensre integrations list", "Show configured integrations"),
("opensre guardrails rules", "List configured guardrail rules"),
("opensre health", "Check integration and agent setup status"),
("opensre doctor", "Run a full environment diagnostic"),
("opensre update", "Update to the latest version"),
("opensre version", "Print detailed version, Python and OS info"),
)
def _commands_from_group(group: click.Group) -> tuple[tuple[str, str], ...]:
ctx = click.Context(group)
rows = []
for name in group.list_commands(ctx):
cmd = group.get_command(ctx, name)
if cmd is not None and not cmd.hidden:
rows.append((name, cmd.get_short_help_str(limit=200)))
return tuple(rows)
def _options_from_command(command: click.Command) -> tuple[tuple[str, str], ...]:
ctx = click.Context(command)
rows: list[tuple[str, str]] = []
for param in command.get_params(ctx):
if getattr(param, "hidden", False):
continue
if not isinstance(param, click.Option):
continue
record = param.get_help_record(ctx)
if record is not None:
rows.append(record)
return tuple(rows)
def _render_usage(console: Console) -> None:
console.print(
Text.assemble(
(" Usage: "),
("opensre", f"bold {TEXT}"),
(" [OPTIONS] [COMMAND] [ARGS]..."),
)
)
console.print(
Text.assemble(
(" ", ""),
("No COMMAND", DIM),
(": start the interactive shell when stdin/stdout are TTYs.", DIM),
)
)
def _render_rows(
console: Console,
*,
title: str,
rows: Sequence[tuple[str, str]],
width: int | None = None,
) -> None:
effective_width = (
width + 2 if width is not None else max((len(label) for label, _ in rows), default=0) + 2
)
console.print(Text.assemble((f" {title}:", f"bold {TEXT}")))
for label, description in rows:
console.print(
Text.assemble(
(" ", ""),
(f"{label:<{effective_width}}", f"bold {BRAND}"),
description,
)
)
def render_help(group: click.Group) -> None:
"""Render the root help view, deriving the command list from the live Click group."""
console = Console(highlight=False)
commands = _commands_from_group(group)
options = _options_from_command(group)
console.print()
_render_usage(console)
console.print()
_render_rows(console, title="Commands", rows=commands, width=16)
console.print()
_render_rows(console, title="Options", rows=options)
console.print()
def render_landing(group: click.Group) -> None:
"""Render the root landing page shown with no subcommand."""
console = Console(highlight=False)
options = _options_from_command(group)
console.print()
console.print(build_ready_panel(console))
console.print(
Text.assemble(
(" ", ""),
"open-source SRE agent for automated incident investigation and root cause analysis",
)
)
console.print()
_render_usage(console)
console.print()
_render_rows(console, title="Quick start", rows=_LANDING_EXAMPLES, width=42)
console.print()
_render_rows(console, title="Options", rows=options)
console.print()
class RichGroup(click.Group):
"""Click group with a custom Rich-powered help screen."""
def format_help(self, ctx: click.Context, _formatter: click.HelpFormatter) -> None:
assert isinstance(ctx.command, click.Group)
render_help(ctx.command)
@@ -0,0 +1,66 @@
from __future__ import annotations
from surfaces.interactive_shell.ui.components.time_format import _fmt_timing
from surfaces.interactive_shell.ui.output.console_state import (
set_live_console,
stop_display,
unregister_live_console,
)
from surfaces.interactive_shell.ui.output.environment import (
_repl_progress_active,
_safe_print,
debug_print,
get_output_format,
)
from surfaces.interactive_shell.ui.output.events import ProgressEvent
from surfaces.interactive_shell.ui.output.renderers import (
render_completed_investigation_footer,
render_divider,
render_event,
render_footer,
render_investigation_header,
)
from surfaces.interactive_shell.ui.output.toggles import (
CtrlOToggleWatcher,
register_tool_detail_toggle,
suppress_stdin_watchers,
toggle_active_tool_details,
)
from surfaces.interactive_shell.ui.output.tracker import (
ProgressTracker,
get_tracker,
reset_tracker,
set_silent_tracker,
)
__all__ = [
# Tracker / progress
"ProgressEvent",
"ProgressTracker",
"get_tracker",
"reset_tracker",
"set_silent_tracker",
# Rendering
"render_completed_investigation_footer",
"render_divider",
"render_event",
"render_footer",
"render_investigation_header",
# Console lifecycle
"set_live_console",
"stop_display",
"unregister_live_console",
# Tool-detail toggle
"CtrlOToggleWatcher",
"register_tool_detail_toggle",
"suppress_stdin_watchers",
"toggle_active_tool_details",
# Output config
"debug_print",
"get_output_format",
# Semi-public helpers used by surfaces/cli/ui/renderer (underscore names are
# intentional — they signal "reach in carefully" rather than stable API)
"_fmt_timing",
"_repl_progress_active",
"_safe_print",
]
@@ -0,0 +1,63 @@
"""CLI boundary wiring — observability and integration ports.
Lives in a leaf module so ``environment`` (imported by ``renderers`` and
``tracker`` for utility plumbing) does not import those modules back —
that would create a static import cycle. Entry points (``__main__``,
MCP, remote server) and tests call :func:`install_product_adapters`
from here.
"""
from __future__ import annotations
def install_harness_ports() -> None:
"""Register integrations/tools adapters into :mod:`platform.harness_ports`.
Harness composition root for the interactive shell and tests. Lives in
``surfaces`` (not ``tools``) because ``tools`` and ``integrations`` are
sibling layers and must not import each other — see ``.importlinter.strict``.
"""
from integrations.harness_adapters import register_harness_adapters as register_integrations
from tools.harness_adapters import register_harness_adapters as register_tools
register_integrations()
register_tools()
def install_product_adapters() -> None:
"""Wire product adapters into observability and integration ports.
Call once from each process entry point (CLI, MCP, remote server).
Idempotent — re-registers the same callables so calling it twice
is a no-op.
Wires:
- debug_print: stderr default → Rich-aware CLI version
- render_investigation_header: no-op default → Rich panel
- progress tracker: Noop default → Rich-backed CLI singleton (lazy)
- remote integrations fetcher: empty default → Tracer Cloud adapter
- harness ports: catalog/store, tool registry, investigation tools, GitHub scope
"""
from integrations.tracer.integrations_adapter import (
fetch_tracer_remote_integrations,
)
from platform.harness_ports import set_remote_integrations_fetcher
from platform.observability.render.debug import set_debug_printer
from platform.observability.render.display import (
set_investigation_footer_renderer,
set_investigation_header_renderer,
)
from platform.observability.render.progress import set_progress_tracker_factory
from surfaces.interactive_shell.ui.output.environment import debug_print
from surfaces.interactive_shell.ui.output.renderers import (
render_completed_investigation_footer,
render_investigation_header,
)
from surfaces.interactive_shell.ui.output.tracker import get_tracker
set_debug_printer(debug_print)
set_investigation_header_renderer(render_investigation_header)
set_investigation_footer_renderer(render_completed_investigation_footer)
set_progress_tracker_factory(get_tracker)
set_remote_integrations_fetcher(fetch_tracer_remote_integrations)
install_harness_ports()
@@ -0,0 +1,93 @@
from __future__ import annotations
import time
from collections.abc import Callable
from typing import Any
from rich.console import Console
_live_console: Console | None = None
_active_display: Any | None = None
_completed_footer_snapshot: tuple[str, float, str, str] | None = None
_tracker_toggle_stop_fn: Callable[[], None] | None = None
_investigation_spinner: Any | None = None
def set_tracker_toggle_stop_fn(fn: Callable[[], None] | None) -> None:
"""Register callback used to stop tracker-owned keyboard watchers."""
global _tracker_toggle_stop_fn
_tracker_toggle_stop_fn = fn
def set_investigation_spinner(spinner: Any | None) -> None:
"""Register the prompt spinner the investigation display animates.
``/investigate`` dispatches as a literal slash command, so the turn-level
"thinking" spinner never starts. Registering the active turn's spinner here
lets ``_ReplEventLogDisplay`` drive it with per-stage phase labels
(``set_phase``) and stop it (``stop``) as the pipeline runs.
"""
global _investigation_spinner
_investigation_spinner = spinner
def get_investigation_spinner() -> Any | None:
return _investigation_spinner
def _capture_footer_snapshot(display: Any) -> None:
"""Record the phase footer fields visible when a display stops."""
global _completed_footer_snapshot
if display is None:
return
t0 = getattr(display, "_t0", None)
if t0 is None:
return
_completed_footer_snapshot = (
getattr(display, "_current_phase", ""),
time.monotonic() - t0,
getattr(display, "_model", ""),
getattr(display, "_mode", "local"),
)
def consume_footer_snapshot() -> tuple[str, float, str, str] | None:
global _completed_footer_snapshot
snapshot, _completed_footer_snapshot = _completed_footer_snapshot, None
return snapshot
def _get_console() -> Console:
"""Return the active Live console when running, else a fresh one."""
return _live_console or Console(highlight=False)
def set_live_console(console: Console | None) -> None:
global _live_console
_live_console = console
def unregister_live_console(expected: Console | None) -> None:
global _live_console
if expected is not None and _live_console is expected:
_live_console = None
def set_active_display(display: Any | None) -> None:
global _active_display
_active_display = display
def clear_active_display(expected: Any) -> None:
global _active_display
if _active_display is expected:
_active_display = None
def stop_display() -> None:
"""Stop any running live display before printing final report output."""
if _active_display is not None:
_active_display.stop()
if _tracker_toggle_stop_fn is not None:
_tracker_toggle_stop_fn()
@@ -0,0 +1,67 @@
from __future__ import annotations
import contextlib
import os
import sys
from platform.observability.render.output_format import get_output_format
from platform.terminal.theme import SECONDARY
from surfaces.interactive_shell.ui.output.repl_progress import repl_safe_progress_requested
def _is_silent_output() -> bool:
return get_output_format() == "none"
def _repl_progress_active() -> bool:
"""True when investigation progress must not use Rich Live."""
if repl_safe_progress_requested():
return True
try:
from prompt_toolkit.application.current import get_app_or_none
except ImportError: # pragma: no cover - optional in minimal installs
return False
return get_app_or_none() is not None
def _safe_print(text: str) -> None:
"""Print text, replacing unencodable characters."""
try:
print(text)
except UnicodeEncodeError:
enc = sys.stdout.encoding or "utf-8"
with contextlib.suppress(BrokenPipeError):
print(text.encode(enc, errors="replace").decode(enc))
except BrokenPipeError:
# Downstream closed the pipe; mirror standard CLI behavior and stop writing.
pass
def _is_verbose() -> bool:
if os.getenv("TRACER_VERBOSE", "").lower() in ("1", "true", "yes"):
return True
try:
from platform.common.runtime_flags import is_debug, is_verbose
return is_verbose() or is_debug()
except Exception:
return False
def debug_print(message: str) -> None:
if not _is_verbose():
return
if get_output_format() == "rich":
from surfaces.interactive_shell.ui.output.console_state import _get_console
_get_console().print(f"[{SECONDARY}]{message}[/]")
else:
print(f"DEBUG: {message}")
# ``install_product_adapters`` lives in
# :mod:`interactive_shell.ui.output.boundary`, not here. Putting
# it in this module would re-introduce a static import cycle:
# ``renderers`` and ``tracker`` already import from this module for
# utility plumbing, and the install function imports them back. Moving
# the wiring into a leaf module keeps the static graph acyclic.
@@ -0,0 +1,50 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable
@dataclass
class ProgressEvent:
node_name: str
elapsed_ms: int
fields_updated: list[str] = field(default_factory=list)
status: str = "completed"
message: str | None = None
@runtime_checkable
class DisplayProtocol(Protocol):
"""Shared interface for :class:`_EventLogDisplay` and :class:`_ReplEventLogDisplay`.
Lets :class:`tracker.ProgressTracker` hold either display type without
``isinstance`` branching on concrete classes.
"""
def stop(self) -> None:
raise NotImplementedError
def step_start(self, node_name: str) -> None:
raise NotImplementedError
def step_complete(self, node_name: str, event: ProgressEvent) -> None:
raise NotImplementedError
def step_subtext(self, node_name: str, text: str, duration: float = 4.0) -> None:
raise NotImplementedError
def set_tool_details(
self,
*,
visible: bool,
records: list[dict[str, Any]],
summary: str,
clear: bool = False,
) -> None:
raise NotImplementedError
def print_above(self, text: str) -> None:
raise NotImplementedError
def print_above_renderable(self, renderable: Any) -> None:
raise NotImplementedError
@@ -0,0 +1,128 @@
from __future__ import annotations
import re
from rich.text import Text
from platform.terminal.theme import (
BRAND,
DIM,
ERROR,
HIGHLIGHT,
SECONDARY,
TEXT,
)
from surfaces.interactive_shell.ui.components.time_format import _elapsed_hms, _fmt_timing
from tools.registry import resolve_tool_display_name
# (padded_label, text_color) -- all labels are 6 chars wide. Every badge draws
# from the theme's accent tokens (HIGHLIGHT / BRAND) so the whole phase strip
# stays within the active palette (e.g. all blue shades under the blue theme),
# alternating the two accents for light per-phase distinction.
BADGE_STYLES: dict[str, tuple[str, str]] = {
"READ": ("READ ", HIGHLIGHT),
"PLAN": ("PLAN ", BRAND),
"INVEST": ("INVEST", HIGHLIGHT),
"DIAG": ("DIAG ", BRAND),
"MERGE": ("MERGE ", HIGHLIGHT),
}
_NODE_EVENT_TYPE: dict[str, str] = {
"extract_alert": "READ",
"resolve_integrations": "READ",
"plan_actions": "PLAN",
"merge_hypotheses": "MERGE",
"investigation_agent": "INVEST",
"diagnose_root_cause": "DIAG",
"opensre_llm_eval": "DIAG",
"publish_findings": "DIAG",
}
_NODE_PHASE: dict[str, str] = {
"extract_alert": "LOAD",
"resolve_integrations": "LOAD",
"plan_actions": "PLAN",
"merge_hypotheses": "DIAGNOSE",
"investigation_agent": "INVESTIGATE",
"diagnose_root_cause": "DIAGNOSE",
"opensre_llm_eval": "DIAGNOSE",
"publish_findings": "PUBLISH",
}
_NODE_LABELS: dict[str, str] = {
"extract_alert": "Reading alert",
"resolve_integrations": "Loading integrations",
"plan_actions": "Planning",
"investigate": "Gathering evidence",
"investigation_agent": "Investigation",
"diagnose_root_cause": "Diagnosing",
"publish_findings": "Publishing",
}
def _node_event_type(node_name: str) -> str:
if node_name.startswith("investigate"):
return "INVEST"
return _NODE_EVENT_TYPE.get(node_name, "DIAG")
def _node_phase_label(node_name: str) -> str:
if node_name.startswith("investigate"):
return "INVESTIGATE"
return _NODE_PHASE.get(node_name, node_name.upper()[:12])
def _node_label(node_name: str) -> str:
if node_name.startswith("investigate_"):
action = node_name[len("investigate_") :]
return f"Investigate · {action.replace('_', ' ').title()}"
return _NODE_LABELS.get(node_name, node_name.replace("_", " ").title())
def _humanise_message(message: str) -> str:
if not message:
return ""
m = re.match(r"Planned actions:\s*\[(.+)\]", message)
if m:
raw = re.findall(r"'([^']+)'", m.group(1))
return ", ".join(resolve_tool_display_name(action) for action in raw)
if "No new actions" in message:
return ""
if "integrations" in message.lower() or "resolved" in message.lower():
m2 = re.search(r"\[(.+)\]", message)
if m2 and (services := re.findall(r"'([^']+)'", m2.group(1))):
return ", ".join(services)
m3 = re.match(r"validity:(\d+%)", message)
if m3:
return f"confidence {m3.group(1)}"
return re.sub(r"^datadog:", "", message)
def build_progress_step_text(
*,
node_name: str,
elapsed_total: float,
elapsed_step_ms: int | None = None,
status: str = "active",
message: str | None = None,
) -> Text:
ev_type = _node_event_type(node_name)
badge_label, badge_color = BADGE_STYLES.get(ev_type, BADGE_STYLES["DIAG"])
label = _node_label(node_name)
err = status == "error"
timing = _fmt_timing(elapsed_step_ms) if elapsed_step_ms is not None else ""
t = Text()
t.append(f"{_elapsed_hms(elapsed_total)} ", style=SECONDARY)
if status == "active":
t.append("· ", style=SECONDARY)
else:
t.append("" if err else "", style=f"bold {ERROR if err else HIGHLIGHT}")
t.append(badge_label, style=f"bold {badge_color}")
t.append(" · ", style=DIM)
t.append(label, style=f"bold {TEXT}")
if msg := _humanise_message(message or ""):
t.append(f" {msg}", style=BRAND)
if timing:
t.append(f" {timing}", style=SECONDARY)
return t
@@ -0,0 +1,253 @@
from __future__ import annotations
import threading
import time
from typing import Any
from rich.console import Console, ConsoleOptions, RenderResult
from rich.text import Text
from platform.observability.trace.redaction import format_json_preview
from platform.terminal.theme import (
BRAND,
DIM,
ERROR,
HIGHLIGHT,
SECONDARY,
TEXT,
)
from surfaces.interactive_shell.ui.components.time_format import _elapsed_hms, _fmt_timing
from surfaces.interactive_shell.ui.output.events import ProgressEvent
from surfaces.interactive_shell.ui.output.labels import (
BADGE_STYLES,
_humanise_message,
_node_event_type,
_node_label,
_node_phase_label,
)
_SPINNER_FRAMES = ("· ", "·· ", "···", "·· ")
_FRAME_SECS = 0.10
class _LiveRenderable:
"""Rich renderable that rebuilds the active event-log on refresh."""
def __init__(self, display: _EventLogDisplay) -> None:
self._d = display
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
d = self._d
now = time.monotonic()
with d._lock:
if d._tool_details_visible:
yield from _render_tool_detail_view(d, options, now)
return
yield from _render_active_steps(d, options, now)
def _render_active_steps(
display: _EventLogDisplay,
options: ConsoleOptions,
now: float,
) -> RenderResult:
for node_name, info in display._active_steps.items():
elapsed_step = now - info["t0"]
elapsed_total = now - display._t0
frame = _SPINNER_FRAMES[int(elapsed_step / _FRAME_SECS) % len(_SPINNER_FRAMES)]
ev_type = _node_event_type(node_name)
badge_label, badge_color = BADGE_STYLES.get(ev_type, BADGE_STYLES["DIAG"])
subtext: str | None = info.get("subtext")
if subtext and now > info.get("subtext_until", 0.0):
subtext = None
t = Text()
t.append(f"{_elapsed_hms(elapsed_total)} ", style=SECONDARY)
t.append(frame, style=SECONDARY)
t.append(badge_label, style=f"bold {badge_color}")
t.append(" · ", style=DIM)
t.append(_node_label(node_name), style=f"bold {TEXT}")
if subtext:
t.append(f"{subtext}", style=BRAND)
t.append(f" {_fmt_timing(int(elapsed_step * 1000))}", style=SECONDARY)
yield t
yield Text("")
yield Text("" * (options.max_width - 1), style=DIM)
yield _footer(display, now - display._t0, "ctrl+o tool details ")
def _render_tool_detail_view(
display: _EventLogDisplay,
options: ConsoleOptions,
now: float,
) -> RenderResult:
elapsed_total = now - display._t0
heading = Text()
heading.append(" Tool Details", style=f"bold {TEXT}")
if display._tool_summary:
heading.append(f" {display._tool_summary}", style=BRAND)
yield heading
records = display._tool_detail_records[-6:]
hidden_count = max(0, len(display._tool_detail_records) - len(records))
if hidden_count:
yield Text(f" {hidden_count} older tool call(s) hidden", style=DIM)
if not records:
yield Text(" No tool calls have finished yet.", style=DIM)
for record in records:
yield from _tool_record_rows(record)
yield Text("" * (options.max_width - 1), style=DIM)
yield _footer(display, elapsed_total, "ctrl+o compact view ", phase="TOOL DETAILS")
def _tool_record_rows(record: dict[str, Any]) -> RenderResult:
elapsed = str(record.get("elapsed") or "")
suffix = f" {elapsed}" if elapsed else ""
row = Text()
row.append("", style=f"bold {HIGHLIGHT}")
row.append(str(record.get("display") or "tool"), style=f"bold {TEXT}")
row.append(suffix, style=SECONDARY)
yield row
if (tool_input := record.get("input")) not in ({}, None):
yield Text(" Input:", style=SECONDARY)
for line in format_json_preview(tool_input, max_chars=1200).splitlines():
yield Text(f" {line}", style=DIM)
if (output := record.get("output")) not in ({}, None, ""):
yield Text(" Output:", style=SECONDARY)
for line in format_json_preview(output, max_chars=2200).splitlines():
yield Text(f" {line}", style=DIM)
yield Text("")
def _footer(
display: _EventLogDisplay,
elapsed: float,
hint: str,
*,
phase: str | None = None,
) -> Text:
ft = Text()
ft.append("", style=f"bold {HIGHLIGHT}")
ft.append(f"{phase or display._current_phase} ", style=f"bold {SECONDARY}")
ft.append(f"{_elapsed_hms(elapsed)} ", style=SECONDARY)
if display._model:
ft.append(f"{display._model} ", style=SECONDARY)
ft.append(f"{display._mode} ", style=SECONDARY)
ft.append(hint, style=DIM)
ft.append("esc to cancel", style=DIM)
return ft
class _EventLogDisplay:
"""Rich Live-backed animated event log. One instance per investigation."""
def __init__(self, model: str = "", mode: str = "local", t0: float | None = None) -> None:
from rich.live import Live
from surfaces.interactive_shell.ui.output.console_state import (
set_active_display,
set_live_console,
)
self._model = model
self._mode = mode
self._t0 = t0 if t0 is not None else time.monotonic()
self._active_steps: dict[str, dict[str, Any]] = {}
self._current_phase = "LOAD"
self._tool_details_visible = False
self._tool_detail_records: list[dict[str, Any]] = []
self._tool_summary = ""
self._lock = threading.Lock()
self._console = Console(highlight=False)
self._live = Live(
_LiveRenderable(self),
console=self._console,
refresh_per_second=10,
auto_refresh=True,
transient=True,
vertical_overflow="ellipsis",
)
self._live.start(refresh=True)
set_live_console(self._console)
set_active_display(self)
def stop(self) -> None:
from surfaces.interactive_shell.ui.output.console_state import (
_capture_footer_snapshot,
clear_active_display,
unregister_live_console,
)
_capture_footer_snapshot(self)
if self._live.is_started:
self._live.stop()
unregister_live_console(self._console)
clear_active_display(self)
def step_start(self, node_name: str) -> None:
with self._lock:
self._active_steps[node_name] = {
"t0": time.monotonic(),
"subtext": None,
"subtext_until": 0.0,
}
self._current_phase = _node_phase_label(node_name)
def set_tool_details(
self,
*,
visible: bool,
records: list[dict[str, Any]],
summary: str,
clear: bool = False,
) -> None:
with self._lock:
self._tool_details_visible = visible
self._tool_detail_records = list(records)
self._tool_summary = summary
if self._live.is_started:
if clear:
self._live.console.clear()
self._live.refresh()
def step_complete(self, node_name: str, event: ProgressEvent) -> None:
elapsed_total = time.monotonic() - self._t0
with self._lock:
self._active_steps.pop(node_name, None)
ev_type = _node_event_type(node_name)
badge_label, badge_color = BADGE_STYLES.get(ev_type, BADGE_STYLES["DIAG"])
err = event.status == "error"
t = Text()
t.append(f"{_elapsed_hms(elapsed_total)} ", style=SECONDARY)
t.append("" if err else "", style=f"bold {ERROR if err else HIGHLIGHT}")
t.append(badge_label, style=f"bold {badge_color}")
t.append(" · ", style=DIM)
t.append(_node_label(node_name), style=f"bold {TEXT}")
if msg := _humanise_message(event.message or ""):
t.append(f" {msg}", style=BRAND)
t.append(f" {_fmt_timing(event.elapsed_ms)}", style=SECONDARY)
if self._live.is_started:
self._live.console.print(t)
def step_subtext(self, node_name: str, text: str, duration: float = 4.0) -> None:
with self._lock:
if node_name in self._active_steps:
self._active_steps[node_name]["subtext"] = text
self._active_steps[node_name]["subtext_until"] = time.monotonic() + duration
def print_above(self, text: str) -> None:
if not text.strip():
return
from rich.markdown import Markdown
from platform.terminal.theme import MARKDOWN_THEME
with self._live.console.use_theme(MARKDOWN_THEME):
self._live.console.print(Markdown(text, code_theme="ansi_dark"))
def print_above_renderable(self, renderable: Any) -> None:
self._live.console.print(renderable)
@@ -0,0 +1,144 @@
from __future__ import annotations
from rich.text import Text
from platform.terminal.theme import (
BRAND,
DIM,
ERROR,
HIGHLIGHT,
SECONDARY,
TEXT,
WARNING,
)
from surfaces.interactive_shell.ui.components.time_format import _elapsed_hms
from surfaces.interactive_shell.ui.output.console_state import (
_get_console,
consume_footer_snapshot,
)
from surfaces.interactive_shell.ui.output.environment import (
_is_silent_output,
_safe_print,
get_output_format,
)
from surfaces.interactive_shell.ui.output.labels import BADGE_STYLES
def render_divider(width: int = 80) -> None:
"""Print a DIM-coloured dashed divider."""
if _is_silent_output():
return
if get_output_format() == "rich":
_get_console().print(Text("" * width, style=DIM))
else:
_safe_print("" * width)
def render_footer(
phase: str,
elapsed: float,
model: str,
mode: str,
*,
show_cancel: bool = True,
) -> None:
"""Print the persistent status footer line."""
if _is_silent_output():
return
if get_output_format() == "rich":
t = Text()
t.append("", style=f"bold {HIGHLIGHT}")
t.append(f"{phase} ", style=f"bold {SECONDARY}")
t.append(f"{_elapsed_hms(elapsed)} ", style=SECONDARY)
if model:
t.append(f"{model} ", style=SECONDARY)
t.append(f"{mode} ", style=SECONDARY)
if show_cancel:
t.append("esc to cancel", style=DIM)
_get_console().print(t)
else:
_safe_print(f"{phase} {elapsed:.1f}s {model} {mode}")
def render_completed_investigation_footer() -> None:
"""Print the captured phase footer once at the bottom of the report."""
snapshot = consume_footer_snapshot()
if snapshot is None or _is_silent_output():
return
phase, elapsed, model, mode = snapshot
render_divider()
render_footer(phase, elapsed, model, mode, show_cancel=False)
def render_event(
event_type: str,
message: str,
*,
insight: str | None = None,
muted: bool = False,
elapsed_s: float = 0.0,
glyph: str = "",
error: bool = False,
) -> None:
"""Print one typed event-log row."""
if _is_silent_output():
return
if get_output_format() == "rich":
badge_label, badge_color = BADGE_STYLES.get(event_type, BADGE_STYLES["DIAG"])
t = Text()
t.append(f"{_elapsed_hms(elapsed_s)} ", style=SECONDARY)
if muted:
t.append(f"{glyph} ", style=SECONDARY)
msg_style = SECONDARY
elif error:
t.append("", style=f"bold {ERROR}")
msg_style = TEXT
else:
t.append(f"{glyph} ", style=f"bold {HIGHLIGHT}")
msg_style = TEXT
t.append(badge_label, style=f"bold {badge_color}")
t.append(" · ", style=DIM)
t.append(message, style=msg_style)
if insight:
t.append(f"{insight}", style=BRAND)
_get_console().print(t)
else:
mark = "" if error else ("·" if muted else "")
line = f" {mark} [{event_type}] {message}"
if insight:
line += f"{insight}"
_safe_print(line)
def render_investigation_header(
alert_name: str,
pipeline_name: str,
severity: str,
alert_id: str | None = None,
) -> None:
sev_color = ERROR if severity.lower() == "critical" else WARNING
fields = [
("Alert ", alert_name, f"bold {TEXT}"),
("Pipeline ", pipeline_name, BRAND),
("Severity ", severity, f"bold {sev_color}"),
]
if alert_id:
fields.append(("Alert ID ", alert_id, SECONDARY))
if get_output_format() == "rich":
console = _get_console()
console.print()
for label, value, style in fields:
console.print(
Text.assemble(
("", f"bold {BRAND}"),
(label, SECONDARY),
(value, style),
)
)
console.print()
else:
print()
for label, value, _ in fields:
print(f"{label}{value}")
print()
@@ -0,0 +1,163 @@
from __future__ import annotations
import shutil
import threading
import time
from typing import Any
from rich.console import Console
from rich.text import Text
from platform.terminal.theme import BRAND, DIM, SECONDARY
from surfaces.interactive_shell.ui.components.time_format import _elapsed_hms
from surfaces.interactive_shell.ui.output.events import ProgressEvent
from surfaces.interactive_shell.ui.output.labels import (
_node_label,
_node_phase_label,
build_progress_step_text,
)
# Timestamp + indent; keep append-only hint lines on one physical row.
_HINT_LINE_OVERHEAD = 20
def _terminal_columns() -> int:
try:
cols = shutil.get_terminal_size(fallback=(80, 24)).columns
except OSError:
cols = 80
return max(40, cols - 1)
def _fit_hint_prefix(prefix: str, *, cols: int | None = None) -> str:
"""Truncate hint text so an append-only lap line stays on a single row."""
budget = max(16, (cols if cols is not None else _terminal_columns()) - _HINT_LINE_OVERHEAD)
if len(prefix) <= budget:
return prefix
if budget <= 3:
return prefix[:budget]
return f"{prefix[: budget - 3]}..."
class _ReplEventLogDisplay:
"""Append-only investigation progress for the interactive REPL.
Live animation is delegated to the prompt spinner (``SpinnerState``): raw
cursor-up frames cannot rewrite a row under ``patch_stdout(raw=True)``, so
the display prints step/lap history append-only and drives the spinner with
per-stage phase labels via the ``console_state`` registration.
"""
def __init__(self, model: str = "", mode: str = "local", t0: float | None = None) -> None:
self._model = model
self._mode = mode
self._t0 = t0 if t0 is not None else time.monotonic()
self._active_steps: dict[str, dict[str, Any]] = {}
self._current_phase = "LOAD"
self._lock = threading.Lock()
self._console = Console(highlight=False)
self._last_emitted_hint: str | None = None
def stop(self) -> None:
from surfaces.interactive_shell.ui.output.console_state import (
_capture_footer_snapshot,
get_investigation_spinner,
)
spinner = get_investigation_spinner()
if spinner is not None:
spinner.stop()
_capture_footer_snapshot(self)
def _emit(self, line: Text | Any) -> None:
from surfaces.interactive_shell.ui.components.choice_menu import prepare_repl_output_line
prepare_repl_output_line()
self._console.print(line)
def animate_hint(self, text: str) -> None:
"""Print one compact append-only lap-status line.
The live "still working" cue is the prompt spinner (driven from
``step_start``); this only records lap hints as scrollback history and
dedupes consecutive identical lines.
"""
prefix = _fit_hint_prefix(text.rstrip("· \t"))
if prefix == self._last_emitted_hint:
return
self._last_emitted_hint = prefix
elapsed_total = time.monotonic() - self._t0
t = Text()
t.append(f"{_elapsed_hms(elapsed_total)} ", style=SECONDARY)
t.append("", style=DIM)
t.append(prefix, style=SECONDARY)
self._emit(t)
def step_start(self, node_name: str) -> None:
from surfaces.interactive_shell.ui.output.console_state import get_investigation_spinner
spinner = get_investigation_spinner()
if spinner is not None:
spinner.set_phase(_node_label(node_name))
with self._lock:
self._active_steps[node_name] = {
"t0": time.monotonic(),
"subtext": None,
"subtext_until": 0.0,
}
self._current_phase = _node_phase_label(node_name)
self._last_emitted_hint = None
self._emit(
build_progress_step_text(
node_name=node_name,
elapsed_total=time.monotonic() - self._t0,
status="active",
)
)
def set_tool_details(
self,
*,
visible: bool,
records: list[dict[str, Any]],
summary: str,
clear: bool = False,
) -> None:
pass
def step_complete(self, node_name: str, event: ProgressEvent) -> None:
self._last_emitted_hint = None
with self._lock:
info = self._active_steps.pop(node_name, {})
subtext = info.get("subtext")
line = build_progress_step_text(
node_name=node_name,
elapsed_total=time.monotonic() - self._t0,
elapsed_step_ms=event.elapsed_ms,
status=event.status,
message=event.message,
)
if subtext:
line.append(f"{subtext}", style=BRAND)
self._emit(line)
def step_subtext(self, node_name: str, text: str, duration: float = 4.0) -> None:
if not text.strip():
return
with self._lock:
if node_name in self._active_steps:
self._active_steps[node_name]["subtext"] = text
self._active_steps[node_name]["subtext_until"] = time.monotonic() + duration
def print_above(self, text: str) -> None:
if not text.strip():
return
from rich.markdown import Markdown
from platform.terminal.theme import MARKDOWN_THEME
with self._console.use_theme(MARKDOWN_THEME):
self._emit(Markdown(text, code_theme="ansi_dark"))
def print_above_renderable(self, renderable: Any) -> None:
self._emit(renderable)
@@ -0,0 +1,32 @@
"""REPL-safe progress signalling without importing the interactive shell runtime."""
from __future__ import annotations
import contextlib
import contextvars
from collections.abc import Generator
_REPL_SAFE_PROGRESS: contextvars.ContextVar[bool] = contextvars.ContextVar(
"repl_safe_progress",
default=False,
)
@contextlib.contextmanager
def repl_safe_progress_scope() -> Generator[None]:
"""Mark the current context (and ``asyncio.to_thread`` children) as REPL-safe.
Investigation dispatch runs in a worker thread where ``get_app_or_none()`` is
unset even though the main thread still has an active ``prompt_async``. Set
this scope around ``asyncio.to_thread`` so progress renderers avoid Rich Live.
"""
token = _REPL_SAFE_PROGRESS.set(True)
try:
yield
finally:
_REPL_SAFE_PROGRESS.reset(token)
def repl_safe_progress_requested() -> bool:
"""True when a parent scope has marked progress as REPL-safe."""
return _REPL_SAFE_PROGRESS.get()
@@ -0,0 +1,145 @@
from __future__ import annotations
import contextlib
import os
import sys
import threading
from collections.abc import Callable, Iterator
from typing import Any
try:
import select
import termios
except ImportError: # pragma: no cover - Windows fallback
select = None # type: ignore[assignment]
termios = None # type: ignore[assignment]
_stdin_watcher_suppression_depth = 0
_stdin_watcher_lock = threading.Lock()
_tool_detail_toggle_callbacks: list[Callable[[], None]] = []
_TOOL_DETAIL_TOGGLE_BYTES = {b"\x0f", b"\x00"} # ctrl+o; ctrl+0/space on some terminals
@contextlib.contextmanager
def suppress_stdin_watchers() -> Iterator[None]:
"""Temporarily prevent raw stdin watcher threads from starting."""
global _stdin_watcher_suppression_depth
with _stdin_watcher_lock:
_stdin_watcher_suppression_depth += 1
try:
yield
finally:
with _stdin_watcher_lock:
_stdin_watcher_suppression_depth = max(0, _stdin_watcher_suppression_depth - 1)
def _stdin_watchers_suppressed() -> bool:
with _stdin_watcher_lock:
return _stdin_watcher_suppression_depth > 0
def register_tool_detail_toggle(callback: Callable[[], None]) -> Callable[[], None]:
"""Register a process-local Ctrl+O handler for the active progress view."""
with _stdin_watcher_lock:
_tool_detail_toggle_callbacks.append(callback)
def _unregister() -> None:
with _stdin_watcher_lock, contextlib.suppress(ValueError):
_tool_detail_toggle_callbacks.remove(callback)
return _unregister
def toggle_active_tool_details() -> bool:
"""Toggle the newest registered tool-detail view, if one exists."""
with _stdin_watcher_lock:
callback = _tool_detail_toggle_callbacks[-1] if _tool_detail_toggle_callbacks else None
if callback is None:
return False
with contextlib.suppress(Exception):
callback()
return True
return False
def _control_char(value: int, existing: Any) -> Any:
if isinstance(existing, bytes):
return bytes([value])
if isinstance(existing, str):
return chr(value)
return value
def _disable_control_char(fd: int, existing: Any) -> Any:
disabled = 0
with contextlib.suppress(Exception):
disabled = int(os.fpathconf(fd, "PC_VDISABLE"))
if disabled < 0 or disabled > 255:
disabled = 0
return _control_char(disabled, existing)
class CtrlOToggleWatcher:
"""Background stdin watcher for Ctrl+O without triggering terminal discard."""
def __init__(self, callback: Callable[[], None]) -> None:
self._callback = callback
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._fd: int | None = None
self._old_attrs: Any = None
def start(self) -> None:
if _stdin_watchers_suppressed() or select is None or termios is None:
return
if not sys.stdin.isatty() or not sys.stdout.isatty():
return
try:
self._fd = sys.stdin.fileno()
self._old_attrs = termios.tcgetattr(self._fd)
new_attrs = termios.tcgetattr(self._fd)
new_attrs[3] &= ~(termios.ICANON | termios.ECHO)
if hasattr(termios, "IEXTEN"):
new_attrs[3] &= ~termios.IEXTEN
if hasattr(termios, "VMIN"):
new_attrs[6][termios.VMIN] = 1
if hasattr(termios, "VTIME"):
new_attrs[6][termios.VTIME] = 0
if hasattr(termios, "VDISCARD"):
index = termios.VDISCARD
new_attrs[6][index] = _disable_control_char(self._fd, new_attrs[6][index])
termios.tcsetattr(self._fd, termios.TCSADRAIN, new_attrs)
except Exception:
self._fd = None
self._old_attrs = None
return
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=0.2)
if self._fd is not None and self._old_attrs is not None and termios is not None:
with contextlib.suppress(Exception):
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_attrs)
from surfaces.interactive_shell.ui.components.key_reader import restore_stdin_terminal
restore_stdin_terminal()
def _run(self) -> None:
if self._fd is None or select is None:
return
while not self._stop.is_set():
try:
readable, _, _ = select.select([self._fd], [], [], 0.1)
except Exception:
return
if not readable:
continue
try:
data = os.read(self._fd, 1)
except Exception:
return
if data in _TOOL_DETAIL_TOGGLE_BYTES:
self._callback()
@@ -0,0 +1,138 @@
from __future__ import annotations
from typing import Any
from rich.text import Text
from platform.observability.trace.redaction import format_json_preview
from platform.terminal.theme import BRAND, DIM, HIGHLIGHT, SECONDARY, TEXT
from surfaces.interactive_shell.ui.components.time_format import _elapsed_hms, _fmt_timing
from surfaces.shared.tool_labels import tool_short_label, tool_source_label
__all__ = [
"build_live_tool_detail_rows",
"build_tool_call_line",
"build_tool_detail_text",
"format_tool_summary",
"make_tool_detail_record",
"record_tool_summary",
"tool_detail_body",
"tool_short_label",
"tool_source_label",
]
def record_tool_summary(
tool_name: str,
summary_counts: dict[str, dict[str, int]],
summary_order: list[tuple[str, str]],
) -> None:
source = tool_source_label(tool_name)
label = tool_short_label(tool_name, source)
source_counts = summary_counts.setdefault(source, {})
if label not in source_counts:
summary_order.append((source, label))
source_counts[label] = source_counts.get(label, 0) + 1
def format_tool_summary(
summary_counts: dict[str, dict[str, int]],
summary_order: list[tuple[str, str]],
) -> str:
source_labels: dict[str, list[str]] = {}
for source, label in summary_order:
count = summary_counts.get(source, {}).get(label, 0)
if count <= 0:
continue
rendered = f"{label} x{count}" if count > 1 else label
source_labels.setdefault(source, []).append(rendered)
parts = [
f"{source}: {', '.join(labels[:4])}{', ...' if len(labels) > 4 else ''}"
for source, labels in source_labels.items()
]
summary = " | ".join(parts[:2])
return summary[:117] + "..." if len(summary) > 120 else summary
def build_tool_call_line(tool_name: str, elapsed_ms: int, elapsed_total: float) -> Text:
source = tool_source_label(tool_name)
label = tool_short_label(tool_name, source)
call_display = f"{source} · {label}" if label else source
t = Text()
t.append(f"{_elapsed_hms(elapsed_total)} ", style=SECONDARY)
t.append("", style=DIM)
t.append(call_display, style=BRAND)
t.append(f" {_fmt_timing(elapsed_ms)}", style=SECONDARY)
return t
def make_tool_detail_record(
display: str,
tool_input: Any,
output: Any,
*,
elapsed: str = "",
) -> dict[str, Any] | None:
if tool_input in ({}, None) and output in ({}, None, ""):
return None
return {"display": display, "input": tool_input, "output": output, "elapsed": elapsed}
def build_tool_detail_text(record: dict[str, Any]) -> Text:
display = str(record.get("display") or "tool")
elapsed = str(record.get("elapsed") or "")
suffix = f" {elapsed}" if elapsed else ""
detail = Text()
detail.append(f" Tool details: {display}{suffix}\n", style=f"bold {TEXT}")
for line in tool_detail_body(record).splitlines():
detail.append(f" {line}\n", style=DIM)
return detail
def tool_detail_body(record: dict[str, Any]) -> str:
body_parts: list[str] = []
if (tool_input := record.get("input")) not in ({}, None):
body_parts.append(f"Input:\n{format_json_preview(tool_input, max_chars=1600)}")
if (output := record.get("output")) not in ({}, None, ""):
body_parts.append(f"Output:\n{format_json_preview(output, max_chars=3000)}")
return "\n\n".join(body_parts)
def build_live_tool_detail_rows(
records: list[dict[str, Any]], max_width: int, now: float
) -> list[Text]:
rows: list[Text] = []
rows.append(Text(" Tool Details", style=f"bold {TEXT}"))
hidden_count = max(0, len(records) - 6)
if hidden_count:
rows.append(Text(f" {hidden_count} older tool call(s) hidden", style=DIM))
if not records:
rows.append(Text(" No tool calls have finished yet.", style=DIM))
for record in records[-6:]:
elapsed = str(record.get("elapsed") or "")
suffix = f" {elapsed}" if elapsed else ""
row = Text()
row.append("", style=f"bold {HIGHLIGHT}")
row.append(str(record.get("display") or "tool"), style=f"bold {TEXT}")
row.append(suffix, style=SECONDARY)
rows.extend([row, *_detail_preview_rows(record), Text("")])
rows.append(Text("" * (max_width - 1), style=DIM))
rows.append(Text(f" ● TOOL DETAILS {_elapsed_hms(now)}", style=SECONDARY))
return rows
def _detail_preview_rows(record: dict[str, Any]) -> list[Text]:
rows: list[Text] = []
if (tool_input := record.get("input")) not in ({}, None):
rows.append(Text(" Input:", style=SECONDARY))
rows.extend(
Text(f" {line}", style=DIM)
for line in format_json_preview(tool_input, max_chars=1200).splitlines()
)
if (output := record.get("output")) not in ({}, None, ""):
rows.append(Text(" Output:", style=SECONDARY))
rows.extend(
Text(f" {line}", style=DIM)
for line in format_json_preview(output, max_chars=2200).splitlines()
)
return rows
@@ -0,0 +1,190 @@
from __future__ import annotations
import time
from typing import TYPE_CHECKING, Any, Protocol, TypeGuard, runtime_checkable
from rich.text import Text
from surfaces.interactive_shell.ui.output.environment import _safe_print
if TYPE_CHECKING:
from surfaces.interactive_shell.ui.output.events import DisplayProtocol
from surfaces.interactive_shell.ui.output.repl_display import _ReplEventLogDisplay
from surfaces.interactive_shell.ui.components.time_format import _elapsed_hms, _fmt_timing
from surfaces.interactive_shell.ui.output.tool_details import (
build_tool_call_line,
build_tool_detail_text,
make_tool_detail_record,
tool_detail_body,
)
from surfaces.interactive_shell.ui.output.tool_details import (
format_tool_summary as _format_tool_summary,
)
from surfaces.interactive_shell.ui.output.tool_details import (
record_tool_summary as _record_tool_summary,
)
from surfaces.shared.tool_labels import tool_short_label, tool_source_label
from tools.registry import resolve_tool_display_name
def _is_repl_display(display: object) -> TypeGuard[_ReplEventLogDisplay]:
from surfaces.interactive_shell.ui.output.repl_display import _ReplEventLogDisplay
return isinstance(display, _ReplEventLogDisplay)
@runtime_checkable
class ToolTrackingSupport(Protocol):
"""Interface that concrete classes must satisfy to use :class:`ToolTrackingMixin`."""
def update_subtext(self, node_name: str, text: str, duration: float = 4.0) -> None:
raise NotImplementedError
def print_above_renderable(self, renderable: Any) -> None:
raise NotImplementedError
class ToolTrackingMixin:
_silent: bool
_rich: bool
_t0: float
_display: DisplayProtocol | None
_tool_start_times: dict[str, float]
_tool_inputs: dict[str, Any]
_tool_details_visible: bool
_tool_detail_records: list[dict[str, Any]]
_printed_tool_detail_ids: set[int]
_tool_summary_counts: dict[str, dict[str, int]]
_tool_summary_order: list[tuple[str, str]]
def update_subtext(self, node_name: str, text: str, duration: float = 4.0) -> None:
raise NotImplementedError
def print_above_renderable(self, renderable: Any) -> None:
raise NotImplementedError
def record_tool_start(
self,
tool_name: str,
tool_input: Any = None,
*,
event_key: str | None = None,
) -> None:
key = event_key or tool_name
self._tool_start_times[key] = time.monotonic()
self._tool_inputs[key] = tool_input
if self._silent:
return
_record_tool_summary(tool_name, self._tool_summary_counts, self._tool_summary_order)
source = tool_source_label(tool_name)
label = tool_short_label(tool_name, source)
current = f"{source} · {label}" if label else source
self.update_subtext("investigation_agent", f"calling {current}...", duration=15.0)
self.update_subtext("investigate", f"calling {current}...", duration=15.0)
self._sync_tool_detail_view()
def record_tool_end(
self,
tool_name: str,
output: Any = None,
*,
event_key: str | None = None,
tool_input: Any = None,
) -> None:
key = event_key or tool_name
start = self._tool_start_times.pop(key, None)
elapsed_ms = int((time.monotonic() - start) * 1000) if start is not None else None
stored_input = self._tool_inputs.pop(key, None)
# UI tool tracking only; tool spans are emitted in core.execution.
if self._silent:
return
self._update_tool_summary_subtext()
self._record_tool_detail(
resolve_tool_display_name(tool_name),
tool_input if tool_input is not None else stored_input,
output,
elapsed=_fmt_timing(elapsed_ms) if elapsed_ms is not None else "",
)
if elapsed_ms is not None and not _is_repl_display(self._display):
# REPL investigations show an aggregate lap summary; one line per tool
# call floods scrollback during multi-lap ReAct loops.
self.print_above_renderable(
build_tool_call_line(tool_name, elapsed_ms, time.monotonic() - self._t0)
)
def print_status_hint(self, text: str) -> None:
if self._silent:
return
if _is_repl_display(self._display):
self._display.animate_hint(text)
return
t = Text()
t.append(f"{_elapsed_hms(time.monotonic() - self._t0)} ", style="dim")
t.append("", style="dim")
t.append(text, style="dim")
self.print_above_renderable(t)
def toggle_tool_details(self) -> None:
if self._silent:
return
self._tool_details_visible = not self._tool_details_visible
if self._rich and self._display is not None and not _is_repl_display(self._display):
self._sync_tool_detail_view(clear=True)
return
label = "shown" if self._tool_details_visible else "hidden"
_safe_print(f" Tool details {label} (ctrl+o)")
if self._tool_details_visible:
self._flush_tool_details()
def _sync_tool_detail_view(self, *, clear: bool = False) -> None:
if self._rich and self._display is not None and not _is_repl_display(self._display):
self._display.set_tool_details(
visible=self._tool_details_visible,
records=self._tool_detail_records,
summary=self.format_tool_summary(),
clear=clear,
)
def _update_tool_summary_subtext(self) -> None:
if summary := self.format_tool_summary():
self.update_subtext("investigation_agent", summary, duration=30.0)
self.update_subtext("investigate", summary, duration=30.0)
def format_tool_summary(self) -> str:
return _format_tool_summary(self._tool_summary_counts, self._tool_summary_order)
def _record_tool_detail(
self,
display: str,
tool_input: Any,
output: Any,
*,
elapsed: str = "",
) -> None:
record = make_tool_detail_record(display, tool_input, output, elapsed=elapsed)
if record is None:
return
self._tool_detail_records.append(record)
if not self._tool_details_visible:
return
if self._rich and self._display is not None and not _is_repl_display(self._display):
self._sync_tool_detail_view()
else:
self._print_tool_detail(record)
def _flush_tool_details(self) -> None:
for record in self._tool_detail_records:
if id(record) not in self._printed_tool_detail_ids:
self._print_tool_detail(record)
def _print_tool_detail(self, record: dict[str, Any]) -> None:
if self._rich:
self.print_above_renderable(build_tool_detail_text(record))
else:
display = str(record.get("display") or "tool")
elapsed = str(record.get("elapsed") or "")
suffix = f" {elapsed}" if elapsed else ""
_safe_print(f" Tool details: {display}{suffix}")
for line in tool_detail_body(record).splitlines():
_safe_print(f" {line}")
self._printed_tool_detail_ids.add(id(record))
@@ -0,0 +1,240 @@
from __future__ import annotations
import os
import textwrap
import time
from collections.abc import Callable
from typing import Any
from surfaces.interactive_shell.ui.components.time_format import _fmt_timing
from surfaces.interactive_shell.ui.output.environment import (
_is_silent_output,
_repl_progress_active,
_safe_print,
get_output_format,
)
from surfaces.interactive_shell.ui.output.events import DisplayProtocol, ProgressEvent
from surfaces.interactive_shell.ui.output.labels import _humanise_message, _node_label
from surfaces.interactive_shell.ui.output.toggles import (
CtrlOToggleWatcher,
register_tool_detail_toggle,
toggle_active_tool_details,
)
from surfaces.interactive_shell.ui.output.tool_tracking import ToolTrackingMixin
def _EventLogDisplay(*args: Any, **kwargs: Any) -> DisplayProtocol:
from surfaces.interactive_shell.ui.output.live_display import _EventLogDisplay
return _EventLogDisplay(*args, **kwargs)
def _ReplEventLogDisplay(*args: Any, **kwargs: Any) -> DisplayProtocol:
from surfaces.interactive_shell.ui.output.repl_display import _ReplEventLogDisplay
return _ReplEventLogDisplay(*args, **kwargs)
def _make_event_log_display(*, t0: float) -> DisplayProtocol:
return _ReplEventLogDisplay(t0=t0) if _repl_progress_active() else _EventLogDisplay(t0=t0)
def _invoke_registered_tool_detail_toggle() -> None:
toggle_active_tool_details()
class ProgressTracker(ToolTrackingMixin):
"""Drives event-log displays from node lifecycle calls."""
def __init__(self) -> None:
self.events: list[ProgressEvent] = []
self._start_times: dict[str, float] = {}
self._t0 = time.monotonic()
self._silent = _is_silent_output()
self._rich = get_output_format() == "rich"
self._repl_append_only = _repl_progress_active()
self._display: DisplayProtocol | None = None
self._tool_start_times: dict[str, float] = {}
self._tool_inputs: dict[str, Any] = {}
self._tool_details_visible = False
self._tool_detail_records: list[dict[str, Any]] = []
self._printed_tool_detail_ids: set[int] = set()
self._tool_summary_counts: dict[str, dict[str, int]] = {}
self._tool_summary_order: list[tuple[str, str]] = []
self._toggle_watcher: CtrlOToggleWatcher | None = None
self._toggle_unregister: Callable[[], None] | None = None
if self._rich and not self._silent:
self._display = _make_event_log_display(t0=self._t0)
self._toggle_unregister = register_tool_detail_toggle(self.toggle_tool_details)
if not self._repl_append_only:
self._toggle_watcher = CtrlOToggleWatcher(_invoke_registered_tool_detail_toggle)
self._toggle_watcher.start()
@property
def has_active_display(self) -> bool:
return self._display is not None
def stop(self) -> None:
self._stop_toggle_watcher()
if self._display:
self._display.stop()
self._display = None
def _stop_toggle_watcher(self) -> None:
if self._toggle_watcher is not None:
self._toggle_watcher.stop()
self._toggle_watcher = None
if self._toggle_unregister is not None:
self._toggle_unregister()
self._toggle_unregister = None
def start(self, node_name: str, message: str | None = None) -> None:
self._start_times[node_name] = time.monotonic()
self.events.append(
ProgressEvent(node_name=node_name, elapsed_ms=0, status="started", message=message)
)
if self._silent:
return
if not self._rich:
_safe_print(f"{_node_label(node_name)}")
return
if node_name == "publish_findings":
self._stop_toggle_watcher()
if self._display:
self._display.stop()
self._display = None
return
if self._display is None:
self._display = _make_event_log_display(t0=self._t0)
self._display.step_start(node_name)
def complete(
self,
node_name: str,
fields_updated: list[str] | None = None,
message: str | None = None,
) -> None:
self._finish(node_name, "completed", fields_updated or [], message)
def error(self, node_name: str, message: str) -> None:
self._finish(node_name, "error", [], message)
def update_subtext(self, node_name: str, text: str, duration: float = 4.0) -> None:
if self._display:
self._display.step_subtext(node_name, text, duration)
def print_above(self, text: str) -> None:
if self._silent:
return
if self._display:
self._display.print_above(text)
return
if text.strip():
cols = max(40, int(os.getenv("COLUMNS", "80")))
for para in text.strip().splitlines():
if not para.strip():
print()
continue
for chunk in textwrap.wrap(para, width=max(40, cols - 2)) or [para]:
print(f" {chunk}")
def print_above_renderable(self, renderable: Any) -> None:
if self._display:
self._display.print_above_renderable(renderable)
else:
from surfaces.interactive_shell.ui.output.console_state import _get_console
_get_console().print(renderable)
def _finish(
self,
node_name: str,
status: str,
fields_updated: list[str],
message: str | None,
) -> None:
elapsed_ms = int(
(time.monotonic() - self._start_times.pop(node_name, time.monotonic())) * 1000
)
event = ProgressEvent(node_name, elapsed_ms, fields_updated, status, message)
self.events.append(event)
# UI progress tracking only; stage spans are emitted in the investigation lifecycle.
if self._silent:
return
if self._rich:
if self._display:
self._display.step_complete(node_name, event)
else:
mark = "" if status == "error" else ""
line = f" {mark} {_node_label(node_name)} {_fmt_timing(elapsed_ms)}"
if msg := _humanise_message(message or ""):
line += f" {msg}"
self.print_above_renderable(line)
return
mark = "" if status == "error" else ""
line = f" {mark} {_node_label(node_name)} {_fmt_timing(elapsed_ms)}"
if msg := _humanise_message(message or ""):
line += f" {msg}"
_safe_print(line)
_tracker: ProgressTracker | None = None
def _register_with_observability(tracker: ProgressTracker) -> None:
"""Tell the observability port which tracker core code should see.
The Rich tracker structurally satisfies the
:class:`platform.observability.render.progress.ProgressReporter` Protocol;
registering it here means any module that imports
``get_progress_tracker`` from core gets the same instance the CLI
is driving.
"""
from platform.observability.render.progress import set_progress_tracker
set_progress_tracker(tracker)
from surfaces.interactive_shell.ui.output.console_state import set_tracker_toggle_stop_fn
set_tracker_toggle_stop_fn(_stop_active_tracker_toggle_watcher)
def get_tracker(*, reset: bool = False) -> ProgressTracker:
global _tracker
if _tracker is None or reset:
if reset and _tracker is not None:
_tracker.stop()
_tracker = ProgressTracker()
_register_with_observability(_tracker)
return _tracker
def reset_tracker() -> ProgressTracker:
return get_tracker(reset=True)
def set_silent_tracker() -> None:
global _tracker
if _tracker is not None:
_tracker.stop()
_tracker = ProgressTracker.__new__(ProgressTracker)
_tracker.events = []
_tracker._start_times = {}
_tracker._t0 = time.monotonic()
_tracker._silent = True
_tracker._rich = False
_tracker._display = None
_tracker._tool_start_times = {}
_tracker._tool_inputs = {}
_tracker._tool_details_visible = False
_tracker._tool_detail_records = []
_tracker._printed_tool_detail_ids = set()
_tracker._tool_summary_counts = {}
_tracker._tool_summary_order = []
_tracker._toggle_watcher = None
_tracker._toggle_unregister = None
_register_with_observability(_tracker)
def _stop_active_tracker_toggle_watcher() -> None:
if _tracker is not None:
_tracker._stop_toggle_watcher()
@@ -0,0 +1,297 @@
"""Live token streaming for interactive-shell LLM responses.
The interactive REPL pins the input box at the bottom of the terminal
via ``patch_stdout``. To keep the input editable while a response
streams (type-ahead) we can't use :class:`rich.live.Live` — ``Live``
does cursor manipulation (cursor-up + erase-line) for in-place redraw,
which fights ``patch_stdout`` and blocks the input buffer from
accepting keystrokes.
Instead this path streams **paragraph-by-paragraph**: chunks accumulate
in ``para_buffer`` and a complete paragraph (text up to the next
``\\n\\n`` outside an open code-fence) renders as ``rich.Markdown`` the
moment its boundary is seen. The trailing partial paragraph is
force-flushed at end-of-stream. Code blocks are kept whole — we never
split on ``\\n\\n`` while a triple-backtick fence is unclosed.
Streaming progress and cancellation are surfaced through optional
attributes on the ``console``: ``update_streaming_progress(bytes)`` is
called per chunk (throttled to ~10/s) so the bottom-toolbar token
counter updates live, and ``cancel_requested`` is polled between chunks
so an Esc press in the prompt cancels promptly. The ``getattr``
indirection keeps this module decoupled from the ``StreamingConsole`` adapter.
"""
from __future__ import annotations
import re
import time
from collections.abc import Iterator
from rich.console import Console
from rich.markdown import Markdown
import platform.terminal.theme as ui_theme
from surfaces.interactive_shell.ui.components.token_format import (
_CHARS_PER_TOKEN,
format_token_count_short,
)
from surfaces.interactive_shell.ui.streaming.console import StreamingConsole
# Throttle for the optional ``update_streaming_progress`` hook on the
# console — caps cross-thread queueing on long bursts of chunks. Same
# value (and intent) as ``runtime.core.state.PROMPT_REFRESH_INTERVAL_S``.
_PROGRESS_INTERVAL_S = 0.1
# Markdown rendering constants — extracted so streaming.py and any
# external caller (e.g. agent_actions.py for the planned-actions
# bullet header) stay in lock-step.
_PARAGRAPH_BREAK = "\n\n"
_CODE_FENCE = "```"
# Match a triple-backtick only when it opens a line. An inline mention
# inside flowing text (e.g. "The ``` marker opens a code block") would
# otherwise flip the odd/even fence count below and stall paragraph
# rendering until end-of-stream. CommonMark's fence syntax requires
# the fence to be at line start anyway, so this is a tighter and
# more accurate check than a naive substring count.
_CODE_FENCE_LINE_RE = re.compile(rf"^{re.escape(_CODE_FENCE)}", re.MULTILINE)
_MARKDOWN_CODE_THEME = "ansi_dark"
STREAM_LABEL_ASSISTANT = "assistant"
STREAM_LABEL_ANSWER = "answer"
def render_response_header(console: Console, label: str) -> None:
"""Print the ``●`` bullet row marker that opens every assistant
response (Claude Code-style row layout). Shared with
``action_turn.run_action_tool_turn`` so the planned-actions path
and the streaming response path use the exact same prefix.
"""
console.print(f"[{ui_theme.BOLD_BRAND}]●[/] [{ui_theme.DIM}]{label}[/]")
def _format_tokens(token_count: int) -> str:
return f"{format_token_count_short(token_count)} tokens"
def stream_to_console(
console: Console,
*,
label: str,
chunks: Iterator[str],
suppress_if_starts_with: str | None = None,
) -> str:
"""Stream chunks to ``console`` and return the accumulated text.
``suppress_if_starts_with`` allows callers to skip live rendering when
the first non-whitespace token indicates a machine-readable payload
(e.g. machine-readable payloads). The return value still contains the full
accumulated text in that case.
"""
if not console.is_terminal:
text = "".join(chunks)
if suppress_if_starts_with is not None and text.lstrip().startswith(
suppress_if_starts_with
):
return text
if text:
console.print()
render_response_header(console, label)
with console.use_theme(ui_theme.MARKDOWN_THEME):
console.print(Markdown(text, code_theme=_MARKDOWN_CODE_THEME))
console.print()
return text
chunks_iter = iter(chunks)
peeked: list[str] = []
def _next_chunk(it: Iterator[str]) -> str | None:
try:
return next(it)
except StopIteration:
return None
if suppress_if_starts_with is not None:
while True:
chunk = _next_chunk(chunks_iter)
if chunk is None:
break
peeked.append(chunk)
stripped = "".join(peeked).lstrip()
if not stripped:
continue
if stripped.startswith(suppress_if_starts_with):
drained: list[str] = []
while True:
rest = _next_chunk(chunks_iter)
if rest is None:
break
drained.append(rest)
return "".join(peeked) + "".join(drained)
break
console.print()
render_response_header(console, label)
# Paragraph-level streaming: chunks accumulate in ``para_buffer``
# until a paragraph boundary (``\n\n`` outside a code block) closes
# the paragraph, at which point we render that paragraph as
# Markdown via ``console.print(Markdown(...))``. Visible "streaming"
# is per-paragraph rather than per-chunk — a true live re-render
# would need cursor manipulation that fights ``patch_stdout``. The
# spinner (``⠋ thinking… (Ns · ↓ X tokens)``) ticks during long
# paragraphs to confirm chunks are still arriving, and code blocks
# are kept whole (we never split on ``\n\n`` while a fence is open).
buffer: list[str] = list(peeked)
para_buffer: list[str] = list(peeked)
started = time.monotonic()
progress_hook = getattr(console, "update_streaming_progress", None)
total_bytes = sum(len(c) for c in peeked)
last_progress_at = 0.0
def _maybe_update_progress(now: float, *, force: bool = False) -> float:
nonlocal progress_hook
if progress_hook is None:
return last_progress_at
if not force and now - last_progress_at < _PROGRESS_INTERVAL_S:
return last_progress_at
try:
progress_hook(total_bytes)
except Exception:
progress_hook = None
return now
def _is_cancelled() -> bool:
# ``getattr`` keeps this layer decoupled from the loop's
# ``StreamingConsole`` — non-interactive callers (the test
# harness, the non-TTY path above) never expose the attribute
# so this stays False for them.
return bool(getattr(console, "cancel_requested", False))
def _render_paragraph(text: str) -> None:
if not text.strip():
return
with console.use_theme(ui_theme.MARKDOWN_THEME):
console.print(Markdown(text.rstrip(), code_theme=_MARKDOWN_CODE_THEME))
def _flush_paragraphs(*, force: bool = False) -> None:
"""Emit any complete paragraphs from ``para_buffer``.
Splits on ``\\n\\n`` (``_PARAGRAPH_BREAK``) but only when an
even number of triple-backtick fences (``_CODE_FENCE``) are
present in the proposed prefix — that's enough to keep code
blocks whole without tracking fence type. A ``\\n\\n`` falling
inside an open fence is skipped so we keep scanning forward;
otherwise a code block with embedded blank lines would defer
every later paragraph to ``force=True`` at EOS. ``force``
flushes any remaining buffer at end-of-stream.
"""
nonlocal para_buffer
break_len = len(_PARAGRAPH_BREAK)
while True:
text = "".join(para_buffer)
search_from = 0
rendered = False
while True:
idx = text.find(_PARAGRAPH_BREAK, search_from)
if idx < 0:
break
paragraph = text[: idx + break_len]
# Odd line-start fence count means a fence is still
# open; the boundary is inside it, so skip and keep
# scanning for the next ``\n\n`` that lands outside
# any fence. Only line-start fences count (per
# CommonMark), so an inline mention like
# ``Use ``` to open a block`` doesn't trip this check.
if len(_CODE_FENCE_LINE_RE.findall(paragraph)) % 2 == 1:
search_from = idx + break_len
continue
_render_paragraph(paragraph)
tail = text[idx + break_len :]
para_buffer = [tail] if tail else []
rendered = True
break
if not rendered:
break
if force:
tail = "".join(para_buffer)
if tail.strip():
_render_paragraph(tail)
para_buffer = []
def _maybe_flush_after_append(chunk: str, prev_chunk: str | None) -> None:
"""Cheap fast-path before the O(buffer) join inside ``_flush_paragraphs``.
A paragraph boundary requires ``\\n\\n``. The second ``\\n``
must be in the *current* chunk (any earlier chunks were already
flushed or had no boundary). Skip the full flush when ``\\n``
is absent here AND the chunk-to-chunk seam can't form a
boundary either. Without this guard, a long single-paragraph
response (e.g. 4k chunks, no blank-line separators) becomes
O(n²) because every chunk triggers a full
``"".join(para_buffer)``.
``prev_chunk`` is the chunk immediately before this one — the
caller threads it explicitly so we don't reach into
``para_buffer[-2]`` and read like magic indexing.
"""
if not chunk:
return
if _PARAGRAPH_BREAK in chunk:
_flush_paragraphs()
return
# Cross-chunk boundary: previous chunk ended with ``\n`` and
# this chunk's leading ``\n`` completes the ``\n\n``.
newline = _PARAGRAPH_BREAK[0]
if (
prev_chunk is not None
and newline in chunk
and prev_chunk.endswith(newline)
and chunk.startswith(newline)
):
_flush_paragraphs()
if peeked:
last_progress_at = _maybe_update_progress(time.monotonic(), force=True)
_flush_paragraphs()
# Track the chunk immediately preceding the current one so the
# cross-chunk seam check can detect ``\n\n`` straddling the
# boundary without reaching into ``para_buffer`` by index.
prev_chunk: str | None = peeked[-1] if peeked else None
try:
while True:
if _is_cancelled():
break
chunk = _next_chunk(chunks_iter)
if chunk is None:
break
if not chunk:
continue
buffer.append(chunk)
para_buffer.append(chunk)
total_bytes += len(chunk)
last_progress_at = _maybe_update_progress(time.monotonic())
_maybe_flush_after_append(chunk, prev_chunk)
prev_chunk = chunk
finally:
# Render whatever's left in the paragraph buffer so the user
# sees the full response even if it didn't end on ``\n\n``.
_flush_paragraphs(force=True)
elapsed = time.monotonic() - started
if buffer:
tokens = _format_tokens(total_bytes // _CHARS_PER_TOKEN)
console.print(f"[{ui_theme.DIM}{elapsed:.1f}s · ↓ {tokens}[/]")
console.print()
return "".join(buffer)
__all__ = [
"StreamingConsole",
"STREAM_LABEL_ANSWER",
"STREAM_LABEL_ASSISTANT",
"format_token_count_short",
"render_response_header",
"stream_to_console",
]
@@ -0,0 +1,62 @@
"""Rich console adapter for REPL dispatch streaming and cancellation."""
from __future__ import annotations
import sys
import threading
from typing import Any, Protocol
from rich.console import Console
from rich.file_proxy import FileProxy
class _PromptSpinner(Protocol):
bytes_in: int
streaming: bool
def stop(self) -> None:
raise NotImplementedError
class StreamingConsole(Console):
"""Console adapter for streaming progress + cancellation checks."""
def __init__(
self,
spinner: _PromptSpinner,
cancel_event: threading.Event,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._spinner = spinner
self._cancel_event = cancel_event
def update_streaming_progress(self, bytes_received: int) -> None:
self._spinner.bytes_in = bytes_received
@property
def cancel_requested(self) -> bool:
return self._cancel_event.is_set()
def print(self, *args: Any, **kwargs: Any) -> None:
"""Reset the TTY column before each print when not streaming."""
if not self._spinner.streaming and not isinstance(sys.stdout, FileProxy):
from surfaces.interactive_shell.ui.components.choice_menu import (
ensure_tty_column_zero,
prepare_repl_output_line,
)
from surfaces.interactive_shell.ui.components.rendering import (
_repl_output_already_prepared,
_repl_table_width,
)
if not args and not kwargs:
ensure_tty_column_zero()
elif not _repl_output_already_prepared():
prepare_repl_output_line()
if sys.stdout.isatty() and "width" not in kwargs:
kwargs["width"] = _repl_table_width(self)
super().print(*args, **kwargs)
__all__ = ["StreamingConsole"]
@@ -0,0 +1,37 @@
"""Command-output tables, provider metadata, and tool catalog helpers."""
from surfaces.interactive_shell.ui.tables.provider import (
detect_provider_model,
resolve_provider_models,
)
from surfaces.interactive_shell.ui.tables.tables import (
MCP_INTEGRATION_SERVICES,
ColumnDef,
print_command_output,
render_integrations_table,
render_mcp_table,
render_models_table,
render_table,
render_tools_table,
)
from surfaces.interactive_shell.ui.tables.tool_catalog import (
ToolCatalogEntry,
build_tool_catalog,
format_tool_catalog_text,
)
__all__ = [
"MCP_INTEGRATION_SERVICES",
"ColumnDef",
"ToolCatalogEntry",
"build_tool_catalog",
"detect_provider_model",
"format_tool_catalog_text",
"print_command_output",
"render_integrations_table",
"render_mcp_table",
"render_models_table",
"render_table",
"render_tools_table",
"resolve_provider_models",
]
@@ -0,0 +1,33 @@
"""LLM provider and model detection for the interactive shell.
Exported
--------
resolve_provider_models(settings, provider) -> (reasoning_model, toolcall_model)
detect_provider_model() -> (provider, model)
"""
from __future__ import annotations
import os
from core.agent_harness.llm_resolution import resolve_provider_models
def detect_provider_model() -> tuple[str, str]:
"""Return (provider, model) for the active LLM config."""
try:
from config.config import LLMSettings
settings = LLMSettings.from_env()
except Exception:
return ("unknown", "unknown")
provider = settings.provider or os.getenv("LLM_PROVIDER", "anthropic")
reasoning_model, _toolcall_model = resolve_provider_models(settings, provider)
return (provider, reasoning_model)
__all__ = [
"detect_provider_model",
"resolve_provider_models",
]
@@ -0,0 +1,215 @@
"""Domain-specific table renderers for the interactive shell.
Concrete renderers for integrations, models, tools, and planned-actions output.
All rendering is delegated to the REPL TTY helpers in :mod:`rendering`.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from rich.console import Console
from rich.markup import escape
from rich.text import Text
from platform.terminal.theme import (
BOLD_BRAND,
DIM,
ERROR,
HIGHLIGHT,
WARNING,
)
from surfaces.interactive_shell.ui.components.rendering import (
_prepare_tty_for_rich,
print_repl_table,
repl_print,
repl_table,
)
from surfaces.interactive_shell.ui.tables.provider import resolve_provider_models
if TYPE_CHECKING:
from surfaces.interactive_shell.ui.tables.tool_catalog import ToolCatalogEntry
# MCP-type services are also rendered under `/mcp list` for focused MCP actions.
MCP_INTEGRATION_SERVICES = frozenset({"github", "openclaw"})
def status_style(status: str) -> str:
return {
"ok": HIGHLIGHT,
"configured": HIGHLIGHT,
"missing": DIM,
"failed": WARNING,
"error": ERROR,
}.get(status, DIM)
# ---------------------------------------------------------------------------
# Generic table abstraction
# ---------------------------------------------------------------------------
@dataclass
class ColumnDef:
"""Declarative column spec for ``render_table``."""
header: str
style: str = ""
no_wrap: bool = False
overflow: str = "fold"
justify: str = "left"
flex: bool = False # auto-sizes to fill remaining terminal width
def render_table(
console: Console,
title: str,
columns: list[ColumnDef],
rows: list[tuple[str | Text, ...]],
*,
title_style: str = BOLD_BRAND,
show_lines: bool = False,
) -> None:
"""TTY-safe generic table renderer.
Handles: TTY prep, repl_table creation, column wiring, auto-escaping
string cells, and print_repl_table. Flex columns share remaining width
after fixed columns claim their budget.
"""
width = _prepare_tty_for_rich(console)
flex_count = sum(1 for c in columns if c.flex)
flex_width = 20
if flex_count:
fixed_budget = sum(14 for c in columns if not c.flex)
flex_width = max(20, (width - fixed_budget) // flex_count)
table = repl_table(title=f"{title}\n", title_style=title_style, show_lines=show_lines)
for col in columns:
col_kwargs: dict[str, Any] = {
"no_wrap": col.no_wrap,
"overflow": col.overflow,
"justify": col.justify,
}
if col.style:
col_kwargs["style"] = col.style
if col.flex:
col_kwargs["max_width"] = flex_width
table.add_column(col.header, **col_kwargs)
for row in rows:
table.add_row(*(escape(v) if isinstance(v, str) else v for v in row))
print_repl_table(console, table, width=width)
# ---------------------------------------------------------------------------
# Concrete table renderers
# ---------------------------------------------------------------------------
_INTEGRATION_COLS: list[ColumnDef] = [
ColumnDef("service", style="bold", no_wrap=True),
ColumnDef("source", style=DIM, no_wrap=True),
ColumnDef("status", no_wrap=True),
ColumnDef("detail", style=DIM, flex=True),
]
_MODEL_COLS: list[ColumnDef] = [
ColumnDef("provider", style="bold", no_wrap=True),
ColumnDef("reasoning model"),
ColumnDef("toolcall model"),
]
_TOOL_COLS: list[ColumnDef] = [
ColumnDef("tool", style="bold", no_wrap=True),
ColumnDef("surfaces", style=DIM, no_wrap=True),
ColumnDef("params", style=DIM),
ColumnDef("description", flex=True),
]
def _integration_row(r: dict[str, str]) -> tuple[str | Text, ...]:
st = r.get("status", "?")
return (
r.get("service", "?"),
r.get("source", "?"),
Text(st, style=status_style(st)),
r.get("detail", ""),
)
def render_integrations_table(console: Console, results: list[dict[str, str]]) -> None:
rows = sorted(results, key=lambda r: r.get("service", ""))
if not rows:
repl_print(
console, f"[{DIM}]no integrations configured. try `opensre onboard` to add one.[/]"
)
return
render_table(console, "Integrations", _INTEGRATION_COLS, [_integration_row(r) for r in rows])
def render_mcp_table(console: Console, results: list[dict[str, str]]) -> None:
rows = sorted(
(r for r in results if r.get("service") in MCP_INTEGRATION_SERVICES),
key=lambda r: r.get("service", ""),
)
if not rows:
repl_print(console, f"[{DIM}]no MCP servers configured.[/]")
return
render_table(console, "MCP servers", _INTEGRATION_COLS, [_integration_row(r) for r in rows])
def render_models_table(console: Console, settings: Any) -> None:
if settings is None:
repl_print(console, f"[{ERROR}]LLM settings unavailable[/] — check provider env vars.")
return
provider = str(getattr(settings, "provider", "unknown"))
reasoning_model, toolcall_model = resolve_provider_models(settings, provider)
render_table(
console,
"LLM connection",
_MODEL_COLS,
[(provider, reasoning_model, toolcall_model)],
)
def render_tools_table(console: Console, entries: list[ToolCatalogEntry]) -> None:
if not entries:
repl_print(console, f"[{DIM}]no tools registered.[/]")
return
render_table(
console,
"Tools",
_TOOL_COLS,
[
(
entry.name,
", ".join(entry.surfaces),
entry.input_schema_summary,
entry.description or "-",
)
for entry in entries
],
show_lines=True,
)
def print_command_output(console: Console, output: str, *, style: str | None = None) -> None:
if not output:
return
text = output.rstrip()
# Parse any ANSI the captured child emitted so its Rich styling (bold, colour)
# survives being re-printed here instead of showing as raw escape codes.
rendered = Text.from_ansi(text) if style is None else Text.from_ansi(text, style=style)
repl_print(console, rendered)
__all__ = [
"ColumnDef",
"MCP_INTEGRATION_SERVICES",
"print_command_output",
"render_integrations_table",
"render_mcp_table",
"render_models_table",
"render_table",
"render_tools_table",
"status_style",
]
@@ -0,0 +1,184 @@
"""Tool-registry catalog generator for the interactive shell.
The OpenSRE tool registry (:mod:`tools.registry`) auto-discovers tools
under ``tools/`` and exposes them via :func:`get_registered_tools`. Each
:class:`~tools.registered_tool.RegisteredTool` carries rich metadata —
``name``, ``description``, ``surfaces``, ``input_schema``, ``source``, and
the module it was discovered in — but none of this is currently visible to
the interactive-shell user.
This module turns the registry snapshot into a compact catalog suitable for
two independent consumers:
1. The ``/tools`` slash command — users can see what tools are wired
into their build of the shell.
2. Future codebase-aware grounding — the catalog text can be injected into
the LLM prompt so the assistant can answer "what tools can the chat
agent call?" accurately. (Wiring lives in a separate later issue;
``format_tool_catalog_text`` is shaped to support both surfaces today.)
Two pure functions:
- :func:`build_tool_catalog` — wraps :func:`get_registered_tools` and returns
:class:`ToolCatalogEntry` records with the fields needed for display and
prompt injection.
- :func:`format_tool_catalog_text` — renders entries as compact Markdown-ish
text grouped by surface, suitable for both terminal display and LLM
grounding.
"""
from __future__ import annotations
import importlib
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from config.constants.paths import REPO_ROOT
from core.domain.types.tools import ToolSurface
from core.tool_framework.registered_tool import RegisteredTool
from tools.registry import get_registered_tools
# Cap one-line schema summaries so wide registries don't break terminal
# wrapping or balloon prompt injection. The cap is generous — tools with
# many params get a trailing ellipsis rather than an unwieldy line.
_MAX_SCHEMA_SUMMARY_CHARS = 200
@dataclass(frozen=True)
class ToolCatalogEntry:
"""Display-shaped projection of a :class:`RegisteredTool`."""
name: str
"""Registered tool name, e.g. ``"search_github_code"``."""
surfaces: tuple[str, ...]
"""Surfaces the tool is exposed on (``"investigation"`` and/or ``"chat"``)."""
description: str
"""One-line description from the tool's metadata, trimmed for display."""
source_file: str
"""Repo-relative path (forward slashes) when ``__file__`` lives under the
checkout root, otherwise the resolved absolute POSIX path when the defining
module is outside the repo (e.g. an installed editable or site-packages
shim). Empty string only when ``origin_module`` is missing, import fails,
or the loaded module exposes no ``__file__``."""
input_schema_summary: str
"""One-line render of top-level params, e.g.
``"query: string, limit?: integer"``. ``"(no params)"`` when the tool
takes no inputs."""
def _resolve_source_file(tool: RegisteredTool) -> str:
"""Best-effort relative path to the tool's defining file.
The tool registry stores ``origin_module`` (a dotted module path); we
import it to read ``__file__``. Failures here are non-fatal — the
catalog still surfaces the tool, just without a source pointer.
"""
module_name = tool.origin_module
if not module_name:
return ""
try:
module = importlib.import_module(module_name)
except Exception:
return ""
file_attr = getattr(module, "__file__", None)
if not file_attr:
return ""
try:
return Path(file_attr).resolve().relative_to(REPO_ROOT).as_posix()
except ValueError:
# Module lives outside the repo root (installed package, namespace
# package). Fall back to the absolute path so users can still open
# it; ``ValueError`` here means ``relative_to`` couldn't compute a
# relative path, not that the file is missing.
return Path(file_attr).as_posix()
def _summarize_input_schema(input_schema: dict[str, Any]) -> str:
"""Render top-level params as a one-line ``name: type`` list.
Required params render as ``name: type``; optional params get a ``?``
suffix (``name?: type``). Untyped properties (no ``type`` key) render
as ``any`` so the user can see the param exists. Returns
``"(no params)"`` for empty schemas.
"""
properties = input_schema.get("properties") or {}
if not properties:
return "(no params)"
required = set(input_schema.get("required") or ())
parts: list[str] = []
for name, info in properties.items():
info_dict = info if isinstance(info, dict) else {}
type_label = str(info_dict.get("type") or "any")
suffix = "" if name in required else "?"
parts.append(f"{name}{suffix}: {type_label}")
rendered = ", ".join(parts)
if len(rendered) > _MAX_SCHEMA_SUMMARY_CHARS:
return rendered[: _MAX_SCHEMA_SUMMARY_CHARS - 1].rstrip(", ") + ""
return rendered
def _entry_from_tool(tool: RegisteredTool) -> ToolCatalogEntry:
return ToolCatalogEntry(
name=tool.name,
surfaces=tuple(tool.surfaces),
description=(tool.description or "").strip(),
source_file=_resolve_source_file(tool),
input_schema_summary=_summarize_input_schema(tool.input_schema),
)
def build_tool_catalog(surface: ToolSurface | None = None) -> list[ToolCatalogEntry]:
"""Return :class:`ToolCatalogEntry` records for tools registered on ``surface``.
Pass ``surface=None`` (default) to get every registered tool, or
``"investigation"`` / ``"chat"`` to filter. The order matches
:func:`get_registered_tools` (alphabetical by tool name).
"""
return [_entry_from_tool(tool) for tool in get_registered_tools(surface)]
def _surface_sort_key(surface: str) -> tuple[int, str]:
"""Stable surface ordering — investigation first, chat second, others alphabetical."""
priority = {"investigation": 0, "chat": 1}
return (priority.get(surface, 99), surface)
def format_tool_catalog_text(entries: list[ToolCatalogEntry]) -> str:
"""Render entries as compact Markdown-ish text grouped by surface.
Each tool may belong to multiple surfaces and will appear under each one
so the user can see at a glance which tools the chat agent versus the
investigation pipeline can reach. Returns ``""`` for an empty catalog.
"""
if not entries:
return ""
by_surface: dict[str, list[ToolCatalogEntry]] = {}
for entry in entries:
for surface in entry.surfaces:
by_surface.setdefault(surface, []).append(entry)
lines: list[str] = []
for surface in sorted(by_surface.keys(), key=_surface_sort_key):
bucket = by_surface[surface]
lines.append(f"## {surface} ({len(bucket)} tool{'s' if len(bucket) != 1 else ''})")
lines.append("")
for entry in bucket:
lines.append(f"- **{entry.name}** — {entry.description}")
if entry.source_file:
lines.append(f" - source: `{entry.source_file}`")
lines.append(f" - params: `{entry.input_schema_summary}`")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
__all__ = [
"ToolCatalogEntry",
"build_tool_catalog",
"format_tool_catalog_text",
]