lisa-cli 0.1.0__tar.gz → 0.2.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.
Files changed (37) hide show
  1. lisa_cli-0.2.0/LICENSE +21 -0
  2. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/PKG-INFO +6 -1
  3. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/pyproject.toml +10 -2
  4. lisa_cli-0.2.0/src/hosts/cli/entry.py +607 -0
  5. lisa_cli-0.2.0/src/hosts/cli/expand.py +158 -0
  6. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/src/hosts/cli/main.py +202 -120
  7. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/src/hosts/cli/transport.py +3 -7
  8. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/src/lisa_cli.egg-info/PKG-INFO +6 -1
  9. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/src/lisa_cli.egg-info/SOURCES.txt +5 -0
  10. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/src/lisa_cli.egg-info/requires.txt +3 -0
  11. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/tests/test_group_resolution.py +35 -0
  12. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/tests/test_pack_update.py +17 -24
  13. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/tests/test_prompt_command.py +22 -0
  14. lisa_cli-0.2.0/tests/test_prompt_entry.py +1038 -0
  15. lisa_cli-0.2.0/tests/test_prompt_expand.py +171 -0
  16. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/tests/test_prompt_transport.py +6 -4
  17. lisa_cli-0.2.0/tests/test_restart.py +83 -0
  18. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/tests/test_run_command.py +14 -9
  19. lisa_cli-0.1.0/tests/test_restart.py +0 -97
  20. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/setup.cfg +0 -0
  21. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/src/hosts/__init__.py +0 -0
  22. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/src/hosts/cli/__init__.py +0 -0
  23. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/src/hosts/cli/render.py +0 -0
  24. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/src/lisa_cli.egg-info/dependency_links.txt +0 -0
  25. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/src/lisa_cli.egg-info/entry_points.txt +0 -0
  26. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/src/lisa_cli.egg-info/top_level.txt +0 -0
  27. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/tests/test_approval_panel.py +0 -0
  28. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/tests/test_last_command.py +0 -0
  29. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/tests/test_models_command.py +0 -0
  30. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/tests/test_profile_option.py +0 -0
  31. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/tests/test_prompt_decisions.py +0 -0
  32. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/tests/test_render_catalogs.py +0 -0
  33. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/tests/test_render_models.py +0 -0
  34. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/tests/test_runtime_teardown.py +0 -0
  35. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/tests/test_streaming.py +0 -0
  36. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/tests/test_transport_stage.py +0 -0
  37. {lisa_cli-0.1.0 → lisa_cli-0.2.0}/tests/test_verify_command.py +0 -0
lisa_cli-0.2.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.1.0
3
+ Version: 0.2.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>=68"]
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.1.0"
10
+ version = "0.2.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
+ ]