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.
- {hx_cli-0.1.0 → hx_cli-0.1.2}/.gitignore +2 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/PKG-INFO +100 -13
- {hx_cli-0.1.0 → hx_cli-0.1.2}/README.md +97 -12
- {hx_cli-0.1.0 → hx_cli-0.1.2}/pyproject.toml +4 -1
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/__init__.py +1 -1
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/cli.py +13 -20
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/config.py +4 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/core/loop.py +17 -8
- hx_cli-0.1.2/src/hx/keys.py +265 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/paths.py +10 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/permissions/engine.py +153 -34
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/permissions/parser.py +94 -24
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/providers/models.py +24 -14
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/app.py +198 -27
- hx_cli-0.1.2/src/hx/tui/clipboard.py +125 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/commands.py +132 -16
- hx_cli-0.1.2/src/hx/tui/fuzzy.py +123 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/hx.tcss +90 -17
- hx_cli-0.1.2/src/hx/tui/killring.py +46 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/renderers.py +13 -4
- hx_cli-0.1.2/src/hx/tui/roles.py +90 -0
- hx_cli-0.1.2/src/hx/tui/theme.py +176 -0
- hx_cli-0.1.2/src/hx/tui/theme_json.py +170 -0
- hx_cli-0.1.2/src/hx/tui/themes/ansi.json +69 -0
- hx_cli-0.1.2/src/hx/tui/themes/dark.json +91 -0
- hx_cli-0.1.2/src/hx/tui/themes/light.json +90 -0
- hx_cli-0.1.2/src/hx/tui/widgets/autocomplete.py +105 -0
- hx_cli-0.1.2/src/hx/tui/widgets/frame.py +61 -0
- hx_cli-0.1.2/src/hx/tui/widgets/header.py +74 -0
- hx_cli-0.1.2/src/hx/tui/widgets/hints.py +75 -0
- hx_cli-0.1.2/src/hx/tui/widgets/input.py +401 -0
- hx_cli-0.1.2/src/hx/tui/widgets/palette.py +222 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/widgets/permission.py +25 -21
- hx_cli-0.1.2/src/hx/tui/widgets/rule.py +26 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/widgets/statusbar.py +0 -7
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/widgets/transcript.py +91 -9
- hx_cli-0.1.2/src/hx/tui/widgets/working.py +151 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/core/test_loop.py +46 -0
- hx_cli-0.1.2/tests/permissions/test_engine.py +320 -0
- hx_cli-0.1.2/tests/permissions/test_parser.py +76 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/test_readme.py +51 -15
- hx_cli-0.1.2/tests/tui/__snapshots__/test_snapshots/test_a_running_turn.raw +192 -0
- hx_cli-0.1.2/tests/tui/__snapshots__/test_snapshots/test_a_turn_with_tool_blocks.raw +194 -0
- hx_cli-0.1.2/tests/tui/__snapshots__/test_snapshots/test_the_completion_popup.raw +191 -0
- hx_cli-0.1.2/tests/tui/__snapshots__/test_snapshots/test_the_idle_screen.raw +190 -0
- hx_cli-0.1.2/tests/tui/test_app.py +1078 -0
- hx_cli-0.1.2/tests/tui/test_autocomplete.py +177 -0
- hx_cli-0.1.2/tests/tui/test_clipboard.py +111 -0
- hx_cli-0.1.2/tests/tui/test_frame.py +82 -0
- hx_cli-0.1.2/tests/tui/test_keys.py +87 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tui/test_renderers.py +2 -1
- hx_cli-0.1.2/tests/tui/test_snapshots.py +100 -0
- hx_cli-0.1.2/tests/tui/test_theme_json.py +105 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tui/test_transcript.py +2 -1
- {hx_cli-0.1.0 → hx_cli-0.1.2}/uv.lock +41 -0
- hx_cli-0.1.0/src/hx/tui/theme.py +0 -322
- hx_cli-0.1.0/src/hx/tui/widgets/input.py +0 -145
- hx_cli-0.1.0/src/hx/tui/widgets/palette.py +0 -130
- hx_cli-0.1.0/src/hx/tui/widgets/working.py +0 -78
- hx_cli-0.1.0/tests/permissions/test_engine.py +0 -143
- hx_cli-0.1.0/tests/permissions/test_parser.py +0 -31
- hx_cli-0.1.0/tests/tui/test_app.py +0 -525
- {hx_cli-0.1.0 → hx_cli-0.1.2}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/install.sh +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/agents/subagent.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/core/compaction.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/core/context.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/core/events.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/core/messages.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/core/session.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/core/usage.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/mcp/client.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/mcp/manager.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/providers/base.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/providers/fake.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/providers/openrouter.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/base.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/bash.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/edit.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/glob.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/grep.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/output.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/read.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/task.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tools/write.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/widgets/__init__.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/widgets/configure.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/widgets/diff.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/src/hx/tui/widgets/todos.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/__init__.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/agents/__init__.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/agents/test_subagent.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/conftest.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/core/__init__.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/core/test_compaction.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/core/test_context.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/core/test_lateinject.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/core/test_usage.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/mcp/__init__.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/mcp/fixtures/echo_server.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/mcp/test_manager.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/permissions/__init__.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/permissions/test_sandbox.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/providers/__init__.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/providers/test_openrouter.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/skills/__init__.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/skills/test_loader.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/test_cli.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/test_integration.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/test_live.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tools/__init__.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tools/test_background.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tools/test_bash.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tools/test_files.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tools/test_output.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tools/test_todo.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tui/__init__.py +0 -0
- {hx_cli-0.1.0 → hx_cli-0.1.2}/tests/tui/test_statusbar.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hx-cli
|
|
3
|
-
Version: 0.1.
|
|
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` |
|
|
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+
|
|
136
|
+
| `ctrl+l` | model picker |
|
|
137
|
+
| `ctrl+o` | expand tool output (also `ctrl+r`) |
|
|
129
138
|
| `ctrl+t` | toggle the todo sidebar |
|
|
130
|
-
| `ctrl+
|
|
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
|
|
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
|
-
|
|
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
|
|
403
|
-
git tag
|
|
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` |
|
|
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+
|
|
104
|
+
| `ctrl+l` | model picker |
|
|
105
|
+
| `ctrl+o` | expand tool output (also `ctrl+r`) |
|
|
99
106
|
| `ctrl+t` | toggle the todo sidebar |
|
|
100
|
-
| `ctrl+
|
|
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
|
|
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
|
-
|
|
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
|
|
373
|
-
git tag
|
|
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
|
-
|
|
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"
|
|
@@ -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.
|
|
401
|
-
|
|
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
|
|