hx-cli 0.2.7__tar.gz → 0.2.9__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 (148) hide show
  1. {hx_cli-0.2.7 → hx_cli-0.2.9}/CHANGELOG.md +46 -1
  2. {hx_cli-0.2.7 → hx_cli-0.2.9}/PKG-INFO +19 -6
  3. {hx_cli-0.2.7 → hx_cli-0.2.9}/README.md +18 -5
  4. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/__init__.py +1 -1
  5. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/cli.py +16 -5
  6. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/context.py +45 -11
  7. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/loop.py +4 -1
  8. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/keys.py +24 -9
  9. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/paths.py +36 -1
  10. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/ansi.py +66 -1
  11. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/editor.py +56 -27
  12. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/keydecode.py +7 -2
  13. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/markdown.py +66 -19
  14. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/terminal.py +1 -1
  15. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/commands.py +9 -5
  16. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/format.py +0 -15
  17. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/runtime.py +2 -2
  18. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/views/pickers.py +2 -1
  19. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/views/status.py +14 -2
  20. {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/terminal.py +4 -0
  21. {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/test_cli.py +10 -1
  22. {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/test_commands.py +28 -0
  23. {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/test_extend.py +79 -0
  24. {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/test_input.py +185 -16
  25. {hx_cli-0.2.7 → hx_cli-0.2.9}/.github/workflows/ci.yml +0 -0
  26. {hx_cli-0.2.7 → hx_cli-0.2.9}/.gitignore +0 -0
  27. {hx_cli-0.2.7 → hx_cli-0.2.9}/examples/hooks/check.sh +0 -0
  28. {hx_cli-0.2.7 → hx_cli-0.2.9}/install.sh +0 -0
  29. {hx_cli-0.2.7 → hx_cli-0.2.9}/pyproject.toml +0 -0
  30. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/agents/__init__.py +0 -0
  31. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/agents/definitions.py +0 -0
  32. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/agents/subagent.py +0 -0
  33. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/auth/__init__.py +0 -0
  34. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/auth/oauth/__init__.py +0 -0
  35. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/auth/oauth/browser.py +0 -0
  36. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/auth/oauth/callback.py +0 -0
  37. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/auth/oauth/codex.py +0 -0
  38. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/auth/oauth/devin.py +0 -0
  39. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/auth/oauth/pkce.py +0 -0
  40. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/auth/resolve.py +0 -0
  41. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/auth/store.py +0 -0
  42. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/config.py +0 -0
  43. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/__init__.py +0 -0
  44. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/checkpoints.py +0 -0
  45. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/compaction.py +0 -0
  46. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/events.py +0 -0
  47. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/images.py +0 -0
  48. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/lateinject.py +0 -0
  49. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/messages.py +0 -0
  50. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/session.py +0 -0
  51. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/title.py +0 -0
  52. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/usage.py +0 -0
  53. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/docs.py +0 -0
  54. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/frontmatter.py +0 -0
  55. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/git.py +0 -0
  56. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/hooks/__init__.py +0 -0
  57. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/hooks/engine.py +0 -0
  58. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/hooks/spec.py +0 -0
  59. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/mcp/__init__.py +0 -0
  60. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/mcp/client.py +0 -0
  61. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/mcp/manager.py +0 -0
  62. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/mcp/oauth.py +0 -0
  63. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/net.py +0 -0
  64. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/permissions/__init__.py +0 -0
  65. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/permissions/engine.py +0 -0
  66. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/permissions/parser.py +0 -0
  67. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/permissions/sandbox.py +0 -0
  68. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/__init__.py +0 -0
  69. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/base.py +0 -0
  70. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/codex.py +0 -0
  71. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/codex_catalogue.py +0 -0
  72. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/devin.py +0 -0
  73. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/devin_catalogue.py +0 -0
  74. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/devin_wire.py +0 -0
  75. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/models.py +0 -0
  76. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/openrouter.py +0 -0
  77. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/protowire.py +0 -0
  78. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/registry.py +0 -0
  79. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/responses_codec.py +0 -0
  80. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/skills/__init__.py +0 -0
  81. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/skills/loader.py +0 -0
  82. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/skills/runtime.py +0 -0
  83. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/__init__.py +0 -0
  84. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/buffer.py +0 -0
  85. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/component.py +0 -0
  86. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/demo.py +0 -0
  87. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/loop.py +0 -0
  88. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/modifiers.py +0 -0
  89. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/primitives.py +0 -0
  90. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/sanitize.py +0 -0
  91. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/screen.py +0 -0
  92. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/syntax.py +0 -0
  93. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/undo.py +0 -0
  94. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/width.py +0 -0
  95. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/word_nav.py +0 -0
  96. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/__init__.py +0 -0
  97. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/anchors.py +0 -0
  98. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/base.py +0 -0
  99. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/bash.py +0 -0
  100. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/edit.py +0 -0
  101. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/glob.py +0 -0
  102. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/grep.py +0 -0
  103. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/output.py +0 -0
  104. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/read.py +0 -0
  105. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/registry.py +0 -0
  106. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/symbols.py +0 -0
  107. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/task.py +0 -0
  108. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/todo.py +0 -0
  109. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/websearch.py +0 -0
  110. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/write.py +0 -0
  111. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/trace.py +0 -0
  112. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/__init__.py +0 -0
  113. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/app.py +0 -0
  114. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/clipboard.py +0 -0
  115. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/fuzzy.py +0 -0
  116. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/glyphs.py +0 -0
  117. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/killring.py +0 -0
  118. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/limits.py +0 -0
  119. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/paint.py +0 -0
  120. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/renderers.py +0 -0
  121. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/roles.py +0 -0
  122. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/theme.py +0 -0
  123. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/theme_json.py +0 -0
  124. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/themes/ansi.json +0 -0
  125. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/themes/dark.json +0 -0
  126. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/themes/light.json +0 -0
  127. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/views/__init__.py +0 -0
  128. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/views/blocks.py +0 -0
  129. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/views/dialog.py +0 -0
  130. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/views/login.py +0 -0
  131. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/views/permission.py +0 -0
  132. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/views/prompt.py +0 -0
  133. {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/views/transcript.py +0 -0
  134. {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/__init__.py +0 -0
  135. {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/conftest.py +0 -0
  136. {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/__init__.py +0 -0
  137. {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/conftest.py +0 -0
  138. {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/fixtures/echo_server.py +0 -0
  139. {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/report.py +0 -0
  140. {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/stub.py +0 -0
  141. {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/test_chat.py +0 -0
  142. {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/test_print.py +0 -0
  143. {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/test_tools.py +0 -0
  144. {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/test_live.py +0 -0
  145. {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/test_live_codex.py +0 -0
  146. {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/test_live_devin.py +0 -0
  147. {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/test_live_tavily.py +0 -0
  148. {hx_cli-0.2.7 → hx_cli-0.2.9}/uv.lock +0 -0
@@ -28,6 +28,49 @@ the project follows [semantic versioning](https://semver.org/).
28
28
 
29
29
  ---
30
30
 
31
+ ## [0.2.9] - 2026-09-27
32
+
33
+ ### Added
34
+
35
+ - `~/.hx/AGENTS.md` holds your own instructions and is loaded into every
36
+ session in every project, ahead of the project's `AGENTS.md`. The first
37
+ session creates it empty, ready to fill in. `hx prompt` and `/prompt` now
38
+ name each `AGENTS.md` in force, and `/prompt` says when one has changed on
39
+ disk since the session started.
40
+
41
+ ### Fixed
42
+
43
+ - An `AGENTS.md` or system-prompt override that is not valid UTF-8 is skipped
44
+ like an unreadable one, instead of stopping HX from starting.
45
+
46
+ ---
47
+
48
+ ## [0.2.8] - 2026-09-27
49
+
50
+ ### Changed
51
+
52
+ - `ctrl+z` undoes in the prompt and `ctrl+shift+z` redoes; `cmd+z` and
53
+ `cmd+shift+z` do the same in terminals that pass cmd through (kitty,
54
+ WezTerm). Apple's Terminal sends `ctrl+shift+z` as `ctrl+z`, so there it
55
+ undoes. Suspend no longer has a default key: bind `app.suspend` in
56
+ `keybindings.json`, which now also accepts `cmd+` and `option+`.
57
+
58
+ ### Fixed
59
+
60
+ - A markdown table wider than the terminal - pasted into the prompt or in a
61
+ reply - wraps its cells inside their columns instead of cutting them off,
62
+ so every word shows; in a terminal too narrow for its columns it is drawn as
63
+ `header: value` records. A bold phrase or link that wraps keeps its style
64
+ on the next line.
65
+ - A long draft in the prompt wraps between words instead of splitting them
66
+ across rows; only a word longer than the row still breaks inside it. The
67
+ cursor after the last character of a full row no longer hides that
68
+ character.
69
+ - The welcome lines wrap on a narrow terminal instead of being cut off
70
+ mid-word.
71
+
72
+ ---
73
+
31
74
  ## [0.2.7] - 2026-09-27
32
75
 
33
76
  ### Added
@@ -699,7 +742,9 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
699
742
  - `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
700
743
  self-update, and `install.sh` bootstrapping uv with a pinned Python.
701
744
 
702
- [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.7...HEAD
745
+ [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.9...HEAD
746
+ [0.2.9]: https://github.com/aletisunil/hx/compare/v0.2.8...v0.2.9
747
+ [0.2.8]: https://github.com/aletisunil/hx/compare/v0.2.7...v0.2.8
703
748
  [0.2.7]: https://github.com/aletisunil/hx/compare/v0.2.6...v0.2.7
704
749
  [0.2.6]: https://github.com/aletisunil/hx/compare/v0.2.5...v0.2.6
705
750
  [0.2.5]: https://github.com/aletisunil/hx/compare/v0.2.4...v0.2.5
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hx-cli
3
- Version: 0.2.7
3
+ Version: 0.2.9
4
4
  Summary: HX - an agent harness for the terminal
5
5
  Project-URL: Homepage, https://hx.sunilaleti.dev
6
6
  Project-URL: Documentation, https://hx.sunilaleti.dev
@@ -366,7 +366,6 @@ be rebound (see [Keybindings](#keybindings)).
366
366
  | `esc` | interrupt the current turn |
367
367
  | `ctrl+c` | copy the selected text; with nothing selected, clear the prompt (twice on an empty prompt exits) |
368
368
  | `ctrl+d` | exit, when the prompt is empty |
369
- | `ctrl+z` | suspend to the background |
370
369
  | `shift+tab` | cycle permission mode |
371
370
  | `ctrl+p` | command palette |
372
371
  | `ctrl+l` | model picker |
@@ -390,8 +389,13 @@ The prompt is a readline-style editor: `ctrl+a`/`ctrl+e` for line start and end,
390
389
  `ctrl+b`/`ctrl+f` by character, `alt+b`/`alt+f` by word, `ctrl+w` and `alt+d` to
391
390
  kill a word, `ctrl+u` and `ctrl+k` to kill to the start or end of a line, then
392
391
  `ctrl+y` to yank it back and `alt+y` to walk further down the kill ring.
393
- `ctrl+_` undoes, `ctrl+shift+z` redoes - `ctrl+z` belongs to the shell, and
394
- suspends HX.
392
+ `ctrl+z` undoes and `ctrl+shift+z` redoes (`ctrl+_` undoes too). `cmd+z` and
393
+ `cmd+shift+z` work in a terminal that passes cmd through to the program, such
394
+ as kitty or WezTerm; Apple's Terminal, iTerm2 and Ghostty keep cmd+z for their
395
+ own Edit menu. A terminal without the kitty protocol or modifyOtherKeys sends
396
+ `ctrl+shift+z` as `ctrl+z`, so there it undoes. Suspending to the background
397
+ has no key by default - bind `app.suspend` in `keybindings.json` (for example
398
+ to `"ctrl+g"`) to get it back.
395
399
 
396
400
  Typing `/` or `@` opens a completion list above the prompt; `tab` cycles it,
397
401
  `enter` accepts, `esc` dismisses.
@@ -461,8 +465,10 @@ Any key can be rebound in `~/.hx/keybindings.json`, keyed by the action ids in
461
465
  }
462
466
  ```
463
467
 
464
- Conflicts and unknown action names are reported as a notice at startup rather
465
- than being silently resolved.
468
+ Modifiers are `ctrl`, `alt` (or `option`), `shift` and `cmd` (or `super`);
469
+ a `cmd` key only arrives from a terminal that passes cmd through. Conflicts and
470
+ unknown action names are reported as a notice at startup rather than being
471
+ silently resolved.
466
472
 
467
473
  ### Commands
468
474
 
@@ -747,6 +753,7 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
747
753
  | `~/.hx/keybindings.json` | your key overrides |
748
754
  | `~/.hx/system-prompt.md` | your system prompt, replacing the built-in one |
749
755
  | `~/.hx/system-prompt-append.md` | text appended to whichever prompt is in force |
756
+ | `~/.hx/AGENTS.md` | your instructions, loaded into every session in every project; created empty |
750
757
  | `~/.hx/sessions/` | transcripts, spilled tool output, subagent sessions |
751
758
  | `~/.hx/sessions/<id>/trace.html` | where `/trace` writes, unless you name a path |
752
759
  | `~/.hx/skills/`, `~/.hx/agents/` | your skills and agents |
@@ -761,6 +768,12 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
761
768
  tests, conventions, what not to touch. `/init` writes a first draft. It is
762
769
  loaded once per session and frozen, so it costs one prefix, not one per turn.
763
770
 
771
+ `~/.hx/AGENTS.md` is the same thing for you rather than the project: how you
772
+ like commits written, tools you always want used, in every repository. It is
773
+ loaded first and the project's `AGENTS.md` after it, so where the two disagree
774
+ the project has the last word. The first session creates it empty, ready to
775
+ fill in; empty, it adds nothing. `hx prompt` and `/prompt` list each one in force.
776
+
764
777
  ---
765
778
 
766
779
  ## Safety
@@ -326,7 +326,6 @@ be rebound (see [Keybindings](#keybindings)).
326
326
  | `esc` | interrupt the current turn |
327
327
  | `ctrl+c` | copy the selected text; with nothing selected, clear the prompt (twice on an empty prompt exits) |
328
328
  | `ctrl+d` | exit, when the prompt is empty |
329
- | `ctrl+z` | suspend to the background |
330
329
  | `shift+tab` | cycle permission mode |
331
330
  | `ctrl+p` | command palette |
332
331
  | `ctrl+l` | model picker |
@@ -350,8 +349,13 @@ The prompt is a readline-style editor: `ctrl+a`/`ctrl+e` for line start and end,
350
349
  `ctrl+b`/`ctrl+f` by character, `alt+b`/`alt+f` by word, `ctrl+w` and `alt+d` to
351
350
  kill a word, `ctrl+u` and `ctrl+k` to kill to the start or end of a line, then
352
351
  `ctrl+y` to yank it back and `alt+y` to walk further down the kill ring.
353
- `ctrl+_` undoes, `ctrl+shift+z` redoes - `ctrl+z` belongs to the shell, and
354
- suspends HX.
352
+ `ctrl+z` undoes and `ctrl+shift+z` redoes (`ctrl+_` undoes too). `cmd+z` and
353
+ `cmd+shift+z` work in a terminal that passes cmd through to the program, such
354
+ as kitty or WezTerm; Apple's Terminal, iTerm2 and Ghostty keep cmd+z for their
355
+ own Edit menu. A terminal without the kitty protocol or modifyOtherKeys sends
356
+ `ctrl+shift+z` as `ctrl+z`, so there it undoes. Suspending to the background
357
+ has no key by default - bind `app.suspend` in `keybindings.json` (for example
358
+ to `"ctrl+g"`) to get it back.
355
359
 
356
360
  Typing `/` or `@` opens a completion list above the prompt; `tab` cycles it,
357
361
  `enter` accepts, `esc` dismisses.
@@ -421,8 +425,10 @@ Any key can be rebound in `~/.hx/keybindings.json`, keyed by the action ids in
421
425
  }
422
426
  ```
423
427
 
424
- Conflicts and unknown action names are reported as a notice at startup rather
425
- than being silently resolved.
428
+ Modifiers are `ctrl`, `alt` (or `option`), `shift` and `cmd` (or `super`);
429
+ a `cmd` key only arrives from a terminal that passes cmd through. Conflicts and
430
+ unknown action names are reported as a notice at startup rather than being
431
+ silently resolved.
426
432
 
427
433
  ### Commands
428
434
 
@@ -707,6 +713,7 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
707
713
  | `~/.hx/keybindings.json` | your key overrides |
708
714
  | `~/.hx/system-prompt.md` | your system prompt, replacing the built-in one |
709
715
  | `~/.hx/system-prompt-append.md` | text appended to whichever prompt is in force |
716
+ | `~/.hx/AGENTS.md` | your instructions, loaded into every session in every project; created empty |
710
717
  | `~/.hx/sessions/` | transcripts, spilled tool output, subagent sessions |
711
718
  | `~/.hx/sessions/<id>/trace.html` | where `/trace` writes, unless you name a path |
712
719
  | `~/.hx/skills/`, `~/.hx/agents/` | your skills and agents |
@@ -721,6 +728,12 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
721
728
  tests, conventions, what not to touch. `/init` writes a first draft. It is
722
729
  loaded once per session and frozen, so it costs one prefix, not one per turn.
723
730
 
731
+ `~/.hx/AGENTS.md` is the same thing for you rather than the project: how you
732
+ like commits written, tools you always want used, in every repository. It is
733
+ loaded first and the project's `AGENTS.md` after it, so where the two disagree
734
+ the project has the last word. The first session creates it empty, ready to
735
+ fill in; empty, it adds nothing. `hx prompt` and `/prompt` list each one in force.
736
+
724
737
  ---
725
738
 
726
739
  ## Safety
@@ -1,5 +1,5 @@
1
1
  """HX - an agent harness for the terminal."""
2
2
 
3
- __version__ = "0.2.7"
3
+ __version__ = "0.2.9"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -346,7 +346,12 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
346
346
  from hx.config import load_settings
347
347
  from hx.core.checkpoints import CheckpointStore
348
348
  from hx.core.compaction import Compactor
349
- from hx.core.context import ContextBuilder, build_project_context, load_system_prompt
349
+ from hx.core.context import (
350
+ ContextBuilder,
351
+ build_project_context,
352
+ load_instructions,
353
+ load_system_prompt,
354
+ )
350
355
  from hx.core.events import EventBus
351
356
  from hx.core.lateinject import Injection, InjectionRegistry
352
357
  from hx.core.loop import AgentLoop
@@ -430,6 +435,7 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
430
435
  settings.cwd,
431
436
  keep_recent_turns=settings.context.keep_recent_turns,
432
437
  )
438
+ instructions = load_instructions(settings.cwd)
433
439
 
434
440
  checkpoints = CheckpointStore(session, session_checkpoints_dir(session.meta.session_id))
435
441
  tools = build_default_registry(shell, jobs, tracker, todos, bus_holder, auth, checkpoints)
@@ -478,7 +484,8 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
478
484
  settings=settings,
479
485
  model_info=model_info,
480
486
  skills_index=build_index(list(skills.values())) or None,
481
- project_context=build_project_context(settings.cwd),
487
+ project_context=build_project_context(settings.cwd, instructions),
488
+ instructions=instructions,
482
489
  hooks=hooks,
483
490
  )
484
491
 
@@ -842,15 +849,17 @@ def run_print_command(parsed: ParsedArgs) -> int:
842
849
  def run_prompt_command(parsed: ParsedArgs) -> int:
843
850
  """``hx prompt`` - print the system prompt this directory resolves to.
844
851
 
845
- The prompt goes to stdout so it can be piped or diffed; where it came from
846
- goes to stderr so it never contaminates that output.
852
+ The prompt goes to stdout so it can be piped or diffed; where it came from,
853
+ and which AGENTS.md files ride alongside it, goes to stderr so it never
854
+ contaminates that output.
847
855
  """
848
856
  from hx.config import load_settings
849
- from hx.core.context import resolve_system_prompt
857
+ from hx.core.context import load_instructions, resolve_system_prompt
850
858
 
851
859
  try:
852
860
  settings = load_settings(parsed.cwd, parsed.overrides)
853
861
  resolved = resolve_system_prompt(settings.cwd, settings.prompt)
862
+ instructions = load_instructions(settings.cwd)
854
863
  except Exception as exc:
855
864
  return _report(exc)
856
865
 
@@ -858,6 +867,8 @@ def run_prompt_command(parsed: ParsedArgs) -> int:
858
867
  print(f"[source] {resolved.source}", file=sys.stderr)
859
868
  for append in resolved.appends:
860
869
  print(f"[append] {append}", file=sys.stderr)
870
+ for loaded in instructions:
871
+ print(f"[instructions] {loaded.path}", file=sys.stderr)
861
872
  return 0
862
873
 
863
874
 
@@ -10,7 +10,7 @@ Layout, in order::
10
10
  [1] system prompt static for the session
11
11
  [2] tool schemas deterministic sort: builtins, then mcp__* alphabetical
12
12
  [3] skills index name + description only (progressive disclosure)
13
- [4] project context AGENTS.md, cwd, git branch, top-level listing
13
+ [4] project context cwd, git branch, top-level listing, ~/.hx/AGENTS.md, AGENTS.md
14
14
  --- breakpoint A (static) ---
15
15
  [5] conversation history
16
16
  --- breakpoint B (rolling, before the last few turns) ---
@@ -238,11 +238,46 @@ def _message_text(message: Message) -> str:
238
238
  return "\n".join(parts)
239
239
 
240
240
 
241
- def build_project_context(cwd: Path) -> str:
242
- """Static per-session project preamble: AGENTS.md contents, cwd, git branch, listing.
241
+ @dataclass(slots=True)
242
+ class Instructions:
243
+ """One AGENTS.md in force: what it is, where it lives, what it says."""
244
+
245
+ heading: str
246
+ path: Path
247
+ text: str
248
+
249
+
250
+ def load_instructions(cwd: Path) -> list[Instructions]:
251
+ """Every AGENTS.md in force, least specific first.
252
+
253
+ ``~/.hx/AGENTS.md`` follows the user into every project; the project's own
254
+ ``AGENTS.md`` comes after it, so where the two disagree the more specific
255
+ one has the last word. A blank or unreadable file is skipped, and running
256
+ from inside ``$HX_HOME`` - where both paths name the same file - loads it once.
257
+ """
258
+ from hx.paths import project_instructions_file, tilde, user_instructions_file
259
+
260
+ user = user_instructions_file()
261
+ found: list[Instructions] = []
262
+ seen: set[Path] = set()
263
+ for heading, path in (
264
+ (f"User instructions ({tilde(user)})", user),
265
+ ("Project instructions (AGENTS.md)", project_instructions_file(cwd)),
266
+ ):
267
+ body = _read_prompt_file(path)
268
+ if not body or path.resolve() in seen:
269
+ continue
270
+ seen.add(path.resolve())
271
+ found.append(Instructions(heading, path, body))
272
+ return found
273
+
274
+
275
+ def build_project_context(cwd: Path, instructions: list[Instructions] | None = None) -> str:
276
+ """Static per-session project preamble: cwd, git branch, listing, AGENTS.md contents.
243
277
 
244
278
  Computed once at startup and then frozen - refreshing it mid-session would
245
- invalidate the prefix.
279
+ invalidate the prefix. Pass ``instructions`` to fold in the AGENTS.md files
280
+ already loaded, so the caller can remember exactly which ones are in force.
246
281
  """
247
282
  lines = [f"Working directory: {cwd}"]
248
283
 
@@ -256,9 +291,8 @@ def build_project_context(cwd: Path) -> str:
256
291
  if entries:
257
292
  lines.append("Top level: " + ", ".join(entries[:60]))
258
293
 
259
- agents_md = cwd / "AGENTS.md"
260
- if agents_md.is_file():
261
- lines.append(f"\n# Project instructions (AGENTS.md)\n\n{agents_md.read_text()}")
294
+ for loaded in load_instructions(cwd) if instructions is None else instructions:
295
+ lines.append(f"\n# {loaded.heading}\n\n{loaded.text}")
262
296
 
263
297
  return "\n".join(lines)
264
298
 
@@ -367,12 +401,12 @@ def resolve_system_prompt(cwd: Path, prompt: PromptSettings | None = None) -> Re
367
401
  def _read_prompt_file(path: Path) -> str:
368
402
  """Contents of a prompt override file, or ``""`` when it is absent or empty.
369
403
 
370
- An unreadable file is treated as absent: a permissions problem on an
371
- optional override must not stop the session from starting.
404
+ An unreadable file - no permission, or not UTF-8 - is treated as absent: a
405
+ problem with an optional override must not stop the session from starting.
372
406
  """
373
407
  try:
374
- return path.read_text().strip() if path.is_file() else ""
375
- except OSError:
408
+ return path.read_text(encoding="utf-8").strip() if path.is_file() else ""
409
+ except (OSError, UnicodeDecodeError):
376
410
  return ""
377
411
 
378
412
 
@@ -18,7 +18,7 @@ import time
18
18
  from dataclasses import dataclass, replace
19
19
  from typing import TYPE_CHECKING, Any, ClassVar
20
20
 
21
- from hx.core.context import AssembledContext
21
+ from hx.core.context import AssembledContext, Instructions
22
22
  from hx.core.events import (
23
23
  CompactionFinished,
24
24
  CompactionStarted,
@@ -103,6 +103,7 @@ class AgentLoop:
103
103
  active_skills: ActiveSkills | None = None,
104
104
  skills_index: str | None = None,
105
105
  project_context: str | None = None,
106
+ instructions: list[Instructions] | None = None,
106
107
  hooks: HookEngine | None = None,
107
108
  ) -> None:
108
109
  self.provider = provider
@@ -118,6 +119,8 @@ class AgentLoop:
118
119
  self.active_skills = active_skills
119
120
  self.skills_index = skills_index
120
121
  self.project_context = project_context
122
+ self.instructions = instructions or []
123
+ """The AGENTS.md files folded into ``project_context``, least specific first."""
121
124
  self.hooks = hooks
122
125
  self._cancelled = False
123
126
  self._turn_index = 0
@@ -71,7 +71,10 @@ def _defaults() -> dict[str, KeyBinding]:
71
71
  ),
72
72
  KeyBinding("app.clear", ("ctrl+c",), "Clear the prompt (twice to exit)"),
73
73
  KeyBinding("app.exit", ("ctrl+d",), "Exit when the prompt is empty"),
74
- KeyBinding("app.suspend", ("ctrl+z",), "Suspend to the background"),
74
+ # Unbound: ctrl+z is undo, as in every windowed editor. Raw mode means
75
+ # the terminal will not suspend for us, so a user who wants job
76
+ # control back binds this in keybindings.json.
77
+ KeyBinding("app.suspend", (), "Suspend to the background"),
75
78
  # Session and mode.
76
79
  KeyBinding("app.mode.cycle", ("shift+tab",), "Cycle permission mode"),
77
80
  KeyBinding("app.commands", ("ctrl+p",), "Open the command palette"),
@@ -102,21 +105,31 @@ def _defaults() -> dict[str, KeyBinding]:
102
105
  KeyBinding("tui.editor.deleteWordForward", ("alt+d",), "Delete the word ahead"),
103
106
  KeyBinding("tui.editor.yank", ("ctrl+y",), "Yank the last kill"),
104
107
  KeyBinding("tui.editor.yankPop", ("alt+y",), "Cycle back through kills"),
105
- # ctrl+z is the app's suspend, as it is in every other terminal
106
- # program, so undo takes readline's own key rather than the one a
107
- # windowed editor would use. The editor had the operation and no
108
- # binding at all, which made ctrl+z-undoes a documented fiction.
109
- KeyBinding("tui.editor.undo", ("ctrl+underscore",), "Undo"),
110
- KeyBinding("tui.editor.redo", ("ctrl+shift+z",), "Redo"),
108
+ # The windowed editor's keys, plus readline's own undo. cmd reaches
109
+ # HX as super, and only from a terminal that passes it through rather
110
+ # than keeping it for its own Edit menu - kitty and WezTerm do. A
111
+ # legacy terminal sends ctrl+shift+z as ctrl+z, so there it undoes.
112
+ KeyBinding("tui.editor.undo", ("ctrl+z", "super+z", "ctrl+underscore"), "Undo"),
113
+ KeyBinding("tui.editor.redo", ("ctrl+shift+z", "super+shift+z"), "Redo"),
111
114
  ]
112
115
  return {binding.id: binding for binding in bindings}
113
116
 
114
117
 
118
+ _ALIASES = {"cmd": "super", "command": "super", "option": "alt"}
119
+ """Modifiers as a Mac user writes them - and as ``/help`` shows them there -
120
+ spelled the way the decoder names them."""
121
+
122
+
123
+ def _canonical(key: str) -> str:
124
+ """``cmd+z`` -> ``super+z``, so a keybindings file can say what it means."""
125
+ return "+".join(_ALIASES.get(part, part) for part in key.split("+"))
126
+
127
+
115
128
  def _normalize(value: object) -> tuple[str, ...] | None:
116
129
  if isinstance(value, str):
117
- return (value,)
130
+ return (_canonical(value),)
118
131
  if isinstance(value, list) and all(isinstance(item, str) for item in value):
119
- return tuple(value)
132
+ return tuple(_canonical(item) for item in value)
120
133
  return None
121
134
 
122
135
 
@@ -173,6 +186,8 @@ def display_key(key: str) -> str:
173
186
  part = _DISPLAY.get(part, part)
174
187
  if part == "alt" and sys.platform == "darwin":
175
188
  part = "option"
189
+ elif part == "super" and sys.platform == "darwin":
190
+ part = "cmd"
176
191
  parts.append(part)
177
192
  return "+".join(parts)
178
193
 
@@ -30,6 +30,20 @@ def user_home() -> Path:
30
30
  return Path(override).expanduser() if override else Path.home() / ".hx"
31
31
 
32
32
 
33
+ def tilde(path: str | Path) -> str:
34
+ """``path`` with the home directory written as ``~``.
35
+
36
+ Compared as text, not resolved: a session records the path it resolved
37
+ when it was made, and the directory may be gone by the time it is drawn.
38
+ """
39
+ text, home = str(path), str(Path.home())
40
+ if text == home:
41
+ return "~"
42
+ if text.startswith(home.rstrip("/") + "/"):
43
+ return "~" + text[len(home.rstrip("/")) :]
44
+ return text
45
+
46
+
33
47
  def user_settings_file() -> Path:
34
48
  return user_home() / "settings.json"
35
49
 
@@ -198,7 +212,28 @@ def project_system_prompt_append_file(cwd: Path | None = None) -> Path:
198
212
  return project_dir(cwd) / "system-prompt-append.md"
199
213
 
200
214
 
215
+ def user_instructions_file() -> Path:
216
+ """Your ``AGENTS.md``: instructions that follow you into every project."""
217
+ return user_home() / "AGENTS.md"
218
+
219
+
220
+ def project_instructions_file(cwd: Path | None = None) -> Path:
221
+ """The project's ``AGENTS.md``, at the root of the working directory."""
222
+ return (cwd or Path.cwd()) / "AGENTS.md"
223
+
224
+
201
225
  def ensure_user_dirs() -> None:
202
- """Create the user-level directory skeleton if it does not exist."""
226
+ """Create the user-level skeleton if it does not exist.
227
+
228
+ That includes an empty ``AGENTS.md``, so there is a file to find and fill
229
+ in; empty, it adds nothing to the prompt. An existing one - including a
230
+ symlink, dangling or not - is never touched, and failing to create it never
231
+ stops a session: the file is optional.
232
+ """
203
233
  for path in (user_home(), sessions_dir(), user_skills_dir(), user_agents_dir(), logs_dir()):
204
234
  path.mkdir(parents=True, exist_ok=True)
235
+ try:
236
+ with user_instructions_file().open("x"):
237
+ pass
238
+ except OSError:
239
+ pass
@@ -270,6 +270,71 @@ def active_background(text: str) -> str:
270
270
  return current
271
271
 
272
272
 
273
+ def open_styles(text: str) -> tuple[str, str]:
274
+ """``(reopen, close)`` for the styling still in force at the end of ``text``.
275
+
276
+ Foreground, bold, italic, underline and a hyperlink - everything but the
277
+ background, which :func:`active_background` carries. A table cell wrapped
278
+ onto a second line needs both halves: ``close`` at its column edge, or a
279
+ link or a bold word runs on into the next cell, and ``reopen`` at the start
280
+ of the next line, or the rest of it arrives unstyled. Each half only
281
+ touches what is actually open, so a caller's own colour around the cell
282
+ survives.
283
+ """
284
+ foreground = link = ""
285
+ bold = italic = underline = False
286
+ for match in _ANY_ESCAPE.finditer(text):
287
+ escape = match.group()
288
+ if escape.startswith("\x1b]8;"):
289
+ link = escape if escape.split(";", 2)[2].rstrip("\x07\x1b\\") else ""
290
+ continue
291
+ sgr = _SGR_PATTERN.fullmatch(escape)
292
+ if not sgr:
293
+ continue
294
+ codes = [int(code or 0) for code in (sgr.group(1) or "0").split(";")]
295
+ index = 0
296
+ while index < len(codes):
297
+ code = codes[index]
298
+ if code == 0:
299
+ foreground, bold, italic, underline = "", False, False, False
300
+ elif code == 1:
301
+ bold = True
302
+ elif code == 22:
303
+ bold = False
304
+ elif code == 3:
305
+ italic = True
306
+ elif code == 23:
307
+ italic = False
308
+ elif code == 4:
309
+ underline = True
310
+ elif code == 24:
311
+ underline = False
312
+ elif 30 <= code <= 37 or 90 <= code <= 97:
313
+ foreground = f"\x1b[{code}m"
314
+ elif code == 39:
315
+ foreground = ""
316
+ elif code in (38, 48) and index + 1 < len(codes):
317
+ # Skip a colour's parameters so they are not read as codes;
318
+ # a truncated one ends the escape, as in active_background.
319
+ span = 3 if codes[index + 1] == 5 else 5 if codes[index + 1] == 2 else 0
320
+ if not span or index + span > len(codes):
321
+ break
322
+ if code == 38:
323
+ foreground = "\x1b[" + ";".join(map(str, codes[index : index + span])) + "m"
324
+ index += span - 1
325
+ index += 1
326
+
327
+ reopen = close = ""
328
+ if foreground:
329
+ reopen, close = reopen + foreground, close + FG_RESET
330
+ for on, start, end in ((bold, "1", "22"), (italic, "3", "23"), (underline, "4", "24")):
331
+ if on:
332
+ reopen, close = reopen + f"\x1b[{start}m", close + f"\x1b[{end}m"
333
+ if link:
334
+ reopen, close = reopen + link, close + "\x1b]8;;\x07"
335
+ return reopen, close
336
+
337
+
273
338
  def fill_line(text: str, width: int, background: str = "") -> str:
274
339
  """One rendered line: padded to ``width``, tinted, and terminated.
275
340
 
@@ -360,7 +425,7 @@ def _wrap_one(text: str, width: int) -> list[str]:
360
425
  emitted = "".join(line[:upto])
361
426
  rest = line[upto + 1 :] # drop the space itself
362
427
  lines.append(carry + emitted)
363
- carry = active_background(carry + emitted)
428
+ carry = active_background(carry + emitted) + open_styles(carry + emitted)[0]
364
429
  line = rest
365
430
  used = sum(
366
431
  0 if is_escape else cell_width(chunk) for is_escape, chunk in _tokenize("".join(rest))