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/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
+ ]