hx-cli 0.2.6__tar.gz → 0.2.8__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 (245) hide show
  1. {hx_cli-0.2.6 → hx_cli-0.2.8}/.github/workflows/ci.yml +13 -0
  2. {hx_cli-0.2.6 → hx_cli-0.2.8}/.gitignore +1 -0
  3. {hx_cli-0.2.6 → hx_cli-0.2.8}/CHANGELOG.md +90 -2
  4. {hx_cli-0.2.6 → hx_cli-0.2.8}/PKG-INFO +42 -25
  5. {hx_cli-0.2.6 → hx_cli-0.2.8}/README.md +41 -24
  6. {hx_cli-0.2.6 → hx_cli-0.2.8}/pyproject.toml +1 -1
  7. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/__init__.py +1 -1
  8. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/config.py +1 -1
  9. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/core/compaction.py +6 -0
  10. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/core/loop.py +15 -11
  11. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/keys.py +24 -9
  12. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/permissions/engine.py +1 -1
  13. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/permissions/sandbox.py +84 -22
  14. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/providers/openrouter.py +16 -6
  15. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/term/ansi.py +66 -1
  16. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/term/editor.py +56 -27
  17. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/term/keydecode.py +178 -15
  18. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/term/loop.py +5 -1
  19. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/term/markdown.py +85 -26
  20. hx_cli-0.2.8/src/hx/term/modifiers.py +116 -0
  21. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/term/primitives.py +54 -3
  22. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/term/terminal.py +115 -67
  23. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/commands.py +34 -16
  24. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/limits.py +4 -0
  25. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/runtime.py +52 -21
  26. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/views/blocks.py +49 -6
  27. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/views/permission.py +14 -5
  28. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/views/prompt.py +149 -15
  29. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/views/status.py +14 -2
  30. hx_cli-0.2.8/tests/conftest.py +28 -0
  31. hx_cli-0.2.8/tests/e2e/conftest.py +302 -0
  32. hx_cli-0.2.8/tests/e2e/report.py +205 -0
  33. hx_cli-0.2.8/tests/e2e/stub.py +363 -0
  34. hx_cli-0.2.8/tests/e2e/terminal.py +477 -0
  35. hx_cli-0.2.8/tests/e2e/test_chat.py +213 -0
  36. hx_cli-0.2.8/tests/e2e/test_cli.py +143 -0
  37. hx_cli-0.2.8/tests/e2e/test_commands.py +255 -0
  38. hx_cli-0.2.8/tests/e2e/test_extend.py +276 -0
  39. hx_cli-0.2.8/tests/e2e/test_input.py +626 -0
  40. hx_cli-0.2.8/tests/e2e/test_print.py +275 -0
  41. hx_cli-0.2.8/tests/e2e/test_tools.py +209 -0
  42. hx_cli-0.2.6/src/hx/providers/fake.py +0 -124
  43. hx_cli-0.2.6/tests/agents/test_subagent.py +0 -310
  44. hx_cli-0.2.6/tests/auth/__init__.py +0 -0
  45. hx_cli-0.2.6/tests/auth/test_codex_oauth.py +0 -226
  46. hx_cli-0.2.6/tests/auth/test_devin_oauth.py +0 -119
  47. hx_cli-0.2.6/tests/auth/test_pkce.py +0 -27
  48. hx_cli-0.2.6/tests/auth/test_resolve.py +0 -166
  49. hx_cli-0.2.6/tests/auth/test_store.py +0 -218
  50. hx_cli-0.2.6/tests/conftest.py +0 -63
  51. hx_cli-0.2.6/tests/core/__init__.py +0 -0
  52. hx_cli-0.2.6/tests/core/test_checkpoints.py +0 -200
  53. hx_cli-0.2.6/tests/core/test_compaction.py +0 -137
  54. hx_cli-0.2.6/tests/core/test_context.py +0 -158
  55. hx_cli-0.2.6/tests/core/test_images.py +0 -281
  56. hx_cli-0.2.6/tests/core/test_lateinject.py +0 -52
  57. hx_cli-0.2.6/tests/core/test_loop.py +0 -738
  58. hx_cli-0.2.6/tests/core/test_loop_images.py +0 -181
  59. hx_cli-0.2.6/tests/core/test_rewind.py +0 -206
  60. hx_cli-0.2.6/tests/core/test_session.py +0 -315
  61. hx_cli-0.2.6/tests/core/test_settings_layers.py +0 -67
  62. hx_cli-0.2.6/tests/core/test_settings_migration.py +0 -152
  63. hx_cli-0.2.6/tests/core/test_steering.py +0 -203
  64. hx_cli-0.2.6/tests/core/test_title.py +0 -70
  65. hx_cli-0.2.6/tests/core/test_usage.py +0 -25
  66. hx_cli-0.2.6/tests/hooks/__init__.py +0 -0
  67. hx_cli-0.2.6/tests/hooks/test_engine.py +0 -205
  68. hx_cli-0.2.6/tests/hooks/test_example_check.py +0 -98
  69. hx_cli-0.2.6/tests/mcp/__init__.py +0 -0
  70. hx_cli-0.2.6/tests/mcp/fixtures/oauth_server.py +0 -438
  71. hx_cli-0.2.6/tests/mcp/test_manager.py +0 -240
  72. hx_cli-0.2.6/tests/mcp/test_oauth.py +0 -670
  73. hx_cli-0.2.6/tests/permissions/__init__.py +0 -0
  74. hx_cli-0.2.6/tests/permissions/test_engine.py +0 -570
  75. hx_cli-0.2.6/tests/permissions/test_parser.py +0 -202
  76. hx_cli-0.2.6/tests/permissions/test_sandbox.py +0 -189
  77. hx_cli-0.2.6/tests/providers/__init__.py +0 -0
  78. hx_cli-0.2.6/tests/providers/test_catalogue.py +0 -349
  79. hx_cli-0.2.6/tests/providers/test_codex.py +0 -113
  80. hx_cli-0.2.6/tests/providers/test_codex_catalogue.py +0 -205
  81. hx_cli-0.2.6/tests/providers/test_devin.py +0 -489
  82. hx_cli-0.2.6/tests/providers/test_devin_catalogue.py +0 -229
  83. hx_cli-0.2.6/tests/providers/test_openrouter.py +0 -308
  84. hx_cli-0.2.6/tests/providers/test_protowire.py +0 -100
  85. hx_cli-0.2.6/tests/providers/test_registry.py +0 -117
  86. hx_cli-0.2.6/tests/providers/test_responses_codec.py +0 -330
  87. hx_cli-0.2.6/tests/skills/__init__.py +0 -0
  88. hx_cli-0.2.6/tests/skills/test_loader.py +0 -157
  89. hx_cli-0.2.6/tests/term/__init__.py +0 -0
  90. hx_cli-0.2.6/tests/term/conftest.py +0 -59
  91. hx_cli-0.2.6/tests/term/test_ansi.py +0 -190
  92. hx_cli-0.2.6/tests/term/test_keydecode.py +0 -233
  93. hx_cli-0.2.6/tests/term/test_loop.py +0 -221
  94. hx_cli-0.2.6/tests/term/test_markdown.py +0 -260
  95. hx_cli-0.2.6/tests/term/test_primitives.py +0 -206
  96. hx_cli-0.2.6/tests/term/test_sanitize.py +0 -77
  97. hx_cli-0.2.6/tests/term/test_screen.py +0 -699
  98. hx_cli-0.2.6/tests/term/test_terminal.py +0 -321
  99. hx_cli-0.2.6/tests/term/test_width.py +0 -109
  100. hx_cli-0.2.6/tests/test_cli.py +0 -409
  101. hx_cli-0.2.6/tests/test_docs.py +0 -203
  102. hx_cli-0.2.6/tests/test_git.py +0 -355
  103. hx_cli-0.2.6/tests/test_install.py +0 -55
  104. hx_cli-0.2.6/tests/test_integration.py +0 -696
  105. hx_cli-0.2.6/tests/test_net.py +0 -110
  106. hx_cli-0.2.6/tests/test_paths.py +0 -54
  107. hx_cli-0.2.6/tests/test_readme.py +0 -309
  108. hx_cli-0.2.6/tests/test_trace.py +0 -538
  109. hx_cli-0.2.6/tests/tools/__init__.py +0 -0
  110. hx_cli-0.2.6/tests/tools/test_background.py +0 -207
  111. hx_cli-0.2.6/tests/tools/test_bash.py +0 -210
  112. hx_cli-0.2.6/tests/tools/test_files.py +0 -284
  113. hx_cli-0.2.6/tests/tools/test_hashline.py +0 -197
  114. hx_cli-0.2.6/tests/tools/test_output.py +0 -31
  115. hx_cli-0.2.6/tests/tools/test_symbols.py +0 -127
  116. hx_cli-0.2.6/tests/tools/test_todo.py +0 -104
  117. hx_cli-0.2.6/tests/tools/test_websearch.py +0 -296
  118. hx_cli-0.2.6/tests/tui/__init__.py +0 -0
  119. hx_cli-0.2.6/tests/tui/support.py +0 -146
  120. hx_cli-0.2.6/tests/tui/test_autocomplete.py +0 -186
  121. hx_cli-0.2.6/tests/tui/test_clipboard.py +0 -111
  122. hx_cli-0.2.6/tests/tui/test_effort.py +0 -167
  123. hx_cli-0.2.6/tests/tui/test_images.py +0 -319
  124. hx_cli-0.2.6/tests/tui/test_keys.py +0 -136
  125. hx_cli-0.2.6/tests/tui/test_login.py +0 -424
  126. hx_cli-0.2.6/tests/tui/test_mcp_login.py +0 -102
  127. hx_cli-0.2.6/tests/tui/test_renderers.py +0 -278
  128. hx_cli-0.2.6/tests/tui/test_session.py +0 -1134
  129. hx_cli-0.2.6/tests/tui/test_theme_json.py +0 -105
  130. hx_cli-0.2.6/tests/tui/test_trace_command.py +0 -257
  131. hx_cli-0.2.6/tests/tui/views/__init__.py +0 -0
  132. hx_cli-0.2.6/tests/tui/views/test_blocks.py +0 -264
  133. hx_cli-0.2.6/tests/tui/views/test_permission.py +0 -328
  134. hx_cli-0.2.6/tests/tui/views/test_pickers.py +0 -314
  135. hx_cli-0.2.6/tests/tui/views/test_prompt.py +0 -374
  136. hx_cli-0.2.6/tests/tui/views/test_runtime.py +0 -737
  137. hx_cli-0.2.6/tests/tui/views/test_status.py +0 -197
  138. hx_cli-0.2.6/tests/tui/views/test_transcript.py +0 -211
  139. {hx_cli-0.2.6 → hx_cli-0.2.8}/examples/hooks/check.sh +0 -0
  140. {hx_cli-0.2.6 → hx_cli-0.2.8}/install.sh +0 -0
  141. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/agents/__init__.py +0 -0
  142. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/agents/definitions.py +0 -0
  143. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/agents/subagent.py +0 -0
  144. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/auth/__init__.py +0 -0
  145. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/auth/oauth/__init__.py +0 -0
  146. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/auth/oauth/browser.py +0 -0
  147. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/auth/oauth/callback.py +0 -0
  148. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/auth/oauth/codex.py +0 -0
  149. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/auth/oauth/devin.py +0 -0
  150. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/auth/oauth/pkce.py +0 -0
  151. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/auth/resolve.py +0 -0
  152. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/auth/store.py +0 -0
  153. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/cli.py +0 -0
  154. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/core/__init__.py +0 -0
  155. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/core/checkpoints.py +0 -0
  156. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/core/context.py +0 -0
  157. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/core/events.py +0 -0
  158. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/core/images.py +0 -0
  159. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/core/lateinject.py +0 -0
  160. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/core/messages.py +0 -0
  161. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/core/session.py +0 -0
  162. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/core/title.py +0 -0
  163. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/core/usage.py +0 -0
  164. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/docs.py +0 -0
  165. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/frontmatter.py +0 -0
  166. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/git.py +0 -0
  167. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/hooks/__init__.py +0 -0
  168. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/hooks/engine.py +0 -0
  169. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/hooks/spec.py +0 -0
  170. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/mcp/__init__.py +0 -0
  171. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/mcp/client.py +0 -0
  172. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/mcp/manager.py +0 -0
  173. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/mcp/oauth.py +0 -0
  174. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/net.py +0 -0
  175. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/paths.py +0 -0
  176. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/permissions/__init__.py +0 -0
  177. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/permissions/parser.py +0 -0
  178. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/providers/__init__.py +0 -0
  179. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/providers/base.py +0 -0
  180. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/providers/codex.py +0 -0
  181. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/providers/codex_catalogue.py +0 -0
  182. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/providers/devin.py +0 -0
  183. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/providers/devin_catalogue.py +0 -0
  184. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/providers/devin_wire.py +0 -0
  185. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/providers/models.py +0 -0
  186. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/providers/protowire.py +0 -0
  187. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/providers/registry.py +0 -0
  188. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/providers/responses_codec.py +0 -0
  189. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/skills/__init__.py +0 -0
  190. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/skills/loader.py +0 -0
  191. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/skills/runtime.py +0 -0
  192. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/term/__init__.py +0 -0
  193. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/term/buffer.py +0 -0
  194. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/term/component.py +0 -0
  195. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/term/demo.py +0 -0
  196. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/term/sanitize.py +0 -0
  197. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/term/screen.py +0 -0
  198. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/term/syntax.py +0 -0
  199. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/term/undo.py +0 -0
  200. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/term/width.py +0 -0
  201. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/term/word_nav.py +0 -0
  202. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tools/__init__.py +0 -0
  203. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tools/anchors.py +0 -0
  204. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tools/base.py +0 -0
  205. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tools/bash.py +0 -0
  206. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tools/edit.py +0 -0
  207. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tools/glob.py +0 -0
  208. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tools/grep.py +0 -0
  209. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tools/output.py +0 -0
  210. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tools/read.py +0 -0
  211. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tools/registry.py +0 -0
  212. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tools/symbols.py +0 -0
  213. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tools/task.py +0 -0
  214. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tools/todo.py +0 -0
  215. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tools/websearch.py +0 -0
  216. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tools/write.py +0 -0
  217. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/trace.py +0 -0
  218. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/__init__.py +0 -0
  219. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/app.py +0 -0
  220. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/clipboard.py +0 -0
  221. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/format.py +0 -0
  222. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/fuzzy.py +0 -0
  223. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/glyphs.py +0 -0
  224. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/killring.py +0 -0
  225. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/paint.py +0 -0
  226. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/renderers.py +0 -0
  227. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/roles.py +0 -0
  228. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/theme.py +0 -0
  229. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/theme_json.py +0 -0
  230. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/themes/ansi.json +0 -0
  231. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/themes/dark.json +0 -0
  232. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/themes/light.json +0 -0
  233. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/views/__init__.py +0 -0
  234. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/views/dialog.py +0 -0
  235. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/views/login.py +0 -0
  236. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/views/pickers.py +0 -0
  237. {hx_cli-0.2.6 → hx_cli-0.2.8}/src/hx/tui/views/transcript.py +0 -0
  238. {hx_cli-0.2.6 → hx_cli-0.2.8}/tests/__init__.py +0 -0
  239. {hx_cli-0.2.6/tests/agents → hx_cli-0.2.8/tests/e2e}/__init__.py +0 -0
  240. {hx_cli-0.2.6/tests/mcp → hx_cli-0.2.8/tests/e2e}/fixtures/echo_server.py +0 -0
  241. {hx_cli-0.2.6 → hx_cli-0.2.8}/tests/test_live.py +0 -0
  242. {hx_cli-0.2.6 → hx_cli-0.2.8}/tests/test_live_codex.py +0 -0
  243. {hx_cli-0.2.6 → hx_cli-0.2.8}/tests/test_live_devin.py +0 -0
  244. {hx_cli-0.2.6 → hx_cli-0.2.8}/tests/test_live_tavily.py +0 -0
  245. {hx_cli-0.2.6 → hx_cli-0.2.8}/uv.lock +0 -0
