hexcli 2.9.0__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.9.0 → hexcli-2.9.1}/CHANGELOG.md +59 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/PKG-INFO +8 -2
- {hexcli-2.9.0 → hexcli-2.9.1}/README.md +7 -1
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/__init__.py +1 -1
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/lineedit.py +178 -4
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/repl.py +26 -4
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/statusbar.py +31 -4
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/ui.py +22 -3
- {hexcli-2.9.0 → hexcli-2.9.1}/.gitignore +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/Hex CLI.cmd +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/LICENSE +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/agent.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/assets/hexcli.ico +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/assets/hexcli.png +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/cancel.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/chatlog.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/commands.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/compaction.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/config.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/diffview.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/distribution.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/doctor.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/escalate.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/http_client.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/launcher.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/llm.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/local_escalation.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/lockfile.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/loop_v2.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/markdown_stream.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/memory.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/network.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/parsing.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/paths.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/prompts.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/protocol_v2.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/safety.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/sessions.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/setup_wizard.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/shell_session.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/stream_render.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/telemetry.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/tools.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/install.ps1 +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/launcher.py +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/pyproject.toml +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/shellai.cmd +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/shellai.example.json +0 -0
- {hexcli-2.9.0 → hexcli-2.9.1}/shellai.py +0 -0
|
@@ -6,6 +6,65 @@ 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
|
+
|
|
9
68
|
## 2.9.0 — 2026-09-13
|
|
10
69
|
|
|
11
70
|
A minor release: `edit_file` and `write_file` behave differently for the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hexcli
|
|
3
|
-
Version: 2.9.
|
|
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 |
|
|
@@ -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,
|
|
@@ -384,6 +384,23 @@ def run_repl(config: dict[str, Any]) -> int:
|
|
|
384
384
|
ui.print_banner(npu_model or str(config.get("model", "?")),
|
|
385
385
|
str(config.get("backend", "ollama")),
|
|
386
386
|
engine="Hexagon NPU" if npu_model else None)
|
|
387
|
+
if live is not None:
|
|
388
|
+
live.banner_printed()
|
|
389
|
+
|
|
390
|
+
def _fresh_screen(with_banner: bool) -> None:
|
|
391
|
+
"""Clear the screen for /clear and /new. The box comes down without
|
|
392
|
+
padding first: text written while it is up is conversation and is
|
|
393
|
+
anchored above the box at the bottom, which is where the banner
|
|
394
|
+
landed on the first attempt (2026-09-13). With the box down the
|
|
395
|
+
banner keeps the top, and resume() pins the box under it again."""
|
|
396
|
+
if live is not None:
|
|
397
|
+
live.suspend()
|
|
398
|
+
os.system("cls" if os.name == "nt" else "clear")
|
|
399
|
+
if live is not None:
|
|
400
|
+
live.screen_cleared() # the box and its pad rows are gone with the screen
|
|
401
|
+
sys.stdout.write("\r") # the clear skipped the margin's fill for this row
|
|
402
|
+
if with_banner:
|
|
403
|
+
_banner()
|
|
387
404
|
|
|
388
405
|
_banner()
|
|
389
406
|
if live is not None:
|
|
@@ -455,6 +472,7 @@ def run_repl(config: dict[str, Any]) -> int:
|
|
|
455
472
|
|
|
456
473
|
read_line = lineedit.make_reader(
|
|
457
474
|
config, tuple(REPL_COMMANDS) + custom_names, lambda: sorted(sa._CONFIG_SETTABLE),
|
|
475
|
+
command_help={**ui.COMMAND_HELP, **{c: "custom command" for c in custom_names}},
|
|
458
476
|
on_zoom=_zoom, on_resize=_resize,
|
|
459
477
|
chrome=live.chrome if live is not None else None,
|
|
460
478
|
placeholder="ask, or / for commands" if live is not None else "",
|
|
@@ -591,22 +609,26 @@ def run_repl(config: dict[str, Any]) -> int:
|
|
|
591
609
|
# away. /clear is now the one reset command; /new remains an alias
|
|
592
610
|
# that keeps the scrollback.
|
|
593
611
|
if norm == "/clear":
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
612
|
+
# The banner comes back only if it was still on screen: a
|
|
613
|
+
# cleared long conversation stays clear.
|
|
614
|
+
_fresh_screen(with_banner=live is None or live.banner_visible)
|
|
597
615
|
sa.sync_session_store(sessions, current_session)
|
|
598
616
|
_close_session_resources(current_session)
|
|
599
617
|
current_session = sa.create_session()
|
|
600
|
-
sys.stdout.write("\r") # the clear skipped the margin's fill for this row
|
|
601
618
|
sa.cprint(" Chat history cleared.", sa.C.DIM)
|
|
619
|
+
if live is not None:
|
|
620
|
+
live.resume()
|
|
602
621
|
continue
|
|
603
622
|
|
|
604
623
|
# ── new session ───────────────────────────────────────────────────
|
|
605
624
|
if norm == "/new":
|
|
625
|
+
_fresh_screen(with_banner=True) # a new session starts the way the program does
|
|
606
626
|
sa.sync_session_store(sessions, current_session)
|
|
607
627
|
_close_session_resources(current_session)
|
|
608
628
|
current_session = sa.create_session()
|
|
609
629
|
sa.cprint(" New session started.", sa.C.DIM)
|
|
630
|
+
if live is not None:
|
|
631
|
+
live.resume()
|
|
610
632
|
continue
|
|
611
633
|
|
|
612
634
|
# ── resume ────────────────────────────────────────────────────────
|
|
@@ -408,6 +408,10 @@ class LiveArea:
|
|
|
408
408
|
self.prompt = prompt
|
|
409
409
|
self._geometry = geometry or console_geometry
|
|
410
410
|
self.editor_rows = 4 # rule, input row, rule, status: what the editor draws
|
|
411
|
+
# Whether the banner printed at the top of the window is still on it:
|
|
412
|
+
# true after banner_printed(), false once anything scrolls the window
|
|
413
|
+
# or clears the screen. /clear reprints the banner only when this holds.
|
|
414
|
+
self.banner_visible = False
|
|
411
415
|
self._pad_top: int | None = None # first row of the blank pad above the conversation
|
|
412
416
|
self._pad_above = 0 # how many pad rows there are
|
|
413
417
|
self._last_shape: tuple[Any, int] | None = None # (window height, usable width) at the last draw
|
|
@@ -489,13 +493,21 @@ class LiveArea:
|
|
|
489
493
|
with self.lock:
|
|
490
494
|
self._drawn = 0
|
|
491
495
|
self._signature = None
|
|
496
|
+
self.banner_visible = False
|
|
492
497
|
self.reset_pad()
|
|
493
498
|
|
|
499
|
+
def banner_printed(self) -> None:
|
|
500
|
+
"""The banner was just written at the top of a fresh screen."""
|
|
501
|
+
with self.lock:
|
|
502
|
+
self.banner_visible = True
|
|
503
|
+
|
|
494
504
|
def note_scroll(self, rows: int) -> None:
|
|
495
505
|
"""The window scrolled up by `rows` (the editor grew past the bottom
|
|
496
506
|
with a multi-line entry): the pad moved up with it, and any part of
|
|
497
507
|
it that left the window is gone."""
|
|
498
508
|
with self.lock:
|
|
509
|
+
if rows > 0:
|
|
510
|
+
self.banner_visible = False
|
|
499
511
|
if rows <= 0 or self._pad_top is None:
|
|
500
512
|
return
|
|
501
513
|
self._pad_top -= rows
|
|
@@ -611,10 +623,17 @@ class LiveArea:
|
|
|
611
623
|
elif below < len(rows):
|
|
612
624
|
self._delete_pad_rows(inner, len(rows) - below) # the rest scrolls
|
|
613
625
|
saved = (m.col, m.word, m.word_vis)
|
|
614
|
-
|
|
626
|
+
# One write, cursor hidden throughout, inside a synchronized update
|
|
627
|
+
# (DEC 2026: Windows Terminal presents the whole frame at once; a
|
|
628
|
+
# console that does not know the sequence ignores it). Each row
|
|
629
|
+
# clears its own line, so a repaint never needs the erase-to-end
|
|
630
|
+
# that blanked the box between frames. That erase, 12 times a
|
|
631
|
+
# second on every spinner tick, was the flicker while the model
|
|
632
|
+
# was thinking (2026-09-13).
|
|
633
|
+
out = ["\033[?2026h\033[?25l", "\n", "\n".join(f"\033[2K{r}" for r in rows), "\r", f"\033[{len(rows)}A"]
|
|
615
634
|
if saved[0]:
|
|
616
635
|
out.append(f"\033[{saved[0]}C")
|
|
617
|
-
out.append("\033[?25h")
|
|
636
|
+
out.append("\033[?25h\033[?2026l")
|
|
618
637
|
inner.write("".join(out))
|
|
619
638
|
m.col, m.word, m.word_vis = saved
|
|
620
639
|
self._drawn = len(rows)
|
|
@@ -670,14 +689,16 @@ class LiveArea:
|
|
|
670
689
|
newlines = rendered.count("\n")
|
|
671
690
|
if newlines and self._pad_above and self._geometry_changed():
|
|
672
691
|
self.reset_pad()
|
|
673
|
-
if newlines and self._pad_above:
|
|
692
|
+
if newlines and (self._pad_above or self.banner_visible):
|
|
674
693
|
geo = self._geo()
|
|
675
694
|
if geo is not None:
|
|
676
695
|
row, height = geo
|
|
677
696
|
need = newlines + (self._drawn_rows_needed() if self.enabled else 0)
|
|
678
697
|
deficit = need - (height - 1 - row)
|
|
679
698
|
if deficit > 0:
|
|
680
|
-
self._delete_pad_rows(inner, deficit, col=col_before)
|
|
699
|
+
freed = self._delete_pad_rows(inner, deficit, col=col_before) if self._pad_above else 0
|
|
700
|
+
if freed < deficit:
|
|
701
|
+
self.banner_visible = False # the window scrolls: the top row is gone
|
|
681
702
|
inner._base.write(rendered)
|
|
682
703
|
if self.enabled:
|
|
683
704
|
self._draw(inner)
|
|
@@ -743,6 +764,12 @@ class LiveArea:
|
|
|
743
764
|
rows = self._compose()
|
|
744
765
|
elif self._drawn and (tuple(rows), self.margin.col) == self._signature:
|
|
745
766
|
return
|
|
767
|
+
if self._drawn == len(rows):
|
|
768
|
+
# Same rows, same place: overwrite in place. No erase, no
|
|
769
|
+
# blank frame between the old box and the new one.
|
|
770
|
+
self._draw(self._inner, rows)
|
|
771
|
+
return
|
|
772
|
+
self._inner._base.write("\033[?2026h") # the erase joins the frame
|
|
746
773
|
self._erase(self._inner)
|
|
747
774
|
self._draw(self._inner, rows)
|
|
748
775
|
|
|
@@ -685,7 +685,7 @@ HELP_TEXT = textwrap.dedent("""
|
|
|
685
685
|
Hex CLI, a local agent on the Hexagon NPU
|
|
686
686
|
|
|
687
687
|
Session
|
|
688
|
-
/new start a new session,
|
|
688
|
+
/new start a new session on a fresh screen, banner and all
|
|
689
689
|
/clear clear the screen and start a new session
|
|
690
690
|
/history list saved sessions
|
|
691
691
|
/resume <n> reopen session n
|
|
@@ -712,11 +712,14 @@ HELP_TEXT = textwrap.dedent("""
|
|
|
712
712
|
replaced with what follows the command. Built-in names win.
|
|
713
713
|
|
|
714
714
|
Keys
|
|
715
|
-
Up / Down history, filtered by what is typed
|
|
715
|
+
Up / Down history, filtered by what is typed; in the / menu, pick
|
|
716
716
|
Tab complete commands, config keys and paths
|
|
717
|
+
Right accept the dim preview of a slash command
|
|
717
718
|
Shift+Enter new line; \\ then Enter also works
|
|
718
719
|
Ctrl+Left / Ctrl+Right move by word
|
|
719
|
-
Ctrl+W / Ctrl+
|
|
720
|
+
Ctrl+W / Ctrl+Backspace delete the word before; Ctrl+Delete the word after
|
|
721
|
+
Ctrl+U / Ctrl+K delete to line start, to line end
|
|
722
|
+
Ctrl+Z / Ctrl+Y undo, redo
|
|
720
723
|
Esc clear the line, or cancel a running turn
|
|
721
724
|
Ctrl+L clear the screen
|
|
722
725
|
Ctrl+Plus / Ctrl+Minus text size in the classic console
|
|
@@ -724,6 +727,22 @@ HELP_TEXT = textwrap.dedent("""
|
|
|
724
727
|
The agent runs qwen3-4b-instruct-2507 on the Hexagon NPU through npurun.
|
|
725
728
|
""").strip()
|
|
726
729
|
|
|
730
|
+
|
|
731
|
+
def _command_help(text: str) -> dict[str, str]:
|
|
732
|
+
"""One line per slash command, parsed from HELP_TEXT so the / menu and
|
|
733
|
+
/help can never disagree: the command word, then its description."""
|
|
734
|
+
out: dict[str, str] = {}
|
|
735
|
+
for line in text.splitlines():
|
|
736
|
+
m = re.match(r"\s+(/[a-z]+)\b[^ ]*.*?\s{2,}(\S.*)$", line)
|
|
737
|
+
if m and m.group(1) not in out:
|
|
738
|
+
out[m.group(1)] = m.group(2).strip()
|
|
739
|
+
out.setdefault("/help", "this list of commands and keys")
|
|
740
|
+
out.setdefault("/quit", out.get("/exit", "quit"))
|
|
741
|
+
return out
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
COMMAND_HELP = _command_help(HELP_TEXT)
|
|
745
|
+
|
|
727
746
|
TOOLS_HELP = textwrap.dedent("""
|
|
728
747
|
Tools available to the agent:
|
|
729
748
|
run_command(command) run PowerShell; risky ones ask first
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|