lisa-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hosts/__init__.py +0 -0
- hosts/cli/__init__.py +0 -0
- hosts/cli/main.py +2692 -0
- hosts/cli/render.py +535 -0
- hosts/cli/transport.py +1623 -0
- lisa_cli-0.1.0.dist-info/METADATA +26 -0
- lisa_cli-0.1.0.dist-info/RECORD +10 -0
- lisa_cli-0.1.0.dist-info/WHEEL +5 -0
- lisa_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lisa_cli-0.1.0.dist-info/top_level.txt +1 -0
hosts/cli/render.py
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""Rich rendering helpers for the CLI host.
|
|
2
|
+
|
|
3
|
+
These functions build Rich renderables (Tables, Panels, Text) from the
|
|
4
|
+
transport-neutral data surfaced by ``host``. They are the CLI-host
|
|
5
|
+
analogue of the plain-text renderers in ``runtime.commands`` — the core
|
|
6
|
+
returns Markdown strings, and here we wrap them in Rich panels/tables for
|
|
7
|
+
the terminal.
|
|
8
|
+
|
|
9
|
+
Nothing in this module imports from ``hosts.cli.repl`` or
|
|
10
|
+
``hosts.cli.main``; it is a leaf rendering module.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from kernel.models import McpServerHealth
|
|
18
|
+
from kernel.tools.mcp_client import MCPToolSpec
|
|
19
|
+
from rich.box import SIMPLE_HEAD
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
from rich.panel import Panel
|
|
22
|
+
from rich.table import Table
|
|
23
|
+
from rich.text import Text
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
# ``HostRuntime`` is used only as a string annotation (stringified by
|
|
27
|
+
# ``from __future__ import annotations``); the runtime symbols are
|
|
28
|
+
# passed in at call time. Deferring this import keeps ``render`` from
|
|
29
|
+
# dragging in ``runtime.runtime`` -> ``kernel.ipsar_fsm`` -> ``openai``
|
|
30
|
+
# (~2s) at module load.
|
|
31
|
+
from runtime.runtime import HostRuntime
|
|
32
|
+
|
|
33
|
+
_TOKENS_PER_MILLION = 1_000_000
|
|
34
|
+
_TOKENS_PER_THOUSAND = 1_000
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _format_tokens(tokens: int) -> str:
|
|
38
|
+
"""Format a token budget compactly (e.g. ``256000`` → ``256k``)."""
|
|
39
|
+
|
|
40
|
+
tokens = int(tokens or 0)
|
|
41
|
+
if tokens <= 0:
|
|
42
|
+
return "—"
|
|
43
|
+
if tokens >= _TOKENS_PER_MILLION:
|
|
44
|
+
return f"{tokens / 1_000_000:g}M"
|
|
45
|
+
if tokens >= _TOKENS_PER_THOUSAND:
|
|
46
|
+
return f"{tokens / 1_000:g}k"
|
|
47
|
+
return str(tokens)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _format_cost(model: object) -> str:
|
|
51
|
+
"""Format a model's per-token price as $/M-tokens (in/out) when known.
|
|
52
|
+
|
|
53
|
+
Reads ``prompt_price`` / ``completion_price`` (per-token USD, the units
|
|
54
|
+
OpenRouter publishes). Falls back to the qualitative ``cost_tier`` label
|
|
55
|
+
so providers without pricing still show something, and a dash when
|
|
56
|
+
neither is present.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
prompt_price = float(getattr(model, "prompt_price", 0.0) or 0.0)
|
|
60
|
+
completion_price = float(getattr(model, "completion_price", 0.0) or 0.0)
|
|
61
|
+
if prompt_price > 0.0 or completion_price > 0.0:
|
|
62
|
+
return f"${prompt_price * 1e6:g}/${completion_price * 1e6:g}"
|
|
63
|
+
cost_tier = str(getattr(model, "cost_tier", "") or "").strip()
|
|
64
|
+
return cost_tier or "—"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Heavy imports are deferred into the functions that use them so that
|
|
68
|
+
# ``--version`` / ``modes`` / ``models`` / ``verify`` don't pay the cost of
|
|
69
|
+
# ``rich.markdown`` (-> ``markdown_it`` + ``pygments`` ~= 1.4s) or
|
|
70
|
+
# ``kernel.ipsar_fsm`` / operation / tool registries (~= 1.5s) unless a
|
|
71
|
+
# render path that needs them actually runs.
|
|
72
|
+
|
|
73
|
+
# ── Style constants ───────────────────────────────────────────────────
|
|
74
|
+
STYLE_BORDER_SESSION = "steel_blue3"
|
|
75
|
+
STYLE_BORDER_RESPONSE = "dark_sea_green4"
|
|
76
|
+
STYLE_BORDER_CATALOG = "slate_blue1"
|
|
77
|
+
STYLE_BORDER_APPROVAL = "gold3"
|
|
78
|
+
STYLE_CONTAINMENT_CONTAINED = "dark_sea_green4"
|
|
79
|
+
STYLE_CONTAINMENT_DEGRADED = "gold3"
|
|
80
|
+
STYLE_CONTAINMENT_NONE = "indian_red"
|
|
81
|
+
STYLE_TEXT_MUTED = "grey58"
|
|
82
|
+
STYLE_TEXT_PRIMARY = "bright_white"
|
|
83
|
+
STYLE_ROW_MODEL = "dark_sea_green4"
|
|
84
|
+
STYLE_TABLE_HEADER = "bold steel_blue3"
|
|
85
|
+
_SECONDS_PER_MINUTE = 60
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _styled_table(title: str) -> Table:
|
|
89
|
+
"""Build a catalog table with the shared Lisa presentation defaults.
|
|
90
|
+
|
|
91
|
+
Minimal-head box (title rule + header rule only) and bold steel-blue
|
|
92
|
+
headers. Deliberately NO zebra striping: terminal backgrounds are
|
|
93
|
+
unknown — dark-grey rows vanish on black consoles, and a lighter pick
|
|
94
|
+
inverts the problem on light ones. The box rules carry the structure.
|
|
95
|
+
"""
|
|
96
|
+
return Table(
|
|
97
|
+
title=title,
|
|
98
|
+
box=SIMPLE_HEAD,
|
|
99
|
+
header_style=STYLE_TABLE_HEADER,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def render_modes(runtime: HostRuntime, *, console: Console | None = None) -> Table:
|
|
104
|
+
"""Build a Rich Table of supported modes from the live workflow catalog.
|
|
105
|
+
|
|
106
|
+
Uses ``kernel.ipsar_fsm.get_workflow_catalog`` (wired by
|
|
107
|
+
``runtime.mcp_pool.setup_framework``) and ``mode_support_gap`` to
|
|
108
|
+
separate supported from unsupported modes.
|
|
109
|
+
"""
|
|
110
|
+
from kernel.ipsar_fsm import get_workflow_catalog
|
|
111
|
+
from runtime.commands import mode_support_gap
|
|
112
|
+
|
|
113
|
+
_ = runtime # catalog is global; runtime kept for future per-host scoping
|
|
114
|
+
catalog = get_workflow_catalog()
|
|
115
|
+
table = _styled_table("Lisa Modes")
|
|
116
|
+
table.add_column("Mode", style="bold")
|
|
117
|
+
table.add_column("Kind")
|
|
118
|
+
table.add_column("Command")
|
|
119
|
+
table.add_column("Description")
|
|
120
|
+
|
|
121
|
+
for mode_id, mode in sorted(catalog.items()):
|
|
122
|
+
gap = mode_support_gap(mode)
|
|
123
|
+
command = mode.command_pattern or "default route"
|
|
124
|
+
description = mode.description or command
|
|
125
|
+
if gap is not None:
|
|
126
|
+
description = f"[yellow]requires {gap}[/yellow]"
|
|
127
|
+
table.add_row(mode_id, mode.mode_kind, command, description)
|
|
128
|
+
|
|
129
|
+
if console is not None:
|
|
130
|
+
console.print(table)
|
|
131
|
+
return table
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def render_models(runtime: HostRuntime, *, console: Console | None = None) -> Table:
|
|
135
|
+
"""Build a Rich Table of configured models from the model manifest.
|
|
136
|
+
|
|
137
|
+
Uses ``runtime.model_manifest.load_model_manifest`` against the
|
|
138
|
+
canonical runtime data-dir path.
|
|
139
|
+
"""
|
|
140
|
+
from runtime.model_manifest import load_model_manifest
|
|
141
|
+
|
|
142
|
+
path = runtime.paths.models_path
|
|
143
|
+
manifest = load_model_manifest(path)
|
|
144
|
+
table = _styled_table("Lisa Models")
|
|
145
|
+
table.add_column("Model", style="bold")
|
|
146
|
+
table.add_column("Provider")
|
|
147
|
+
table.add_column("Aliases")
|
|
148
|
+
table.add_column("Reasoning")
|
|
149
|
+
table.add_column("Context", justify="right")
|
|
150
|
+
table.add_column("Cost", justify="right")
|
|
151
|
+
table.add_column("Capabilities")
|
|
152
|
+
|
|
153
|
+
for model in manifest.models:
|
|
154
|
+
aliases = ", ".join(sorted(model.aliases)) or "—"
|
|
155
|
+
reasoning = "yes" if model.reasoning_model else "no"
|
|
156
|
+
context = _format_tokens(model.context_window)
|
|
157
|
+
cost = _format_cost(model)
|
|
158
|
+
capabilities = ", ".join(model.capabilities) or "—"
|
|
159
|
+
table.add_row(
|
|
160
|
+
model.model_id, model.provider, aliases, reasoning, context, cost, capabilities
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if console is not None:
|
|
164
|
+
console.print(table)
|
|
165
|
+
return table
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def render_pack_list_panel(runtime: HostRuntime, *, console: Console | None = None) -> Panel:
|
|
169
|
+
"""Render the pack activation list from ``runtime.commands.render_pack_list``.
|
|
170
|
+
|
|
171
|
+
The core returns a Markdown string; we wrap it in a Rich Panel for the
|
|
172
|
+
terminal host.
|
|
173
|
+
"""
|
|
174
|
+
from rich.markdown import Markdown
|
|
175
|
+
from runtime.commands import render_pack_list
|
|
176
|
+
|
|
177
|
+
markdown_text = render_pack_list(runtime)
|
|
178
|
+
panel = Panel(
|
|
179
|
+
Markdown(markdown_text),
|
|
180
|
+
title="Pack Activation",
|
|
181
|
+
border_style=STYLE_BORDER_SESSION,
|
|
182
|
+
)
|
|
183
|
+
if console is not None:
|
|
184
|
+
console.print(panel)
|
|
185
|
+
return panel
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def render_operations(runtime: HostRuntime, *, console: Console | None = None) -> Table:
|
|
189
|
+
"""Build a Rich Table of internal operations with IDs for allowlist authoring.
|
|
190
|
+
|
|
191
|
+
Lists every operation discovered from content packs via the
|
|
192
|
+
``lisa-catalog`` MCP server. The ID column is exactly the string to copy
|
|
193
|
+
into a mode's ``allowed_operation_ids`` front-matter list.
|
|
194
|
+
"""
|
|
195
|
+
from kernel.operations.registry import get_operation_catalog
|
|
196
|
+
|
|
197
|
+
_ = runtime # catalog is global; runtime kept for future per-host scoping
|
|
198
|
+
catalog = get_operation_catalog()
|
|
199
|
+
table = _styled_table("Lisa Operations")
|
|
200
|
+
table.add_column("Operation ID", style="bold")
|
|
201
|
+
table.add_column("Kind")
|
|
202
|
+
table.add_column("Description")
|
|
203
|
+
table.add_column("Tools", overflow="fold")
|
|
204
|
+
|
|
205
|
+
for operation_id, definition in sorted(catalog.items()):
|
|
206
|
+
description = definition.description or "—"
|
|
207
|
+
tools = ", ".join(definition.tool_ids) if definition.tool_ids else "—"
|
|
208
|
+
table.add_row(operation_id, definition.execution_kind, description, tools)
|
|
209
|
+
|
|
210
|
+
if console is not None:
|
|
211
|
+
console.print(table)
|
|
212
|
+
return table
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def render_tools(
|
|
216
|
+
runtime: HostRuntime,
|
|
217
|
+
*,
|
|
218
|
+
console: Console | None = None,
|
|
219
|
+
) -> Table:
|
|
220
|
+
"""Build a Rich Table of internal tools with IDs for allowlist authoring.
|
|
221
|
+
|
|
222
|
+
Lists every internal tool discovered from content packs via the
|
|
223
|
+
``lisa-catalog`` MCP server. The ID column is exactly the string to copy
|
|
224
|
+
into a mode's ``allowed_tool_ids`` front-matter list. MCP server tools
|
|
225
|
+
are listed by the dedicated ``lisa mcp`` command (and REPL ``/mcp``).
|
|
226
|
+
"""
|
|
227
|
+
_ = runtime # catalog is global; runtime kept for future per-host scoping
|
|
228
|
+
table = _styled_table("Lisa Internal Tools")
|
|
229
|
+
table.add_column("Tool ID", style="bold")
|
|
230
|
+
table.add_column("Description")
|
|
231
|
+
table.add_column("Max/Turn")
|
|
232
|
+
table.add_column("Timeout")
|
|
233
|
+
|
|
234
|
+
from kernel.tools.registry import get_tool_catalog
|
|
235
|
+
|
|
236
|
+
catalog = get_tool_catalog()
|
|
237
|
+
for tool_id, definition in sorted(catalog.items()):
|
|
238
|
+
description = definition.description or "—"
|
|
239
|
+
table.add_row(
|
|
240
|
+
tool_id,
|
|
241
|
+
description,
|
|
242
|
+
str(definition.max_calls_per_turn),
|
|
243
|
+
f"{definition.timeout_seconds}s",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if console is not None:
|
|
247
|
+
console.print(table)
|
|
248
|
+
return table
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def render_mcp_servers(
|
|
252
|
+
health: dict[str, McpServerHealth],
|
|
253
|
+
*,
|
|
254
|
+
console: Console | None = None,
|
|
255
|
+
) -> Table:
|
|
256
|
+
"""Render a Rich Table summarizing every configured MCP server.
|
|
257
|
+
|
|
258
|
+
Columns: Server | Status | Transport | URL/Command | Tools | Last Error.
|
|
259
|
+
Reads the per-server :class:`McpServerHealth` collected by warming the
|
|
260
|
+
MCP pool (see :func:`hosts.cli.main._collect_mcp_tools`). The status,
|
|
261
|
+
tool count, and last error reflect the current runtime state — a cold
|
|
262
|
+
server that failed to start shows ``failed`` with the failure reason,
|
|
263
|
+
not ``cold``.
|
|
264
|
+
"""
|
|
265
|
+
table = _styled_table("MCP Servers")
|
|
266
|
+
table.add_column("Server", style="bold")
|
|
267
|
+
table.add_column("Status")
|
|
268
|
+
table.add_column("Transport")
|
|
269
|
+
table.add_column("URL/Command", overflow="fold")
|
|
270
|
+
table.add_column("Tools", justify="right")
|
|
271
|
+
table.add_column("Last Error", overflow="fold")
|
|
272
|
+
|
|
273
|
+
for server_id in sorted(health):
|
|
274
|
+
h = health[server_id]
|
|
275
|
+
endpoint = h.url or h.command or "—"
|
|
276
|
+
last_error = _truncate(h.last_error, 200) or "—"
|
|
277
|
+
status_style = _status_style(h.status)
|
|
278
|
+
# A cold server that hasn't been connected yet shows tool_count=0
|
|
279
|
+
# because tools/list hasn't run — render an em-dash (like the empty
|
|
280
|
+
# Last Error column) instead of a misleading 0.
|
|
281
|
+
tool_display = "—" if (h.status == "cold" and h.tool_count == 0) else str(h.tool_count)
|
|
282
|
+
table.add_row(
|
|
283
|
+
server_id,
|
|
284
|
+
f"[{status_style}]{_status_pill(h.status)}[/]",
|
|
285
|
+
h.transport,
|
|
286
|
+
endpoint,
|
|
287
|
+
tool_display,
|
|
288
|
+
last_error,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if console is not None:
|
|
292
|
+
console.print(table)
|
|
293
|
+
return table
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def render_mcp_server_tools(
|
|
297
|
+
specs: list[MCPToolSpec],
|
|
298
|
+
health: McpServerHealth,
|
|
299
|
+
*,
|
|
300
|
+
console: Console | None = None,
|
|
301
|
+
) -> Table:
|
|
302
|
+
"""Render a Rich Table of one MCP server's tools with descriptions + required args.
|
|
303
|
+
|
|
304
|
+
Used by ``lisa mcp <server_id>`` to drill into a single server. The
|
|
305
|
+
header (status, transport, URL/command) comes from ``health``; the tool
|
|
306
|
+
rows come from ``specs``. Tool names are the bare strings referenced by
|
|
307
|
+
per-server ``tool_policy.allowed_tools`` / ``denied_tools`` in
|
|
308
|
+
``mcp_servers.yaml``.
|
|
309
|
+
"""
|
|
310
|
+
table = _styled_table(f"MCP Server: {health.server_id}")
|
|
311
|
+
table.add_column("Tool", style="bold", overflow="fold")
|
|
312
|
+
table.add_column("Description", overflow="fold")
|
|
313
|
+
table.add_column("Required args", overflow="fold")
|
|
314
|
+
|
|
315
|
+
for spec in specs:
|
|
316
|
+
description = _truncate(spec.description, 80) or "—"
|
|
317
|
+
required = _required_args(spec)
|
|
318
|
+
table.add_row(spec.name, description, required)
|
|
319
|
+
|
|
320
|
+
if console is not None:
|
|
321
|
+
console.print(table)
|
|
322
|
+
return table
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _status_style(status: str) -> str:
|
|
326
|
+
"""Return a Rich style name for an MCP server status glyph."""
|
|
327
|
+
if status == "warm":
|
|
328
|
+
return "green"
|
|
329
|
+
if status == "failed":
|
|
330
|
+
return "red"
|
|
331
|
+
if status == "cold":
|
|
332
|
+
return "yellow"
|
|
333
|
+
return "grey58"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _status_pill(status: str) -> str:
|
|
337
|
+
"""Render an MCP server status as a glyph pill (``● warm``)."""
|
|
338
|
+
glyph = {"warm": "●", "cold": "○", "failed": "✗"}.get(status, "·")
|
|
339
|
+
return f"{glyph} {status}"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _truncate(text: str, limit: int) -> str:
|
|
343
|
+
"""Truncate ``text`` to ``limit`` chars, appending an ellipsis when cut."""
|
|
344
|
+
if not text:
|
|
345
|
+
return ""
|
|
346
|
+
if len(text) <= limit:
|
|
347
|
+
return text
|
|
348
|
+
return text[:limit].rstrip() + "…"
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _required_args(spec: MCPToolSpec) -> str:
|
|
352
|
+
"""Extract the comma-joined required arg names from a tool's input schema."""
|
|
353
|
+
schema = spec.input_schema or {}
|
|
354
|
+
required = schema.get("required")
|
|
355
|
+
if isinstance(required, list) and required:
|
|
356
|
+
return ", ".join(str(r) for r in required)
|
|
357
|
+
properties = schema.get("properties")
|
|
358
|
+
if isinstance(properties, dict) and properties:
|
|
359
|
+
return ", ".join(str(k) for k in properties)
|
|
360
|
+
return "—"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def render_markdown_panel(
|
|
364
|
+
text: str,
|
|
365
|
+
*,
|
|
366
|
+
title: str = "Response",
|
|
367
|
+
border_style: str = STYLE_BORDER_RESPONSE,
|
|
368
|
+
console: Console | None = None,
|
|
369
|
+
) -> Panel:
|
|
370
|
+
"""Wrap a Markdown string in a Rich Panel.
|
|
371
|
+
|
|
372
|
+
Convenience helper for rendering any plain-text/Markdown response from
|
|
373
|
+
``host`` in a terminal panel.
|
|
374
|
+
"""
|
|
375
|
+
from rich.markdown import Markdown
|
|
376
|
+
|
|
377
|
+
panel = Panel(Markdown(text), title=title, border_style=border_style)
|
|
378
|
+
if console is not None:
|
|
379
|
+
console.print(panel)
|
|
380
|
+
return panel
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
#: Diff-detection thresholds: minimum hunk-header count and combined +/- lines
|
|
384
|
+
#: for the approval preview to render as a colored diff instead of flat text.
|
|
385
|
+
_DIFF_MIN_HUNKS = 1
|
|
386
|
+
_DIFF_MIN_CHANGED_LINES = 2
|
|
387
|
+
_DIFF_MIN_TOTAL_LINES = 3
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _looks_like_diff(text: str) -> bool:
|
|
391
|
+
"""Heuristic: does this approval preview carry a unified diff?"""
|
|
392
|
+
lines = [ln for ln in text.strip().splitlines() if ln.strip()]
|
|
393
|
+
if len(lines) < _DIFF_MIN_TOTAL_LINES:
|
|
394
|
+
return False
|
|
395
|
+
hunk = sum(1 for ln in lines if ln.startswith("@@"))
|
|
396
|
+
adds = sum(1 for ln in lines if ln.startswith("+"))
|
|
397
|
+
dels = sum(1 for ln in lines if ln.startswith("-"))
|
|
398
|
+
return hunk >= _DIFF_MIN_HUNKS and (adds + dels) >= _DIFF_MIN_CHANGED_LINES
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _diff_text(text: str) -> Text:
|
|
402
|
+
"""Render a unified diff with per-line colors (adds/dels/hunk headers)."""
|
|
403
|
+
out = Text()
|
|
404
|
+
for line in text.strip().splitlines():
|
|
405
|
+
if line.startswith("+"):
|
|
406
|
+
out.append(line + "\n", style="green3")
|
|
407
|
+
elif line.startswith("-"):
|
|
408
|
+
out.append(line + "\n", style="indian_red")
|
|
409
|
+
elif line.startswith("@@"):
|
|
410
|
+
out.append(line + "\n", style="steel_blue3")
|
|
411
|
+
else:
|
|
412
|
+
out.append(line + "\n", style=STYLE_TEXT_MUTED)
|
|
413
|
+
return out
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _containment_badge(containment: str) -> tuple[str, str]:
|
|
417
|
+
"""Return (label, style) for a sandbox containment state."""
|
|
418
|
+
state = (containment or "unknown").strip().lower()
|
|
419
|
+
if state == "contained":
|
|
420
|
+
return "● CONTAINED (sandbox)", STYLE_CONTAINMENT_CONTAINED
|
|
421
|
+
if state == "degraded":
|
|
422
|
+
return "▲ DEGRADED — sandbox cannot run", STYLE_CONTAINMENT_DEGRADED
|
|
423
|
+
return "✗ NOT SANDBOXED", STYLE_CONTAINMENT_NONE
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _format_approval_expiry(seconds: object) -> str:
|
|
427
|
+
"""Format a host-supplied approval TTL for compact panel display."""
|
|
428
|
+
try:
|
|
429
|
+
remaining = max(0, int(seconds))
|
|
430
|
+
except (TypeError, ValueError):
|
|
431
|
+
return ""
|
|
432
|
+
if remaining == 0:
|
|
433
|
+
return "expires now"
|
|
434
|
+
if remaining < _SECONDS_PER_MINUTE:
|
|
435
|
+
return f"expires in {remaining}s"
|
|
436
|
+
minutes, seconds = divmod(remaining, _SECONDS_PER_MINUTE)
|
|
437
|
+
if minutes < _SECONDS_PER_MINUTE:
|
|
438
|
+
return f"expires in {minutes}m {seconds}s"
|
|
439
|
+
hours, minutes = divmod(minutes, _SECONDS_PER_MINUTE)
|
|
440
|
+
return f"expires in {hours}h {minutes}m"
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def render_approval_panel(
|
|
444
|
+
payload: dict[str, object],
|
|
445
|
+
*,
|
|
446
|
+
console: Console | None = None,
|
|
447
|
+
) -> Panel:
|
|
448
|
+
"""Render the approval panel for a pending action.
|
|
449
|
+
|
|
450
|
+
``payload`` is the structured ``data["approval"]`` the host attaches to the
|
|
451
|
+
turn response (``lisa.approval.v1``). It shows the tool/pack and effect
|
|
452
|
+
tier, the proposed command, the sandbox containment badge, and the
|
|
453
|
+
decision hints. The host decides approve/ask/deny and owns the policy
|
|
454
|
+
write; this function only renders — it never decides. The hints are
|
|
455
|
+
the ``lisa approve <id>`` / ``lisa deny <id>`` commands, so the operator
|
|
456
|
+
can decide from the same shell that raised the request (the CLI has no
|
|
457
|
+
interactive loop; keystroke hints went with the retired REPL).
|
|
458
|
+
"""
|
|
459
|
+
|
|
460
|
+
target = f"{payload.get('pack_id') or ''}:{payload.get('target_id') or ''}".strip(":")
|
|
461
|
+
effect = str(payload.get("effect") or "").strip() or "execute"
|
|
462
|
+
command = str(payload.get("command") or "").strip()
|
|
463
|
+
preview = str(payload.get("preview") or "").strip()
|
|
464
|
+
request_id = str(payload.get("request_id") or "").strip()
|
|
465
|
+
containment, containment_style = _containment_badge(str(payload.get("containment") or ""))
|
|
466
|
+
reasons = str(payload.get("containment_reasons") or "").strip()
|
|
467
|
+
policy_reason = str(payload.get("policy_reason") or "").strip()
|
|
468
|
+
policy_tier = str(payload.get("policy_tier") or "").strip()
|
|
469
|
+
expiry = _format_approval_expiry(payload.get("expires_in_seconds"))
|
|
470
|
+
|
|
471
|
+
body = Text()
|
|
472
|
+
if _looks_like_diff(preview or ""):
|
|
473
|
+
# A file-edit approval ships a unified diff — color the diff lines
|
|
474
|
+
# directly into the body (green adds, red dels, cyan hunk headers)
|
|
475
|
+
# so edits read at a glance instead of as a flat preview block.
|
|
476
|
+
body.append(_diff_text(preview))
|
|
477
|
+
body.append("\n")
|
|
478
|
+
else:
|
|
479
|
+
body.append(f"{preview or 'Run an action'}\n", style=STYLE_TEXT_PRIMARY)
|
|
480
|
+
if command:
|
|
481
|
+
body.append("\n$ ", style=STYLE_TEXT_MUTED)
|
|
482
|
+
body.append(command + "\n", style="bright_white")
|
|
483
|
+
body.append("\ntool ", style=STYLE_TEXT_MUTED)
|
|
484
|
+
body.append(f"{target or '(unknown)'}\n")
|
|
485
|
+
body.append("effect ", style=STYLE_TEXT_MUTED)
|
|
486
|
+
body.append(f"{effect}\n")
|
|
487
|
+
body.append("scope ", style=STYLE_TEXT_MUTED)
|
|
488
|
+
body.append(f"{containment}\n", style=containment_style)
|
|
489
|
+
if reasons:
|
|
490
|
+
body.append(" ", style=STYLE_TEXT_MUTED)
|
|
491
|
+
body.append(f"{reasons}\n", style=STYLE_CONTAINMENT_DEGRADED)
|
|
492
|
+
if policy_reason:
|
|
493
|
+
body.append("policy ", style=STYLE_TEXT_MUTED)
|
|
494
|
+
if policy_tier:
|
|
495
|
+
body.append(f"{policy_tier}: ")
|
|
496
|
+
body.append(policy_reason + "\n", style=STYLE_TEXT_MUTED)
|
|
497
|
+
if expiry:
|
|
498
|
+
body.append(expiry + "\n", style=STYLE_TEXT_MUTED)
|
|
499
|
+
|
|
500
|
+
body.append("\nDecide from the same shell, without a session:\n", style=STYLE_TEXT_MUTED)
|
|
501
|
+
body.append(
|
|
502
|
+
f" lisa approve {request_id} # run it\n",
|
|
503
|
+
style=STYLE_TEXT_PRIMARY,
|
|
504
|
+
)
|
|
505
|
+
body.append(
|
|
506
|
+
f" lisa approve {request_id} --remember <prefix>\n",
|
|
507
|
+
style=STYLE_TEXT_MUTED,
|
|
508
|
+
)
|
|
509
|
+
body.append(
|
|
510
|
+
f" lisa deny {request_id} · lisa policy\n",
|
|
511
|
+
style=STYLE_TEXT_MUTED,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
panel = Panel(
|
|
515
|
+
body,
|
|
516
|
+
title="Approval required",
|
|
517
|
+
border_style=STYLE_BORDER_APPROVAL,
|
|
518
|
+
padding=(0, 1),
|
|
519
|
+
)
|
|
520
|
+
if console is not None:
|
|
521
|
+
console.print(panel)
|
|
522
|
+
return panel
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
__all__ = [
|
|
526
|
+
"render_approval_panel",
|
|
527
|
+
"render_markdown_panel",
|
|
528
|
+
"render_mcp_server_tools",
|
|
529
|
+
"render_mcp_servers",
|
|
530
|
+
"render_models",
|
|
531
|
+
"render_modes",
|
|
532
|
+
"render_operations",
|
|
533
|
+
"render_pack_list_panel",
|
|
534
|
+
"render_tools",
|
|
535
|
+
]
|