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.
Files changed (49) hide show
  1. {hexcli-2.9.0 → hexcli-2.10.0}/.gitignore +4 -0
  2. {hexcli-2.9.0 → hexcli-2.10.0}/CHANGELOG.md +95 -0
  3. {hexcli-2.9.0 → hexcli-2.10.0}/PKG-INFO +8 -2
  4. {hexcli-2.9.0 → hexcli-2.10.0}/README.md +7 -1
  5. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/__init__.py +1 -1
  6. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/agent.py +5 -2
  7. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/lineedit.py +183 -5
  8. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/parsing.py +79 -30
  9. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/repl.py +26 -4
  10. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/statusbar.py +31 -4
  11. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/ui.py +22 -3
  12. {hexcli-2.9.0 → hexcli-2.10.0}/Hex CLI.cmd +0 -0
  13. {hexcli-2.9.0 → hexcli-2.10.0}/LICENSE +0 -0
  14. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/assets/hexcli.ico +0 -0
  15. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/assets/hexcli.png +0 -0
  16. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/cancel.py +0 -0
  17. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/chatlog.py +0 -0
  18. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/commands.py +0 -0
  19. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/compaction.py +0 -0
  20. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/config.py +0 -0
  21. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/diffview.py +0 -0
  22. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/distribution.py +0 -0
  23. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/doctor.py +0 -0
  24. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/escalate.py +0 -0
  25. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/http_client.py +0 -0
  26. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/launcher.py +0 -0
  27. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/llm.py +0 -0
  28. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/local_escalation.py +0 -0
  29. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/lockfile.py +0 -0
  30. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/loop_v2.py +0 -0
  31. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/markdown_stream.py +0 -0
  32. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/memory.py +0 -0
  33. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/network.py +0 -0
  34. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/paths.py +0 -0
  35. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/prompts.py +0 -0
  36. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/protocol_v2.py +0 -0
  37. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/safety.py +0 -0
  38. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/sessions.py +0 -0
  39. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/setup_wizard.py +0 -0
  40. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/shell_session.py +0 -0
  41. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/stream_render.py +0 -0
  42. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/telemetry.py +0 -0
  43. {hexcli-2.9.0 → hexcli-2.10.0}/hexcli/tools.py +0 -0
  44. {hexcli-2.9.0 → hexcli-2.10.0}/install.ps1 +0 -0
  45. {hexcli-2.9.0 → hexcli-2.10.0}/launcher.py +0 -0
  46. {hexcli-2.9.0 → hexcli-2.10.0}/pyproject.toml +0 -0
  47. {hexcli-2.9.0 → hexcli-2.10.0}/shellai.cmd +0 -0
  48. {hexcli-2.9.0 → hexcli-2.10.0}/shellai.example.json +0 -0
  49. {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.9.0
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+U` `Ctrl+K` | delete the word before, to line start, to line end |
171
+ | `Ctrl+W` `Ctrl+Backspace` | delete the word before the caret |
172
+ | `Ctrl+Delete` | delete the word after the caret |
173
+ | `Ctrl+U` `Ctrl+K` | delete to line start, to line end |
174
+ | `Ctrl+Home` `Ctrl+End` | start or end of a multi-line entry |
169
175
  | `Esc` | clear the line |
170
176
  | `Ctrl+V` | paste a block. Nothing is sent until you press Enter |
171
177
  | `Shift+Enter` | new line inside the entry |
@@ -136,9 +136,15 @@ follows the command:
136
136
  |---|---|
137
137
  | `↑` `↓` | history. With text typed, searches by that prefix |
138
138
  | `Tab` | complete commands, config keys, and file paths |
139
+ | `Right` | accept the dim preview of a slash command (`/he` shows `lp`) |
140
+ | `/` | opens the command menu under the input; `Up` `Down` pick, `Tab` takes the pick, `Enter` runs it |
141
+ | `Ctrl+Z` `Ctrl+Y` | undo, redo (a typed word is one step) |
139
142
  | `Ctrl+←` `Ctrl+→` | move by word |
140
143
  | `Home` `End` | start or end of line |
141
- | `Ctrl+W` `Ctrl+U` `Ctrl+K` | delete the word before, to line start, to line end |
144
+ | `Ctrl+W` `Ctrl+Backspace` | delete the word before the caret |
145
+ | `Ctrl+Delete` | delete the word after the caret |
146
+ | `Ctrl+U` `Ctrl+K` | delete to line start, to line end |
147
+ | `Ctrl+Home` `Ctrl+End` | start or end of a multi-line entry |
142
148
  | `Esc` | clear the line |
143
149
  | `Ctrl+V` | paste a block. Nothing is sent until you press Enter |
144
150
  | `Shift+Enter` | new line inside the entry |
@@ -3,4 +3,4 @@
3
3
  # The one place the version is written. pyproject.toml reads it (hatch
4
4
  # dynamic version), agent.VERSION re-exports it, and CI refuses a release
5
5
  # tag that does not match it.
6
- __version__ = "2.9.0"
6
+ __version__ = "2.10.0"
@@ -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
- "Respond with exactly one JSON object as specified. No prose."
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": BACKSPACE,
85
+ "\x08": BACKSPACE, "\x7f": KILL_WORD, # 0x7f is Ctrl+Backspace on a Windows console
80
86
  "\x01": HOME, "\x05": END,
81
87
  "\x02": LEFT, "\x06": RIGHT,
82
88
  "\x0e": DOWN, "\x10": UP,
83
89
  "\x03": INTERRUPT, "\x04": EOF_KEY,
84
90
  "\x0b": KILL_LINE, "\x15": KILL_TO_START, "\x17": KILL_WORD,
85
91
  "\x0c": CLEAR_SCREEN, "\x1b": ESCAPE,
92
+ "\x1a": UNDO, "\x19": REDO,
86
93
  }
87
94
 
88
95
 
@@ -480,6 +487,7 @@ class LineEditor:
480
487
  *,
481
488
  history: History | None = None,
482
489
  completer: Callable[[str], list[str]] | None = None,
490
+ command_help: Mapping[str, str] | None = None,
483
491
  read_key: Callable[[], str] | None = None,
484
492
  write: Callable[[str], None] | None = None,
485
493
  width: int | None = None,
@@ -498,6 +506,16 @@ class LineEditor:
498
506
  ) -> None:
499
507
  self.history = history or History()
500
508
  self.completer = completer
509
+ self.command_help: Mapping[str, str] = dict(command_help or {})
510
+ # Undo/redo over the entry: (buffer, pos) snapshots. Typing a word
511
+ # is one step (a run of non-space characters, then the space), a
512
+ # kill or a paste is one step; a cursor move is none.
513
+ self._undo: list[tuple[str, int]] = []
514
+ self._redo: list[tuple[str, int]] = []
515
+ self._undo_kind = ""
516
+ # The / menu: which of the matching commands is selected.
517
+ self._menu_index = 0
518
+ self._menu_key: str | None = None
501
519
  self._read_key = read_key or windows_key_reader()
502
520
  self._write = write or (lambda s: (sys.stdout.write(s), sys.stdout.flush()) and None)
503
521
  self._forced_width = width
@@ -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
- try:
102
- parsed = json.loads(text)
103
- if isinstance(parsed, dict):
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
- try:
110
- parsed = json.loads(stripped)
111
- if isinstance(parsed, dict):
112
- return parsed
113
- except json.JSONDecodeError:
114
- pass
115
- # Extract the FIRST complete {...} object by brace balancing.
116
- #
117
- # This used to be a greedy re.search(r"\{.*\}"), which spans from the first
118
- # brace to the LAST one. When the model emits several actions in a row —
119
- # {"action":"edit_file",…},{"action":"verify_syntax",…},{"action":"run_code",…}
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
- os.system("cls" if os.name == "nt" else "clear")
595
- if live is not None:
596
- live.screen_cleared() # the box and its pad rows are gone with the screen
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
- out = ["\033[?25l", "\n", "\n".join(rows), "\r", f"\033[{len(rows)}A"]
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, keep the screen
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+U / Ctrl+K delete the word, to line start, to line end
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