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
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:
@@ -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 0x40–0x7E 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",
|
||||
]
|
||||
Reference in New Issue
Block a user