hexcli 2.8.1__tar.gz → 2.9.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {hexcli-2.8.1 → hexcli-2.9.1}/CHANGELOG.md +148 -0
  2. {hexcli-2.8.1 → hexcli-2.9.1}/PKG-INFO +8 -2
  3. {hexcli-2.8.1 → hexcli-2.9.1}/README.md +7 -1
  4. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/__init__.py +1 -1
  5. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/launcher.py +17 -1
  6. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/lineedit.py +178 -4
  7. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/loop_v2.py +4 -1
  8. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/protocol_v2.py +333 -19
  9. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/repl.py +26 -4
  10. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/statusbar.py +31 -4
  11. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/tools.py +13 -2
  12. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/ui.py +22 -3
  13. {hexcli-2.8.1 → hexcli-2.9.1}/.gitignore +0 -0
  14. {hexcli-2.8.1 → hexcli-2.9.1}/Hex CLI.cmd +0 -0
  15. {hexcli-2.8.1 → hexcli-2.9.1}/LICENSE +0 -0
  16. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/agent.py +0 -0
  17. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/assets/hexcli.ico +0 -0
  18. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/assets/hexcli.png +0 -0
  19. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/cancel.py +0 -0
  20. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/chatlog.py +0 -0
  21. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/commands.py +0 -0
  22. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/compaction.py +0 -0
  23. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/config.py +0 -0
  24. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/diffview.py +0 -0
  25. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/distribution.py +0 -0
  26. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/doctor.py +0 -0
  27. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/escalate.py +0 -0
  28. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/http_client.py +0 -0
  29. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/llm.py +0 -0
  30. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/local_escalation.py +0 -0
  31. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/lockfile.py +0 -0
  32. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/markdown_stream.py +0 -0
  33. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/memory.py +0 -0
  34. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/network.py +0 -0
  35. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/parsing.py +0 -0
  36. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/paths.py +0 -0
  37. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/prompts.py +0 -0
  38. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/safety.py +0 -0
  39. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/sessions.py +0 -0
  40. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/setup_wizard.py +0 -0
  41. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/shell_session.py +0 -0
  42. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/stream_render.py +0 -0
  43. {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/telemetry.py +0 -0
  44. {hexcli-2.8.1 → hexcli-2.9.1}/install.ps1 +0 -0
  45. {hexcli-2.8.1 → hexcli-2.9.1}/launcher.py +0 -0
  46. {hexcli-2.8.1 → hexcli-2.9.1}/pyproject.toml +0 -0
  47. {hexcli-2.8.1 → hexcli-2.9.1}/shellai.cmd +0 -0
  48. {hexcli-2.8.1 → hexcli-2.9.1}/shellai.example.json +0 -0
  49. {hexcli-2.8.1 → hexcli-2.9.1}/shellai.py +0 -0
@@ -6,6 +6,154 @@ the Hexagon NPU, not single-run anecdotes.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 2.9.1 — 2026-09-13
10
+
11
+ A patch release: the input line and the status bar; nothing model-facing
12
+ and nothing the launcher hands the server. Gate: CI green on main; smoke
13
+ 10/10 on a fresh server.
14
+
15
+ - The status bar no longer flickers while a turn runs. Every spinner tick
16
+ (12 a second) went through the full redraw: erase the box to the end of
17
+ the screen, then rewrite its rows, with the cursor visible during the
18
+ erase, so Windows Terminal could present the blank frame in between.
19
+ `LiveArea.repaint` now overwrites the box in place when its row count is
20
+ unchanged, each row clears its own line, and `_draw` emits one write with
21
+ the cursor hidden throughout inside a DEC 2026 synchronized update
22
+ (Terminal 1.24 presents the frame atomically; a console that does not
23
+ know the sequence ignores it). The erase path, still used when the box
24
+ grows or shrinks, joins the same frame.
25
+ - The input line previews the best slash-command match while a command
26
+ name is typed: `/he` shows a dim `lp` after the caret, Right arrow
27
+ accepts it with the trailing space a Tab completion adds, and the text
28
+ submitted is only ever what was typed. The preview appears for a bare
29
+ `/word` at the end of the line, never for arguments or paths (no
30
+ filesystem walk per keystroke), and not once the name is complete. Ties
31
+ go to the first match in `REPL_COMMANDS` order (`/c` previews `/clear`);
32
+ Tab still lists the rest. The preview counts toward the row's width so a
33
+ long name still wraps exactly; the caret arithmetic sees only the real
34
+ text.
35
+ - Ctrl+Backspace deletes the word before the caret. A Windows console
36
+ delivers it as DEL (0x7f), which the key map bound to a one-character
37
+ backspace, so it ate one letter per press. Ctrl+Delete deletes the word
38
+ after the caret, and Ctrl+Home / Ctrl+End jump to the start or end of a
39
+ multi-line entry (Home / End stay on the current line), all through the
40
+ extended scancodes the console already sends.
41
+ - Undo and redo on the input line: Ctrl+Z and Ctrl+Y. A run of typed
42
+ characters up to a space is one step, so undo removes the last word; a
43
+ kill, a paste, a completion or a history recall is one step each; cursor
44
+ moves are none. A new edit after an undo drops the redo branch. Two
45
+ hundred steps per entry, cleared when the line is submitted.
46
+ - A command menu under the input while a bare `/word` is typed: the
47
+ matching commands in command order with their one-line descriptions
48
+ (parsed from the `/help` text, so the two can never disagree; custom
49
+ commands say so), the pick marked, eight rows with a "… n more" line
50
+ past that. Up and Down move the pick, Tab takes it with the argument
51
+ space, Enter runs it, Esc closes the menu with the line, and the dim
52
+ preview after the caret follows the pick. Tab on an ambiguous command
53
+ word therefore takes the pick now instead of stopping at the shared
54
+ prefix; off the menu (config keys, paths) Tab still advances as far as
55
+ certainty goes. The menu rows are part of the entry, so the box grows
56
+ into the pad the way a multi-line entry does.
57
+ - `/clear` and `/new` on the live layout. The first fix printed the banner
58
+ with the box up, and text written then is conversation: anchored above
59
+ the box at the bottom, growing upward, so the banner appeared at the
60
+ bottom. Both commands now take the box down without padding, clear,
61
+ print, and pin the box again under what they printed. `/new` always
62
+ prints the banner (a new session starts the way the program does);
63
+ `/clear` prints it only if it was still on screen, which the live area
64
+ tracks: set when the banner is printed, cleared when a write scrolls the
65
+ window past its pad, when a growing entry scrolls it, or when the screen
66
+ is cleared.
67
+
68
+ ## 2.9.0 — 2026-09-13
69
+
70
+ A minor release: `edit_file` and `write_file` behave differently for the
71
+ model, and the launcher hands the agent its package path. Gate: extended
72
+ suite at 5 runs PASS after recheck, uc1 at 6 runs, smoke 10/10, CI green.
73
+
74
+ - `edit_file` merges the model's change into the file instead of pasting
75
+ its memory of the file. Multi-turn traces (uc1-t3/t4, 2026-09-13) show
76
+ the 4B rebuilding lines from the traceback in its history rather than
77
+ copying them: `for item in data` where the file says `items`, `"average":
78
+ average` where it says `avg`, a dropped trailing comment. Its
79
+ `old_string` then sits at 40–95 % similarity while the change it wants is
80
+ small and clear (`appned` → `append`; add a key to a dict). The old
81
+ fallback pasted `new_string` over the closest region when similarity was
82
+ ≥ 95 %, which installed the misremembered names: five of six live uc1-t4
83
+ runs ended in `NameError: name 'average' is not defined`, put there by the
84
+ harness, and the model then retried the same line four times. The paste
85
+ is gone. `protocol_v2._apply_one` now runs a token-level three-way merge
86
+ between the file's closest region, `old_string` and `new_string`: hunks
87
+ the model changed are applied where the file agrees with what the model
88
+ saw, tokens the model misremembered keep the file's text, and a hunk that
89
+ touches a misremembered token refuses (unless the "change" merely restates
90
+ what the file already says, which is skipped). Inserted lines are
91
+ re-indented by the delta between the model's line and the file's; a
92
+ whitespace run holding a newline never matches one that does not; a
93
+ replacement that would restate the tokens beside its anchor, grow into a
94
+ side the model did not see correctly, or carry JSON escapes into a file
95
+ that has no backslashes, refuses. Every refusal still reports the closest
96
+ region as before. Replaying the saved traces against the fixtures: of 20
97
+ distinct failed uc1-t3 edits 5 now apply correctly, 15 still error, 0
98
+ apply wrongly; of 23 non-exact uc1-t4 attempts 8 apply, 12 refuse, 0
99
+ produce a harmful file (the first draft produced two syntax errors, from
100
+ un-shifted indentation and literal `\"` sequences, both now refused or
101
+ fixed).
102
+ - `edit_file` decodes a double-escaped `old_string`. 58 of 667 saved
103
+ edit_file calls (38 of them errors) carried `\"` or literal `\n` because
104
+ the model escaped its JSON arguments twice (agentic-3: `\"name\":
105
+ \"demo\"` for a file holding `"name": "demo"`). When the raw string is
106
+ absent from the file and the decoded one is present, both strings are
107
+ decoded once and the tiers run again. `write_file` does the same for a
108
+ body that is one line of literal `\n` sequences with no real line break
109
+ (1 of 389 saved calls), and leaves any body with a real newline alone.
110
+ - Audit of the same bug classes elsewhere: protocol v2's `edit` action
111
+ shares the applier and gains all of the above; the JSON action parser
112
+ only tracks escapes to find brace boundaries and never re-escapes; tier 3
113
+ (indent shift) replaces whole lines and has no trailing-segment case;
114
+ `run_command` carried one escaped argument in 303 calls and it was a
115
+ legitimate regex.
116
+ - The edit and write event lines name what landed a non-exact edit
117
+ (`, transfer match`, `, unescaped match`, `, indent match`; `, unescaped`
118
+ on a write) so an arm log shows which calls the fallbacks rescued; the
119
+ model still reads `Edited <path>` / `Wrote <path>`.
120
+ - A checkout's launcher runs the checkout's code. `python -m hexcli.agent`
121
+ resolves the package from the working directory, then site-packages; the
122
+ Terminal profile starts in the home directory, so since the 2.8.0
123
+ packaging the launcher started from `Hex CLI.cmd` was silently running
124
+ the pip-installed copy (2.7.1 on the owner's machine on 2026-09-13,
125
+ found when a new input-line feature did not appear) while the checkout
126
+ sat unused. The launcher now prepends its own package's parent to the
127
+ child's `PYTHONPATH` (`_agent_env`), a no-op for an installed copy.
128
+ - `run_recheck.cmd` documents that the case list must be quoted: cmd splits
129
+ an unquoted comma list into arguments, so the first 2026-09-13 recheck ran
130
+ one case and handed the second case's name to the gate as a baseline path.
131
+ - Live numbers (fresh server per arm, 2026-09-13, a day with under 1 GB
132
+ free on the 16 GB machine). Smoke 10/10. uc1 at 6 runs: t1 6/6, t2 6/6,
133
+ t3 5/6 (3/3 on 09-12, 15/24 pooled over earlier arms; the miss called no
134
+ tool), t4 1/6 (0/3 in every baseline: the first pass ever, on a merged
135
+ insertion beside the file's own `avg`), t5 0/6, t6 0/6 as before; the
136
+ merge landed 13 edits across uc1/uc2 that would have errored. uc2 at 3
137
+ runs: t2 2/3 (the model edited on an explain-only turn; both stray edits
138
+ refused), the rest 3/3. uc3 at 3 runs, twice: every run invalid on both
139
+ attempts, the client's 300 s timeout expiring while the server answered
140
+ 429 busy at ~3,200 tokens of context (the 09-12 baseline lost one of
141
+ three the same way; no HTP hang in the server log), so uc3 has no valid
142
+ measurement today. Extended suite at 5 runs, seed 20260913: 31/44 pass^5
143
+ (32/44 on the 2.7.x baseline, 31/41 on 09-05), run-level 162/205 vs
144
+ 165/208, Fisher p=1.0; not one fallback tier fired in those 220 runs (the
145
+ event-line markers show none), so the suite is a regression check only.
146
+ Gate RECHECK on agentic-3 (4/5) and self-correct-1 (4/5); recheck at 6
147
+ runs: self-correct-1 5/6, agentic-3 4/6 (two double-escaped runs, one
148
+ rescued by the decode, one landing the model's own comma-less JSON, and
149
+ one old_string hallucinated three times) → gate FAIL; a second agentic-3
150
+ recheck on a fresh server 6/6 → gate PASS. Ceiling panel: +lint-1
151
+ 3/5→5/5, +trap-4 0/5→1/5, −regression-anchor-1 5/5→4/5 (the model wrote
152
+ invalid JSON through an exact-match edit). Results:
153
+ `merge_transfer_r5.json`, `multiturn_uc{1,2,3}_merge_*_20260913.json`,
154
+ log `merge_transfer.log`; the earlier anchor-only tier's arm is
155
+ `delta_transfer_r5.json` (gate PASS after recheck, 27/44, superseded).
156
+
9
157
  ## 2.8.1 — 2026-09-12
10
158
 
11
159
  A patch release: nothing model-facing and nothing the launcher hands the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hexcli
3
- Version: 2.8.1
3
+ Version: 2.9.1
4
4
  Summary: Local Hexagon NPU terminal agent for Snapdragon X Elite Windows ARM64
5
5
  Project-URL: Homepage, https://github.com/NathanL15/Hex-CLI
6
6
  Project-URL: Repository, https://github.com/NathanL15/Hex-CLI
@@ -163,9 +163,15 @@ follows the command:
163
163
  |---|---|
164
164
  | `↑` `↓` | history. With text typed, searches by that prefix |
165
165
  | `Tab` | complete commands, config keys, and file paths |
166
+ | `Right` | accept the dim preview of a slash command (`/he` shows `lp`) |
167
+ | `/` | opens the command menu under the input; `Up` `Down` pick, `Tab` takes the pick, `Enter` runs it |
168
+ | `Ctrl+Z` `Ctrl+Y` | undo, redo (a typed word is one step) |
166
169
  | `Ctrl+←` `Ctrl+→` | move by word |
167
170
  | `Home` `End` | start or end of line |
168
- | `Ctrl+W` `Ctrl+U` `Ctrl+K` | delete the word before, to line start, to line end |
171
+ | `Ctrl+W` `Ctrl+Backspace` | delete the word before the caret |
172
+ | `Ctrl+Delete` | delete the word after the caret |
173
+ | `Ctrl+U` `Ctrl+K` | delete to line start, to line end |
174
+ | `Ctrl+Home` `Ctrl+End` | start or end of a multi-line entry |
169
175
  | `Esc` | clear the line |
170
176
  | `Ctrl+V` | paste a block. Nothing is sent until you press Enter |
171
177
  | `Shift+Enter` | new line inside the entry |
@@ -136,9 +136,15 @@ follows the command:
136
136
  |---|---|
137
137
  | `↑` `↓` | history. With text typed, searches by that prefix |
138
138
  | `Tab` | complete commands, config keys, and file paths |
139
+ | `Right` | accept the dim preview of a slash command (`/he` shows `lp`) |
140
+ | `/` | opens the command menu under the input; `Up` `Down` pick, `Tab` takes the pick, `Enter` runs it |
141
+ | `Ctrl+Z` `Ctrl+Y` | undo, redo (a typed word is one step) |
139
142
  | `Ctrl+←` `Ctrl+→` | move by word |
140
143
  | `Home` `End` | start or end of line |
141
- | `Ctrl+W` `Ctrl+U` `Ctrl+K` | delete the word before, to line start, to line end |
144
+ | `Ctrl+W` `Ctrl+Backspace` | delete the word before the caret |
145
+ | `Ctrl+Delete` | delete the word after the caret |
146
+ | `Ctrl+U` `Ctrl+K` | delete to line start, to line end |
147
+ | `Ctrl+Home` `Ctrl+End` | start or end of a multi-line entry |
142
148
  | `Esc` | clear the line |
143
149
  | `Ctrl+V` | paste a block. Nothing is sent until you press Enter |
144
150
  | `Shift+Enter` | new line inside the entry |
@@ -3,4 +3,4 @@
3
3
  # The one place the version is written. pyproject.toml reads it (hatch
4
4
  # dynamic version), agent.VERSION re-exports it, and CI refuses a release
5
5
  # tag that does not match it.
6
- __version__ = "2.8.1"
6
+ __version__ = "2.9.1"
@@ -446,10 +446,26 @@ def run_npurun_path() -> int:
446
446
  # up the same SDK and Rewind settings, not whatever the shell had.
447
447
  return subprocess.run(
448
448
  [sys.executable, "-m", "hexcli.agent", "--config", str(NPURUN_CONFIG), *sys.argv[1:]],
449
- env=_npurun_env(),
449
+ env=_agent_env(),
450
450
  ).returncode
451
451
 
452
452
 
453
+ def _agent_env() -> dict[str, str]:
454
+ """The server environment plus the path of THIS package, so the child
455
+ runs the same code as the launcher. `python -m hexcli.agent` resolves
456
+ the package from the working directory first and site-packages next;
457
+ the Terminal profile starts in the home directory, so a checkout's
458
+ launcher was starting the pip-installed copy (2.7.1 on the owner's
459
+ machine on 2026-09-13, with none of the 2.8.x or later changes) while
460
+ the checkout sat unused. Prepending the package's parent to PYTHONPATH
461
+ is a no-op for an installed copy and the fix for a checkout."""
462
+ env = dict(_npurun_env())
463
+ here = str(Path(__file__).resolve().parent.parent)
464
+ prior = env.get("PYTHONPATH", "")
465
+ env["PYTHONPATH"] = here if not prior else f"{here}{os.pathsep}{prior}"
466
+ return env
467
+
468
+
453
469
  # Flags the REPL answers on its own; no server needed, so `hex --version`
454
470
  # must not start one (or write the runtime config) first.
455
471
  _NO_SERVER_FLAGS = frozenset({"-h", "--help", "--version", "--doctor", "--update",
@@ -32,7 +32,7 @@ import re
32
32
  import sys
33
33
  import time
34
34
  import unicodedata
35
- from collections.abc import Callable, Iterable, Sequence
35
+ from collections.abc import Callable, Iterable, Mapping, Sequence
36
36
  from pathlib import Path
37
37
  from typing import Any
38
38
 
@@ -52,9 +52,14 @@ HOME = "<home>"
52
52
  END = "<end>"
53
53
  WORD_LEFT = "<word-left>"
54
54
  WORD_RIGHT = "<word-right>"
55
- KILL_WORD = "<kill-word>" # Ctrl+W
55
+ KILL_WORD = "<kill-word>" # Ctrl+W, Ctrl+Backspace
56
+ KILL_WORD_FORWARD = "<kill-word-forward>" # Ctrl+Delete
57
+ BUFFER_START = "<buffer-start>" # Ctrl+Home: the first line of a multi-line entry
58
+ BUFFER_END = "<buffer-end>" # Ctrl+End
56
59
  KILL_LINE = "<kill-line>" # Ctrl+K
57
60
  KILL_TO_START = "<kill-to-start>" # Ctrl+U
61
+ UNDO = "<undo>" # Ctrl+Z
62
+ REDO = "<redo>" # Ctrl+Y
58
63
  CLEAR_SCREEN = "<clear-screen>" # Ctrl+L
59
64
  INTERRUPT = "<interrupt>" # Ctrl+C
60
65
  EOF_KEY = "<eof>" # Ctrl+D on an empty buffer
@@ -73,16 +78,18 @@ _EXTENDED = {
73
78
  "H": UP, "P": DOWN, "K": LEFT, "M": RIGHT,
74
79
  "G": HOME, "O": END, "S": DELETE,
75
80
  "s": WORD_LEFT, "t": WORD_RIGHT,
81
+ "\x93": KILL_WORD_FORWARD, "w": BUFFER_START, "u": BUFFER_END,
76
82
  }
77
83
  _CONTROL = {
78
84
  "\r": ENTER, "\n": NEWLINE, "\t": TAB,
79
- "\x08": BACKSPACE, "\x7f": BACKSPACE,
85
+ "\x08": BACKSPACE, "\x7f": KILL_WORD, # 0x7f is Ctrl+Backspace on a Windows console
80
86
  "\x01": HOME, "\x05": END,
81
87
  "\x02": LEFT, "\x06": RIGHT,
82
88
  "\x0e": DOWN, "\x10": UP,
83
89
  "\x03": INTERRUPT, "\x04": EOF_KEY,
84
90
  "\x0b": KILL_LINE, "\x15": KILL_TO_START, "\x17": KILL_WORD,
85
91
  "\x0c": CLEAR_SCREEN, "\x1b": ESCAPE,
92
+ "\x1a": UNDO, "\x19": REDO,
86
93
  }
87
94
 
88
95
 
@@ -480,6 +487,7 @@ class LineEditor:
480
487
  *,
481
488
  history: History | None = None,
482
489
  completer: Callable[[str], list[str]] | None = None,
490
+ command_help: Mapping[str, str] | None = None,
483
491
  read_key: Callable[[], str] | None = None,
484
492
  write: Callable[[str], None] | None = None,
485
493
  width: int | None = None,
@@ -498,6 +506,16 @@ class LineEditor:
498
506
  ) -> None:
499
507
  self.history = history or History()
500
508
  self.completer = completer
509
+ self.command_help: Mapping[str, str] = dict(command_help or {})
510
+ # Undo/redo over the entry: (buffer, pos) snapshots. Typing a word
511
+ # is one step (a run of non-space characters, then the space), a
512
+ # kill or a paste is one step; a cursor move is none.
513
+ self._undo: list[tuple[str, int]] = []
514
+ self._redo: list[tuple[str, int]] = []
515
+ self._undo_kind = ""
516
+ # The / menu: which of the matching commands is selected.
517
+ self._menu_index = 0
518
+ self._menu_key: str | None = None
501
519
  self._read_key = read_key or windows_key_reader()
502
520
  self._write = write or (lambda s: (sys.stdout.write(s), sys.stdout.flush()) and None)
503
521
  self._forced_width = width
@@ -621,6 +639,16 @@ class LineEditor:
621
639
  hint = self.placeholder[: max(0, self.usable - visible_len(last_prompt) - 1)]
622
640
  styled = f"\033[2m{hint}\033[0m" if self.styled else hint
623
641
  logical[-1] = (last_prompt + styled, visible_len(last_prompt) + len(hint))
642
+ ghost = self._ghost() if chrome else ""
643
+ if ghost:
644
+ # The rest of the best slash-command match, dim, after the
645
+ # cursor. It counts toward the row's width so wrapping stays
646
+ # exact; the cursor arithmetic below only sees the real text.
647
+ text, vis = logical[-1]
648
+ styled = f"\033[2m{ghost}\033[0m" if self.styled else ghost
649
+ logical[-1] = (text + styled, vis + len(ghost))
650
+ if chrome:
651
+ logical.extend(self._menu_rows())
624
652
  for line in below:
625
653
  logical.append((line, visible_len(line)))
626
654
 
@@ -825,6 +853,84 @@ class LineEditor:
825
853
 
826
854
  # -- completion ---------------------------------------------------------
827
855
 
856
+ def _ghost(self) -> str:
857
+ """The rest of the best slash-command match while a command name is
858
+ being typed at the end of the line: "/he" previews "lp". Empty when
859
+ the name is complete, ambiguous beyond a shared prefix is fine (the
860
+ first match in command order wins, Tab still lists them), and empty
861
+ for anything that is not a bare command word, so paths and
862
+ arguments never trigger a filesystem walk on every keystroke."""
863
+ buf = self.buffer
864
+ if (self.completer is None or self.pos != len(buf) or not buf.startswith("/")
865
+ or any(ch.isspace() for ch in buf)):
866
+ return ""
867
+ try:
868
+ candidates = self.completer(buf)
869
+ except Exception: # noqa: BLE001 — a preview must never break typing
870
+ return ""
871
+ word = buf.lower()
872
+ if any(c.lower() == word for c in candidates):
873
+ return ""
874
+ items = self._menu_items()
875
+ if items:
876
+ cand = items[self._menu_index]
877
+ return cand[len(buf):] if cand.lower().startswith(word) and len(cand) > len(buf) else ""
878
+ for cand in candidates:
879
+ if cand.lower().startswith(word) and len(cand) > len(buf):
880
+ return cand[len(buf):]
881
+ return ""
882
+
883
+ MENU_ROWS = 8
884
+
885
+ def _menu_items(self) -> list[str]:
886
+ """The slash commands matching a bare /word at the end of the line,
887
+ in command order; the menu the editor draws under the input row.
888
+ The selection index survives while the list is the same and resets
889
+ when typing changes it."""
890
+ buf = self.buffer
891
+ if (self.completer is None or self.pos != len(buf) or not buf.startswith("/")
892
+ or any(ch.isspace() for ch in buf)):
893
+ self._menu_key = None
894
+ return []
895
+ try:
896
+ items = [c for c in self.completer(buf) if c.startswith("/")]
897
+ except Exception: # noqa: BLE001
898
+ items = []
899
+ key = "\0".join(items)
900
+ if key != self._menu_key:
901
+ self._menu_key = key
902
+ self._menu_index = 0
903
+ if self._menu_index >= len(items):
904
+ self._menu_index = 0
905
+ return items
906
+
907
+ def _menu_rows(self) -> list[tuple[str, int]]:
908
+ """(rendered row, visible width) per menu line, clipped to the width."""
909
+ items = self._menu_items()
910
+ if not items:
911
+ return []
912
+ first = max(0, min(self._menu_index - self.MENU_ROWS + 1, len(items) - self.MENU_ROWS))
913
+ first = max(0, min(first, self._menu_index))
914
+ shown = items[first:first + self.MENU_ROWS]
915
+ name_w = max(len(c) for c in shown)
916
+ rows: list[tuple[str, int]] = []
917
+ for n, cand in enumerate(shown, first):
918
+ desc = self.command_help.get(cand, "custom command" if cand not in self.command_help and cand in items else "")
919
+ avail = max(0, self.usable - 4 - name_w - 2)
920
+ desc = desc[:avail]
921
+ plain = f" {'▸' if n == self._menu_index else ' '} {cand.ljust(name_w)} {desc}".rstrip()
922
+ if self.styled and n == self._menu_index:
923
+ text = f"\033[1m{plain}\033[0m"
924
+ elif self.styled:
925
+ text = f"\033[2m{plain}\033[0m"
926
+ else:
927
+ text = plain
928
+ rows.append((text, visible_len(plain)))
929
+ if len(items) > len(shown):
930
+ more = f" … {len(items) - len(shown)} more"
931
+ rows.append((f"\033[2m{more}\033[0m" if self.styled else more, len(more)))
932
+ return rows
933
+
828
934
  def _complete(self, prompt: str) -> None:
829
935
  if self.completer is None:
830
936
  return
@@ -871,6 +977,8 @@ class LineEditor:
871
977
  ``input()`` does, so callers keep their existing handlers."""
872
978
  self.buffer, self.pos = "", 0
873
979
  self._hist_index, self._hist_prefix, self._saved_draft = None, "", ""
980
+ self._undo, self._redo, self._undo_kind = [], [], ""
981
+ self._menu_index, self._menu_key = 0, None
874
982
  self._rendered_rows, self._cursor_row = 0, 0
875
983
  self._last_text = None
876
984
  self._last_size = (self.usable, self.height)
@@ -951,9 +1059,56 @@ class LineEditor:
951
1059
  self.render(prompt)
952
1060
 
953
1061
  def _handle(self, key: str, prompt: str) -> str | None:
954
- """Apply one key. Returns the finished line, or None to keep editing."""
1062
+ """Apply one key. Returns the finished line, or None to keep editing.
1063
+ Every change to the text lands on the undo stack; a run of typed
1064
+ characters up to a space is one step."""
1065
+ if key == UNDO:
1066
+ if self._undo:
1067
+ self._redo.append((self.buffer, self.pos))
1068
+ self.buffer, self.pos = self._undo.pop()
1069
+ self._undo_kind = ""
1070
+ return None
1071
+ if key == REDO:
1072
+ if self._redo:
1073
+ self._undo.append((self.buffer, self.pos))
1074
+ self.buffer, self.pos = self._redo.pop()
1075
+ self._undo_kind = ""
1076
+ return None
1077
+ before = (self.buffer, self.pos)
1078
+ result = self._handle_key(key, prompt)
1079
+ if self.buffer != before[0]:
1080
+ if key == BACKSPACE:
1081
+ kind = "backspace"
1082
+ elif len(key) == 1 and key.isprintable() and not key.isspace():
1083
+ kind = "type"
1084
+ else:
1085
+ kind = ""
1086
+ if not (kind and kind == self._undo_kind):
1087
+ self._undo.append(before)
1088
+ del self._undo[:-200]
1089
+ self._undo_kind = kind
1090
+ self._redo.clear()
1091
+ elif key not in (LEFT, RIGHT, HOME, END, WORD_LEFT, WORD_RIGHT, BUFFER_START, BUFFER_END, IDLE):
1092
+ self._undo_kind = ""
1093
+ return result
1094
+
1095
+ def _handle_key(self, key: str, prompt: str) -> str | None:
955
1096
  buf = self.buffer
956
1097
 
1098
+ # The / menu: Up/Down pick, Tab takes the pick, Enter runs it.
1099
+ menu = self._menu_items() if key in (UP, DOWN, TAB, ENTER) else []
1100
+ if menu and key in (UP, DOWN) and len(menu) > 1:
1101
+ self._menu_index = (self._menu_index + (-1 if key == UP else 1)) % len(menu)
1102
+ return None
1103
+ if menu and key == TAB:
1104
+ self.buffer = menu[self._menu_index] + " "
1105
+ self.pos = len(self.buffer)
1106
+ return None
1107
+ if menu and key == ENTER and buf.lower() != menu[self._menu_index].lower():
1108
+ self.buffer = menu[self._menu_index]
1109
+ self.pos = len(self.buffer)
1110
+ buf = self.buffer
1111
+
957
1112
  if key == ENTER:
958
1113
  # A trailing backslash is an explicit "keep going" — the one way to
959
1114
  # get a multi-line entry without pasting. It must be preceded by
@@ -989,6 +1144,14 @@ class LineEditor:
989
1144
  self.pos = max(0, self.pos - 1)
990
1145
  return None
991
1146
  if key == RIGHT:
1147
+ if self.pos == len(buf):
1148
+ ghost = self._ghost()
1149
+ if ghost:
1150
+ # Accept the preview; the space means "now the argument",
1151
+ # as a Tab completion does.
1152
+ self.buffer = buf + ghost + " "
1153
+ self.pos = len(self.buffer)
1154
+ return None
992
1155
  self.pos = min(len(buf), self.pos + 1)
993
1156
  return None
994
1157
  if key == WORD_LEFT:
@@ -1008,6 +1171,15 @@ class LineEditor:
1008
1171
  self.buffer = buf[:start] + buf[self.pos:]
1009
1172
  self.pos = start
1010
1173
  return None
1174
+ if key == KILL_WORD_FORWARD:
1175
+ self.buffer = buf[:self.pos] + buf[self._word_end():]
1176
+ return None
1177
+ if key == BUFFER_START:
1178
+ self.pos = 0
1179
+ return None
1180
+ if key == BUFFER_END:
1181
+ self.pos = len(buf)
1182
+ return None
1011
1183
  if key == KILL_TO_START:
1012
1184
  start = self._line_start()
1013
1185
  self.buffer = buf[:start] + buf[self.pos:]
@@ -1061,6 +1233,7 @@ def make_reader(
1061
1233
  config: dict[str, Any],
1062
1234
  commands: Sequence[str],
1063
1235
  config_keys: Callable[[], Iterable[str]] | None = None,
1236
+ command_help: Mapping[str, str] | None = None,
1064
1237
  on_zoom: Callable[[int], Any] | None = None,
1065
1238
  on_resize: Callable[[], Any] | None = None,
1066
1239
  chrome: Callable[[int], tuple[list[str], list[str]]] | None = None,
@@ -1095,6 +1268,7 @@ def make_reader(
1095
1268
  editor = LineEditor(
1096
1269
  history=History(path, int(config.get("input_history_limit", 500))),
1097
1270
  completer=default_completer(commands, config_keys),
1271
+ command_help=command_help,
1098
1272
  margin=int(config.get("side_padding", 0) or 0),
1099
1273
  on_zoom=on_zoom,
1100
1274
  on_resize=on_resize,
@@ -157,7 +157,10 @@ def _tool_edit(agent: Any, args: dict[str, Any], payload: list[tuple[str, str]]
157
157
  tmp = path.parent / (path.name + ".tmp")
158
158
  tmp.write_text(new_content, encoding="utf-8")
159
159
  tmp.replace(path)
160
- ui.tool_event("edit", f"{path} ({len(payload)} block(s))")
160
+ from . import protocol_v2 as p2
161
+ tier = p2.LAST_APPLY_TIER
162
+ suffix = "" if tier == "exact" else f", {tier} match"
163
+ ui.tool_event("edit", f"{path} ({len(payload)} block(s){suffix})")
161
164
  return f"Edited {path}: {len(payload)} block(s) applied."
162
165
 
163
166