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.
Files changed (49) hide show
  1. {hexcli-2.9.0 → hexcli-2.9.1}/CHANGELOG.md +59 -0
  2. {hexcli-2.9.0 → hexcli-2.9.1}/PKG-INFO +8 -2
  3. {hexcli-2.9.0 → hexcli-2.9.1}/README.md +7 -1
  4. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/__init__.py +1 -1
  5. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/lineedit.py +178 -4
  6. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/repl.py +26 -4
  7. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/statusbar.py +31 -4
  8. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/ui.py +22 -3
  9. {hexcli-2.9.0 → hexcli-2.9.1}/.gitignore +0 -0
  10. {hexcli-2.9.0 → hexcli-2.9.1}/Hex CLI.cmd +0 -0
  11. {hexcli-2.9.0 → hexcli-2.9.1}/LICENSE +0 -0
  12. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/agent.py +0 -0
  13. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/assets/hexcli.ico +0 -0
  14. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/assets/hexcli.png +0 -0
  15. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/cancel.py +0 -0
  16. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/chatlog.py +0 -0
  17. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/commands.py +0 -0
  18. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/compaction.py +0 -0
  19. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/config.py +0 -0
  20. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/diffview.py +0 -0
  21. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/distribution.py +0 -0
  22. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/doctor.py +0 -0
  23. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/escalate.py +0 -0
  24. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/http_client.py +0 -0
  25. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/launcher.py +0 -0
  26. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/llm.py +0 -0
  27. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/local_escalation.py +0 -0
  28. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/lockfile.py +0 -0
  29. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/loop_v2.py +0 -0
  30. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/markdown_stream.py +0 -0
  31. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/memory.py +0 -0
  32. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/network.py +0 -0
  33. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/parsing.py +0 -0
  34. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/paths.py +0 -0
  35. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/prompts.py +0 -0
  36. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/protocol_v2.py +0 -0
  37. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/safety.py +0 -0
  38. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/sessions.py +0 -0
  39. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/setup_wizard.py +0 -0
  40. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/shell_session.py +0 -0
  41. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/stream_render.py +0 -0
  42. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/telemetry.py +0 -0
  43. {hexcli-2.9.0 → hexcli-2.9.1}/hexcli/tools.py +0 -0
  44. {hexcli-2.9.0 → hexcli-2.9.1}/install.ps1 +0 -0
  45. {hexcli-2.9.0 → hexcli-2.9.1}/launcher.py +0 -0
  46. {hexcli-2.9.0 → hexcli-2.9.1}/pyproject.toml +0 -0
  47. {hexcli-2.9.0 → hexcli-2.9.1}/shellai.cmd +0 -0
  48. {hexcli-2.9.0 → hexcli-2.9.1}/shellai.example.json +0 -0
  49. {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.0
3
+ Version: 2.9.1
4
4
  Summary: Local Hexagon NPU terminal agent for Snapdragon X Elite Windows ARM64
5
5
  Project-URL: Homepage, https://github.com/NathanL15/Hex-CLI
6
6
  Project-URL: Repository, https://github.com/NathanL15/Hex-CLI
@@ -163,9 +163,15 @@ follows the command:
163
163
  |---|---|
164
164
  | `↑` `↓` | history. With text typed, searches by that prefix |
165
165
  | `Tab` | complete commands, config keys, and file paths |
166
+ | `Right` | accept the dim preview of a slash command (`/he` shows `lp`) |
167
+ | `/` | opens the command menu under the input; `Up` `Down` pick, `Tab` takes the pick, `Enter` runs it |
168
+ | `Ctrl+Z` `Ctrl+Y` | undo, redo (a typed word is one step) |
166
169
  | `Ctrl+←` `Ctrl+→` | move by word |
167
170
  | `Home` `End` | start or end of line |
168
- | `Ctrl+W` `Ctrl+U` `Ctrl+K` | delete the word before, to line start, to line end |
171
+ | `Ctrl+W` `Ctrl+Backspace` | delete the word before the caret |
172
+ | `Ctrl+Delete` | delete the word after the caret |
173
+ | `Ctrl+U` `Ctrl+K` | delete to line start, to line end |
174
+ | `Ctrl+Home` `Ctrl+End` | start or end of a multi-line entry |
169
175
  | `Esc` | clear the line |
170
176
  | `Ctrl+V` | paste a block. Nothing is sent until you press Enter |
171
177
  | `Shift+Enter` | new line inside the entry |
@@ -136,9 +136,15 @@ follows the command:
136
136
  |---|---|
137
137
  | `↑` `↓` | history. With text typed, searches by that prefix |
138
138
  | `Tab` | complete commands, config keys, and file paths |
139
+ | `Right` | accept the dim preview of a slash command (`/he` shows `lp`) |
140
+ | `/` | opens the command menu under the input; `Up` `Down` pick, `Tab` takes the pick, `Enter` runs it |
141
+ | `Ctrl+Z` `Ctrl+Y` | undo, redo (a typed word is one step) |
139
142
  | `Ctrl+←` `Ctrl+→` | move by word |
140
143
  | `Home` `End` | start or end of line |
141
- | `Ctrl+W` `Ctrl+U` `Ctrl+K` | delete the word before, to line start, to line end |
144
+ | `Ctrl+W` `Ctrl+Backspace` | delete the word before the caret |
145
+ | `Ctrl+Delete` | delete the word after the caret |
146
+ | `Ctrl+U` `Ctrl+K` | delete to line start, to line end |
147
+ | `Ctrl+Home` `Ctrl+End` | start or end of a multi-line entry |
142
148
  | `Esc` | clear the line |
143
149
  | `Ctrl+V` | paste a block. Nothing is sent until you press Enter |
144
150
  | `Shift+Enter` | new line inside the entry |
@@ -3,4 +3,4 @@
3
3
  # The one place the version is written. pyproject.toml reads it (hatch
4
4
  # dynamic version), agent.VERSION re-exports it, and CI refuses a release
5
5
  # tag that does not match it.
6
- __version__ = "2.9.0"
6
+ __version__ = "2.9.1"
@@ -32,7 +32,7 @@ import re
32
32
  import sys
33
33
  import time
34
34
  import unicodedata
35
- from collections.abc import Callable, Iterable, Sequence
35
+ from collections.abc import Callable, Iterable, Mapping, Sequence
36
36
  from pathlib import Path
37
37
  from typing import Any
38
38
 
@@ -52,9 +52,14 @@ HOME = "<home>"
52
52
  END = "<end>"
53
53
  WORD_LEFT = "<word-left>"
54
54
  WORD_RIGHT = "<word-right>"
55
- KILL_WORD = "<kill-word>" # Ctrl+W
55
+ KILL_WORD = "<kill-word>" # Ctrl+W, Ctrl+Backspace
56
+ KILL_WORD_FORWARD = "<kill-word-forward>" # Ctrl+Delete
57
+ BUFFER_START = "<buffer-start>" # Ctrl+Home: the first line of a multi-line entry
58
+ BUFFER_END = "<buffer-end>" # Ctrl+End
56
59
  KILL_LINE = "<kill-line>" # Ctrl+K
57
60
  KILL_TO_START = "<kill-to-start>" # Ctrl+U
61
+ UNDO = "<undo>" # Ctrl+Z
62
+ REDO = "<redo>" # Ctrl+Y
58
63
  CLEAR_SCREEN = "<clear-screen>" # Ctrl+L
59
64
  INTERRUPT = "<interrupt>" # Ctrl+C
60
65
  EOF_KEY = "<eof>" # Ctrl+D on an empty buffer
@@ -73,16 +78,18 @@ _EXTENDED = {
73
78
  "H": UP, "P": DOWN, "K": LEFT, "M": RIGHT,
74
79
  "G": HOME, "O": END, "S": DELETE,
75
80
  "s": WORD_LEFT, "t": WORD_RIGHT,
81
+ "\x93": KILL_WORD_FORWARD, "w": BUFFER_START, "u": BUFFER_END,
76
82
  }
77
83
  _CONTROL = {
78
84
  "\r": ENTER, "\n": NEWLINE, "\t": TAB,
79
- "\x08": BACKSPACE, "\x7f": BACKSPACE,
85
+ "\x08": BACKSPACE, "\x7f": KILL_WORD, # 0x7f is Ctrl+Backspace on a Windows console
80
86
  "\x01": HOME, "\x05": END,
81
87
  "\x02": LEFT, "\x06": RIGHT,
82
88
  "\x0e": DOWN, "\x10": UP,
83
89
  "\x03": INTERRUPT, "\x04": EOF_KEY,
84
90
  "\x0b": KILL_LINE, "\x15": KILL_TO_START, "\x17": KILL_WORD,
85
91
  "\x0c": CLEAR_SCREEN, "\x1b": ESCAPE,
92
+ "\x1a": UNDO, "\x19": REDO,
86
93
  }
87
94
 
88
95
 
@@ -480,6 +487,7 @@ class LineEditor:
480
487
  *,
481
488
  history: History | None = None,
482
489
  completer: Callable[[str], list[str]] | None = None,
490
+ command_help: Mapping[str, str] | None = None,
483
491
  read_key: Callable[[], str] | None = None,
484
492
  write: Callable[[str], None] | None = None,
485
493
  width: int | None = None,
@@ -498,6 +506,16 @@ class LineEditor:
498
506
  ) -> None:
