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.
- {lisa_cli-0.3.0/src/lisa_cli.egg-info → lisa_cli-0.3.1}/PKG-INFO +1 -1
- lisa_cli-0.3.1/pyproject.toml +177 -0
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/hosts/cli/entry.py +13 -7
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/hosts/cli/main.py +24 -15
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/hosts/cli/render.py +7 -2
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/hosts/cli/transport.py +74 -23
- {lisa_cli-0.3.0 → lisa_cli-0.3.1/src/lisa_cli.egg-info}/PKG-INFO +1 -1
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/SOURCES.txt +6 -1
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_approval_panel.py +1 -2
- lisa_cli-0.3.1/tests/test_changed_coverage.py +158 -0
- lisa_cli-0.3.1/tests/test_check_coverage_gate.py +108 -0
- lisa_cli-0.3.1/tests/test_check_docs.py +227 -0
- lisa_cli-0.3.1/tests/test_direct_io_policy.py +134 -0
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_group_resolution.py +2 -4
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_last_command.py +11 -4
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_models_command.py +4 -6
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_profile_option.py +23 -8
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_prompt_decisions.py +7 -19
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_prompt_transport.py +98 -6
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_render_catalogs.py +3 -2
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_streaming.py +1 -1
- lisa_cli-0.3.1/tests/test_version_output.py +71 -0
- lisa_cli-0.3.0/pyproject.toml +0 -75
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/LICENSE +0 -0
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/setup.cfg +0 -0
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/hosts/__init__.py +0 -0
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/hosts/cli/__init__.py +0 -0
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/hosts/cli/expand.py +0 -0
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/dependency_links.txt +0 -0
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/entry_points.txt +0 -0
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/requires.txt +0 -0
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/src/lisa_cli.egg-info/top_level.txt +0 -0
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_pack_update.py +0 -0
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_prompt_command.py +0 -0
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_prompt_entry.py +0 -0
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_prompt_expand.py +0 -0
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_render_models.py +1 -1
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_restart.py +0 -0
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_runtime_teardown.py +0 -0
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_transport_stage.py +0 -0
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_update_command.py +0 -0
- {lisa_cli-0.3.0 → lisa_cli-0.3.1}/tests/test_verify_command.py +0 -0
|
@@ -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()
|
|
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()
|
|
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:
|
|
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:
|
|
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) ->
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
493
|
-
"(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2113
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|