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.
- {hexcli-2.8.1 → hexcli-2.9.1}/CHANGELOG.md +148 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/PKG-INFO +8 -2
- {hexcli-2.8.1 → hexcli-2.9.1}/README.md +7 -1
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/__init__.py +1 -1
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/launcher.py +17 -1
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/lineedit.py +178 -4
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/loop_v2.py +4 -1
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/protocol_v2.py +333 -19
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/repl.py +26 -4
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/statusbar.py +31 -4
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/tools.py +13 -2
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/ui.py +22 -3
- {hexcli-2.8.1 → hexcli-2.9.1}/.gitignore +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/Hex CLI.cmd +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/LICENSE +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/agent.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/assets/hexcli.ico +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/assets/hexcli.png +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/cancel.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/chatlog.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/commands.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/compaction.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/config.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/diffview.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/distribution.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/doctor.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/escalate.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/http_client.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/llm.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/local_escalation.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/lockfile.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/markdown_stream.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/memory.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/network.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/parsing.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/paths.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/prompts.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/safety.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/sessions.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/setup_wizard.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/shell_session.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/stream_render.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/hexcli/telemetry.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/install.ps1 +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/launcher.py +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/pyproject.toml +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/shellai.cmd +0 -0
- {hexcli-2.8.1 → hexcli-2.9.1}/shellai.example.json +0 -0
- {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.
|
|
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+
|
|
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+
|
|
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 |
|
|
@@ -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=
|
|
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":
|
|
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
|
-
|
|
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
|
|