hexcli 2.9.0__tar.gz → 2.10.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {hexcli-2.9.0 → hexcli-2.10.0}/.gitignore +4 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/CHANGELOG.md +95 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/PKG-INFO +8 -2
- {hexcli-2.9.0 → hexcli-2.10.0}/README.md +7 -1
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/__init__.py +1 -1
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/agent.py +5 -2
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/lineedit.py +183 -5
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/parsing.py +79 -30
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/repl.py +26 -4
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/statusbar.py +31 -4
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/ui.py +22 -3
- {hexcli-2.9.0 → hexcli-2.10.0}/Hex CLI.cmd +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/LICENSE +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/assets/hexcli.ico +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/assets/hexcli.png +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/cancel.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/chatlog.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/commands.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/compaction.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/config.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/diffview.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/distribution.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/doctor.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/escalate.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/http_client.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/launcher.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/llm.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/local_escalation.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/lockfile.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/loop_v2.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/markdown_stream.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/memory.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/network.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/paths.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/prompts.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/protocol_v2.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/safety.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/sessions.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/setup_wizard.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/shell_session.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/stream_render.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/telemetry.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/tools.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/install.ps1 +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/launcher.py +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/pyproject.toml +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/shellai.cmd +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/shellai.example.json +0 -0
- {hexcli-2.9.0 → hexcli-2.10.0}/shellai.py +0 -0
|
@@ -56,3 +56,7 @@ tools/backend_bench/.battery_probe.ps1
|
|
|
56
56
|
|
|
57
57
|
# python -m build output
|
|
58
58
|
dist/
|
|
59
|
+
|
|
60
|
+
# Local-only notes: research surveys and working documents that are not user-facing.
|
|
61
|
+
# Only the paper (docs/paper) and user-facing docs are committed (owner rule, 2026-09-14).
|
|
62
|
+
docs/local/
|
|
@@ -6,6 +6,101 @@ the Hexagon NPU, not single-run anecdotes.
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## 2.10.0 — 2026-09-14
|
|
10
|
+
|
|
11
|
+
A minor release: the retry feedback the model reads changed. Gate: extended
|
|
12
|
+
suite at 5 runs, seed 20260914, 32/44 pass^5 (the 2.7.x baseline is 32/44), run-level
|
|
13
|
+
159/205 vs 165/208 (p=0.72); one gate case missed once and passed its 6-run
|
|
14
|
+
recheck; CI green on main and the tag.
|
|
15
|
+
|
|
16
|
+
- A reply whose first JSON object does not decode is never "finished" by a
|
|
17
|
+
later object in the same reply. The owner's 2026-09-13 session: asked for
|
|
18
|
+
a calculator page, the model answered with a `write_file` holding 1.6K of
|
|
19
|
+
HTML and, behind it, a `finish` saying the file was created. Fifteen
|
|
20
|
+
attribute quotes (`onclick=\"input('7')">`) were unescaped, the string
|
|
21
|
+
closed early, the object failed to decode, the parser moved on to the
|
|
22
|
+
next complete object, accepted the finish, and the turn claimed a file it
|
|
23
|
+
never wrote; the next turn found nothing to open. `parse_json_object` now
|
|
24
|
+
decodes the first object from the first brace with `raw_decode` (batched
|
|
25
|
+
actions still take the first and let the loop drive the rest), repairs a
|
|
26
|
+
stray quote inside a string value up to sixty-four times by escaping the
|
|
27
|
+
last unescaped quote before the decoder's error (a truncated string is
|
|
28
|
+
left alone), accepts raw control characters inside strings, and returns
|
|
29
|
+
nothing when the first object still fails, so the loop's existing retry
|
|
30
|
+
fires. That retry now tells the model the decoder's complaint, the
|
|
31
|
+
character offset, the text around it and the quoting rule instead of
|
|
32
|
+
"not valid JSON". The session's reply, verbatim, is a fixture: it decodes
|
|
33
|
+
to the write with all 1,466 characters of HTML.
|
|
34
|
+
- The `/` command menu sits above the input row, between the top rule and
|
|
35
|
+
the prompt. The box is pinned to the window's last rows, so the rows
|
|
36
|
+
2.9.1 added below the input pushed the input row and the caret up
|
|
37
|
+
whenever the menu appeared or changed height; rows above the input grow
|
|
38
|
+
the box upward and the caret stays where it is.
|
|
39
|
+
- `run_arm.cmd` stops when the suite exits non-zero. An aborted suite (six
|
|
40
|
+
consecutive backend timeouts on a starved machine, 2026-09-13) left the
|
|
41
|
+
previous arm's results file in place, and the script copied it as the
|
|
42
|
+
candidate and gated it, which read as a RECHECK verdict against stale
|
|
43
|
+
data. It now logs the abort and writes no candidate.
|
|
44
|
+
|
|
45
|
+
## 2.9.1 — 2026-09-13
|
|
46
|
+
|
|
47
|
+
A patch release: the input line and the status bar; nothing model-facing
|
|
48
|
+
and nothing the launcher hands the server. Gate: CI green on main; smoke
|
|
49
|
+
10/10 on a fresh server.
|
|
50
|
+
|
|
51
|
+
- The status bar no longer flickers while a turn runs. Every spinner tick
|
|
52
|
+
(12 a second) went through the full redraw: erase the box to the end of
|
|
53
|
+
the screen, then rewrite its rows, with the cursor visible during the
|
|
54
|
+
erase, so Windows Terminal could present the blank frame in between.
|
|
55
|
+
`LiveArea.repaint` now overwrites the box in place when its row count is
|
|
56
|
+
unchanged, each row clears its own line, and `_draw` emits one write with
|
|
57
|
+
the cursor hidden throughout inside a DEC 2026 synchronized update
|
|
58
|
+
(Terminal 1.24 presents the frame atomically; a console that does not
|
|
59
|
+
know the sequence ignores it). The erase path, still used when the box
|
|
60
|
+
grows or shrinks, joins the same frame.
|
|
61
|
+
- The input line previews the best slash-command match while a command
|
|
62
|
+
name is typed: `/he` shows a dim `lp` after the caret, Right arrow
|
|
63
|
+
accepts it with the trailing space a Tab completion adds, and the text
|
|
64
|
+
submitted is only ever what was typed. The preview appears for a bare
|
|
65
|
+
`/word` at the end of the line, never for arguments or paths (no
|
|
66
|
+
filesystem walk per keystroke), and not once the name is complete. Ties
|
|
67
|
+
go to the first match in `REPL_COMMANDS` order (`/c` previews `/clear`);
|
|
68
|
+
Tab still lists the rest. The preview counts toward the row's width so a
|
|
69
|
+
long name still wraps exactly; the caret arithmetic sees only the real
|
|
70
|
+
text.
|
|
71
|
+
- Ctrl+Backspace deletes the word before the caret. A Windows console
|
|
72
|
+
delivers it as DEL (0x7f), which the key map bound to a one-character
|
|
73
|
+
backspace, so it ate one letter per press. Ctrl+Delete deletes the word
|
|
74
|
+
after the caret, and Ctrl+Home / Ctrl+End jump to the start or end of a
|
|
75
|
+
multi-line entry (Home / End stay on the current line), all through the
|
|
76
|
+
extended scancodes the console already sends.
|
|
77
|
+
- Undo and redo on the input line: Ctrl+Z and Ctrl+Y. A run of typed
|
|
78
|
+
characters up to a space is one step, so undo removes the last word; a
|
|
79
|
+
kill, a paste, a completion or a history recall is one step each; cursor
|
|
80
|
+
moves are none. A new edit after an undo drops the redo branch. Two
|
|
81
|
+
hundred steps per entry, cleared when the line is submitted.
|
|
82
|
+
- A command menu under the input while a bare `/word` is typed: the
|
|
83
|
+
matching commands in command order with their one-line descriptions
|
|
84
|
+
(parsed from the `/help` text, so the two can never disagree; custom
|
|
85
|
+
commands say so), the pick marked, eight rows with a "… n more" line
|
|
86
|
+
past that. Up and Down move the pick, Tab takes it with the argument
|
|
87
|
+
space, Enter runs it, Esc closes the menu with the line, and the dim
|
|
88
|
+
preview after the caret follows the pick. Tab on an ambiguous command
|
|
89
|
+
word therefore takes the pick now instead of stopping at the shared
|
|
90
|
+
prefix; off the menu (config keys, paths) Tab still advances as far as
|
|
91
|
+
certainty goes. The menu rows are part of the entry, so the box grows
|
|
92
|
+
into the pad the way a multi-line entry does.
|
|
93
|
+
- `/clear` and `/new` on the live layout. The first fix printed the banner
|
|
94
|
+
with the box up, and text written then is conversation: anchored above
|
|
95
|
+
the box at the bottom, growing upward, so the banner appeared at the
|
|
96
|
+
bottom. Both commands now take the box down without padding, clear,
|
|
97
|
+
print, and pin the box again under what they printed. `/new` always
|
|
98
|
+
prints the banner (a new session starts the way the program does);
|
|
99
|
+
`/clear` prints it only if it was still on screen, which the live area
|
|
100
|
+
tracks: set when the banner is printed, cleared when a write scrolls the
|
|
101
|
+
window past its pad, when a growing entry scrolls it, or when the screen
|
|
102
|
+
is cleared.
|
|
103
|
+
|
|
9
104
|
## 2.9.0 — 2026-09-13
|
|
10
105
|
|
|
11
106
|
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.
|
|
3
|
+
Version: 2.10.0
|
|
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 |
|
|
@@ -1289,9 +1289,12 @@ def _run_autopilot_turn(
|
|
|
1289
1289
|
"one JSON object. No prose."
|
|
1290
1290
|
)
|
|
1291
1291
|
else:
|
|
1292
|
+
detail = parsing.describe_json_error(raw)
|
|
1292
1293
|
feedback = (
|
|
1293
|
-
"Your response was not valid JSON
|
|
1294
|
-
"
|
|
1294
|
+
"Your response was not valid JSON"
|
|
1295
|
+
+ (f": {detail}. Inside a string value every double quote must be "
|
|
1296
|
+
"written as \\\". " if detail else ". ")
|
|
1297
|
+
+ "Respond with exactly one JSON object as specified. No prose."
|
|
1295
1298
|
)
|
|
1296
1299
|
messages.append({"role": "assistant", "content": strip_thinking(raw)})
|
|
1297
1300
|
messages.append({"role": "user", "content": feedback})
|
|
@@ -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
|
|
@@ -611,6 +629,12 @@ class LineEditor:
|
|
|
611
629
|
logical: list[tuple[str, int]] = [] # (rendered text, visible width)
|
|
612
630
|
for line in above:
|
|
613
631
|
logical.append((line, visible_len(line)))
|
|
632
|
+
# The / menu sits between the top rule and the input row. The box is
|
|
633
|
+
# pinned to the window's last rows, so rows added BELOW the input
|
|
634
|
+
# pushed the input row and the caret up when the menu appeared; rows
|
|
635
|
+
# above it grow the box upward and the caret stays put.
|
|
636
|
+
menu = self._menu_rows() if chrome else []
|
|
637
|
+
logical.extend(menu)
|
|
614
638
|
for line in prompt_lines[:-1]:
|
|
615
639
|
logical.append((line, visible_len(line)))
|
|
616
640
|
last_prompt = prompt_lines[-1]
|
|
@@ -621,6 +645,14 @@ class LineEditor:
|
|
|
621
645
|
hint = self.placeholder[: max(0, self.usable - visible_len(last_prompt) - 1)]
|
|
622
646
|
styled = f"\033[2m{hint}\033[0m" if self.styled else hint
|
|
623
647
|
logical[-1] = (last_prompt + styled, visible_len(last_prompt) + len(hint))
|
|
648
|
+
ghost = self._ghost() if chrome else ""
|
|
649
|
+
if ghost:
|
|
650
|
+
# The rest of the best slash-command match, dim, after the
|
|
651
|
+
# cursor. It counts toward the row's width so wrapping stays
|
|
652
|
+
# exact; the cursor arithmetic below only sees the real text.
|
|
653
|
+
text, vis = logical[-1]
|
|
654
|
+
styled = f"\033[2m{ghost}\033[0m" if self.styled else ghost
|
|
655
|
+
logical[-1] = (text + styled, vis + len(ghost))
|
|
624
656
|
for line in below:
|
|
625
657
|
logical.append((line, visible_len(line)))
|
|
626
658
|
|
|
@@ -628,7 +660,7 @@ class LineEditor:
|
|
|
628
660
|
before = self.buffer[:self.pos]
|
|
629
661
|
cur_line = before.count("\n")
|
|
630
662
|
col_in_line = len(before) - (before.rfind("\n") + 1)
|
|
631
|
-
cursor_logical = len(above) + len(prompt_lines) - 1 + cur_line
|
|
663
|
+
cursor_logical = len(above) + len(menu) + len(prompt_lines) - 1 + cur_line
|
|
632
664
|
cursor_vis = visible_len(prefixes[cur_line]) + col_in_line
|
|
633
665
|
|
|
634
666
|
pieces: list[str] = []
|
|
@@ -825,6 +857,84 @@ class LineEditor:
|
|
|
825
857
|
|
|
826
858
|
# -- completion ---------------------------------------------------------
|
|
827
859
|
|
|
860
|
+
def _ghost(self) -> str:
|
|
861
|
+
"""The rest of the best slash-command match while a command name is
|
|
862
|
+
being typed at the end of the line: "/he" previews "lp". Empty when
|
|
863
|
+
the name is complete, ambiguous beyond a shared prefix is fine (the
|
|
864
|
+
first match in command order wins, Tab still lists them), and empty
|
|
865
|
+
for anything that is not a bare command word, so paths and
|
|
866
|
+
arguments never trigger a filesystem walk on every keystroke."""
|
|
867
|
+
buf = self.buffer
|
|
868
|
+
if (self.completer is None or self.pos != len(buf) or not buf.startswith("/")
|
|
869
|
+
or any(ch.isspace() for ch in buf)):
|
|
870
|
+
return ""
|
|
871
|
+
try:
|
|
872
|
+
candidates = self.completer(buf)
|
|
873
|
+
except Exception: # noqa: BLE001 — a preview must never break typing
|
|
874
|
+
return ""
|
|
875
|
+
word = buf.lower()
|
|
876
|
+
if any(c.lower() == word for c in candidates):
|
|
877
|
+
return ""
|
|
878
|
+
items = self._menu_items()
|
|
879
|
+
if items:
|
|
880
|
+
cand = items[self._menu_index]
|
|
881
|
+
return cand[len(buf):] if cand.lower().startswith(word) and len(cand) > len(buf) else ""
|
|
882
|
+
for cand in candidates:
|
|
883
|
+
if cand.lower().startswith(word) and len(cand) > len(buf):
|
|
884
|
+
return cand[len(buf):]
|
|
885
|
+
return ""
|
|
886
|
+
|
|
887
|
+
MENU_ROWS = 8
|
|
888
|
+
|
|
889
|
+
def _menu_items(self) -> list[str]:
|
|
890
|
+
"""The slash commands matching a bare /word at the end of the line,
|
|
891
|
+
in command order; the menu the editor draws under the input row.
|
|
892
|
+
The selection index survives while the list is the same and resets
|
|
893
|
+
when typing changes it."""
|
|
894
|
+
buf = self.buffer
|
|
895
|
+
if (self.completer is None or self.pos != len(buf) or not buf.startswith("/")
|
|
896
|
+
or any(ch.isspace() for ch in buf)):
|
|
897
|
+
self._menu_key = None
|
|
898
|
+
return []
|
|
899
|
+
try:
|
|
900
|
+
items = [c for c in self.completer(buf) if c.startswith("/")]
|
|
901
|
+
except Exception: # noqa: BLE001
|
|
902
|
+
items = []
|
|
903
|
+
key = "\0".join(items)
|
|
904
|
+
if key != self._menu_key:
|
|
905
|
+
self._menu_key = key
|
|
906
|
+
self._menu_index = 0
|
|
907
|
+
if self._menu_index >= len(items):
|
|
908
|
+
self._menu_index = 0
|
|
909
|
+
return items
|
|
910
|
+
|
|
911
|
+
def _menu_rows(self) -> list[tuple[str, int]]:
|
|
912
|
+
"""(rendered row, visible width) per menu line, clipped to the width."""
|
|
913
|
+
items = self._menu_items()
|
|
914
|
+
if not items:
|
|
915
|
+
return []
|
|
916
|
+
first = max(0, min(self._menu_index - self.MENU_ROWS + 1, len(items) - self.MENU_ROWS))
|
|
917
|
+
first = max(0, min(first, self._menu_index))
|
|
918
|
+
shown = items[first:first + self.MENU_ROWS]
|
|
919
|
+
name_w = max(len(c) for c in shown)
|
|
920
|
+
rows: list[tuple[str, int]] = []
|
|
921
|
+
for n, cand in enumerate(shown, first):
|
|
922
|
+
desc = self.command_help.get(cand, "custom command" if cand not in self.command_help and cand in items else "")
|
|
923
|
+
avail = max(0, self.usable - 4 - name_w - 2)
|
|
924
|
+
desc = desc[:avail]
|
|
925
|
+
plain = f" {'▸' if n == self._menu_index else ' '} {cand.ljust(name_w)} {desc}".rstrip()
|
|
926
|
+
if self.styled and n == self._menu_index:
|
|
927
|
+
text = f"\033[1m{plain}\033[0m"
|
|
928
|
+
elif self.styled:
|
|
929
|
+
text = f"\033[2m{plain}\033[0m"
|
|
930
|
+
else:
|
|
931
|
+
text = plain
|
|
932
|
+
rows.append((text, visible_len(plain)))
|
|
933
|
+
if len(items) > len(shown):
|
|
934
|
+
more = f" … {len(items) - len(shown)} more"
|
|
935
|
+
rows.append((f"\033[2m{more}\033[0m" if self.styled else more, len(more)))
|
|
936
|
+
return rows
|
|
937
|
+
|
|
828
938
|
def _complete(self, prompt: str) -> None:
|
|
829
939
|
if self.completer is None:
|
|
830
940
|
return
|
|
@@ -871,6 +981,8 @@ class LineEditor:
|
|
|
871
981
|
``input()`` does, so callers keep their existing handlers."""
|
|
872
982
|
self.buffer, self.pos = "", 0
|
|
873
983
|
self._hist_index, self._hist_prefix, self._saved_draft = None, "", ""
|
|
984
|
+
self._undo, self._redo, self._undo_kind = [], [], ""
|
|
985
|
+
self._menu_index, self._menu_key = 0, None
|
|
874
986
|
self._rendered_rows, self._cursor_row = 0, 0
|
|
875
987
|
self._last_text = None
|
|
876
988
|
self._last_size = (self.usable, self.height)
|
|
@@ -951,9 +1063,56 @@ class LineEditor:
|
|
|
951
1063
|
self.render(prompt)
|
|
952
1064
|
|
|
953
1065
|
def _handle(self, key: str, prompt: str) -> str | None:
|
|
954
|
-
"""Apply one key. Returns the finished line, or None to keep editing.
|
|
1066
|
+
"""Apply one key. Returns the finished line, or None to keep editing.
|
|
1067
|
+
Every change to the text lands on the undo stack; a run of typed
|
|
1068
|
+
characters up to a space is one step."""
|
|
1069
|
+
if key == UNDO:
|
|
1070
|
+
if self._undo:
|
|
1071
|
+
self._redo.append((self.buffer, self.pos))
|
|
1072
|
+
self.buffer, self.pos = self._undo.pop()
|
|
1073
|
+
self._undo_kind = ""
|
|
1074
|
+
return None
|
|
1075
|
+
if key == REDO:
|
|
1076
|
+
if self._redo:
|
|
1077
|
+
self._undo.append((self.buffer, self.pos))
|
|
1078
|
+
self.buffer, self.pos = self._redo.pop()
|
|
1079
|
+
self._undo_kind = ""
|
|
1080
|
+
return None
|
|
1081
|
+
before = (self.buffer, self.pos)
|
|
1082
|
+
result = self._handle_key(key, prompt)
|
|
1083
|
+
if self.buffer != before[0]:
|
|
1084
|
+
if key == BACKSPACE:
|
|
1085
|
+
kind = "backspace"
|
|
1086
|
+
elif len(key) == 1 and key.isprintable() and not key.isspace():
|
|
1087
|
+
kind = "type"
|
|
1088
|
+
else:
|
|
1089
|
+
kind = ""
|
|
1090
|
+
if not (kind and kind == self._undo_kind):
|
|
1091
|
+
self._undo.append(before)
|
|
1092
|
+
del self._undo[:-200]
|
|
1093
|
+
self._undo_kind = kind
|
|
1094
|
+
self._redo.clear()
|
|
1095
|
+
elif key not in (LEFT, RIGHT, HOME, END, WORD_LEFT, WORD_RIGHT, BUFFER_START, BUFFER_END, IDLE):
|
|
1096
|
+
self._undo_kind = ""
|
|
1097
|
+
return result
|
|
1098
|
+
|
|
1099
|
+
def _handle_key(self, key: str, prompt: str) -> str | None:
|
|
955
1100
|
buf = self.buffer
|
|
956
1101
|
|
|
1102
|
+
# The / menu: Up/Down pick, Tab takes the pick, Enter runs it.
|
|
1103
|
+
menu = self._menu_items() if key in (UP, DOWN, TAB, ENTER) else []
|
|
1104
|
+
if menu and key in (UP, DOWN) and len(menu) > 1:
|
|
1105
|
+
self._menu_index = (self._menu_index + (-1 if key == UP else 1)) % len(menu)
|
|
1106
|
+
return None
|
|
1107
|
+
if menu and key == TAB:
|
|
1108
|
+
self.buffer = menu[self._menu_index] + " "
|
|
1109
|
+
self.pos = len(self.buffer)
|
|
1110
|
+
return None
|
|
1111
|
+
if menu and key == ENTER and buf.lower() != menu[self._menu_index].lower():
|
|
1112
|
+
self.buffer = menu[self._menu_index]
|
|
1113
|
+
self.pos = len(self.buffer)
|
|
1114
|
+
buf = self.buffer
|
|
1115
|
+
|
|
957
1116
|
if key == ENTER:
|
|
958
1117
|
# A trailing backslash is an explicit "keep going" — the one way to
|
|
959
1118
|
# get a multi-line entry without pasting. It must be preceded by
|
|
@@ -989,6 +1148,14 @@ class LineEditor:
|
|
|
989
1148
|
self.pos = max(0, self.pos - 1)
|
|
990
1149
|
return None
|
|
991
1150
|
if key == RIGHT:
|
|
1151
|
+
if self.pos == len(buf):
|
|
1152
|
+
ghost = self._ghost()
|
|
1153
|
+
if ghost:
|
|
1154
|
+
# Accept the preview; the space means "now the argument",
|
|
1155
|
+
# as a Tab completion does.
|
|
1156
|
+
self.buffer = buf + ghost + " "
|
|
1157
|
+
self.pos = len(self.buffer)
|
|
1158
|
+
return None
|
|
992
1159
|
self.pos = min(len(buf), self.pos + 1)
|
|
993
1160
|
return None
|
|
994
1161
|
if key == WORD_LEFT:
|
|
@@ -1008,6 +1175,15 @@ class LineEditor:
|
|
|
1008
1175
|
self.buffer = buf[:start] + buf[self.pos:]
|
|
1009
1176
|
self.pos = start
|
|
1010
1177
|
return None
|
|
1178
|
+
if key == KILL_WORD_FORWARD:
|
|
1179
|
+
self.buffer = buf[:self.pos] + buf[self._word_end():]
|
|
1180
|
+
return None
|
|
1181
|
+
if key == BUFFER_START:
|
|
1182
|
+
self.pos = 0
|
|
1183
|
+
return None
|
|
1184
|
+
if key == BUFFER_END:
|
|
1185
|
+
self.pos = len(buf)
|
|
1186
|
+
return None
|
|
1011
1187
|
if key == KILL_TO_START:
|
|
1012
1188
|
start = self._line_start()
|
|
1013
1189
|
self.buffer = buf[:start] + buf[self.pos:]
|
|
@@ -1061,6 +1237,7 @@ def make_reader(
|
|
|
1061
1237
|
config: dict[str, Any],
|
|
1062
1238
|
commands: Sequence[str],
|
|
1063
1239
|
config_keys: Callable[[], Iterable[str]] | None = None,
|
|
1240
|
+
command_help: Mapping[str, str] | None = None,
|
|
1064
1241
|
on_zoom: Callable[[int], Any] | None = None,
|
|
1065
1242
|
on_resize: Callable[[], Any] | None = None,
|
|
1066
1243
|
chrome: Callable[[int], tuple[list[str], list[str]]] | None = None,
|
|
@@ -1095,6 +1272,7 @@ def make_reader(
|
|
|
1095
1272
|
editor = LineEditor(
|
|
1096
1273
|
history=History(path, int(config.get("input_history_limit", 500))),
|
|
1097
1274
|
completer=default_completer(commands, config_keys),
|
|
1275
|
+
command_help=command_help,
|
|
1098
1276
|
margin=int(config.get("side_padding", 0) or 0),
|
|
1099
1277
|
on_zoom=on_zoom,
|
|
1100
1278
|
on_resize=on_resize,
|
|
@@ -93,43 +93,92 @@ def strip_thinking(text: str) -> str:
|
|
|
93
93
|
# Parsing
|
|
94
94
|
# ---------------------------------------------------------------------------
|
|
95
95
|
|
|
96
|
+
_STRAY_QUOTE_MSGS = ("Expecting ',' delimiter", "Expecting ':' delimiter",
|
|
97
|
+
"Expecting property name")
|
|
98
|
+
_MAX_QUOTE_REPAIRS = 64
|
|
99
|
+
_DECODER = json.JSONDecoder(strict=False) # raw newlines inside a written file are fine
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _escaped_at(text: str, i: int) -> bool:
|
|
103
|
+
"""Is the character at i preceded by an odd run of backslashes?"""
|
|
104
|
+
n = 0
|
|
105
|
+
while i - 1 - n >= 0 and text[i - 1 - n] == "\\":
|
|
106
|
+
n += 1
|
|
107
|
+
return n % 2 == 1
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _repair_stray_quote(text: str, err: json.JSONDecodeError) -> str | None:
|
|
111
|
+
"""Escape the quote that ended a string early. A 4B writing 1.6K of HTML
|
|
112
|
+
inside a JSON string forgets the backslash on a closing attribute quote
|
|
113
|
+
(`onclick=\\"input('7')">`, sixteen times in one reply, 2026-09-13): the
|
|
114
|
+
string closes there and the decoder trips on the next token. The last
|
|
115
|
+
unescaped quote before the error is that closer; escape it and let the
|
|
116
|
+
caller decode again. A truncated string (\"Unterminated string\") is not
|
|
117
|
+
repairable and is left alone."""
|
|
118
|
+
if not any(err.msg.startswith(m) for m in _STRAY_QUOTE_MSGS):
|
|
119
|
+
return None
|
|
120
|
+
q = text.rfind('"', 0, err.pos)
|
|
121
|
+
while q > 0 and _escaped_at(text, q):
|
|
122
|
+
q = text.rfind('"', 0, q)
|
|
123
|
+
if q <= 0:
|
|
124
|
+
return None
|
|
125
|
+
return text[:q] + "\\" + text[q:]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _loads_object(text: str) -> dict[str, Any] | None:
|
|
129
|
+
"""The first JSON object in `text`, decoded from its first brace and
|
|
130
|
+
ignoring whatever follows it (a second batched action, prose). Stray
|
|
131
|
+
quotes inside string values are repaired a bounded number of times."""
|
|
132
|
+
start = text.find("{")
|
|
133
|
+
if start < 0:
|
|
134
|
+
return None
|
|
135
|
+
for _ in range(_MAX_QUOTE_REPAIRS + 1):
|
|
136
|
+
try:
|
|
137
|
+
parsed, _end = _DECODER.raw_decode(text, start)
|
|
138
|
+
return parsed if isinstance(parsed, dict) else None
|
|
139
|
+
except json.JSONDecodeError as err:
|
|
140
|
+
fixed = _repair_stray_quote(text, err)
|
|
141
|
+
if fixed is None:
|
|
142
|
+
return None
|
|
143
|
+
text = fixed
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def describe_json_error(raw_text: str) -> str:
|
|
148
|
+
"""What is wrong with the first JSON object in a reply, for the retry
|
|
149
|
+
feedback: the decoder's message, the character offset and the text
|
|
150
|
+
around it. Empty when the object decodes."""
|
|
151
|
+
text = strip_thinking(raw_text).strip()
|
|
152
|
+
start = max(0, text.find("{"))
|
|
153
|
+
try:
|
|
154
|
+
_DECODER.raw_decode(text, start)
|
|
155
|
+
return ""
|
|
156
|
+
except json.JSONDecodeError as err:
|
|
157
|
+
lo, hi = max(0, err.pos - 40), min(len(text), err.pos + 20)
|
|
158
|
+
return f"{err.msg} at character {err.pos - start}, near: {text[lo:hi]!r}"
|
|
159
|
+
|
|
160
|
+
|
|
96
161
|
def parse_json_object(raw_text: str) -> dict[str, Any] | None:
|
|
97
162
|
text = strip_thinking(raw_text).strip()
|
|
98
163
|
if not text:
|
|
99
164
|
return None
|
|
100
165
|
# Direct parse
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return parsed
|
|
105
|
-
except json.JSONDecodeError:
|
|
106
|
-
pass
|
|
166
|
+
parsed = _loads_object(text)
|
|
167
|
+
if parsed is not None:
|
|
168
|
+
return parsed
|
|
107
169
|
# Strip markdown fences
|
|
108
170
|
stripped = re.sub(r"^```[a-zA-Z]*\s*|```\s*$", "", text, flags=re.MULTILINE).strip()
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
# — that match is not valid JSON, so the whole response was discarded, the
|
|
121
|
-
# identical retry was issued up to 3×, and the turn ended with no tool call
|
|
122
|
-
# at all. Measured 2026-07-30: this is what actually killed uc1-t4/t5/t6
|
|
123
|
-
# (0/3 each), NOT a context-length cliff. Batching is a natural response to
|
|
124
|
-
# rules that prescribe an edit→verify→run sequence, so take the first
|
|
125
|
-
# action and let the loop drive the rest.
|
|
126
|
-
for candidate in _iter_json_objects(text):
|
|
127
|
-
try:
|
|
128
|
-
parsed = json.loads(candidate)
|
|
129
|
-
if isinstance(parsed, dict):
|
|
130
|
-
return parsed
|
|
131
|
-
except json.JSONDecodeError:
|
|
132
|
-
continue
|
|
171
|
+
parsed = _loads_object(stripped)
|
|
172
|
+
if parsed is not None:
|
|
173
|
+
return parsed
|
|
174
|
+
# Nothing decodable from the first brace. Batched actions ({edit}{verify}
|
|
175
|
+
# {run}) are handled above: raw_decode takes the FIRST object and ignores
|
|
176
|
+
# the rest, and the loop drives the next step (measured 2026-07-30: the
|
|
177
|
+
# old greedy match discarded such replies and killed uc1-t4/t5/t6). A
|
|
178
|
+
# broken first object is a malformed reply, never skipped for a later
|
|
179
|
+
# one: on 2026-09-13 the finish behind an unparseable write_file was
|
|
180
|
+
# accepted and the turn claimed a file it had not written. The loop
|
|
181
|
+
# retries with the decoder's complaint (describe_json_error).
|
|
133
182
|
return None
|
|
134
183
|
|
|
135
184
|
|
|
@@ -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
|