@@ -36,8 +36,21 @@ jobs:
36
36
  - uses: actions/checkout@v4
37
37
  - uses: astral-sh/setup-uv@v3
38
38
  - run: uv sync --extra dev --python ${{ matrix.python }}
39
+ # bubblewrap, so the Linux sandbox tests run instead of skipping. Ubuntu
40
+ # 24.04 confines unprivileged user namespaces with AppArmor, which bwrap
41
+ # needs; the runner is throwaway, so lift that rather than ship a profile.
42
+ - if: runner.os == 'Linux'
43
+ run: |
44
+ sudo apt-get update && sudo apt-get install -y bubblewrap
45
+ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
39
46
  # `live` needs an API key and real spend, so it never runs in CI.
40
47
  - run: uv run pytest -m "not live"
48
+ # What every end-to-end test saw, screen by screen - pass or fail.
49
+ - uses: actions/upload-artifact@v4
50
+ if: always()
51
+ with:
52
+ name: e2e-report-${{ matrix.os }}-${{ matrix.python }}
53
+ path: e2e-report/
41
54
 
42
55
  install-script:
43
56
  runs-on: ubuntu-latest
@@ -17,3 +17,4 @@ htmlcov/
17
17
  # and the rest - lives under $HX_HOME/projects/<slug>, outside the checkout
