lisa-cli 0.1.0__tar.gz → 0.3.0__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/LICENSE +21 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/PKG-INFO +6 -1
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/pyproject.toml +10 -2
- lisa_cli-0.3.0/src/hosts/cli/entry.py +607 -0
- lisa_cli-0.3.0/src/hosts/cli/expand.py +158 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/src/hosts/cli/main.py +355 -197
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/src/hosts/cli/transport.py +205 -46
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/src/lisa_cli.egg-info/PKG-INFO +6 -1
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/src/lisa_cli.egg-info/SOURCES.txt +6 -1
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/src/lisa_cli.egg-info/requires.txt +3 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/tests/test_group_resolution.py +35 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/tests/test_pack_update.py +17 -24
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/tests/test_prompt_command.py +22 -0
- lisa_cli-0.3.0/tests/test_prompt_entry.py +1038 -0
- lisa_cli-0.3.0/tests/test_prompt_expand.py +171 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/tests/test_prompt_transport.py +163 -20
- lisa_cli-0.3.0/tests/test_restart.py +83 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/tests/test_transport_stage.py +195 -0
- lisa_cli-0.3.0/tests/test_update_command.py +249 -0
- lisa_cli-0.1.0/tests/test_restart.py +0 -97
- lisa_cli-0.1.0/tests/test_run_command.py +0 -127
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/setup.cfg +0 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/src/hosts/__init__.py +0 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/src/hosts/cli/__init__.py +0 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/src/hosts/cli/render.py +0 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/src/lisa_cli.egg-info/dependency_links.txt +0 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/src/lisa_cli.egg-info/entry_points.txt +0 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/src/lisa_cli.egg-info/top_level.txt +0 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/tests/test_approval_panel.py +0 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/tests/test_last_command.py +0 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/tests/test_models_command.py +0 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/tests/test_profile_option.py +0 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/tests/test_prompt_decisions.py +0 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/tests/test_render_catalogs.py +0 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/tests/test_render_models.py +0 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/tests/test_runtime_teardown.py +0 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/tests/test_streaming.py +0 -0
- {lisa_cli-0.1.0 → lisa_cli-0.3.0}/tests/test_verify_command.py +0 -0
lisa_cli-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Migus Group
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lisa-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Terminal CLI for the Lisa Harness
|
|
5
|
+
License-Expression: MIT
|
|
5
6
|
Requires-Python: >=3.11
|
|
7
|
+
License-File: LICENSE
|
|
6
8
|
Requires-Dist: lisa-harness-runtime
|
|
7
9
|
Requires-Dist: lisa-harness-kernel
|
|
8
10
|
Requires-Dist: lisa-harness-sdk
|
|
@@ -24,3 +26,6 @@ Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
|
24
26
|
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
25
27
|
Requires-Dist: ruff>=0.8.0; extra == "dev"
|
|
26
28
|
Requires-Dist: types-PyYAML>=6.0; extra == "dev"
|
|
29
|
+
Provides-Extra: prompt
|
|
30
|
+
Requires-Dist: prompt-toolkit>=3.0; extra == "prompt"
|
|
31
|
+
Dynamic: license-file
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["setuptools>=
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
5
|
[tool.setuptools.packages.find]
|
|
@@ -7,9 +7,11 @@ where = ["src"]
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
name = "lisa-cli"
|
|
10
|
-
version = "0.
|
|
10
|
+
version = "0.3.0"
|
|
11
11
|
description = "Terminal CLI for the Lisa Harness"
|
|
12
12
|
requires-python = ">=3.11"
|
|
13
|
+
license = "MIT"
|
|
14
|
+
license-files = ["LICENSE"]
|
|
13
15
|
dependencies = [
|
|
14
16
|
"lisa-harness-runtime",
|
|
15
17
|
"lisa-harness-kernel",
|
|
@@ -39,6 +41,9 @@ dev = [
|
|
|
39
41
|
"ruff>=0.8.0",
|
|
40
42
|
"types-PyYAML>=6.0",
|
|
41
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"]
|
|
42
47
|
|
|
43
48
|
[tool.pytest.ini_options]
|
|
44
49
|
pythonpath = ["src"]
|
|
@@ -62,6 +67,9 @@ ignore = ["PLR0913"]
|
|
|
62
67
|
# Startup ordering and lazy imports keep CLI help fast; Typer owns command signatures.
|
|
63
68
|
"**/hosts/cli/main.py" = ["E402", "PLC0415", "PLR0917"]
|
|
64
69
|
"**/hosts/cli/render.py" = ["PLC0415"]
|
|
70
|
+
# Interactive entry is imported only on the no-args path; the PT prompt
|
|
71
|
+
# glyph is intentional (matches the transport's palette).
|
|
72
|
+
"**/hosts/cli/entry.py" = ["PLC0415", "RUF001"]
|
|
65
73
|
|
|
66
74
|
[tool.ruff.lint.mccabe]
|
|
67
75
|
max-complexity = 10
|
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
"""Interactive input surfaces for one-shot commands.
|
|
2
|
+
|
|
3
|
+
``lisa prompt`` invoked with no positional reads its message from the
|
|
4
|
+
terminal instead of argv so the message is never subject to the shell's
|
|
5
|
+
argument grammar: quotes, backticks, and apostrophes arrive literally
|
|
6
|
+
without escaping. The collected text becomes the one-shot turn — no
|
|
7
|
+
REPL loop, no transcript, one invocation = one turn.
|
|
8
|
+
|
|
9
|
+
Two readers, same contract:
|
|
10
|
+
|
|
11
|
+
- :func:`read_prompt_message` — plain ``input()`` loop (no extra
|
|
12
|
+
dependency). Lines accumulate until EOF on an empty line (Ctrl-D, or
|
|
13
|
+
Ctrl-Z+Enter on Windows).
|
|
14
|
+
- :func:`read_prompt_message_tui` — prompt_toolkit single-box editor
|
|
15
|
+
(when the optional ``lisa-cli[prompt]`` extra is installed, with
|
|
16
|
+
graceful fallback to the plain reader). Send and newline are
|
|
17
|
+
swappable between Enter and Ctrl-J; the shipped default is swapped —
|
|
18
|
+
Enter inserts a newline and Ctrl-J sends — so a hurried Enter never fires the
|
|
19
|
+
turn (finish with Ctrl-J, or Ctrl-D on an empty line in the plain
|
|
20
|
+
reader) and no trailing keystroke can reach the caller's shell.
|
|
21
|
+
``--swap-keys`` / truthy ``LISA_PROMPT_KEYS`` restores chat-box
|
|
22
|
+
semantics (Enter sends).
|
|
23
|
+
|
|
24
|
+
The TUI reader owns its navigation model rather than trusting
|
|
25
|
+
prompt_toolkit's defaults: Ctrl+Up/Ctrl+Down are inert (prompt_toolkit
|
|
26
|
+
binds them to history navigation that replaces the whole multiline
|
|
27
|
+
buffer — the corruption that forced Ctrl-C), Alt+Up/Alt+Down walk
|
|
28
|
+
submitted-prompt history instead, and backspace deletes whole grapheme
|
|
29
|
+
clusters so multi-codepoint emoji (ZWJ families, flags, skin tones)
|
|
30
|
+
never tear. Word motion (Ctrl+Left/Right) keeps the prompt_toolkit
|
|
31
|
+
defaults.
|
|
32
|
+
|
|
33
|
+
``LISA_PROMPT_PS1`` customizes or suppresses the editor hints, PS1-style:
|
|
34
|
+
unset keeps the defaults, empty suppresses the hint text entirely, any
|
|
35
|
+
other value replaces it verbatim (plain text, no templates). The plain
|
|
36
|
+
reader prints it as its banner; the TUI reader puts it in the bottom
|
|
37
|
+
toolbar and drops the toolbar when suppressed.
|
|
38
|
+
|
|
39
|
+
Both return ``None`` to mean "operator aborted with no message"; the
|
|
40
|
+
caller turns that into the conventional SIGINT exit. Whitespace-only
|
|
41
|
+
text means "nothing entered" and is a usage error at the call site.
|
|
42
|
+
Quote characters survive because no shell ever tokenized this input;
|
|
43
|
+
``$(cmd)`` does NOT pass literally — the editor path expands it
|
|
44
|
+
(:mod:`hosts.cli.expand`). The history file under ``state/`` persists
|
|
45
|
+
submitted prompts for ``Alt+Up`` reuse in the TUI reader.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import os
|
|
51
|
+
import unicodedata
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
from typing import Any
|
|
54
|
+
|
|
55
|
+
from rich.console import Console
|
|
56
|
+
|
|
57
|
+
#: Unicode general categories that extend a grapheme cluster: combining
|
|
58
|
+
#: marks, enclosing marks, format characters (variation selectors, ZWSP),
|
|
59
|
+
#: and modifier symbols (skin-tone modifiers). ZWJ itself is ``Cf`` but is
|
|
60
|
+
#: spelled out for clarity at the fold sites.
|
|
61
|
+
_EXTEND_CATEGORIES = ("Mn", "Me", "Mc", "Cf", "Sk")
|
|
62
|
+
|
|
63
|
+
#: Regional Indicator codepoint range (U+1F1E6 - U+1F1FF): the two halves of
|
|
64
|
+
#: an emoji flag. A flag is exactly one pair; a lone indicator is torn.
|
|
65
|
+
_RI_FIRST = 0x1F1E6
|
|
66
|
+
_RI_LAST = 0x1F1FF
|
|
67
|
+
_RI_PAIR = 2 # one flag = exactly two regional indicators
|
|
68
|
+
|
|
69
|
+
#: Env var customizing the editor hints, PS1-style. Empty value suppresses
|
|
70
|
+
#: the hints entirely; any other value replaces the default hint text
|
|
71
|
+
#: verbatim (plain text — no template expansion); unset defers to the
|
|
72
|
+
#: settings file, then built-in defaults.
|
|
73
|
+
_PROMPT_PS1_ENV = "LISA_PROMPT_PS1"
|
|
74
|
+
|
|
75
|
+
#: Settings file for the editor hints: ``<data-dir>/config/prompt_hints.yaml``.
|
|
76
|
+
#: A hand-editable YAML blob; each recognized key overrides that fragment of
|
|
77
|
+
#: the default hint line (or ``null``/``""`` suppresses it):
|
|
78
|
+
#:
|
|
79
|
+
#: send: "Ctrl-J sends" # how the editor sends
|
|
80
|
+
#: newline: "Enter adds a line" # what plain Enter does
|
|
81
|
+
#: abort: "Ctrl-C aborts" # how to back out
|
|
82
|
+
#: banner: "…quoted are literal…" # plain reader's first line
|
|
83
|
+
#:
|
|
84
|
+
#: Precedence for the whole-line text: ``LISA_PROMPT_PS1`` (verbatim, or
|
|
85
|
+
#: empty = suppressed) > the settings file (fragments assembled) > the
|
|
86
|
+
#: built-in defaults. Per-fragment env backstops
|
|
87
|
+
#: (``LISA_PROMPT_HINT_SEND`` etc.) sit between the file and the default:
|
|
88
|
+
#: env > file > built-in, per fragment.
|
|
89
|
+
_PROMPT_HINTS_FILE = "prompt_hints.yaml"
|
|
90
|
+
_PROMPT_HINT_ENV_PREFIX = "LISA_PROMPT_HINT_"
|
|
91
|
+
_FRAGMENT_ENV = {
|
|
92
|
+
"send": "LISA_PROMPT_HINT_SEND",
|
|
93
|
+
"newline": "LISA_PROMPT_HINT_NEWLINE",
|
|
94
|
+
"abort": "LISA_PROMPT_HINT_ABORT",
|
|
95
|
+
"banner": "LISA_PROMPT_HINT_BANNER",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#: Env var selecting the editor key model, PS1-style. Unset or falsy
|
|
99
|
+
#: (``0/false/no/off``): swapped default — Enter inserts a newline, Ctrl-J
|
|
100
|
+
#: sends (newline-first, so an impatient finisher cannot
|
|
101
|
+
#: send a half-typed message, and a double-tap cannot reach the shell).
|
|
102
|
+
#: Truthy (``1/true/yes/on``): chat-box — Enter sends, Ctrl-J
|
|
103
|
+
#: adds a newline. ``--swap-keys`` wins over the env var.
|
|
104
|
+
_PROMPT_KEYS_ENV = "LISA_PROMPT_KEYS"
|
|
105
|
+
_TRUTHY = {"1", "true", "yes", "on"}
|
|
106
|
+
|
|
107
|
+
#: Marker printed before the plain reader so the operator knows the
|
|
108
|
+
#: terminal, not argv, owns the input now. $() expands (expand.py runs
|
|
109
|
+
#: over editor text); quote characters survive because no shell ever
|
|
110
|
+
#: tokenized this input. The three strings below are the built-in
|
|
111
|
+
#: fallbacks; the settings file (prompt_hints.yaml) and env backstops
|
|
112
|
+
#: override fragments — see :func:`resolve_hints`.
|
|
113
|
+
_DEFAULT_BANNER = "Lisa prompt - quotes are literal; $(cmd) is expanded. ✨"
|
|
114
|
+
_DEFAULT_SEND = {
|
|
115
|
+
"plain": "Ctrl-D on an empty line sends",
|
|
116
|
+
"chat": "Enter sends",
|
|
117
|
+
"swap": "Ctrl-J sends",
|
|
118
|
+
}
|
|
119
|
+
_DEFAULT_NEWLINE = {
|
|
120
|
+
"plain": "each line is kept",
|
|
121
|
+
"chat": "Ctrl-J adds a line",
|
|
122
|
+
"swap": "Enter adds a line",
|
|
123
|
+
}
|
|
124
|
+
_DEFAULT_ABORT = "Ctrl-C aborts"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def prompt_keys_env_wants_enter_send() -> bool:
|
|
128
|
+
"""Resolve the key model from ``LISA_PROMPT_KEYS`` (env back for the flag).
|
|
129
|
+
|
|
130
|
+
The reader calls this only when neither ``--swap-keys`` nor
|
|
131
|
+
``--no-swap-keys`` was given. Returns ``True`` when the env asks for
|
|
132
|
+
chat-box (Enter sends — the exceptional model); ``False`` otherwise
|
|
133
|
+
(unset/env falsy = the shipped swapped layout, Enter = newline).
|
|
134
|
+
"""
|
|
135
|
+
value = os.getenv(_PROMPT_KEYS_ENV)
|
|
136
|
+
if value is None:
|
|
137
|
+
return False
|
|
138
|
+
return value.strip().lower() in _TRUTHY
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
#: prompt_toolkit pieces when the optional extra is installed; ``None``
|
|
142
|
+
#: means the plain reader is the only reader. The import lives here
|
|
143
|
+
#: rather than in main.py so ``--help`` and piped runs never even import
|
|
144
|
+
#: this module, and the extra is genuinely optional.
|
|
145
|
+
_PT: dict[str, Any] | None = None
|
|
146
|
+
try:
|
|
147
|
+
from prompt_toolkit import PromptSession as _PromptSession
|
|
148
|
+
from prompt_toolkit.formatted_text import HTML as _HTML
|
|
149
|
+
from prompt_toolkit.history import FileHistory as _FileHistory
|
|
150
|
+
from prompt_toolkit.key_binding import KeyBindings as _KeyBindings
|
|
151
|
+
|
|
152
|
+
_PT = {
|
|
153
|
+
"PromptSession": _PromptSession,
|
|
154
|
+
"HTML": _HTML,
|
|
155
|
+
"FileHistory": _FileHistory,
|
|
156
|
+
"KeyBindings": _KeyBindings,
|
|
157
|
+
}
|
|
158
|
+
except ImportError: # optional extra lisa-cli[prompt] absent
|
|
159
|
+
_PT = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def prompt_ps1() -> str | None:
|
|
163
|
+
"""Resolve the editor hint text from ``LISA_PROMPT_PS1``.
|
|
164
|
+
|
|
165
|
+
- Unset: built-in hint (plain reader's banner, TUI's toolbar).
|
|
166
|
+
- Set to empty: ``None`` — hints suppressed entirely.
|
|
167
|
+
- Any other value: that text verbatim (plain text, no templates).
|
|
168
|
+
|
|
169
|
+
A whitespace-only value suppresses too: an operator asking for a
|
|
170
|
+
quieter editor should get it, not a near-blank banner.
|
|
171
|
+
"""
|
|
172
|
+
value = os.getenv(_PROMPT_PS1_ENV)
|
|
173
|
+
if value is None:
|
|
174
|
+
return None
|
|
175
|
+
if not value.strip():
|
|
176
|
+
return None
|
|
177
|
+
return value # verbatim — no strip, the operator owns the spacing
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def settings_file_path(data_dir: str | Path, profile: str) -> Path:
|
|
181
|
+
"""The prompt-hints settings file under the profile's config dir.
|
|
182
|
+
|
|
183
|
+
``<data-dir>/config/prompt_hints.yaml`` — hand-editable, alongside the
|
|
184
|
+
other authored config (models.yaml, policy.yaml). Profile resolution
|
|
185
|
+
mirrors :func:`prompt_history_path`.
|
|
186
|
+
"""
|
|
187
|
+
from runtime.config import profile_data_dir # lazy: import cost
|
|
188
|
+
|
|
189
|
+
base = str(data_dir).strip() if str(data_dir).strip() else ""
|
|
190
|
+
if not base and profile.strip():
|
|
191
|
+
base = str(profile_data_dir(profile.strip()))
|
|
192
|
+
if not base:
|
|
193
|
+
base = os.getenv("LISA_DATA_DIR", "") or str(Path.home() / ".lisa")
|
|
194
|
+
return Path(base).expanduser() / "config" / _PROMPT_HINTS_FILE
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def load_hint_fragments(data_dir: str | Path, profile: str) -> dict[str, str | None]:
|
|
198
|
+
"""Read the settings-file hint fragments (missing file: all absent).
|
|
199
|
+
|
|
200
|
+
Recognized keys (send, newline, abort, banner) map to their string
|
|
201
|
+
value, or ``None`` when the file does not exist, is unreadable, is
|
|
202
|
+
not valid YAML, or the key is missing/an empty string (which the
|
|
203
|
+
resolver treats as "fragment not set" — to suppress a fragment use
|
|
204
|
+
the key's YAML null... which is the same as absent: both mean the
|
|
205
|
+
backstops decide; there is no per-fragment suppression by design,
|
|
206
|
+
a hint line with a hole is worse than no hole).
|
|
207
|
+
|
|
208
|
+
A malformed file is a loud stderr warning once per process, then the
|
|
209
|
+
resolver falls back to built-ins — settings never block a turn.
|
|
210
|
+
"""
|
|
211
|
+
path = settings_file_path(data_dir, profile)
|
|
212
|
+
if not path.is_file():
|
|
213
|
+
return {}
|
|
214
|
+
try:
|
|
215
|
+
import yaml # pyyaml is a hard dependency of the CLI host
|
|
216
|
+
|
|
217
|
+
with open(path, encoding="utf-8") as fh:
|
|
218
|
+
data = yaml.safe_load(fh)
|
|
219
|
+
except Exception: # pylint: disable=broad-except # settings must not block
|
|
220
|
+
import sys
|
|
221
|
+
|
|
222
|
+
print(f"lisa: ignoring unreadable {path}", file=sys.stderr, flush=True)
|
|
223
|
+
return {}
|
|
224
|
+
if not isinstance(data, dict):
|
|
225
|
+
return {}
|
|
226
|
+
out: dict[str, str | None] = {}
|
|
227
|
+
for key in ("send", "newline", "abort", "banner"):
|
|
228
|
+
value = data.get(key)
|
|
229
|
+
if isinstance(value, str) and value.strip():
|
|
230
|
+
out[key] = value
|
|
231
|
+
return out
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def resolve_hints(
|
|
235
|
+
data_dir: str | Path,
|
|
236
|
+
profile: str,
|
|
237
|
+
*,
|
|
238
|
+
layout: str,
|
|
239
|
+
) -> dict[str, str | None]:
|
|
240
|
+
"""Assemble the effective hint fragments for one reader invocation.
|
|
241
|
+
|
|
242
|
+
``layout`` selects the key-model facts: ``"plain"`` (EOF-send), or
|
|
243
|
+
``"chat"`` / ``"swap"`` (which of Enter/Ctrl-J sends). Precedence
|
|
244
|
+
per fragment: env backstop > settings file > built-in default.
|
|
245
|
+
Returns ``{send, newline, abort, banner}`` — a ``None`` value means
|
|
246
|
+
the fragment is suppressed (only possible when the env backstop or
|
|
247
|
+
settings file sets it explicitly empty... which :func:`load_hint_
|
|
248
|
+
fragments` treats as absent today; the assembly below keeps the
|
|
249
|
+
None slot so callers can render "suppressed" cleanly later).
|
|
250
|
+
"""
|
|
251
|
+
file_fragments = load_hint_fragments(data_dir, profile)
|
|
252
|
+
resolved: dict[str, str | None] = {}
|
|
253
|
+
for key in ("send", "newline", "abort", "banner"):
|
|
254
|
+
env_value = os.getenv(_FRAGMENT_ENV[key])
|
|
255
|
+
if env_value is not None:
|
|
256
|
+
resolved[key] = env_value.strip() or None
|
|
257
|
+
continue
|
|
258
|
+
if key in file_fragments:
|
|
259
|
+
resolved[key] = file_fragments[key]
|
|
260
|
+
continue
|
|
261
|
+
resolved[key] = None
|
|
262
|
+
# Old installers seeded TUI defaults even for the plain reader.
|
|
263
|
+
# Treat those defaults as layout facts, preserving custom hint text.
|
|
264
|
+
if resolved["send"] in (
|
|
265
|
+
*_DEFAULT_SEND.values(),
|
|
266
|
+
"Ctrl-J or Alt+Enter sends",
|
|
267
|
+
):
|
|
268
|
+
resolved["send"] = _DEFAULT_SEND[layout]
|
|
269
|
+
if resolved["newline"] in (
|
|
270
|
+
*_DEFAULT_NEWLINE.values(),
|
|
271
|
+
"Alt+Enter or Ctrl-J adds a line",
|
|
272
|
+
):
|
|
273
|
+
resolved["newline"] = _DEFAULT_NEWLINE[layout]
|
|
274
|
+
if resolved["send"] is None:
|
|
275
|
+
resolved["send"] = _DEFAULT_SEND[layout]
|
|
276
|
+
if resolved["newline"] is None:
|
|
277
|
+
resolved["newline"] = _DEFAULT_NEWLINE[layout]
|
|
278
|
+
if resolved["abort"] is None:
|
|
279
|
+
resolved["abort"] = _DEFAULT_ABORT
|
|
280
|
+
return resolved
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def prompt_history_path(data_dir: str | Path, profile: str) -> Path:
|
|
284
|
+
"""History file for prompt-mode input under the profile's state dir.
|
|
285
|
+
|
|
286
|
+
Lives in ``<data-dir>/state/prompt_history.txt`` — the same category
|
|
287
|
+
the REPL-era history used (machine-written, small). Profile resolution
|
|
288
|
+
mirrors ``_resolve_profiled_data_dir``: explicit data dir wins, then
|
|
289
|
+
the profile dir; empty hands the decision to the framework's default
|
|
290
|
+
(``LISA_DATA_DIR`` > ``~/.lisa``).
|
|
291
|
+
"""
|
|
292
|
+
base = str(data_dir).strip() if str(data_dir).strip() else ""
|
|
293
|
+
if not base and profile.strip():
|
|
294
|
+
from runtime.config import profile_data_dir # lazy: import cost
|
|
295
|
+
|
|
296
|
+
base = str(profile_data_dir(profile.strip()))
|
|
297
|
+
if not base:
|
|
298
|
+
base = os.getenv("LISA_DATA_DIR", "") or str(Path.home() / ".lisa")
|
|
299
|
+
return Path(base).expanduser() / "state" / "prompt_history.txt"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def read_prompt_message(
|
|
303
|
+
console: Console,
|
|
304
|
+
*,
|
|
305
|
+
data_dir: str | Path = "",
|
|
306
|
+
profile: str = "",
|
|
307
|
+
) -> str | None:
|
|
308
|
+
"""Read a multiline message from the plain terminal (no dependencies).
|
|
309
|
+
|
|
310
|
+
Lines accumulate until EOF on an empty line (Ctrl-D on POSIX,
|
|
311
|
+
Ctrl-Z+Enter on Windows). Returns ``None`` when the operator aborts
|
|
312
|
+
with Ctrl-C — a one-line notice is printed so pipelines read
|
|
313
|
+
"cancelled, not crashed"; the caller exits with ``_ABORT_EXIT``.
|
|
314
|
+
|
|
315
|
+
Pasting is safe: a paste of N lines adds N lines of buffer and does
|
|
316
|
+
not submit, so quotes, backticks, and apostrophes arrive literally
|
|
317
|
+
(no shell ever tokenizes this input; ``$(cmd)`` is expanded by the
|
|
318
|
+
call site — see :mod:`hosts.cli.expand`).
|
|
319
|
+
|
|
320
|
+
The banner respects ``LISA_PROMPT_PS1`` (whole-line replace/suppress)
|
|
321
|
+
and the hint-fragment settings (prompt_hints.yaml / env backstops)
|
|
322
|
+
for banner, send, and abort fragments; the Ctrl-Z note stays (a
|
|
323
|
+
settings file cannot claim Ctrl-Z sends — it suspends, factually).
|
|
324
|
+
"""
|
|
325
|
+
hint = prompt_ps1() if _PROMPT_PS1_ENV in os.environ else None
|
|
326
|
+
if _PROMPT_PS1_ENV in os.environ:
|
|
327
|
+
# PS1 present: an empty/whitespace value means explicitly
|
|
328
|
+
# suppressed — no fragments, no banner. A value is a verbatim
|
|
329
|
+
# whole-line replace. The settings file only participates when
|
|
330
|
+
# PS1 is unset.
|
|
331
|
+
if hint:
|
|
332
|
+
console.print(f"[dim]{hint}[/dim]")
|
|
333
|
+
else:
|
|
334
|
+
fragments = resolve_hints(data_dir, profile, layout="plain")
|
|
335
|
+
if any(fragments.values()):
|
|
336
|
+
parts = [
|
|
337
|
+
fragments.get("banner") or _DEFAULT_BANNER,
|
|
338
|
+
f"{fragments['send']}; {fragments['abort']}.",
|
|
339
|
+
]
|
|
340
|
+
console.print(f"[dim]{' '.join(parts)}[/dim]")
|
|
341
|
+
lines: list[str] = []
|
|
342
|
+
while True:
|
|
343
|
+
try:
|
|
344
|
+
lines.append(input())
|
|
345
|
+
except EOFError:
|
|
346
|
+
break
|
|
347
|
+
except KeyboardInterrupt:
|
|
348
|
+
console.print("[red]⏹ Entry aborted by operator (Ctrl-C).[/red]")
|
|
349
|
+
return None
|
|
350
|
+
return "\n".join(lines)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _delete_last_grapheme(text: str) -> int:
|
|
354
|
+
"""Length (in codepoints) of the last extended grapheme cluster.
|
|
355
|
+
|
|
356
|
+
prompt_toolkit's backspace deletes one codepoint, which tears
|
|
357
|
+
multi-codepoint emoji: a ZWJ family loses its last member and leaves
|
|
358
|
+
a dangling ``U+200D`` (rendered as broken halves), a flag loses one
|
|
359
|
+
regional indicator, a skin-tone modifier splits off its base. The
|
|
360
|
+
returned length deletes the whole cluster with a pragmatic
|
|
361
|
+
UAX #29-like rule:
|
|
362
|
+
|
|
363
|
+
- A trailing regional indicator: a complete pair is one cluster; an
|
|
364
|
+
odd trailing run means the pair is already torn — the single
|
|
365
|
+
dangling indicator counts alone so the next delete can re-pair.
|
|
366
|
+
- Otherwise trailing combining marks / format chars / modifier
|
|
367
|
+
symbols (variation selectors, skin tones) fold onto their base;
|
|
368
|
+
a ``U+200D`` directly before that base folds the base's own
|
|
369
|
+
cluster in (👨ZWJ👩ZWJ👧ZWJ👦 deletes as one).
|
|
370
|
+
- A whitespace base never folds onto an emoji cluster.
|
|
371
|
+
- An empty result means the whole text was combining/format noise.
|
|
372
|
+
|
|
373
|
+
This is a heuristic, not full UAX #29 — tuned for what chat inputs
|
|
374
|
+
actually hit (emoji families, flags, tones, VS16, CJK) and
|
|
375
|
+
conservative elsewhere.
|
|
376
|
+
"""
|
|
377
|
+
n = len(text)
|
|
378
|
+
if not n:
|
|
379
|
+
return 0
|
|
380
|
+
last = text[-1]
|
|
381
|
+
if _RI_FIRST <= ord(last) <= _RI_LAST: # regional indicator — pairs only
|
|
382
|
+
i = n
|
|
383
|
+
while i > 0 and _RI_FIRST <= ord(text[i - 1]) <= _RI_LAST:
|
|
384
|
+
i -= 1
|
|
385
|
+
run = n - i
|
|
386
|
+
return 1 if run % 2 == 1 else _RI_PAIR
|
|
387
|
+
i = n
|
|
388
|
+
while i > 0 and (
|
|
389
|
+
unicodedata.category(text[i - 1]) in _EXTEND_CATEGORIES or text[i - 1] == "\u200d"
|
|
390
|
+
):
|
|
391
|
+
i -= 1
|
|
392
|
+
if i == 0:
|
|
393
|
+
return n # everything was combining/format noise; it is one cluster
|
|
394
|
+
i -= 1 # the base codepoint
|
|
395
|
+
# ZWJ chain: a U+200D directly before this cluster's base folds that
|
|
396
|
+
# base's cluster in (👨+ZWJ+👩+ZWJ+👧+ZWJ+👦 is one cluster). A
|
|
397
|
+
# whitespace base never folds: 'a <ZWJ>' is a torn input, not a family.
|
|
398
|
+
while i > 1 and text[i - 1] == "\u200d" and not text[i - 2].isspace():
|
|
399
|
+
i -= 2
|
|
400
|
+
return n - i
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _delete_first_grapheme(text: str) -> int:
|
|
404
|
+
"""Length (in codepoints) of the first extended grapheme cluster.
|
|
405
|
+
|
|
406
|
+
The forward-delete counterpart of :func:`_delete_last_grapheme`:
|
|
407
|
+
same rule, walked from the front. Leading regional indicators delete
|
|
408
|
+
as a pair when two or more are present (an odd leading run deletes
|
|
409
|
+
two first, so a torn flag re-pairs on subsequent deletes).
|
|
410
|
+
"""
|
|
411
|
+
n = len(text)
|
|
412
|
+
if not n:
|
|
413
|
+
return 0
|
|
414
|
+
if _RI_FIRST <= ord(text[0]) <= _RI_LAST:
|
|
415
|
+
i = 0
|
|
416
|
+
while i < n and _RI_FIRST <= ord(text[i]) <= _RI_LAST:
|
|
417
|
+
i += 1
|
|
418
|
+
return _RI_PAIR if i > 1 else 1
|
|
419
|
+
i = 1
|
|
420
|
+
while i < n:
|
|
421
|
+
ch = text[i]
|
|
422
|
+
if unicodedata.category(ch) in _EXTEND_CATEGORIES or ch == "\u200d":
|
|
423
|
+
i += 1
|
|
424
|
+
if i < n and text[i - 1] == "\u200d" and not text[i].isspace():
|
|
425
|
+
i += 1 # the base after the ZWJ folds in
|
|
426
|
+
continue
|
|
427
|
+
continue
|
|
428
|
+
break
|
|
429
|
+
return i
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _add_navigate_bindings(bindings: Any, *, swapped: bool = True) -> None:
|
|
433
|
+
"""Bind submit/newline and navigation keys.
|
|
434
|
+
|
|
435
|
+
``swapped`` (the default) puts newline on Enter and send on
|
|
436
|
+
Ctrl-J — the impatient-finisher-safe
|
|
437
|
+
model: Enter never sends, so a hurried double-tap adds a blank line
|
|
438
|
+
instead of firing the turn, and no buffered keystroke can reach the
|
|
439
|
+
caller's shell after the process exits. ``swapped=False`` is
|
|
440
|
+
chat-box semantics (Enter sends), opt-in via ``--swap-keys`` /
|
|
441
|
+
``LISA_PROMPT_KEYS``. Either way Ctrl+Up/Down stay inert
|
|
442
|
+
(prompt_toolkit's history navigation replaces the whole multiline
|
|
443
|
+
buffer) and Alt+Up/Down walk prompt history.
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
def _send(event: Any) -> None:
|
|
447
|
+
event.current_buffer.validate_and_handle()
|
|
448
|
+
|
|
449
|
+
def _newline(event: Any) -> None:
|
|
450
|
+
event.current_buffer.insert_text("\n")
|
|
451
|
+
|
|
452
|
+
bindings.add("c-j" if swapped else "enter", eager=True)(_send)
|
|
453
|
+
bindings.add("enter" if swapped else "c-j")(_newline)
|
|
454
|
+
|
|
455
|
+
# Override prompt_toolkit's default Escape+Enter submit binding too.
|
|
456
|
+
@bindings.add("escape", "[", "2", "7", ";", "3", ";", "1", "3", "~")
|
|
457
|
+
@bindings.add("escape", "[", "1", "3", ";", "3", "u")
|
|
458
|
+
@bindings.add("escape", "enter")
|
|
459
|
+
def _ignore_alt_enter(event: Any) -> None:
|
|
460
|
+
pass
|
|
461
|
+
|
|
462
|
+
@bindings.add("c-up", eager=True)
|
|
463
|
+
def _inert_c_up(event: Any) -> None:
|
|
464
|
+
pass
|
|
465
|
+
|
|
466
|
+
@bindings.add("c-down", eager=True)
|
|
467
|
+
def _inert_c_down(event: Any) -> None:
|
|
468
|
+
pass
|
|
469
|
+
|
|
470
|
+
@bindings.add("escape", "up")
|
|
471
|
+
def _history_prev(event: Any) -> None:
|
|
472
|
+
event.current_buffer.history_backward()
|
|
473
|
+
|
|
474
|
+
@bindings.add("escape", "down")
|
|
475
|
+
def _history_next(event: Any) -> None:
|
|
476
|
+
event.current_buffer.history_forward()
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _add_grapheme_delete_bindings(bindings: Any) -> None:
|
|
480
|
+
"""Bind backspace (Ctrl-H) and forward Delete to cluster-aware cuts.
|
|
481
|
+
|
|
482
|
+
prompt_toolkit deletes one codepoint, tearing multi-codepoint emoji
|
|
483
|
+
(ZWJ families, flags, skin tones) into broken halves.
|
|
484
|
+
"""
|
|
485
|
+
|
|
486
|
+
@bindings.add("c-h", eager=True)
|
|
487
|
+
def _grapheme_backspace(event: Any) -> None:
|
|
488
|
+
buffer = event.current_buffer
|
|
489
|
+
pos = buffer.cursor_position
|
|
490
|
+
if pos == 0:
|
|
491
|
+
return
|
|
492
|
+
cut = _delete_last_grapheme(buffer.text[:pos])
|
|
493
|
+
buffer.text = buffer.text[: pos - cut] + buffer.text[pos:]
|
|
494
|
+
buffer.cursor_position = pos - cut
|
|
495
|
+
|
|
496
|
+
@bindings.add("delete", eager=True)
|
|
497
|
+
def _grapheme_delete(event: Any) -> None:
|
|
498
|
+
buffer = event.current_buffer
|
|
499
|
+
pos = buffer.cursor_position
|
|
500
|
+
if pos >= len(buffer.text):
|
|
501
|
+
return
|
|
502
|
+
cut = _delete_first_grapheme(buffer.text[pos:])
|
|
503
|
+
buffer.text = buffer.text[:pos] + buffer.text[pos + cut :]
|
|
504
|
+
buffer.cursor_position = pos
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _tui_key_bindings(swapped: bool = True) -> Any:
|
|
508
|
+
"""Build the owned key model for the TUI reader.
|
|
509
|
+
|
|
510
|
+
``swapped`` selects the send/newline layout (see
|
|
511
|
+
:func:`_add_navigate_bindings`); this exists so the reader body
|
|
512
|
+
stays under its complexity budget.
|
|
513
|
+
"""
|
|
514
|
+
bindings = _PT["KeyBindings"]()
|
|
515
|
+
_add_navigate_bindings(bindings, swapped=swapped)
|
|
516
|
+
_add_grapheme_delete_bindings(bindings)
|
|
517
|
+
return bindings
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def read_prompt_message_tui(
|
|
521
|
+
console: Console,
|
|
522
|
+
*,
|
|
523
|
+
history_path: Path,
|
|
524
|
+
swapped_keys: bool | None = None,
|
|
525
|
+
data_dir: str | Path = "",
|
|
526
|
+
profile: str = "",
|
|
527
|
+
) -> str | None:
|
|
528
|
+
"""Read a single-box message with prompt_toolkit (extra installed).
|
|
529
|
+
|
|
530
|
+
Owned key model (prompt_toolkit's defaults are not trusted for a
|
|
531
|
+
multiline chat box); ``swapped_keys`` picks the layout, tri-state:
|
|
532
|
+
|
|
533
|
+
- ``True`` (the shipped default; also when the flag is absent and
|
|
534
|
+
``LISA_PROMPT_KEYS`` is unset/falsy): Enter inserts a newline;
|
|
535
|
+
Ctrl-J sends. Enter never sends — a
|
|
536
|
+
hurried double-tap adds a blank line instead of firing the turn,
|
|
537
|
+
and no buffered keystroke can reach the caller's shell after the
|
|
538
|
+
process exits.
|
|
539
|
+
- ``False`` (chat-box, opt-in via ``--swap-keys`` or truthy
|
|
540
|
+
``LISA_PROMPT_KEYS``): Enter sends; Ctrl-J adds a
|
|
541
|
+
newline.
|
|
542
|
+
- Ctrl+Up / Ctrl+Down are inert: prompt_toolkit binds them to
|
|
543
|
+
previous/next-history, which replaces the whole multiline buffer
|
|
544
|
+
with a history entry — the corruption that forced Ctrl-C.
|
|
545
|
+
- Alt+Up / Alt+Down walk submitted-prompt history (from
|
|
546
|
+
``history_path``).
|
|
547
|
+
- Backspace and Delete remove whole grapheme clusters so
|
|
548
|
+
multi-codepoint emoji (ZWJ families, flags, skin tones) never tear.
|
|
549
|
+
- Ctrl+Left / Ctrl+Right keep the default word motion; Ctrl-C aborts
|
|
550
|
+
(returns ``None``); bare Ctrl-D or an empty submit also returns
|
|
551
|
+
``None`` (nothing to send).
|
|
552
|
+
|
|
553
|
+
Falls back to :func:`read_prompt_message` when the extra is absent.
|
|
554
|
+
The bottom toolbar respects ``LISA_PROMPT_PS1`` (suppressed when
|
|
555
|
+
empty, custom text when set).
|
|
556
|
+
"""
|
|
557
|
+
if _PT is None:
|
|
558
|
+
console.print(
|
|
559
|
+
"[yellow]Full editor unavailable: prompt-toolkit is not installed. "
|
|
560
|
+
"Using plain input; Ctrl-D on an empty line sends.[/yellow]"
|
|
561
|
+
)
|
|
562
|
+
return read_prompt_message(console, data_dir=data_dir, profile=profile)
|
|
563
|
+
if swapped_keys is None:
|
|
564
|
+
# Neither flag nor parameter picked a layout: the env decides.
|
|
565
|
+
# Truthy LISA_PROMPT_KEYS = chat-box (Enter sends); unset or
|
|
566
|
+
# falsy = the shipped swapped layout (Enter = newline).
|
|
567
|
+
swapped_keys = not prompt_keys_env_wants_enter_send()
|
|
568
|
+
history_path.parent.mkdir(parents=True, exist_ok=True)
|
|
569
|
+
try:
|
|
570
|
+
history = _PT["FileHistory"](str(history_path))
|
|
571
|
+
bindings = _tui_key_bindings(swapped=swapped_keys)
|
|
572
|
+
if _PROMPT_PS1_ENV in os.environ:
|
|
573
|
+
hint = prompt_ps1()
|
|
574
|
+
else:
|
|
575
|
+
fragments = resolve_hints(data_dir, profile, layout="swap" if swapped_keys else "chat")
|
|
576
|
+
hint = (
|
|
577
|
+
f"{fragments['send']} · {fragments['newline']} · {fragments['abort']}"
|
|
578
|
+
if any(fragments[k] for k in ("send", "newline", "abort"))
|
|
579
|
+
else None
|
|
580
|
+
)
|
|
581
|
+
hint_html: Any | None = (
|
|
582
|
+
_PT["HTML"](f"<style fg='ansibrightblack'>{hint}</style>") if hint else None
|
|
583
|
+
)
|
|
584
|
+
session: Any = _PT["PromptSession"](
|
|
585
|
+
history=history,
|
|
586
|
+
multiline=True,
|
|
587
|
+
key_bindings=bindings,
|
|
588
|
+
bottom_toolbar=hint_html,
|
|
589
|
+
)
|
|
590
|
+
text: str | None = session.prompt("❯ ", enable_suspend=True)
|
|
591
|
+
except KeyboardInterrupt:
|
|
592
|
+
console.print("[red]⏹ Entry aborted by operator (Ctrl-C).[/red]")
|
|
593
|
+
return None
|
|
594
|
+
except (EOFError, OSError) as exc:
|
|
595
|
+
console.print(f"[red]Failed to read prompt input: {exc}[/red]")
|
|
596
|
+
return None
|
|
597
|
+
if text is None or not text.strip():
|
|
598
|
+
return None
|
|
599
|
+
return text.strip("\n")
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
__all__ = [
|
|
603
|
+
"prompt_history_path",
|
|
604
|
+
"prompt_keys_env_wants_enter_send",
|
|
605
|
+
"read_prompt_message",
|
|
606
|
+
"read_prompt_message_tui",
|
|
607
|
+
]
|