hx-cli 0.1.0__tar.gz → 0.1.2__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 (134) hide show
  1. {hx_cli-0.1.0 → hx_cli-0.1.2}/.gitignore +2 -0
  2. {hx_cli-0.1.0 → hx_cli-0.1.2}/PKG-INFO +100 -13
  3. {hx_cli-0.1.0 → hx_cli-0.1.2}/README.md +97 -12
  4. {hx_cli-0.1.0 → hx_cli-0.1.2}/pyproject.toml +4 -1
  5. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/__init__.py +1 -1
  6. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/cli.py +13 -20
  7. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/config.py +4 -0
  8. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/core/loop.py +17 -8
  9. hx_cli-0.1.2/src/hx/keys.py +265 -0
  10. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/paths.py +10 -0
  11. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/permissions/engine.py +153 -34
  12. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/permissions/parser.py +94 -24
  13. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/providers/models.py +24 -14
  14. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/app.py +198 -27
  15. hx_cli-0.1.2/src/hx/tui/clipboard.py +125 -0
  16. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/commands.py +132 -16
  17. hx_cli-0.1.2/src/hx/tui/fuzzy.py +123 -0
  18. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/hx.tcss +90 -17
  19. hx_cli-0.1.2/src/hx/tui/killring.py +46 -0
  20. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/renderers.py +13 -4
  21. hx_cli-0.1.2/src/hx/tui/roles.py +90 -0
  22. hx_cli-0.1.2/src/hx/tui/theme.py +176 -0
  23. hx_cli-0.1.2/src/hx/tui/theme_json.py +170 -0
  24. hx_cli-0.1.2/src/hx/tui/themes/ansi.json +69 -0
  25. hx_cli-0.1.2/src/hx/tui/themes/dark.json +91 -0
  26. hx_cli-0.1.2/src/hx/tui/themes/light.json +90 -0
  27. hx_cli-0.1.2/src/hx/tui/widgets/autocomplete.py +105 -0
  28. hx_cli-0.1.2/src/hx/tui/widgets/frame.py +61 -0
  29. hx_cli-0.1.2/src/hx/tui/widgets/header.py +74 -0
  30. hx_cli-0.1.2/src/hx/tui/widgets/hints.py +75 -0
  31. hx_cli-0.1.2/src/hx/tui/widgets/input.py +401 -0
  32. hx_cli-0.1.2/src/hx/tui/widgets/palette.py +222 -0
  33. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/widgets/permission.py +25 -21
  34. hx_cli-0.1.2/src/hx/tui/widgets/rule.py +26 -0
  35. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/widgets/statusbar.py +0 -7
  36. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/widgets/transcript.py +91 -9
  37. hx_cli-0.1.2/src/hx/tui/widgets/working.py +151 -0
  38. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/core/test_loop.py +46 -0
  39. hx_cli-0.1.2/tests/permissions/test_engine.py +320 -0
  40. hx_cli-0.1.2/tests/permissions/test_parser.py +76 -0
  41. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/test_readme.py +51 -15
  42. hx_cli-0.1.2/tests/tui/__snapshots__/test_snapshots/test_a_running_turn.raw +192 -0
  43. hx_cli-0.1.2/tests/tui/__snapshots__/test_snapshots/test_a_turn_with_tool_blocks.raw +194 -0
  44. hx_cli-0.1.2/tests/tui/__snapshots__/test_snapshots/test_the_completion_popup.raw +191 -0
  45. hx_cli-0.1.2/tests/tui/__snapshots__/test_snapshots/test_the_idle_screen.raw +190 -0
  46. hx_cli-0.1.2/tests/tui/test_app.py +1078 -0
  47. hx_cli-0.1.2/tests/tui/test_autocomplete.py +177 -0
  48. hx_cli-0.1.2/tests/tui/test_clipboard.py +111 -0
  49. hx_cli-0.1.2/tests/tui/test_frame.py +82 -0
  50. hx_cli-0.1.2/tests/tui/test_keys.py +87 -0
  51. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tui/test_renderers.py +2 -1
  52. hx_cli-0.1.2/tests/tui/test_snapshots.py +100 -0
  53. hx_cli-0.1.2/tests/tui/test_theme_json.py +105 -0
  54. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tui/test_transcript.py +2 -1
  55. {hx_cli-0.1.0 → hx_cli-0.1.2}/uv.lock +41 -0
  56. hx_cli-0.1.0/src/hx/tui/theme.py +0 -322
  57. hx_cli-0.1.0/src/hx/tui/widgets/input.py +0 -145
  58. hx_cli-0.1.0/src/hx/tui/widgets/palette.py +0 -130
  59. hx_cli-0.1.0/src/hx/tui/widgets/working.py +0 -78
  60. hx_cli-0.1.0/tests/permissions/test_engine.py +0 -143
  61. hx_cli-0.1.0/tests/permissions/test_parser.py +0 -31
  62. hx_cli-0.1.0/tests/tui/test_app.py +0 -525
  63. {hx_cli-0.1.0 → hx_cli-0.1.2}/.github/workflows/ci.yml +0 -0
  64. {hx_cli-0.1.0 → hx_cli-0.1.2}/install.sh +0 -0
  65. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/agents/__init__.py +0 -0
  66. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/agents/definitions.py +0 -0
  67. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/agents/subagent.py +0 -0
  68. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/core/__init__.py +0 -0
  69. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/core/compaction.py +0 -0
  70. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/core/context.py +0 -0
  71. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/core/events.py +0 -0
  72. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/core/lateinject.py +0 -0
  73. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/core/messages.py +0 -0
  74. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/core/session.py +0 -0
  75. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/core/usage.py +0 -0
  76. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/frontmatter.py +0 -0
  77. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/mcp/__init__.py +0 -0
  78. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/mcp/client.py +0 -0
  79. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/mcp/manager.py +0 -0
  80. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/permissions/__init__.py +0 -0
  81. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/permissions/sandbox.py +0 -0
  82. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/providers/__init__.py +0 -0
  83. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/providers/base.py +0 -0
  84. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/providers/fake.py +0 -0
  85. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/providers/openrouter.py +0 -0
  86. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/skills/__init__.py +0 -0
  87. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/skills/loader.py +0 -0
  88. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/skills/runtime.py +0 -0
  89. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/__init__.py +0 -0
  90. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/base.py +0 -0
  91. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/bash.py +0 -0
  92. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/edit.py +0 -0
  93. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/glob.py +0 -0
  94. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/grep.py +0 -0
  95. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/output.py +0 -0
  96. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/read.py +0 -0
  97. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/registry.py +0 -0
  98. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/task.py +0 -0
  99. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/todo.py +0 -0
  100. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/write.py +0 -0
  101. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/__init__.py +0 -0
  102. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/widgets/__init__.py +0 -0
  103. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/widgets/configure.py +0 -0
  104. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/widgets/diff.py +0 -0
  105. {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/widgets/todos.py +0 -0
  106. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/__init__.py +0 -0
  107. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/agents/__init__.py +0 -0
  108. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/agents/test_subagent.py +0 -0
  109. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/conftest.py +0 -0
  110. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/core/__init__.py +0 -0
  111. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/core/test_compaction.py +0 -0
  112. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/core/test_context.py +0 -0
  113. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/core/test_lateinject.py +0 -0
  114. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/core/test_usage.py +0 -0
  115. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/mcp/__init__.py +0 -0
  116. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/mcp/fixtures/echo_server.py +0 -0
  117. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/mcp/test_manager.py +0 -0
  118. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/permissions/__init__.py +0 -0
  119. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/permissions/test_sandbox.py +0 -0
  120. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/providers/__init__.py +0 -0
  121. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/providers/test_openrouter.py +0 -0
  122. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/skills/__init__.py +0 -0
  123. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/skills/test_loader.py +0 -0
  124. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/test_cli.py +0 -0
  125. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/test_integration.py +0 -0
  126. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/test_live.py +0 -0
  127. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tools/__init__.py +0 -0
  128. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tools/test_background.py +0 -0
  129. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tools/test_bash.py +0 -0
  130. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tools/test_files.py +0 -0
  131. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tools/test_output.py +0 -0
  132. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tools/test_todo.py +0 -0
  133. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tui/__init__.py +0 -0
  134. {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tui/test_statusbar.py +0 -0
@@ -12,3 +12,5 @@ build/
12
12
  htmlcov/
13
13
  .DS_Store
14
14
  .hx/sessions/
15
+ .hx/settings.json
16
+ snapshot_report.html
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hx-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: HX - a terminal coding agent
5
5
  Project-URL: Homepage, https://github.com/aletisunil/hx
6
6
  Project-URL: Issues, https://github.com/aletisunil/hx/issues
@@ -22,9 +22,11 @@ Provides-Extra: dev
22
22
  Requires-Dist: mypy>=1.11; extra == 'dev'
23
23
  Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
24
24
  Requires-Dist: pytest-cov>=5.0; extra == 'dev'
25
+ Requires-Dist: pytest-textual-snapshot>=1.0; extra == 'dev'
25
26
  Requires-Dist: pytest>=8.2; extra == 'dev'
26
27
  Requires-Dist: ruff>=0.6; extra == 'dev'
27
28
  Requires-Dist: textual-dev>=1.5; extra == 'dev'
29
+ Requires-Dist: types-pygments; extra == 'dev'
28
30
  Requires-Dist: types-pyyaml; extra == 'dev'
29
31
  Description-Content-Type: text/markdown
30
32
 
@@ -117,23 +119,72 @@ it pipes cleanly.
117
119
 
118
120
  ### Keys
119
121
 
122
+ Every key below is defined once, in `src/hx/keys.py`. `/help`, the hints line
123
+ under the prompt and this table all read from that registry, and any of them can
124
+ be rebound (see [Keybindings](#keybindings)).
125
+
120
126
  | Key | Does |
121
127
  |---|---|
122
128
  | `enter` | send |
123
- | `ctrl+j` | newline |
129
+ | `ctrl+j` | newline (also `shift+enter`) |
124
130
  | `esc` | interrupt the current turn |
125
- | `ctrl+c` | cancel the current turn |
131
+ | `ctrl+c` | clear the prompt; twice on an empty prompt exits |
132
+ | `ctrl+d` | exit, when the prompt is empty |
133
+ | `ctrl+z` | suspend to the background |
126
134
  | `shift+tab` | cycle permission mode |
127
135
  | `ctrl+p` | command palette |
128
- | `ctrl+r` | expand the last tool output |
136
+ | `ctrl+l` | model picker |
137
+ | `ctrl+o` | expand tool output (also `ctrl+r`) |
129
138
  | `ctrl+t` | toggle the todo sidebar |
130
- | `ctrl+d` | quit |
139
+ | `ctrl+x` | copy the selected message to the clipboard |
140
+ | `ctrl+up` | previous message |
141
+ | `ctrl+down` | next message |
142
+ | `pgup` | scroll the transcript up |
143
+ | `pgdn` | scroll the transcript down |
144
+ | `ctrl+home` | jump to the start of the transcript |
145
+ | `ctrl+end` | jump back to the newest output |
131
146
  | `@path` | complete a file path |
132
147
  | `!command` | run a shell command directly, no model turn |
133
148
 
134
149
  `!` still goes through the permission engine and the sandbox — it skips the
135
150
  model, not the safety layers.
136
151
 
152
+ The prompt is a readline-style editor: `ctrl+a`/`ctrl+e` for line start and end,
153
+ `ctrl+b`/`ctrl+f` by character, `alt+b`/`alt+f` by word, `ctrl+w` and `alt+d` to
154
+ kill a word, `ctrl+u` and `ctrl+k` to kill to the start or end of a line, then
155
+ `ctrl+y` to yank it back and `alt+y` to walk further down the kill ring.
156
+ `ctrl+z` undoes, `ctrl+shift+z` redoes.
157
+
158
+ Typing `/` or `@` opens a completion list above the prompt; `tab` cycles it,
159
+ `enter` accepts, `esc` dismisses.
160
+
161
+ #### Copying
162
+
163
+ `ctrl+x` copies the message the cursor is on, and `/copy` copies the last reply.
164
+ HX writes through the platform's own clipboard tool first (`pbcopy`, `wl-copy`,
165
+ `xclip`, `xsel`) and falls back to OSC 52, which is also always sent over SSH so
166
+ the text lands on the machine you are actually sitting at.
167
+
168
+ Because `ctrl+c` clears the prompt, drag-selecting in the transcript is your
169
+ terminal's own selection rather than the TUI's — use your terminal's copy key
170
+ for that. HX does not emit OSC 133 prompt markers yet, so shell-integration
171
+ features that jump between prompts will not see HX's messages.
172
+
173
+ #### Keybindings
174
+
175
+ Any key can be rebound in `~/.hx/keybindings.json`, keyed by the action ids in
176
+ `src/hx/keys.py`:
177
+
178
+ ```json
179
+ {
180
+ "app.tools.expand": "ctrl+r",
181
+ "app.message.copy": ["ctrl+x", "alt+c"]
182
+ }
183
+ ```
184
+
185
+ Conflicts and unknown action names are reported as a notice at startup rather
186
+ than being silently resolved.
187
+
137
188
  ### Commands
138
189
 
139
190
  | Command | Does |
@@ -152,10 +203,11 @@ model, not the safety layers.
152
203
  | `/skills` | installed skills |
153
204
  | `/agents` | subagent types |
154
205
  | `/mcp` | server status |
155
- | `/theme [name]` | `dark`, `light`, `ansi` |
206
+ | `/theme [name]` | `dark`, `light`, `ansi`, or any theme in `~/.hx/themes` |
207
+ | `/copy` | copy the last reply to the clipboard |
156
208
  | `/init` | generate an `HX.md` for the project |
157
209
  | `/help` | list commands and keys |
158
- | `/quit` | exit |
210
+ | `/quit` | exit (also `/exit`, `/q`) |
159
211
 
160
212
  ### The status bar
161
213
 
@@ -179,7 +231,8 @@ rule without discarding yours. Everything else is replaced.
179
231
 
180
232
  ```jsonc
181
233
  {
182
- "theme": "dark", // dark | light | ansi
234
+ "theme": "dark", // dark | light | ansi, or a file in ~/.hx/themes
235
+ "quiet_startup": false, // skip the startup header
183
236
  "telemetry": false,
184
237
 
185
238
  "models": {
@@ -214,8 +267,31 @@ rule without discarding yours. Everything else is replaced.
214
267
  ```
215
268
 
216
269
  Environment overrides: `HX_MODEL`, `HX_SUBAGENT_MODEL`, `HX_MAX_TOKENS`,
217
- `HX_PERMISSION_MODE`, `HX_SANDBOX`, `HX_COMPACT_AT`, `HX_THEME`. Also
218
- `HX_HOME` to relocate user state.
270
+ `HX_PERMISSION_MODE`, `HX_SANDBOX`, `HX_COMPACT_AT`, `HX_THEME`,
271
+ `HX_QUIET_STARTUP`. Also `HX_HOME` to relocate user state.
272
+
273
+ ### Themes
274
+
275
+ A theme is a JSON file: a `vars` block of raw colours, and a `colors` block
276
+ mapping semantic roles onto them. Drop one in `~/.hx/themes/mine.json` and
277
+ `/theme mine` picks it up — no restart, no code change.
278
+
279
+ ```jsonc
280
+ {
281
+ "name": "mine",
282
+ "dark": true,
283
+ "vars": { "green": "#b5bd68", "gray": "#808080" },
284
+ "colors": {
285
+ "background": "#18181e",
286
+ "success": "green", // a vars key, or a literal
287
+ "muted": "gray"
288
+ // ...every role; a missing one is an error, not a silent black
289
+ }
290
+ }
291
+ ```
292
+
293
+ The role names are pi's, so [pi](https://github.com/earendil-works/pi) theme
294
+ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
219
295
 
220
296
  ### Where things live
221
297
 
@@ -224,6 +300,8 @@ Environment overrides: `HX_MODEL`, `HX_SUBAGENT_MODEL`, `HX_MAX_TOKENS`,
224
300
  | `~/.hx/settings.json` | your settings |
225
301
  | `~/.hx/auth.json` | API key, mode 0600 |
226
302
  | `~/.hx/models.json` | cached model catalogue, refreshed daily |
303
+ | `~/.hx/themes/` | your themes, one JSON file each |
304
+ | `~/.hx/keybindings.json` | your key overrides |
227
305
  | `~/.hx/sessions/` | transcripts, spilled tool output, subagent sessions |
228
306
  | `~/.hx/skills/`, `~/.hx/agents/` | your skills and agents |
229
307
  | `./.hx/settings.json` | project settings, checked in if you like |
@@ -364,6 +442,13 @@ OPENROUTER_API_KEY=... uv run pytest -m live
364
442
  Tests marked `sandbox` exercise the real OS sandbox and are skipped where no
365
443
  backend exists.
366
444
 
445
+ The TUI has SVG layout snapshots. Read the diff before accepting a change to
446
+ them - they exist to catch a frame that quietly lost a row:
447
+
448
+ ```sh
449
+ uv run pytest tests/tui/test_snapshots.py --snapshot-update
450
+ ```
451
+
367
452
  ### Layout
368
453
 
369
454
  ```
@@ -374,7 +459,9 @@ src/hx/
374
459
  tools/ Bash, Read, Write, Edit, Glob, Grep, TodoWrite, Task, output capping
375
460
  permissions/ rule engine, shell decomposition, Seatbelt/bubblewrap
376
461
  skills/ agents/ mcp/
377
- tui/ Textual app, commands, theme, per-tool renderers, widgets
462
+ keys.py keybinding registry: ids, defaults, descriptions, user overrides
463
+ tui/ Textual app, commands, per-tool renderers, widgets
464
+ themes/ the shipped palettes, as JSON
378
465
  ```
379
466
 
380
467
  The core is headless and emits events; the TUI and print mode are both just
@@ -399,8 +486,8 @@ One-time setup:
399
486
  Each release:
400
487
 
401
488
  ```sh
402
- # bump version in pyproject.toml, commit
403
- git tag v0.1.0 && git push origin main --tags
489
+ # bump __version__ in src/hx/__init__.py, commit
490
+ git tag v$(uv run hx --version | cut -d' ' -f2) && git push origin main --tags
404
491
  ```
405
492
 
406
493
  The `publish` job runs only on `refs/tags/v*` and only after lint, the test
@@ -87,23 +87,72 @@ it pipes cleanly.
87
87
 
88
88
  ### Keys
89
89
 
90
+ Every key below is defined once, in `src/hx/keys.py`. `/help`, the hints line
91
+ under the prompt and this table all read from that registry, and any of them can
92
+ be rebound (see [Keybindings](#keybindings)).
93
+
90
94
  | Key | Does |
91
95
  |---|---|
92
96
  | `enter` | send |
93
- | `ctrl+j` | newline |
97
+ | `ctrl+j` | newline (also `shift+enter`) |
94
98
  | `esc` | interrupt the current turn |
95
- | `ctrl+c` | cancel the current turn |
99
+ | `ctrl+c` | clear the prompt; twice on an empty prompt exits |
100
+ | `ctrl+d` | exit, when the prompt is empty |
101
+ | `ctrl+z` | suspend to the background |
96
102
  | `shift+tab` | cycle permission mode |
97
103
  | `ctrl+p` | command palette |
98
- | `ctrl+r` | expand the last tool output |
104
+ | `ctrl+l` | model picker |
105
+ | `ctrl+o` | expand tool output (also `ctrl+r`) |
99
106
  | `ctrl+t` | toggle the todo sidebar |
100
- | `ctrl+d` | quit |
107
+ | `ctrl+x` | copy the selected message to the clipboard |
108
+ | `ctrl+up` | previous message |
109
+ | `ctrl+down` | next message |
110
+ | `pgup` | scroll the transcript up |
111
+ | `pgdn` | scroll the transcript down |
112
+ | `ctrl+home` | jump to the start of the transcript |
113
+ | `ctrl+end` | jump back to the newest output |
101
114
  | `@path` | complete a file path |
102
115
  | `!command` | run a shell command directly, no model turn |
103
116
 
104
117
  `!` still goes through the permission engine and the sandbox — it skips the
105
118
  model, not the safety layers.
106
119
 
120
+ The prompt is a readline-style editor: `ctrl+a`/`ctrl+e` for line start and end,
121
+ `ctrl+b`/`ctrl+f` by character, `alt+b`/`alt+f` by word, `ctrl+w` and `alt+d` to
122
+ kill a word, `ctrl+u` and `ctrl+k` to kill to the start or end of a line, then
123
+ `ctrl+y` to yank it back and `alt+y` to walk further down the kill ring.
124
+ `ctrl+z` undoes, `ctrl+shift+z` redoes.
125
+
126
+ Typing `/` or `@` opens a completion list above the prompt; `tab` cycles it,
127
+ `enter` accepts, `esc` dismisses.
128
+
129
+ #### Copying
130
+
131
+ `ctrl+x` copies the message the cursor is on, and `/copy` copies the last reply.
132
+ HX writes through the platform's own clipboard tool first (`pbcopy`, `wl-copy`,
133
+ `xclip`, `xsel`) and falls back to OSC 52, which is also always sent over SSH so
134
+ the text lands on the machine you are actually sitting at.
135
+
136
+ Because `ctrl+c` clears the prompt, drag-selecting in the transcript is your
137
+ terminal's own selection rather than the TUI's — use your terminal's copy key
138
+ for that. HX does not emit OSC 133 prompt markers yet, so shell-integration
139
+ features that jump between prompts will not see HX's messages.
140
+
141
+ #### Keybindings
142
+
143
+ Any key can be rebound in `~/.hx/keybindings.json`, keyed by the action ids in
144
+ `src/hx/keys.py`:
145
+
146
+ ```json
147
+ {
148
+ "app.tools.expand": "ctrl+r",
149
+ "app.message.copy": ["ctrl+x", "alt+c"]
150
+ }
151
+ ```
152
+
153
+ Conflicts and unknown action names are reported as a notice at startup rather
154
+ than being silently resolved.
155
+
107
156
  ### Commands
108
157
 
109
158
  | Command | Does |
@@ -122,10 +171,11 @@ model, not the safety layers.
122
171
  | `/skills` | installed skills |
123
172
  | `/agents` | subagent types |
124
173
  | `/mcp` | server status |
125
- | `/theme [name]` | `dark`, `light`, `ansi` |
174
+ | `/theme [name]` | `dark`, `light`, `ansi`, or any theme in `~/.hx/themes` |
175
+ | `/copy` | copy the last reply to the clipboard |
126
176
  | `/init` | generate an `HX.md` for the project |
127
177
  | `/help` | list commands and keys |
128
- | `/quit` | exit |
178
+ | `/quit` | exit (also `/exit`, `/q`) |
129
179
 
130
180
  ### The status bar
131
181
 
@@ -149,7 +199,8 @@ rule without discarding yours. Everything else is replaced.
149
199
 
150
200
  ```jsonc
151
201
  {
152
- "theme": "dark", // dark | light | ansi
202
+ "theme": "dark", // dark | light | ansi, or a file in ~/.hx/themes
203
+ "quiet_startup": false, // skip the startup header
153
204
  "telemetry": false,
154
205
 
155
206
  "models": {
@@ -184,8 +235,31 @@ rule without discarding yours. Everything else is replaced.
184
235
  ```
185
236
 
186
237
  Environment overrides: `HX_MODEL`, `HX_SUBAGENT_MODEL`, `HX_MAX_TOKENS`,
187
- `HX_PERMISSION_MODE`, `HX_SANDBOX`, `HX_COMPACT_AT`, `HX_THEME`. Also
188
- `HX_HOME` to relocate user state.
238
+ `HX_PERMISSION_MODE`, `HX_SANDBOX`, `HX_COMPACT_AT`, `HX_THEME`,
239
+ `HX_QUIET_STARTUP`. Also `HX_HOME` to relocate user state.
240
+
241
+ ### Themes
242
+
243
+ A theme is a JSON file: a `vars` block of raw colours, and a `colors` block
244
+ mapping semantic roles onto them. Drop one in `~/.hx/themes/mine.json` and
245
+ `/theme mine` picks it up — no restart, no code change.
246
+
247
+ ```jsonc
248
+ {
249
+ "name": "mine",
250
+ "dark": true,
251
+ "vars": { "green": "#b5bd68", "gray": "#808080" },
252
+ "colors": {
253
+ "background": "#18181e",
254
+ "success": "green", // a vars key, or a literal
255
+ "muted": "gray"
256
+ // ...every role; a missing one is an error, not a silent black
257
+ }
258
+ }
259
+ ```
260
+
261
+ The role names are pi's, so [pi](https://github.com/earendil-works/pi) theme
262
+ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
189
263
 
190
264
  ### Where things live
191
265
 
@@ -194,6 +268,8 @@ Environment overrides: `HX_MODEL`, `HX_SUBAGENT_MODEL`, `HX_MAX_TOKENS`,
194
268
  | `~/.hx/settings.json` | your settings |
195
269
  | `~/.hx/auth.json` | API key, mode 0600 |
196
270
  | `~/.hx/models.json` | cached model catalogue, refreshed daily |
271
+ | `~/.hx/themes/` | your themes, one JSON file each |
272
+ | `~/.hx/keybindings.json` | your key overrides |
197
273
  | `~/.hx/sessions/` | transcripts, spilled tool output, subagent sessions |
198
274
  | `~/.hx/skills/`, `~/.hx/agents/` | your skills and agents |
199
275
  | `./.hx/settings.json` | project settings, checked in if you like |
@@ -334,6 +410,13 @@ OPENROUTER_API_KEY=... uv run pytest -m live
334
410
  Tests marked `sandbox` exercise the real OS sandbox and are skipped where no
335
411
  backend exists.
336
412
 
413
+ The TUI has SVG layout snapshots. Read the diff before accepting a change to
414
+ them - they exist to catch a frame that quietly lost a row:
415
+
416
+ ```sh
417
+ uv run pytest tests/tui/test_snapshots.py --snapshot-update
418
+ ```
419
+
337
420
  ### Layout
338
421
 
339
422
  ```
@@ -344,7 +427,9 @@ src/hx/
344
427
  tools/ Bash, Read, Write, Edit, Glob, Grep, TodoWrite, Task, output capping
345
428
  permissions/ rule engine, shell decomposition, Seatbelt/bubblewrap
346
429
  skills/ agents/ mcp/
347
- tui/ Textual app, commands, theme, per-tool renderers, widgets
430
+ keys.py keybinding registry: ids, defaults, descriptions, user overrides
431
+ tui/ Textual app, commands, per-tool renderers, widgets
432
+ themes/ the shipped palettes, as JSON
348
433
  ```
349
434
 
350
435
  The core is headless and emits events; the TUI and print mode are both just
@@ -369,8 +454,8 @@ One-time setup:
369
454
  Each release:
370
455
 
371
456
  ```sh
372
- # bump version in pyproject.toml, commit
373
- git tag v0.1.0 && git push origin main --tags
457
+ # bump __version__ in src/hx/__init__.py, commit
458
+ git tag v$(uv run hx --version | cut -d' ' -f2) && git push origin main --tags
374
459
  ```
375
460
 
376
461
  The `publish` job runs only on `refs/tags/v*` and only after lint, the test
@@ -35,6 +35,8 @@ dev = [
35
35
  "mypy>=1.11",
36
36
  "textual-dev>=1.5",
37
37
  "types-PyYAML",
38
+ "types-Pygments",
39
+ "pytest-textual-snapshot>=1.0",
38
40
  ]
39
41
 
40
42
  [project.scripts]
@@ -63,7 +65,8 @@ ignore = ["E501"]
63
65
 
64
66
  [tool.ruff.lint.per-file-ignores]
65
67
  # Blocking filesystem calls are the point in a test assertion, not a hazard.
66
- "tests/*" = ["B011", "ASYNC240"]
68
+ # RUF001: the TUI's glyphs are deliberate, and a test has to spell them exactly.
69
+ "tests/*" = ["B011", "ASYNC240", "RUF001"]
67
70
 
68
71
  [tool.mypy]
69
72
  python_version = "3.11"
@@ -1,5 +1,5 @@
1
1
  """HX - a terminal coding agent."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.2"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -17,7 +17,7 @@ import contextlib
17
17
  import getpass
18
18
  import subprocess
19
19
  import sys
20
- from dataclasses import dataclass
20
+ from dataclasses import dataclass, field
21
21
  from pathlib import Path
22
22
  from typing import Any
23
23
 
@@ -156,6 +156,8 @@ class Runtime:
156
156
  agents: Any = None
157
157
  mcp: Any = None
158
158
  tools: Any = None
159
+ notices: list[str] = field(default_factory=list)
160
+ """Startup messages for the user - shown once, in the transcript."""
159
161
 
160
162
  @property
161
163
  def sandbox_active(self) -> bool:
@@ -220,7 +222,7 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
220
222
  from hx.mcp.manager import MCPManager
221
223
  from hx.mcp.manager import load_configs as load_mcp_configs
222
224
  from hx.paths import ensure_user_dirs, session_outputs_dir
223
- from hx.permissions.engine import PermissionEngine, load_rules
225
+ from hx.permissions.engine import PermissionEngine, load_rules, migrate_legacy_rules
224
226
  from hx.permissions.sandbox import Sandbox, default_policy
225
227
  from hx.providers.models import ModelRegistry
226
228
  from hx.providers.openrouter import OpenRouterProvider, load_api_key
@@ -260,12 +262,13 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
260
262
  injections.register("todos", todo_injector(todos))
261
263
  injections.register("stale_files", _stale_files_injector(tracker, Injection))
262
264
 
265
+ # Rules come from the settings files only, read once here: `settings`
266
+ # already merges those same files, and loading both would list and match
267
+ # every rule twice.
268
+ notices = migrate_legacy_rules(settings.cwd)
263
269
  permissions = PermissionEngine(
264
270
  mode=settings.permissions.mode,
265
- rules=[
266
- *load_rules(settings.cwd),
267
- *_rules_from_settings(settings.permissions),
268
- ],
271
+ rules=load_rules(settings.cwd),
269
272
  cwd=settings.cwd,
270
273
  )
271
274
 
@@ -330,6 +333,7 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
330
333
  agents=agents,
331
334
  mcp=MCPManager(load_mcp_configs(settings.cwd)),
332
335
  tools=tools,
336
+ notices=notices,
333
337
  )
334
338
 
335
339
 
@@ -358,20 +362,6 @@ def _stale_files_injector(tracker: Any, injection_cls: Any) -> Any:
358
362
  return inject
359
363
 
360
364
 
361
- def _rules_from_settings(permissions: Any) -> list[Any]:
362
- """Rules passed on the command line or via env, layered on top of the files."""
363
- from hx.permissions.engine import Decision, parse_rule
364
-
365
- rules: list[Any] = []
366
- for texts, decision in (
367
- (permissions.deny, Decision.DENY),
368
- (permissions.ask, Decision.ASK),
369
- (permissions.allow, Decision.ALLOW),
370
- ):
371
- rules.extend(parse_rule(str(text), "settings", decision) for text in texts)
372
- return rules
373
-
374
-
375
365
  def prompt_for_api_key() -> bool:
376
366
  """First-run onboarding. Returns True once a key has been saved.
377
367
 
@@ -431,6 +421,7 @@ def run_tui_command(parsed: ParsedArgs) -> int:
431
421
  skills=runtime.skills,
432
422
  agents=runtime.agents,
433
423
  mcp=runtime.mcp,
424
+ notices=runtime.notices,
434
425
  )
435
426
  finally:
436
427
  runtime.bus.close()
@@ -469,6 +460,8 @@ def run_print_command(parsed: ParsedArgs) -> int:
469
460
 
470
461
  renderer = asyncio.create_task(render())
471
462
  await asyncio.sleep(0)
463
+ for notice in runtime.notices:
464
+ print(f"[hx] {notice}", file=sys.stderr)
472
465
  for status in await runtime.connect_mcp():
473
466
  if not status.connected:
474
467
  print(f"[mcp] {status.name} unavailable: {status.error}", file=sys.stderr)
@@ -87,6 +87,8 @@ class Settings:
87
87
  context: ContextSettings = field(default_factory=ContextSettings)
88
88
  bash: BashSettings = field(default_factory=BashSettings)
89
89
  theme: str = "dark"
90
+ quiet_startup: bool = False
91
+ """Skip the startup header. For anyone who has read it already."""
90
92
  telemetry: bool = False
91
93
 
92
94
 
@@ -177,6 +179,7 @@ def _build_settings(data: dict[str, Any], cwd: Path) -> Settings:
177
179
  shell=bash.get("shell"),
178
180
  ),
179
181
  theme=data.get("theme", "dark"),
182
+ quiet_startup=bool(data.get("quietStartup", data.get("quiet_startup", False))),
180
183
  telemetry=bool(data.get("telemetry", False)),
181
184
  )
182
185
 
@@ -241,6 +244,7 @@ _ENV_MAP: dict[str, tuple[str, ...]] = {
241
244
  "HX_SANDBOX": ("permissions", "sandbox"),
242
245
  "HX_COMPACT_AT": ("context", "compact_at"),
243
246
  "HX_THEME": ("theme",),
247
+ "HX_QUIET_STARTUP": ("quietStartup",),
244
248
  }
245
249
 
246
250
 
@@ -397,15 +397,9 @@ class AgentLoop:
397
397
  detail=_permission_detail(call),
398
398
  origin=self.origin,
399
399
  )
400
- self.bus.publish(
401
- PermissionRequested(
402
- request_id=call.id,
403
- tool_name=call.name,
404
- description=request.description,
405
- detail=request.detail,
406
- )
400
+ allowed, reason = await self.permissions.request(
401
+ request, on_ask=lambda: self._announce_ask(call.id, request)
407
402
  )
408
- allowed, reason = await self.permissions.request(request)
409
403
  if allowed:
410
404
  return None
411
405
  return ToolResultBlock(
@@ -414,6 +408,21 @@ class AgentLoop:
414
408
  is_error=True,
415
409
  )
416
410
 
411
+ def _announce_ask(self, call_id: str, request: Any) -> None:
412
+ """Announce only the calls that actually stop for approval.
413
+
414
+ Publishing on every check made the headless renderer print a permission
415
+ line for calls that were auto-allowed and never asked about.
416
+ """
417
+ self.bus.publish(
418
+ PermissionRequested(
419
+ request_id=call_id,
420
+ tool_name=request.tool_name,
421
+ description=request.description,
422
+ detail=request.detail,
423
+ )
424
+ )
425
+
417
426
  def _emit_progress(self, tool_use_id: str, chunk: str) -> None:
418
427
  from hx.core.events import ToolCallProgress
419
428