18
18
  # entirely, so there is nothing here left to hide.
19
19
  snapshot_report.html
20
+ e2e-report/
@@ -7,7 +7,7 @@ wheel, `hx changelog` prints it, and a session can answer "what's new?" or
7
7
  "which version added web search?" from it rather than from a guess. `hx docs`
8
8
  prints the manual next to it.
9
9
 
10
- The rules, enforced by `tests/test_docs.py` so a release cannot forget them:
10
+ The rules, enforced by `tests/e2e/test_cli.py` so a release cannot forget them:
11
11
 
12
12
  - Every released version has a `## [x.y.z] - YYYY-MM-DD` section here, and the
13
13
  version in `src/hx/__init__.py` always has one. Bumping the version without
@@ -28,6 +28,92 @@ the project follows [semantic versioning](https://semver.org/).
28
28
 
29
29
  ---
30
30
 
31
+ ## [0.2.8] - 2026-09-27
32
+
33
+ ### Changed
34
+
35
+ - `ctrl+z` undoes in the prompt and `ctrl+shift+z` redoes; `cmd+z` and
36
+ `cmd+shift+z` do the same in terminals that pass cmd through (kitty,
37
+ WezTerm). Apple's Terminal sends `ctrl+shift+z` as `ctrl+z`, so there it
38
+ undoes. Suspend no longer has a default key: bind `app.suspend` in
39
+ `keybindings.json`, which now also accepts `cmd+` and `option+`.
40
+
41
+ ### Fixed
42
+
43
+ - A markdown table wider than the terminal - pasted into the prompt or in a
44
+ reply - wraps its cells inside their columns instead of cutting them off,
45
+ so every word shows; in a terminal too narrow for its columns it is drawn as
46
+ `header: value` records. A bold phrase or link that wraps keeps its style
47
+ on the next line.
48
+ - A long draft in the prompt wraps between words instead of splitting them
49
+ across rows; only a word longer than the row still breaks inside it. The
50
+ cursor after the last character of a full row no longer hides that
51
+ character.
52
+ - The welcome lines wrap on a narrow terminal instead of being cut off
53
+ mid-word.
54
+
55
+ ---
56
+
57
+ ## [0.2.7] - 2026-09-27
58
+
59
+ ### Added
60
+
61
+ - `HX_OPENROUTER_BASE_URL` points the OpenRouter route at any OpenAI-compatible
62
+ endpoint - a gateway, a recording proxy.
63
+ - Pressing `esc` on a running turn leaves an "Interrupted" line in the transcript,
64
+ so a reply cut off mid-sentence no longer reads as a finished one.
65
+ - A paste over 10 lines or 1000 characters shows in the prompt as
66
+ `[Pasted text #1 +40 lines]` and is sent in full; the token moves and deletes
67
+ as one character, and survives history recall and `ctrl+k`/`ctrl+y`.
68
+ - `\` then `enter` inserts a newline in any terminal, including Apple's Terminal
69
+ and tmux without extended keys, where `shift+enter` cannot be told from `enter`. `\\` then
70
+ `enter` sends a message that ends in a backslash.
71
+
72
+ ### Fixed
73
+
74
+ - `shift+enter` inserts a newline instead of sending the message in kitty, Ghostty,
75
+ WezTerm, iTerm2 and tmux (with `extended-keys on`): HX now asks the terminal for
76
+ the kitty keyboard protocol, falling back to xterm's modifyOtherKeys, and hands
77
+ it back on exit, `ctrl+z` and `/fullscreen`. `ctrl+shift+z` (redo) works there too.
78
+ - `shift+enter` also inserts a newline on macOS in terminals that send it as a
79
+ plain `enter` (VS Code's, Apple's Terminal) or as `option+enter` (the Claude
80
+ desktop app's terminal panel): HX asks macOS which modifiers are held when the
81
+ key arrives. A real `option+enter` still steers, an `enter` hard on the heels of
82
+ a shifted character (`?` then `enter`) still sends, and `HX_MODIFIER_PROBE=0`
83
+ turns the check off.
84
+ - A multi-line paste keeps its line breaks. Terminals send them as carriage
85
+ returns, which HX was deleting, so a pasted table arrived as one long line.
86
+ - Lines typed with `shift+enter` stay separate lines in the transcript instead of
87
+ being joined into one paragraph.
88
+ - On macOS the sandbox now refuses *writes* to credential paths (`~/.ssh`,
89
+ `~/.aws`, `~/.hx/auth.json`, ...) as well as reads, so a session opened in your
90
+ home directory cannot plant an `authorized_keys` entry.
91
+ - On Linux the same holds for a credential path that does not exist yet: a
92
+ session opened in your home directory with no `~/.ssh` could create one and
93
+ plant an `authorized_keys` on the host. HX now creates such a path, empty and
94
+ private (`0700` for a directory, `0600` for a file), and mounts a read-only
95
+ shadow over it. Existing credential directories are shadowed read-only too, so
96
+ a write fails with "Read-only file system" instead of vanishing.
97
+ - Shell commands the model runs show their output in the transcript again - the
98
+ last lines, with `ctrl+o` for the rest - instead of only "Took 12ms".
99
+ - `/context` no longer fails with "'UsageLedger' object has no attribute
100
+ 'total_tokens'"; it lists what fills the window.
101
+ - `/copy` copies the last reply and says how it was copied, instead of failing.
102
+ - `/theme light` reports "Theme: light" rather than "Theme: None".
103
+ - `/compact` on a short session says only that there is nothing to compact, no
104
+ longer followed by a "Compacting…" that never finishes; a compaction that
105
+ saves nothing still reports that it finished, and the context gauge keeps
106
+ counting the system prompt afterwards.
107
+ - `/help` lines its key descriptions up however long a binding is, and a long
108
+ row in `/agents`, `/cost` or any other command output wraps under its own
109
+ column instead of at the left edge.
110
+ - The "always allow" option on an approval names where the rule is really
111
+ saved (`~/.hx`), and the answered approval shows a file relative to the
112
+ project rather than as an absolute path.
113
+ - `/permissions` no longer says the same thing twice.
114
+
115
+ ---
116
+
31
117
  ## [0.2.6] - 2026-09-23
32
118
 
33
119
  ### Added
@@ -639,7 +725,9 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
639
725
  - `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
640
726
  self-update, and `install.sh` bootstrapping uv with a pinned Python.
641
727
 
642
- [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.6...HEAD
728
+ [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.8...HEAD
729
+ [0.2.8]: https://github.com/aletisunil/hx/compare/v0.2.7...v0.2.8
730
+ [0.2.7]: https://github.com/aletisunil/hx/compare/v0.2.6...v0.2.7
643
731
  [0.2.6]: https://github.com/aletisunil/hx/compare/v0.2.5...v0.2.6
644
732
  [0.2.5]: https://github.com/aletisunil/hx/compare/v0.2.4...v0.2.5
645
733
  [0.2.4]: https://github.com/aletisunil/hx/compare/v0.2.3...v0.2.4
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hx-cli
3
- Version: 0.2.6
3
+ Version: 0.2.8
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
@@ -350,7 +350,7 @@ allowlist it if you would rather not be asked:
350
350
  ```
351
351
 
352
352
  `CHANGELOG.md` lists what each version added, and a release cannot skip it:
353
- `tests/test_docs.py` fails when `__version__` has no section (see
353
+ `tests/e2e/test_cli.py` fails when `__version__` has no section (see
354
354
  [Releasing](#releasing)).
355
355
 
356
356
  ### Keys
@@ -362,11 +362,10 @@ be rebound (see [Keybindings](#keybindings)).
362
362
  | Key | Does |
363
363
  |---|---|
364
364
  | `enter` | send |
365
- | `ctrl+j` | newline (also `shift+enter`) |
365
+ | `shift+enter` | newline (also `ctrl+j`; in a terminal that cannot send `shift+enter`, end the line with `\` and press `enter`) |
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
 
@@ -664,8 +670,11 @@ merge: it is read per layer, and only from your own settings. See
664
670
  Environment overrides: `HX_MODEL`, `HX_SUBAGENT_MODEL`, `HX_MAX_TOKENS`,
665
671
  `HX_PERMISSION_MODE`, `HX_SANDBOX`, `HX_COMPACT_AT`, `HX_GIT_NOTICES`,
666
672
  `HX_THEME`, `HX_QUIET_STARTUP`. Also `HX_HOME` to relocate user state, `HX_TAVILY_API_KEY`
667
- for web search, and `HX_CA_BUNDLE` / `HX_SSL_NO_VERIFY` for TLS (see
668
- [Credentials](#credentials)).
673
+ for web search, `HX_CA_BUNDLE` / `HX_SSL_NO_VERIFY` for TLS (see
674
+ [Credentials](#credentials)), and `HX_OPENROUTER_BASE_URL` to point the OpenRouter
675
+ route at another OpenAI-compatible endpoint - a gateway, or a recording proxy.
676
+ `HX_MODIFIER_PROBE=0` stops HX asking macOS whether shift is held when an
677
+ `enter` arrives (see [Keys](#keys)).
669
678
 
670
679
  ### The system prompt
671
680
 
@@ -774,7 +783,8 @@ confidence prompts rather than passing.
774
783
  **An OS sandbox** wraps command execution: Seatbelt on macOS, bubblewrap on
775
784
  Linux. The filesystem is readable, writes are confined to the project and the
776
785
  temp dir, credential paths (`~/.ssh`, `~/.aws`, and HX's own `auth.json`) are
777
- unreadable, and outbound network is off. If neither backend is present the
786
+ unreadable and unwritable - even inside the project, and even before they
787
+ exist - and outbound network is off. If neither backend is present the
778
788
  status bar says `no-sandbox` rather than implying protection that is not there.
779
789
 
780
790
  Modes cycle with shift+tab: `plan` (read-only — mutating tools are not even
@@ -1117,6 +1127,23 @@ uv run mypy # strict
1117
1127
  uv run hx # run from the checkout
1118
1128
  ```
1119
1129
 
1130
+ The suite is end-to-end. Each test in `tests/e2e/` starts the installed `hx`
1131
+ binary - on a real pseudo-terminal for the TUI, or as a plain subprocess for
1132
+ print mode and the other commands - in a private `$HOME` and `$HX_HOME`, and
1133
+ points it at a local OpenRouter stand-in with `HX_OPENROUTER_BASE_URL`.
1134
+ Everything between the keyboard and the socket is the shipped code; only the
1135
+ model's replies are scripted. The screen is read back through a VT emulator,
1136
+ so assertions are on the cells a terminal shows, not on the bytes HX meant to
1137
+ write.
1138
+
1139
+ Every run writes `e2e-report/`: `index.html` shows each test's outcome and
1140
+ every screen it checked, rendered in colour, and one text file per screen lets
1141
+ two runs be compared with `diff -r`. CI uploads it from every test job as an
1142
+ `e2e-report-*` artifact. Set `HX_E2E_REPORT` to write it somewhere else.
1143
+
1144
+ Tests that need the OS sandbox are skipped where there is no backend
1145
+ (`sandbox-exec` on macOS, `bwrap` on Linux).
1146
+
1120
1147
  Tests marked `live` hit a real API. The OpenRouter ones cost money:
1121
1148
 
1122
1149
  ```sh
@@ -1133,17 +1160,7 @@ uv run pytest -m live tests/test_live_devin.py
1133
1160
 
1134
1161
  They are the only place the wire formats, streaming, tool use and a genuine
1135
1162
  cache hit are proven against a real service; everything else runs against the
1136
- scripted provider.
1137
-
1138
- Tests marked `sandbox` exercise the real OS sandbox and are skipped where no
1139
- backend exists.
1140
-
1141
- The TUI has SVG layout snapshots. Read the diff before accepting a change to
1142
- them - they exist to catch a frame that quietly lost a row:
1143
-
1144
- ```sh
1145
- uv run pytest tests/tui/test_snapshots.py --snapshot-update
1146
- ```
1163
+ stand-in.
1147
1164
 
1148
1165
  ### Layout
1149
1166
 
@@ -1152,7 +1169,7 @@ src/hx/
1152
1169
  cli.py config.py paths.py frontmatter.py git.py
1153
1170
  core/ loop, context assembly, compaction, late injection, sessions, usage
1154
1171
  auth/ credential store, OAuth flows, per-route resolution
1155
- providers/ OpenRouter, Codex, Devin, the model catalogue, a scripted provider for tests
1172
+ providers/ OpenRouter, Codex, Devin, the model catalogue
1156
1173
  tools/ Bash, Read, Write, Edit, Glob, Grep, Symbols, TodoWrite, Task,
1157
1174
  WebSearch, WebFetch, line anchors, output capping
1158
1175
  permissions/ rule engine, shell decomposition, Seatbelt/bubblewrap
@@ -1189,12 +1206,12 @@ Each release:
1189
1206
  # 1. move CHANGELOG.md's [Unreleased] entries under the new version, dated,
1190
1207
  # and leave a fresh empty [Unreleased] behind
1191
1208
  # 2. bump __version__ in src/hx/__init__.py
1192
- # 3. uv run pytest tests/test_docs.py # the version must have a section
1209
+ # 3. uv run pytest tests/e2e/test_cli.py # the version must have a section
1193
1210
  git commit -am "release: X.Y.Z"
1194
1211
  git tag v$(uv run hx --version | cut -d' ' -f2) && git push origin main --tags
1195
1212
  ```
1196
1213
 
1197
- Step 1 is not optional and is not a convention: `tests/test_docs.py` fails the
1214
+ Step 1 is not optional and is not a convention: `tests/e2e/test_cli.py` fails the
1198
1215
  whole suite when `__version__` has no `CHANGELOG.md` section, so a release that
1199
1216
  skips it cannot get past CI to the `publish` job. The file ships inside the
1200
1217
  wheel and `hx changelog` prints it, so a version with no section there reaches
@@ -310,7 +310,7 @@ allowlist it if you would rather not be asked:
310
310
  ```
311
311
 
312
312
  `CHANGELOG.md` lists what each version added, and a release cannot skip it:
313
- `tests/test_docs.py` fails when `__version__` has no section (see
313
+ `tests/e2e/test_cli.py` fails when `__version__` has no section (see
314
314
  [Releasing](#releasing)).
315
315
 
316
316
  ### Keys
@@ -322,11 +322,10 @@ be rebound (see [Keybindings](#keybindings)).
322
322
  | Key | Does |
323
323
  |---|---|
324
324
  | `enter` | send |
325
- | `ctrl+j` | newline (also `shift+enter`) |
325
+ | `shift+enter` | newline (also `ctrl+j`; in a terminal that cannot send `shift+enter`, end the line with `\` and press `enter`) |
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
 
@@ -624,8 +630,11 @@ merge: it is read per layer, and only from your own settings. See
624
630
  Environment overrides: `HX_MODEL`, `HX_SUBAGENT_MODEL`, `HX_MAX_TOKENS`,
625
631
  `HX_PERMISSION_MODE`, `HX_SANDBOX`, `HX_COMPACT_AT`, `HX_GIT_NOTICES`,
626
632
  `HX_THEME`, `HX_QUIET_STARTUP`. Also `HX_HOME` to relocate user state, `HX_TAVILY_API_KEY`
627
- for web search, and `HX_CA_BUNDLE` / `HX_SSL_NO_VERIFY` for TLS (see
628
- [Credentials](#credentials)).
633
+ for web search, `HX_CA_BUNDLE` / `HX_SSL_NO_VERIFY` for TLS (see
634
+ [Credentials](#credentials)), and `HX_OPENROUTER_BASE_URL` to point the OpenRouter
635
+ route at another OpenAI-compatible endpoint - a gateway, or a recording proxy.
636
+ `HX_MODIFIER_PROBE=0` stops HX asking macOS whether shift is held when an
637
+ `enter` arrives (see [Keys](#keys)).
629
638
 
630
639
  ### The system prompt
631
640
 
@@ -734,7 +743,8 @@ confidence prompts rather than passing.
734
743
  **An OS sandbox** wraps command execution: Seatbelt on macOS, bubblewrap on
735
744
  Linux. The filesystem is readable, writes are confined to the project and the
736
745
  temp dir, credential paths (`~/.ssh`, `~/.aws`, and HX's own `auth.json`) are
737
- unreadable, and outbound network is off. If neither backend is present the
746
+ unreadable and unwritable - even inside the project, and even before they
747
+ exist - and outbound network is off. If neither backend is present the
738
748
  status bar says `no-sandbox` rather than implying protection that is not there.
739
749
 
740
750
  Modes cycle with shift+tab: `plan` (read-only — mutating tools are not even
@@ -1077,6 +1087,23 @@ uv run mypy # strict
1077
1087
  uv run hx # run from the checkout
1078
1088
  ```
1079
1089
 
1090
+ The suite is end-to-end. Each test in `tests/e2e/` starts the installed `hx`
1091
+ binary - on a real pseudo-terminal for the TUI, or as a plain subprocess for
1092
+ print mode and the other commands - in a private `$HOME` and `$HX_HOME`, and
1093
+ points it at a local OpenRouter stand-in with `HX_OPENROUTER_BASE_URL`.
1094
+ Everything between the keyboard and the socket is the shipped code; only the
1095
+ model's replies are scripted. The screen is read back through a VT emulator,
1096
+ so assertions are on the cells a terminal shows, not on the bytes HX meant to
1097
+ write.
1098
+
1099
+ Every run writes `e2e-report/`: `index.html` shows each test's outcome and
1100
+ every screen it checked, rendered in colour, and one text file per screen lets
1101
+ two runs be compared with `diff -r`. CI uploads it from every test job as an
1102
+ `e2e-report-*` artifact. Set `HX_E2E_REPORT` to write it somewhere else.
1103
+
1104
+ Tests that need the OS sandbox are skipped where there is no backend
1105
+ (`sandbox-exec` on macOS, `bwrap` on Linux).
1106
+
1080
1107
  Tests marked `live` hit a real API. The OpenRouter ones cost money:
1081
1108
 
1082
1109
  ```sh
@@ -1093,17 +1120,7 @@ uv run pytest -m live tests/test_live_devin.py
1093
1120
 
1094
1121
  They are the only place the wire formats, streaming, tool use and a genuine
1095
1122
  cache hit are proven against a real service; everything else runs against the
1096
- scripted provider.
1097
-
1098
- Tests marked `sandbox` exercise the real OS sandbox and are skipped where no
1099
- backend exists.
1100
-
1101
- The TUI has SVG layout snapshots. Read the diff before accepting a change to
1102
- them - they exist to catch a frame that quietly lost a row:
1103
-
1104
- ```sh
1105
- uv run pytest tests/tui/test_snapshots.py --snapshot-update
1106
- ```
1123
+ stand-in.
1107
1124
 
1108
1125
  ### Layout
1109
1126
 
@@ -1112,7 +1129,7 @@ src/hx/
1112
1129
  cli.py config.py paths.py frontmatter.py git.py
1113
1130
  core/ loop, context assembly, compaction, late injection, sessions, usage
1114
1131
  auth/ credential store, OAuth flows, per-route resolution
1115
- providers/ OpenRouter, Codex, Devin, the model catalogue, a scripted provider for tests
1132
+ providers/ OpenRouter, Codex, Devin, the model catalogue
1116
1133
  tools/ Bash, Read, Write, Edit, Glob, Grep, Symbols, TodoWrite, Task,
1117
1134
  WebSearch, WebFetch, line anchors, output capping
1118
1135
  permissions/ rule engine, shell decomposition, Seatbelt/bubblewrap
@@ -1149,12 +1166,12 @@ Each release:
1149
1166
  # 1. move CHANGELOG.md's [Unreleased] entries under the new version, dated,
1150
1167
  # and leave a fresh empty [Unreleased] behind
1151
1168
  # 2. bump __version__ in src/hx/__init__.py
1152
- # 3. uv run pytest tests/test_docs.py # the version must have a section
1169
+ # 3. uv run pytest tests/e2e/test_cli.py # the version must have a section
1153
1170
  git commit -am "release: X.Y.Z"
1154
1171
  git tag v$(uv run hx --version | cut -d' ' -f2) && git push origin main --tags
1155
1172
  ```
1156
1173
 
1157
- Step 1 is not optional and is not a convention: `tests/test_docs.py` fails the
1174
+ Step 1 is not optional and is not a convention: `tests/e2e/test_cli.py` fails the
1158
1175
  whole suite when `__version__` has no `CHANGELOG.md` section, so a release that
1159
1176
  skips it cannot get past CI to the `publish` job. The file ships inside the
1160
1177
  wheel and `hx changelog` prints it, so a version with no section there reaches
@@ -124,5 +124,5 @@ asyncio_mode = "auto"
124
124
  addopts = "-q --strict-markers -m 'not live'"
125
125
  markers = [
126
126
  "live: hits a real API (OpenRouter, or Tavily for the web search tools)",
127
- "sandbox: requires sandbox-exec (macOS) or bwrap (Linux)",
127
+ "e2e: drives the installed hx binary against a local OpenRouter stand-in",
128
128
  ]
@@ -1,5 +1,5 @@
1
1
  """HX - an agent harness for the terminal."""
2
2
 
3
- __version__ = "0.2.6"
3
+ __version__ = "0.2.8"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -5,7 +5,7 @@ Precedence, lowest to highest::
5
5
  defaults
6
6
  < ~/.hx/settings.json
7
7
  < <cwd>/.hx/settings.json (shared, check it in)
8
- < <cwd>/.hx/settings.local.json (this machine only, never checked in)
8
+ < ~/.hx/projects/<project>/settings.local.json (this machine only)
9
9
  < environment
10
10
  < CLI flags
11
11
 
@@ -105,6 +105,12 @@ class Compactor:
105
105
 
106
106
  return list(messages[:boundary]), list(messages[boundary:])
107
107
 
108
+ def can_compact(self, messages: list[Message]) -> bool:
109
+ """Whether :meth:`compact` would summarise anything - checked before a
110
+ compaction is announced, so one is never started only to do nothing."""
111
+ dropped, _ = self.split(messages)
112
+ return len(dropped) >= self.MIN_MESSAGES_TO_COMPACT
113
+
108
114
  async def compact(
109
115
  self,
110
116
  messages: list[Message],
@@ -790,8 +790,7 @@ class AgentLoop:
790
790
  fraction = self.session.usage.context_fraction
791
791
  if not self.compactor.should_compact(fraction, self.settings.context.compact_at):
792
792
  return
793
- dropped, _ = self.compactor.split(self.session.active_messages())
794
- if len(dropped) < self.compactor.MIN_MESSAGES_TO_COMPACT:
793
+ if not self.compactor.can_compact(self.session.active_messages()):
795
794
  return
796
795
  await self.compact(reason=f"context at {fraction:.0%} of the window")
797
796
 
@@ -805,6 +804,11 @@ class AgentLoop:
805
804
  """
806
805
  if self.compactor is None:
807
806
  return False
807
+ # Decided before anything is announced: "Compacting…" followed by
808
+ # nothing, beside the caller's "nothing to compact", reads as a
809
+ # compaction that started and never finished.
810
+ if not self.compactor.can_compact(self.session.active_messages()):
811
+ return False
808
812
 
809
813
  self.bus.publish(CompactionStarted(reason=reason))
810
814
  try:
@@ -813,16 +817,16 @@ class AgentLoop:
813
817
  self.bus.publish(ErrorRaised(message=f"Compaction failed: {exc}", recoverable=True))
814
818
  return False
815
819
 
816
- if not result.dropped:
817
- self.bus.publish(
818
- CompactionFinished(
819
- tokens_before=result.tokens_before, tokens_after=result.tokens_after
820
- )
821
- )
822
- return False
823
-
824
820
  self.session.record_compaction(result.dropped, result.kept, result.summary)
825
- self.session.usage.context_tokens = result.tokens_after
821
+ # The estimates cover the conversation only; the system prompt and tools
822
+ # ahead of it are still sent, so they stay in the gauge - estimated the
823
+ # same way, rather than subtracted out of the provider's real count.
824
+ prefix = sum(
825
+ section.tokens
826
+ for section in (self.last_context.sections if self.last_context else [])
827
+ if section.name != "history"
828
+ )
829
+ self.session.usage.context_tokens = prefix + result.tokens_after
826
830
  self.bus.publish(
827
831
  CompactionFinished(tokens_before=result.tokens_before, tokens_after=result.tokens_after)
828
832
  )
@@ -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
 
@@ -29,7 +29,7 @@ class GrantScope(StrEnum):
29
29
  ONCE = "once"
30
30
  SESSION = "session"
31
31
  ALWAYS = "always"
32
- """Persisted to ``.hx/settings.local.json``."""
32
+ """Persisted to ``~/.hx/projects/<project>/settings.local.json``."""
33
33
 
34
34
 
35
35
  DEFAULT_MUTATING_TOOLS = frozenset({"Bash", "Write", "Edit", "Task"})