lisa-cli 0.3.0__tar.gz → 0.3.1__tar.gz

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.
Files changed (42) hide show
  1. {lisa_cli-0.3.0/src/lisa_cli.egg-info → lisa_cli-0.3.1}/PKG-INFO +1 -1
  2. lisa_cli-0.3.1/pyproject.toml +177 -0
  3. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/hosts/cli/entry.py +13 -7
  4. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/hosts/cli/main.py +24 -15
  5. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/hosts/cli/render.py +7 -2
  6. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/hosts/cli/transport.py +74 -23
  7. {lisa_cli-0.3.0 → lisa_cli-0.3.1/src/lisa_cli.egg-info}/PKG-INFO +1 -1
  8. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/SOURCES.txt +6 -1
  9. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_approval_panel.py +1 -2
  10. lisa_cli-0.3.1/tests/test_changed_coverage.py +158 -0
  11. lisa_cli-0.3.1/tests/test_check_coverage_gate.py +108 -0
  12. lisa_cli-0.3.1/tests/test_check_docs.py +227 -0
  13. lisa_cli-0.3.1/tests/test_direct_io_policy.py +134 -0
  14. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_group_resolution.py +2 -4
  15. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_last_command.py +11 -4
  16. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_models_command.py +4 -6
  17. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_profile_option.py +23 -8
  18. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_prompt_decisions.py +7 -19
  19. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_prompt_transport.py +98 -6
  20. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_render_catalogs.py +3 -2
  21. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_streaming.py +1 -1
  22. lisa_cli-0.3.1/tests/test_version_output.py +71 -0
  23. lisa_cli-0.3.0/pyproject.toml +0 -75
  24. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/LICENSE +0 -0
  25. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/setup.cfg +0 -0
  26. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/hosts/__init__.py +0 -0
  27. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/hosts/cli/__init__.py +0 -0
  28. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/hosts/cli/expand.py +0 -0
  29. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/dependency_links.txt +0 -0
  30. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/entry_points.txt +0 -0
  31. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/requires.txt +0 -0
  32. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/top_level.txt +0 -0
  33. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_pack_update.py +0 -0
  34. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_prompt_command.py +0 -0
  35. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_prompt_entry.py +0 -0
  36. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_prompt_expand.py +0 -0
  37. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_render_models.py +1 -1
  38. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_restart.py +0 -0
  39. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_runtime_teardown.py +0 -0
  40. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_transport_stage.py +0 -0
  41. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_update_command.py +0 -0
  42. {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_verify_command.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lisa-cli
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Terminal CLI for the Lisa Harness
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
@@ -0,0 +1,177 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [tool.setuptools.packages.find]
6
+ where = ["src"]
7
+
8
+ [project]
9
+ name = "lisa-cli"
10
+ version = "0.3.1"
11
+ description = "Terminal CLI for the Lisa Harness"
12
+ requires-python = ">=3.11"
13
+ license = "MIT"
14
+ license-files = ["LICENSE"]
15
+ dependencies = [
16
+ "lisa-harness-runtime",
17
+ "lisa-harness-kernel",
18
+ "lisa-harness-sdk",
19
+ "lisa-harness-mcp-servers",
20
+ "lisa-pack-core",
21
+ "lisa-pack-introspect",
22
+ "lisa-pack-memory",
23
+ "lisa-pack-recovery",
24
+ "openai>=1.30.0",
25
+ "rich>=13.7.0",
26
+ "typer>=0.12.3",
27
+ "pyyaml>=6.0",
28
+ "httpx>=0.27.0",
29
+ "pydantic>=2.7.0",
30
+ "python-dotenv>=1.0.0",
31
+ ]
32
+
33
+ [project.scripts]
34
+ lisa = "hosts.cli.main:main"
35
+
36
+ [project.optional-dependencies]
37
+ dev = [
38
+ "pyinstaller>=6.10.0",
39
+ "pytest>=8.0.0",
40
+ "pytest-asyncio>=0.23.0",
41
+ "ruff>=0.8.0",
42
+ "types-PyYAML>=6.0",
43
+ ]
44
+ # Interactive no-args `lisa prompt` editor: single-box input with
45
+ # history. Without the extra the plain EOF-terminated reader is used.
46
+ prompt = ["prompt-toolkit>=3.0"]
47
+
48
+ [tool.pytest.ini_options]
49
+ pythonpath = ["src"]
50
+ testpaths = ["tests"]
51
+ markers = [
52
+ "live: hits real external signal or provider APIs and prints a tester-visible summary",
53
+ "integration: multi-module in-process test; may require local services",
54
+ "eval: deterministic agent regression evals (no model calls, no I/O)",
55
+ ]
56
+
57
+ [tool.coverage.run]
58
+ branch = true
59
+ relative_files = true
60
+ source = ["src"]
61
+ omit = [
62
+ "*/tests/*",
63
+ "*/test_*.py",
64
+ "*/conftest.py",
65
+ ]
66
+ source_pkgs = ["hosts.cli"]
67
+
68
+ [tool.coverage.paths]
69
+ source = [
70
+ "src",
71
+ "*/src",
72
+ ]
73
+
74
+ [tool.coverage.report]
75
+ show_missing = true
76
+ skip_covered = true
77
+ fail_under = 85
78
+ exclude_also = [
79
+ ".*/tests/.*",
80
+ ".*/test_[^/]*\\.py$",
81
+ ]
82
+
83
+ [tool.ruff]
84
+ line-length = 100
85
+ target-version = "py311"
86
+ extend-exclude = ["vulture_allowlist.py"]
87
+
88
+ [tool.ruff.lint]
89
+ select = [
90
+ "E",
91
+ "F",
92
+ "I",
93
+ "UP",
94
+ "B",
95
+ "SIM",
96
+ "PL",
97
+ "C90",
98
+ "RET",
99
+ "RUF",
100
+ "W",
101
+ "YTT",
102
+ "C4",
103
+ "PERF",
104
+ "PIE",
105
+ "FURB",
106
+ "G",
107
+ ]
108
+ ignore = ["PLR0913", "PLC0415", "RUF001", "RUF002", "RUF003"]
109
+
110
+ [tool.ruff.lint.per-file-ignores]
111
+ "**/tests/**" = ["PLC0415", "PLR2004"]
112
+ "**/hosts/cli/main.py" = ["E402", "PLC0415", "PLR0917"]
113
+ "**/hosts/cli/render.py" = ["PLC0415"]
114
+ "**/hosts/cli/entry.py" = ["PLC0415", "RUF001"]
115
+
116
+ [tool.ruff.lint.mccabe]
117
+ max-complexity = 10
118
+
119
+ [tool.ruff.lint.pylint]
120
+ max-returns = 6
121
+ max-branches = 12
122
+ max-statements = 50
123
+ max-locals = 15
124
+
125
+ [tool.ruff.format]
126
+ line-ending = "lf"
127
+
128
+ [tool.mypy]
129
+ python_version = "3.11"
130
+ files = ["src/hosts/cli"]
131
+ mypy_path = ["$MYPY_CONFIG_FILE_DIR/src"]
132
+ explicit_package_bases = true
133
+ strict = true
134
+ warn_unused_ignores = true
135
+ warn_redundant_casts = true
136
+ warn_return_any = true
137
+ no_implicit_optional = true
138
+ check_untyped_defs = true
139
+ disallow_untyped_defs = true
140
+ disallow_incomplete_defs = true
141
+ exclude = ["vulture_allowlist.py", '(^|/)(tests|test_[^/]*\.py$)']
142
+
143
+ [tool.vulture]
144
+ paths = ["src/hosts/cli", "vulture_allowlist.py"]
145
+ min_confidence = 80
146
+ exclude = [
147
+ "**/tests/",
148
+ "**/test_*.py",
149
+ "**/conftest.py",
150
+ "**/*egg-info*/",
151
+ "**/__pycache__/",
152
+ ".venv/",
153
+ ]
154
+ ignore_names = [
155
+ "main",
156
+ "*SignalCollector",
157
+ "*Collector",
158
+ "*ProcessingLoop",
159
+ "*Detector",
160
+ "*Registry",
161
+ "*Store",
162
+ "*Broker",
163
+ "*Catalog",
164
+ "*Spec",
165
+ "*Config",
166
+ "*Policy",
167
+ "*Manifest",
168
+ "*Error",
169
+ "*Exception",
170
+ "*Context",
171
+ "*Fragments",
172
+ "*Sections",
173
+ "*Evidence",
174
+ "*Summary",
175
+ "*Anomalies",
176
+ ]
177
+ ignore_decorators = ["@property"]
@@ -50,10 +50,13 @@ from __future__ import annotations
50
50
  import os
51
51
  import unicodedata
52
52
  from pathlib import Path
53
- from typing import Any
53
+ from typing import TYPE_CHECKING, Any
54
54
 
55
55
  from rich.console import Console
56
56
 
57
+ if TYPE_CHECKING:
58
+ from prompt_toolkit.key_binding import KeyBindings
59
+
57
60
  #: Unicode general categories that extend a grapheme cluster: combining
58
61
  #: marks, enclosing marks, format characters (variation selectors, ZWSP),
59
62
  #: and modifier symbols (skin-tone modifiers). ZWJ itself is ``Cf`` but is
@@ -186,7 +189,7 @@ def settings_file_path(data_dir: str | Path, profile: str) -> Path:
186
189
  """
187
190
  from runtime.config import profile_data_dir # lazy: import cost
188
191
 
189
- base = str(data_dir).strip() if str(data_dir).strip() else ""
192
+ base = str(data_dir).strip() or ""
190
193
  if not base and profile.strip():
191
194
  base = str(profile_data_dir(profile.strip()))
192
195
  if not base:
@@ -289,7 +292,7 @@ def prompt_history_path(data_dir: str | Path, profile: str) -> Path:
289
292
  the profile dir; empty hands the decision to the framework's default
290
293
  (``LISA_DATA_DIR`` > ``~/.lisa``).
291
294
  """
292
- base = str(data_dir).strip() if str(data_dir).strip() else ""
295
+ base = str(data_dir).strip() or ""
293
296
  if not base and profile.strip():
294
297
  from runtime.config import profile_data_dir # lazy: import cost
295
298
 
@@ -429,7 +432,7 @@ def _delete_first_grapheme(text: str) -> int:
429
432
  return i
430
433
 
431
434
 
432
- def _add_navigate_bindings(bindings: Any, *, swapped: bool = True) -> None:
435
+ def _add_navigate_bindings(bindings: KeyBindings, *, swapped: bool = True) -> None:
433
436
  """Bind submit/newline and navigation keys.
434
437
 
435
438
  ``swapped`` (the default) puts newline on Enter and send on
@@ -476,7 +479,7 @@ def _add_navigate_bindings(bindings: Any, *, swapped: bool = True) -> None:
476
479
  event.current_buffer.history_forward()
477
480
 
478
481
 
479
- def _add_grapheme_delete_bindings(bindings: Any) -> None:
482
+ def _add_grapheme_delete_bindings(bindings: KeyBindings) -> None:
480
483
  """Bind backspace (Ctrl-H) and forward Delete to cluster-aware cuts.
481
484
 
482
485
  prompt_toolkit deletes one codepoint, tearing multi-codepoint emoji
@@ -504,14 +507,17 @@ def _add_grapheme_delete_bindings(bindings: Any) -> None:
504
507
  buffer.cursor_position = pos
505
508
 
506
509
 
507
- def _tui_key_bindings(swapped: bool = True) -> Any:
510
+ def _tui_key_bindings(swapped: bool = True) -> KeyBindings:
508
511
  """Build the owned key model for the TUI reader.
509
512
 
510
513
  ``swapped`` selects the send/newline layout (see
511
514
  :func:`_add_navigate_bindings`); this exists so the reader body
512
515
  stays under its complexity budget.
513
516
  """
514
- bindings = _PT["KeyBindings"]()
517
+ pt = _PT
518
+ if pt is None: # pragma: no cover - guarded by the caller's _PT check
519
+ raise RuntimeError("_tui_key_bindings requires the prompt extra")
520
+ bindings: KeyBindings = pt["KeyBindings"]()
515
521
  _add_navigate_bindings(bindings, swapped=swapped)
516
522
  _add_grapheme_delete_bindings(bindings)
517
523
  return bindings
@@ -19,7 +19,7 @@ import subprocess
19
19
  import sys
20
20
  from collections.abc import Callable
21
21
  from pathlib import Path
22
- from typing import TYPE_CHECKING, Annotated, Any, Literal, NamedTuple
22
+ from typing import TYPE_CHECKING, Annotated, Any, Literal, NamedTuple, cast
23
23
 
24
24
  import typer
25
25
  from kernel.models import McpServerHealth
@@ -187,7 +187,7 @@ _TRUTHY = {"1", "true", "yes", "on"}
187
187
  # by ``_cli_callback``) recorded here for the prompt command to OR in when
188
188
  # lazy routing lands there (``lisa -r "fix it"``). One CLI process runs the
189
189
  # group callback at most once, before any command body.
190
- _GROUP_HOIST: dict[str, bool] = {}
190
+ _GROUP_HOIST: dict[str, bool | None] = {}
191
191
 
192
192
  # Env var enabling lazy routing: an unrecognized first positional is
193
193
  # resolved by the custom group (``_LisaGroup.resolve_command``) to the
@@ -301,7 +301,7 @@ def _make_pack_command(name: str) -> Any:
301
301
  bare_prefix = prefix.lstrip("/")
302
302
  if bare_prefix and tokens[0].startswith(bare_prefix) and len(bare_prefix) > len(best):
303
303
  best = bare_prefix
304
- command = "/" + (best if best else name)
304
+ command = "/" + (best or name)
305
305
  for token in args:
306
306
  command += " " + token
307
307
  _run_host_command(
@@ -417,7 +417,7 @@ def _get_version() -> str:
417
417
 
418
418
  return _pkg_version("lisa-cli")
419
419
  except Exception: # pylint: disable=broad-except
420
- return "0.3.0"
420
+ return "0.3.1"
421
421
 
422
422
 
423
423
  _VERSION_OPTION = typer.Option(
@@ -489,8 +489,9 @@ ProfileOption = Annotated[
489
489
  typer.Option(
490
490
  "--profile",
491
491
  help=(
492
- "Named data-dir profile: resolves to ~/.lisa/profiles/<name> "
493
- "(overrides LISA_PROFILE; --data-dir still wins). Shares the "
492
+ "Named data-dir profile: resolves to <profiles-root>/<name> "
493
+ "(default ~/.lisa/profiles; relocatable via LISA_PROFILES_DIR). "
494
+ "Overrides LISA_PROFILE; --data-dir still wins. Shares the "
494
495
  "base packs venv, isolates threads/memory/models/policy."
495
496
  ),
496
497
  ),
@@ -711,7 +712,7 @@ def _initialize_runtime(
711
712
  wiring -> MCP config compilation) lives in the shared bootstrap so
712
713
  the CLI and web hosts cannot drift.
713
714
 
714
- ``profile`` resolves to ``~/.lisa/profiles/<name>`` here (CLI arg >
715
+ ``profile`` resolves to ``<profiles-root>/<name>`` here (CLI arg >
715
716
  ``LISA_PROFILE``; an explicit ``data_dir`` still wins) because
716
717
  ``--data-dir`` and ``--profile`` are both CLI-level data-dir inputs —
717
718
  the framework's ``HostConfig.from_env`` only knows the env var.
@@ -777,7 +778,11 @@ def _render_runtime_error(console: Console, exc: Exception, *, title: str = "Err
777
778
  # ---------------------------------------------------------------------------
778
779
 
779
780
 
780
- @app.callback()
781
+ # invoke_without_command lets the eager --version flag short-circuit on a bare
782
+ # `lisa --version` (Click otherwise raises "Missing command" before the
783
+ # callback runs). With no subcommand and no --version the callback mirrors the
784
+ # group's no-args help + exit 2.
785
+ @app.callback(invoke_without_command=True)
781
786
  def _cli_callback(
782
787
  ctx: typer.Context,
783
788
  version: bool = _VERSION_OPTION,
@@ -821,6 +826,9 @@ def _cli_callback(
821
826
  if version:
822
827
  console.print(f"lisa v{_get_version()}")
823
828
  raise typer.Exit()
829
+ if ctx.invoked_subcommand is None:
830
+ console.print(ctx.get_help())
831
+ raise typer.Exit(code=2)
824
832
 
825
833
 
826
834
  # ---------------------------------------------------------------------------
@@ -2109,8 +2117,10 @@ def _collect_interactive_or_error(
2109
2117
  if sys.stdin.isatty() and sys.stdout.isatty():
2110
2118
  from hosts.cli import entry as entry_mod
2111
2119
 
2112
- reader = _prompt_reader or entry_mod.read_prompt_message_tui
2113
- if reader is entry_mod.read_prompt_message_tui:
2120
+ if _prompt_reader is not None:
2121
+ # Custom reader seam (tests): the plain-reader contract.
2122
+ terminal_message = _prompt_reader(console)
2123
+ else:
2114
2124
  # Flag state -> reader layout (True=shipped, False=chat-box):
2115
2125
  # absent (None) -> env decides (swapped_keys=None)
2116
2126
  # --no-swap-keys -> shipped (swapped_keys=True)
@@ -2122,16 +2132,13 @@ def _collect_interactive_or_error(
2122
2132
  resolved = True
2123
2133
  else:
2124
2134
  resolved = False
2125
- terminal_message = reader(
2135
+ terminal_message = entry_mod.read_prompt_message_tui(
2126
2136
  console,
2127
2137
  history_path=entry_mod.prompt_history_path(data_dir, profile),
2128
2138
  swapped_keys=resolved,
2129
2139
  data_dir=data_dir,
2130
2140
  profile=profile,
2131
2141
  )
2132
- else:
2133
- # Custom reader seam (tests): the plain-reader contract.
2134
- terminal_message = reader(console)
2135
2142
  if terminal_message is None:
2136
2143
  raise typer.Exit(code=_ABORT_EXIT) from None
2137
2144
  if terminal_message.strip():
@@ -2226,7 +2233,9 @@ async def _resolve_pending_request_id(runtime: HostRuntime) -> str:
2226
2233
  logger.debug("prompt: pending-approval resolution failed: %s", exc)
2227
2234
  return ""
2228
2235
  if len(pending) == 1:
2229
- request_id = pending[0].invocation.request_id
2236
+ # The service reaches the CLI as Any (duck-typed seam), so reassert
2237
+ # the declared kernel field type for the return contract.
2238
+ request_id = cast("str", pending[0].invocation.request_id)
2230
2239
  logger.info("prompt: resolved bare decision to pending %s", request_id[:8])
2231
2240
  return request_id
2232
2241
  return ""
@@ -423,8 +423,10 @@ def _containment_badge(containment: str) -> tuple[str, str]:
423
423
  return "✗ NOT SANDBOXED", STYLE_CONTAINMENT_NONE
424
424
 
425
425
 
426
- def _format_approval_expiry(seconds: object) -> str:
426
+ def _format_approval_expiry(seconds: int | float | str | None) -> str:
427
427
  """Format a host-supplied approval TTL for compact panel display."""
428
+ if seconds is None:
429
+ return ""
428
430
  try:
429
431
  remaining = max(0, int(seconds))
430
432
  except (TypeError, ValueError):
@@ -466,7 +468,10 @@ def render_approval_panel(
466
468
  reasons = str(payload.get("containment_reasons") or "").strip()
467
469
  policy_reason = str(payload.get("policy_reason") or "").strip()
468
470
  policy_tier = str(payload.get("policy_tier") or "").strip()
469
- expiry = _format_approval_expiry(payload.get("expires_in_seconds"))
471
+ raw_expiry = payload.get("expires_in_seconds")
472
+ expiry = _format_approval_expiry(
473
+ raw_expiry if isinstance(raw_expiry, (int, float, str)) else None
474
+ )
470
475
 
471
476
  body = Text()
472
477
  if _looks_like_diff(preview or ""):
@@ -337,6 +337,9 @@ class PromptTransport:
337
337
  self._tool_error_count: int = 0
338
338
  self._fork_children: dict[int, dict[str, Any]] = {}
339
339
  self._fork_total: int = 0
340
+ # Tool call hierarchy: stack of (tool_name, tool_kind, parent_index)
341
+ # for rendering nested tool calls with indentation
342
+ self._tool_call_stack: list[tuple[str, str, int]] = []
340
343
  # Per-model per-token prices (USD) injected by the command path:
341
344
  # ``{model_id: (prompt_price, completion_price)}``. Empty = the
342
345
  # footer omits the cost estimate (manifest unreadable/unknown model).
@@ -496,6 +499,7 @@ class PromptTransport:
496
499
  self._status_reasoning_chars = 0
497
500
  self._fork_children = {}
498
501
  self._fork_total = 0
502
+ self._tool_call_stack = []
499
503
  self._current_mode = ""
500
504
  self._reset_model_status()
501
505
  self._active_label = ""
@@ -733,7 +737,7 @@ class PromptTransport:
733
737
  fails fast on stderr instead of printing a panel the operator can
734
738
  never answer.
735
739
  """
736
- from hosts.cli.render import render_approval_panel # noqa: PLC0415
740
+ from hosts.cli.render import render_approval_panel
737
741
 
738
742
  self.pending_approval = approval
739
743
  self.approval_unresolved = True
@@ -774,7 +778,11 @@ class PromptTransport:
774
778
  if exc is not None:
775
779
  body = f"{type(exc).__name__}: {exc}\n\n{message}"
776
780
  logger.error("transport error: %s", body)
777
- self.console.print(Panel(body, title="Error", border_style=STYLE_BORDER_ERROR))
781
+ # Escape Rich markup in the error body to avoid MarkupError
782
+ # when the message contains paths like /home/adam/Code/lisa
783
+ from rich.markup import escape
784
+
785
+ self.console.print(Panel(escape(body), title="Error", border_style=STYLE_BORDER_ERROR))
778
786
 
779
787
  # ── Internals ───────────────────────────────────────────────────
780
788
 
@@ -861,7 +869,15 @@ class PromptTransport:
861
869
  """
862
870
  if label == "tool_dispatch":
863
871
  self._tool_call_count += 1
864
- self._active_label = self._render_tool_dispatch(data).plain
872
+ # Track tool call hierarchy for nested visualization
873
+ parent_idx = len(self._tool_call_stack) - 1 if self._tool_call_stack else -1
874
+ tool_name = str(data.get("tool") or "")
875
+ tool_kind = str(data.get("tool_kind") or "")
876
+ self._tool_call_stack.append((tool_name, tool_kind, parent_idx))
877
+ # Update activity label with hierarchy depth indicator
878
+ depth = len(self._tool_call_stack) - 1
879
+ depth_indicator = " " * depth + "└─ " if depth > 0 else ""
880
+ self._active_label = f"{depth_indicator}{self._render_tool_dispatch(data).plain}"
865
881
  self._last_tool_data = data
866
882
  self._last_tool_streaming = bool(data.get("stream_output"))
867
883
  self._last_tool_output_lines = 0
@@ -900,6 +916,9 @@ class PromptTransport:
900
916
  self._tool_ok_count += 1
901
917
  elif status == "error":
902
918
  self._tool_error_count += 1
919
+ # Pop from tool call stack for hierarchy tracking
920
+ if self._tool_call_stack:
921
+ self._tool_call_stack.pop()
903
922
  # The in-flight tool-output box closes with the row: a succeeded tool
904
923
  # discards its window (the ``⎿ N lines`` summary reports the shape),
905
924
  # a failed one flushes the tail to scrollback so the operator still
@@ -999,7 +1018,7 @@ class PromptTransport:
999
1018
  if not isinstance(arguments, dict) or not arguments:
1000
1019
  return
1001
1020
  try:
1002
- import json # noqa: PLC0415
1021
+ import json
1003
1022
 
1004
1023
  rendered = json.dumps(arguments, indent=2, sort_keys=True, default=str)
1005
1024
  except (TypeError, ValueError):
@@ -1374,7 +1393,9 @@ class PromptTransport:
1374
1393
  if age < _STALL_AFTER_S:
1375
1394
  return
1376
1395
  style = STYLE_ROW_ERROR if age >= _STALL_ALERT_S else STYLE_ROW_TRANSITION
1377
- parts.append(Text(f"no data {age:.0f}s", style=style))
1396
+ # Visual stall indicator: pulsing block + age
1397
+ stall_glyph = "▮" if int(age) % 2 == 0 else "▯"
1398
+ parts.append(Text(f"{stall_glyph} stalled {age:.0f}s", style=style))
1378
1399
 
1379
1400
  def _activity_spinner(self) -> str:
1380
1401
  """Pick the spinner for the current phase.
@@ -1658,25 +1679,9 @@ class PromptTransport:
1658
1679
 
1659
1680
  out = Text()
1660
1681
  if kind == "transition":
1661
- target = ""
1662
- if data.get("handoff_to"):
1663
- target = f"→ {data['handoff_to']}"
1664
- elif data.get("transition") == "fork":
1665
- n = data.get("fork_children") or 0
1666
- target = f"fork ({n} children)"
1667
- elif data.get("transitioned_to"):
1668
- target = f"→ {data['transitioned_to']}"
1669
- out.append("stage: tool_dispatch ", style=STYLE_TEXT_MUTED)
1670
- out.append(glyph, style=STYLE_ROW_TRANSITION)
1671
- out.append(f" {tool}", style=STYLE_TOOL_NAME)
1672
- if target:
1673
- out.append(f" {target}", style=STYLE_ROW_TRANSITION)
1682
+ self._render_transition_dispatch(out, data, glyph=glyph, tool=tool, complete=complete)
1674
1683
  else:
1675
- out.append(glyph, style=STYLE_ROW_TOOL)
1676
- out.append(f" {tool}", style=STYLE_TOOL_NAME)
1677
- arg = self._primary_arg_preview(data)
1678
- if arg:
1679
- out.append(f" {arg}", style=STYLE_ARG_PREVIEW)
1684
+ self._render_plain_dispatch(out, data, glyph=glyph, tool=tool)
1680
1685
 
1681
1686
  if status_glyph:
1682
1687
  status_style = STYLE_ROW_ERROR if status_glyph == "✗" else STYLE_ROW_SUCCESS
@@ -1690,6 +1695,52 @@ class PromptTransport:
1690
1695
  out.append(summary, style=STYLE_TEXT_MUTED)
1691
1696
  return out
1692
1697
 
1698
+ def _render_transition_dispatch(
1699
+ self,
1700
+ out: Text,
1701
+ data: dict[str, Any],
1702
+ *,
1703
+ glyph: str,
1704
+ tool: str,
1705
+ complete: dict[str, Any] | None = None,
1706
+ ) -> None:
1707
+ """Render the transition-stage dispatch line (handoff/fork marker).
1708
+
1709
+ The transition target rides the *completion* event (the kernel
1710
+ knows where it routed only when the transition landed), so read
1711
+ it from ``complete`` falling back to the dispatch payload — the
1712
+ same pattern the duration read uses.
1713
+ """
1714
+ data = {**data, **(complete or {})}
1715
+ target = ""
1716
+ if data.get("handoff_to"):
1717
+ target = f"→ {data['handoff_to']}"
1718
+ elif data.get("transition") == "fork":
1719
+ n = data.get("fork_children") or 0
1720
+ target = f"fork ({n} children)"
1721
+ elif data.get("transitioned_to"):
1722
+ target = f"→ {data['transitioned_to']}"
1723
+ out.append("stage: tool_dispatch ", style=STYLE_TEXT_MUTED)
1724
+ out.append(glyph, style=STYLE_ROW_TRANSITION)
1725
+ out.append(f" {tool}", style=STYLE_TOOL_NAME)
1726
+ if target:
1727
+ out.append(f" {target}", style=STYLE_ROW_TRANSITION)
1728
+
1729
+ def _render_plain_dispatch(
1730
+ self, out: Text, data: dict[str, Any], *, glyph: str, tool: str
1731
+ ) -> None:
1732
+ """Render the plain dispatch line with tool-hierarchy indentation."""
1733
+ out.append(glyph, style=STYLE_ROW_TOOL)
1734
+ # Add hierarchy indentation for nested tool calls
1735
+ depth = len(self._tool_call_stack) - 1
1736
+ if depth > 0:
1737
+ indent = " " * depth + "└─ "
1738
+ out.append(indent, style=STYLE_TEXT_MUTED)
1739
+ out.append(f" {tool}", style=STYLE_TOOL_NAME)
1740
+ arg = self._primary_arg_preview(data)
1741
+ if arg:
1742
+ out.append(f" {arg}", style=STYLE_ARG_PREVIEW)
1743
+
1693
1744
  def _append_aligned_status(self, row: Text, tail: str, style: str, *, suffix: str = "") -> None:
1694
1745
  """Append ``tail`` right-aligned in a fixed status column (tty only).
1695
1746
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lisa-cli
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Terminal CLI for the Lisa Harness
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
@@ -14,6 +14,10 @@ src/lisa_cli.egg-info/entry_points.txt
14
14
  src/lisa_cli.egg-info/requires.txt
15
15
  src/lisa_cli.egg-info/top_level.txt
16
16
  tests/test_approval_panel.py
17
+ tests/test_changed_coverage.py
18
+ tests/test_check_coverage_gate.py
19
+ tests/test_check_docs.py
20
+ tests/test_direct_io_policy.py
17
21
  tests/test_group_resolution.py
18
22
  tests/test_last_command.py
19
23
  tests/test_models_command.py
@@ -31,4 +35,5 @@ tests/test_runtime_teardown.py
31
35
  tests/test_streaming.py
32
36
  tests/test_transport_stage.py
33
37
  tests/test_update_command.py
34
- tests/test_verify_command.py
38
+ tests/test_verify_command.py
39
+ tests/test_version_output.py
@@ -56,7 +56,7 @@ def test_render_approval_panel_includes_command_and_hints() -> None:
56
56
  assert "run a shell command" in text
57
57
  assert "ls -la" in text
58
58
  assert "approve" in text and "deny" in text
59
- assert 'lisa approve abc123def456' in text
59
+ assert "lisa approve abc123def456" in text
60
60
  assert "execute action requires approval" in text
61
61
  assert "expires in 1m 30s" in text
62
62
  # Containment badge is shown and styled as contained.
@@ -175,4 +175,3 @@ async def test_start_thinking_disarms_pending_approval() -> None:
175
175
  transport.pending_approval = _payload()
176
176
  transport.start_thinking()
177
177
  assert transport.pending_approval is None
178
-