499
507
  self.history = history or History()
500
508
  self.completer = completer
509
+ self.command_help: Mapping[str, str] = dict(command_help or {})
510
+ # Undo/redo over the entry: (buffer, pos) snapshots. Typing a word
511
+ # is one step (a run of non-space characters, then the space), a
512
+ # kill or a paste is one step; a cursor move is none.
513
+ self._undo: list[tuple[str, int]] = []
514
+ self._redo: list[tuple[str, int]] = []
515
+ self._undo_kind = ""
516
+ # The / menu: which of the matching commands is selected.
517
+ self._menu_index = 0
518
+ self._menu_key: str | None = None
501
519
  self._read_key = read_key or windows_key_reader()
502
520
  self._write = write or (lambda s: (sys.stdout.write(s), sys.stdout.flush()) and None)
503
521
  self._forced_width = width
@@ -621,6 +639,16 @@ class LineEditor:
621
639
  hint = self.placeholder[: max(0, self.usable - visible_len(last_prompt) - 1)]
622
640
  styled = f"\033[2m{hint}\033[0m" if self.styled else hint
623
641
  logical[-1] = (last_prompt + styled, visible_len(last_prompt) + len(hint))
642
+ ghost = self._ghost() if chrome else ""
643
+ if ghost:
644
+ # The rest of the best slash-command match, dim, after the
645
+ # cursor. It counts toward the row's width so wrapping stays
646
+ # exact; the cursor arithmetic below only sees the real text.
647
+ text, vis = logical[-1]
648
+ styled = f"\033[2m{ghost}\033[0m" if self.styled else ghost
649
+ logical[-1] = (text + styled, vis + len(ghost))
650
+ if chrome:
651
+ logical.extend(self._menu_rows())
624
652
  for line in below:
625
653
  logical.append((line, visible_len(line)))
626
654
 
@@ -825,6 +853,84 @@ class LineEditor:
825
853
 
826
854
  # -- completion ---------------------------------------------------------
827
855
 
856
+ def _ghost(self) -> str:
857
+ """The rest of the best slash-command match while a command name is
858
+ being typed at the end of the line: "/he" previews "lp". Empty when
859
+ the name is complete, ambiguous beyond a shared prefix is fine (the
860
+ first match in command order wins, Tab still lists them), and empty
861
+ for anything that is not a bare command word, so paths and
862
+ arguments never trigger a filesystem walk on every keystroke."""
863
+ buf = self.buffer
864
+ if (self.completer is None or self.pos != len(buf) or not buf.startswith("/")
865
+ or any(ch.isspace() for ch in buf)):
866
+ return ""
867
+ try:
868
+ candidates = self.completer(buf)
869
+ except Exception: # noqa: BLE001 — a preview must never break typing
870
+ return ""
871
+ word = buf.lower()
872
+ if any(c.lower() == word for c in candidates):
873
+ return ""
874
+ items = self._menu_items()
875
+ if items:
876
+ cand = items[self._menu_index]
877
+ return cand[len(buf):] if cand.lower().startswith(word) and len(cand) > len(buf) else ""
878
+ for cand in candidates:
879
+ if cand.lower().startswith(word) and len(cand) > len(buf):
880
+ return cand[len(buf):]
881
+ return ""
882
+
883
+ MENU_ROWS = 8
884
+
885
+ def _menu_items(self) -> list[str]:
886
+ """The slash commands matching a bare /word at the end of the line,
887
+ in command order; the menu the editor draws under the input row.
888
+ The selection index survives while the list is the same and resets
889
+ when typing changes it."""
890
+ buf = self.buffer
891
+ if (self.completer is None or self.pos != len(buf) or not buf.startswith("/")
892
+ or any(ch.isspace() for ch in buf)):
893
+ self._menu_key = None
894
+ return []
895
+ try:
896
+ items = [c for c in self.completer(buf) if c.startswith("/")]
897
+ except Exception: # noqa: BLE001
898
+ items = []
899
+ key = "\0".join(items)
900
+ if key != self._menu_key:
901
+ self._menu_key = key
902
+ self._menu_index = 0
903
+ if self._menu_index >= len(items):
904
+ self._menu_index = 0
905
+ return items
906
+
907
+ def _menu_rows(self) -> list[tuple[str, int]]:
908
+ """(rendered row, visible width) per menu line, clipped to the width."""
909
+ items = self._menu_items()
910
+ if not items:
911
+ return []
912
+ first = max(0, min(self._menu_index - self.MENU_ROWS + 1, len(items) - self.MENU_ROWS))
913
+ first = max(0, min(first, self._menu_index))
914
+ shown = items[first:first + self.MENU_ROWS]
915
+ name_w = max(len(c) for c in shown)
916
+ rows: list[tuple[str, int]] = []
917
+ for n, cand in enumerate(shown, first):
918
+ desc = self.command_help.get(cand, "custom command" if cand not in self.command_help and cand in items else "")
919
+ avail = max(0, self.usable - 4 - name_w - 2)
920
+ desc = desc[:avail]
921
+ plain = f" {'▸' if n == self._menu_index else ' '} {cand.ljust(name_w)} {desc}".rstrip()
922
+ if self.styled and n == self._menu_index:
923
+ text = f"\033[1m{plain}\033[0m"
924
+ elif self.styled:
925
+ text = f"\033[2m{plain}\033[0m"
926
+ else:
927
+ text = plain
928
+ rows.append((text, visible_len(plain)))
929
+ if len(items) > len(shown):
930
+ more = f" … {len(items) - len(shown)} more"
931
+ rows.append((f"\033[2m{more}\033[0m" if self.styled else more, len(more)))
932
+ return rows
933
+
828
934
  def _complete(self, prompt: str) -> None:
829
935
  if self.completer is None:
830
936
  return
@@ -871,6 +977,8 @@ class LineEditor:
871
977
  ``input()`` does, so callers keep their existing handlers."""
872
978
  self.buffer, self.pos = "", 0
873
979
  self._hist_index, self._hist_prefix, self._saved_draft = None, "", ""
980
+ self._undo, self._redo, self._undo_kind = [], [], ""
981
+ self._menu_index, self._menu_key = 0, None
874
982
  self._rendered_rows, self._cursor_row = 0, 0
875
983
  self._last_text = None
876
984
  self._last_size = (self.usable, self.height)
@@ -951,9 +1059,56 @@ class LineEditor:
951
1059
  self.render(prompt)
952
1060
 
953
1061
  def _handle(self, key: str, prompt: str) -> str | None:
954
- """Apply one key. Returns the finished line, or None to keep editing."""
1062
+ """Apply one key. Returns the finished line, or None to keep editing.
1063
+ Every change to the text lands on the undo stack; a run of typed
1064
+ characters up to a space is one step."""
1065
+ if key == UNDO:
1066
+ if self._undo:
1067
+ self._redo.append((self.buffer, self.pos))
1068
+ self.buffer, self.pos = self._undo.pop()
1069
+ self._undo_kind = ""
1070
+ return None
1071
+ if key == REDO:
1072
+ if self._redo:
1073
+ self._undo.append((self.buffer, self.pos))
1074
+ self.buffer, self.pos = self._redo.pop()
1075
+ self._undo_kind = ""
1076
+ return None
1077
+ before = (self.buffer, self.pos)
1078
+ result = self._handle_key(key, prompt)
1079
+ if self.buffer != before[0]:
1080
+ if key == BACKSPACE:
1081
+ kind = "backspace"
1082
+ elif len(key) == 1 and key.isprintable() and not key.isspace():
1083
+ kind = "type"
1084
+ else:
1085
+ kind = ""
1086
+ if not (kind and kind == self._undo_kind):
1087
+ self._undo.append(before)
1088
+ del self._undo[:-200]
1089
+ self._undo_kind = kind
1090
+ self._redo.clear()
1091
+ elif key not in (LEFT, RIGHT, HOME, END, WORD_LEFT, WORD_RIGHT, BUFFER_START, BUFFER_END, IDLE):
1092
+ self._undo_kind = ""
1093
+ return result
1094
+
1095
+ def _handle_key(self, key: str, prompt: str) -> str | None:
955
1096
  buf = self.buffer
956
1097
 
1098
+ # The / menu: Up/Down pick, Tab takes the pick, Enter runs it.
1099
+ menu = self._menu_items() if key in (UP, DOWN, TAB, ENTER) else []
1100
+ if menu and key in (UP, DOWN) and len(menu) > 1:
1101
+ self._menu_index = (self._menu_index + (-1 if key == UP else 1)) % len(menu)
1102
+ return None
1103
+ if menu and key == TAB:
1104
+ self.buffer = menu[self._menu_index] + " "
1105
+ self.pos = len(self.buffer)
1106
+ return None
1107
+ if menu and key == ENTER and buf.lower() != menu[self._menu_index].lower():
1108
+ self.buffer = menu[self._menu_index]
1109
+ self.pos = len(self.buffer)
1110
+ buf = self.buffer
1111
+
957
1112
  if key == ENTER:
958
1113
  # A trailing backslash is an explicit "keep going" — the one way to
959
1114
  # get a multi-line entry without pasting. It must be preceded by
@@ -989,6 +1144,14 @@ class LineEditor:
989
1144
  self.pos = max(0, self.pos - 1)
990
1145
  return None
991
1146
  if key == RIGHT:
1147
+ if self.pos == len(buf):
1148
+ ghost = self._ghost()
1149
+ if ghost:
1150
+ # Accept the preview; the space means "now the argument",
1151
+ # as a Tab completion does.
1152
+ self.buffer = buf + ghost + " "
1153
+ self.pos = len(self.buffer)
1154
+ return None
992
1155
  self.pos = min(len(buf), self.pos + 1)
993
1156
  return None
994
1157
  if key == WORD_LEFT:
@@ -1008,6 +1171,15 @@ class LineEditor:
1008
1171
  self.buffer = buf[:start] + buf[self.pos:]
1009
1172
  self.pos = start
1010
1173
  return None
1174
+ if key == KILL_WORD_FORWARD:
1175
+ self.buffer = buf[:self.pos] + buf[self._word_end():]
1176
+ return None
1177
+ if key == BUFFER_START:
1178
+ self.pos = 0
1179
+ return None
1180
+ if key == BUFFER_END:
1181
+ self.pos = len(buf)
1182
+ return None
1011
1183
  if key == KILL_TO_START:
1012
1184
  start = self._line_start()
1013
1185
  self.buffer = buf[:start] + buf[self.pos:]
@@ -1061,6 +1233,7 @@ def make_reader(
1061
1233
  config: dict[str, Any],
1062
1234
  commands: Sequence[str],
1063
1235
  config_keys: Callable[[], Iterable[str]] | None = None,
1236
+ command_help: Mapping[str, str] | None = None,
1064
1237
  on_zoom: Callable[[int], Any] | None = None,
1065
1238
  on_resize: Callable[[], Any] | None = None,
1066
1239
  chrome: Callable[[int], tuple[list[str], list[str]]] | None = None,
@@ -1095,6 +1268,7 @@ def make_reader(
1095
1268
  editor = LineEditor(
1096
1269
  history=History(path, int(config.get("input_history_limit", 500))),
1097
1270
  completer=default_completer(commands, config_keys),
1271
+ command_help=command_help,
1098
1272
  margin=int(config.get("side_padding", 0) or 0),
1099
1273
  on_zoom=on_zoom,
1100
1274
  on_resize=on_resize,
@@ -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
File without changes
File without changes
File without changes
File without changes