hx-cli 0.2.7__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 (148) hide show
  1. {hx_cli-0.2.7 → hx_cli-0.2.8}/CHANGELOG.md +28 -1
  2. {hx_cli-0.2.7 → hx_cli-0.2.8}/PKG-INFO +12 -6
  3. {hx_cli-0.2.7 → hx_cli-0.2.8}/README.md +11 -5
  4. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/__init__.py +1 -1
  5. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/keys.py +24 -9
  6. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/ansi.py +66 -1
  7. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/editor.py +56 -27
  8. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/keydecode.py +7 -2
  9. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/markdown.py +66 -19
  10. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/terminal.py +1 -1
  11. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/runtime.py +1 -1
  12. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/views/status.py +14 -2
  13. {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/terminal.py +4 -0
  14. {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/test_input.py +185 -16
  15. {hx_cli-0.2.7 → hx_cli-0.2.8}/.github/workflows/ci.yml +0 -0
  16. {hx_cli-0.2.7 → hx_cli-0.2.8}/.gitignore +0 -0
  17. {hx_cli-0.2.7 → hx_cli-0.2.8}/examples/hooks/check.sh +0 -0
  18. {hx_cli-0.2.7 → hx_cli-0.2.8}/install.sh +0 -0
  19. {hx_cli-0.2.7 → hx_cli-0.2.8}/pyproject.toml +0 -0
  20. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/agents/__init__.py +0 -0
  21. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/agents/definitions.py +0 -0
  22. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/agents/subagent.py +0 -0
  23. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/auth/__init__.py +0 -0
  24. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/auth/oauth/__init__.py +0 -0
  25. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/auth/oauth/browser.py +0 -0
  26. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/auth/oauth/callback.py +0 -0
  27. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/auth/oauth/codex.py +0 -0
  28. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/auth/oauth/devin.py +0 -0
  29. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/auth/oauth/pkce.py +0 -0
  30. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/auth/resolve.py +0 -0
  31. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/auth/store.py +0 -0
  32. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/cli.py +0 -0
  33. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/config.py +0 -0
  34. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/__init__.py +0 -0
  35. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/checkpoints.py +0 -0
  36. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/compaction.py +0 -0
  37. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/context.py +0 -0
  38. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/events.py +0 -0
  39. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/images.py +0 -0
  40. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/lateinject.py +0 -0
  41. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/loop.py +0 -0
  42. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/messages.py +0 -0
  43. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/session.py +0 -0
  44. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/title.py +0 -0
  45. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/usage.py +0 -0
  46. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/docs.py +0 -0
  47. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/frontmatter.py +0 -0
  48. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/git.py +0 -0
  49. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/hooks/__init__.py +0 -0
  50. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/hooks/engine.py +0 -0
  51. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/hooks/spec.py +0 -0
  52. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/mcp/__init__.py +0 -0
  53. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/mcp/client.py +0 -0
  54. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/mcp/manager.py +0 -0
  55. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/mcp/oauth.py +0 -0
  56. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/net.py +0 -0
  57. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/paths.py +0 -0
  58. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/permissions/__init__.py +0 -0
  59. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/permissions/engine.py +0 -0
  60. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/permissions/parser.py +0 -0
  61. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/permissions/sandbox.py +0 -0
  62. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/__init__.py +0 -0
  63. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/base.py +0 -0
  64. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/codex.py +0 -0
  65. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/codex_catalogue.py +0 -0
  66. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/devin.py +0 -0
  67. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/devin_catalogue.py +0 -0
  68. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/devin_wire.py +0 -0
  69. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/models.py +0 -0
  70. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/openrouter.py +0 -0
  71. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/protowire.py +0 -0
  72. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/registry.py +0 -0
  73. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/responses_codec.py +0 -0
  74. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/skills/__init__.py +0 -0
  75. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/skills/loader.py +0 -0
  76. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/skills/runtime.py +0 -0
  77. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/__init__.py +0 -0
  78. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/buffer.py +0 -0
  79. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/component.py +0 -0
  80. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/demo.py +0 -0
  81. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/loop.py +0 -0
  82. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/modifiers.py +0 -0
  83. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/primitives.py +0 -0
  84. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/sanitize.py +0 -0
  85. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/screen.py +0 -0
  86. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/syntax.py +0 -0
  87. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/undo.py +0 -0
  88. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/width.py +0 -0
  89. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/word_nav.py +0 -0
  90. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/__init__.py +0 -0
  91. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/anchors.py +0 -0
  92. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/base.py +0 -0
  93. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/bash.py +0 -0
  94. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/edit.py +0 -0
  95. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/glob.py +0 -0
  96. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/grep.py +0 -0
  97. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/output.py +0 -0
  98. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/read.py +0 -0
  99. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/registry.py +0 -0
  100. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/symbols.py +0 -0
  101. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/task.py +0 -0
  102. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/todo.py +0 -0
  103. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/websearch.py +0 -0
  104. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/write.py +0 -0
  105. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/trace.py +0 -0
  106. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/__init__.py +0 -0
  107. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/app.py +0 -0
  108. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/clipboard.py +0 -0
  109. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/commands.py +0 -0
  110. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/format.py +0 -0
  111. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/fuzzy.py +0 -0
  112. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/glyphs.py +0 -0
  113. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/killring.py +0 -0
  114. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/limits.py +0 -0
  115. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/paint.py +0 -0
  116. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/renderers.py +0 -0
  117. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/roles.py +0 -0
  118. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/theme.py +0 -0
  119. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/theme_json.py +0 -0
  120. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/themes/ansi.json +0 -0
  121. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/themes/dark.json +0 -0
  122. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/themes/light.json +0 -0
  123. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/views/__init__.py +0 -0
  124. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/views/blocks.py +0 -0
  125. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/views/dialog.py +0 -0
  126. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/views/login.py +0 -0
  127. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/views/permission.py +0 -0
  128. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/views/pickers.py +0 -0
  129. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/views/prompt.py +0 -0
  130. {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/views/transcript.py +0 -0
  131. {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/__init__.py +0 -0
  132. {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/conftest.py +0 -0
  133. {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/__init__.py +0 -0
  134. {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/conftest.py +0 -0
  135. {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/fixtures/echo_server.py +0 -0
  136. {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/report.py +0 -0
  137. {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/stub.py +0 -0
  138. {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/test_chat.py +0 -0
  139. {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/test_cli.py +0 -0
  140. {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/test_commands.py +0 -0
  141. {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/test_extend.py +0 -0
  142. {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/test_print.py +0 -0
  143. {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/test_tools.py +0 -0
  144. {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/test_live.py +0 -0
  145. {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/test_live_codex.py +0 -0
  146. {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/test_live_devin.py +0 -0
  147. {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/test_live_tavily.py +0 -0
  148. {hx_cli-0.2.7 → hx_cli-0.2.8}/uv.lock +0 -0
@@ -28,6 +28,32 @@ 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
+
31
57
  ## [0.2.7] - 2026-09-27
32
58
 
33
59
  ### Added
@@ -699,7 +725,8 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
699
725
  - `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
700
726
  self-update, and `install.sh` bootstrapping uv with a pinned Python.
701
727
 
702
- [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.7...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
703
730
  [0.2.7]: https://github.com/aletisunil/hx/compare/v0.2.6...v0.2.7
704
731
  [0.2.6]: https://github.com/aletisunil/hx/compare/v0.2.5...v0.2.6
705
732
  [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.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
@@ -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
 
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  """HX - an agent harness for the terminal."""
2
2
 
3
- __version__ = "0.2.7"
3
+ __version__ = "0.2.8"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -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
 
@@ -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))
@@ -54,11 +54,12 @@ class LayoutLine:
54
54
 
55
55
 
56
56
  def layout(text: str, width: int, cursor: int) -> list[LayoutLine]:
57
- """Map the buffer onto visual rows, wrapping to ``width``.
57
+ """Map the buffer onto visual rows, wrapping to ``width`` between words.
58
58
 
59
- Wrapping happens at the width, not at a word boundary: a prompt is being
60
- typed into, and a line that reflows under the cursor as a word grows is
61
- disorienting in a way it is not in rendered prose.
59
+ The rows of a line are exactly its text, cut up: nothing is added or
60
+ dropped at a break, so a buffer offset maps onto a row and a column by
61
+ counting alone. That is why the space a line breaks on stays at the end
62
+ of its row - see :func:`_wrap_words` - rather than being swallowed.
62
63
  """
63
64
  if width < 1:
64
65
  width = 1
@@ -66,42 +67,66 @@ def layout(text: str, width: int, cursor: int) -> list[LayoutLine]:
66
67
  rows: list[LayoutLine] = []
67
68
  offset = 0
68
69
  for logical in text.split("\n"):
69
- chunks = _wrap_hard(logical, width)
70
- for chunk in chunks:
70
+ chunks = _wrap_words(logical, width)
71
+ for index, chunk in enumerate(chunks):
71
72
  length = len(chunk)
72
73
  column: int | None = None
73
- if offset <= cursor <= offset + length:
74
- # A cursor exactly at a wrap point belongs to the row it
75
- # continues onto, not the one it just left - except at the very
76
- # end of the buffer, where there is no next row.
77
- at_wrap = cursor == offset + length and length == _visible_len(chunk, width)
78
- if not (at_wrap and chunk is not chunks[-1]):
79
- column = cell_width(chunk[: cursor - offset])
74
+ # A cursor at a break belongs to the row it continues onto: what
75
+ # is typed there lands at the start of that row, so the cursor is
76
+ # drawn there too. Past the last row there is nothing to continue.
77
+ last = index == len(chunks) - 1
78
+ if offset <= cursor < offset + length or (cursor == offset + length and last):
79
+ column = cell_width(chunk[: cursor - offset])
80
80
  rows.append(LayoutLine(chunk, column))
81
81
  offset += length
82
82
  offset += 1 # the newline itself
83
83
  return rows
84
84
 
85
85
 
86
- def _visible_len(chunk: str, width: int) -> int:
87
- return len(chunk) if cell_width(chunk) >= width else -1
86
+ def _wrap_words(line: str, width: int) -> list[str]:
87
+ """Break a logical line into rows of at most ``width`` cells, between words.
88
88
 
89
-
90
- def _wrap_hard(line: str, width: int) -> list[str]:
91
- """Break a logical line into rows of at most ``width`` cells."""
89
+ A row ends after the last space that fits. The space the break falls on
90
+ may hang one cell past ``width`` - a word that exactly fills a row keeps
91
+ its space rather than pushing it to the front of the next one - which is
92
+ what the editor's right-hand padding is for. A word too long for any row
93
+ breaks where it hits the edge, as does a run that is nothing but spaces.
94
+ """
92
95
  if not line:
93
96
  return [""]
97
+ clusters = list(grapheme_clusters(line))
94
98
  rows: list[str] = []
95
- current = ""
99
+ start = 0
96
100
  used = 0
97
- for cluster in grapheme_clusters(line):
101
+ # Where the row may end: just past a space with a word before it.
102
+ fold: int | None = None
103
+ worded = False
104
+ index = 0
105
+ while index < len(clusters):
106
+ cluster = clusters[index]
98
107
  step = cell_width(cluster)
99
- if used + step > width and current:
100
- rows.append(current)
101
- current, used = "", 0
102
- current += cluster
108
+ if used + step > width and index > start:
109
+ if cluster == " " and worded and used == width:
110
+ end = index + 1 # hang it
111
+ elif fold is not None:
112
+ end = fold
113
+ else:
114
+ end = index
115
+ rows.append("".join(clusters[start:end]))
116
+ start, fold, worded = end, None, False
117
+ used = sum(cell_width(c) for c in clusters[start:index])
118
+ worded = any(c != " " for c in clusters[start:index])
119
+ if end > index:
120
+ index = end
121
+ continue
122
+ if cluster == " ":
123
+ if worded:
124
+ fold = index + 1
125
+ else:
126
+ worded = True
103
127
  used += step
104
- rows.append(current)
128
+ index += 1
129
+ rows.append("".join(clusters[start:]))
105
130
  return rows
106
131
 
107
132
 
@@ -224,6 +249,10 @@ class Editor(Widget):
224
249
  placeholder = not self.buffer.text and self.placeholder
225
250
  if row.cursor_column is None:
226
251
  return truncate_to_width(row.text, inner)
252
+ # The cursor may sit one cell past the text column - on a space hanging
253
+ # off a full row, or after the last character of one - and it is drawn
254
+ # there, in the right-hand padding, rather than over the text.
255
+ room = inner + min(1, self._padding_x)
227
256
 
228
257
  clusters = list(grapheme_clusters(row.text))
229
258
  consumed = 0
@@ -247,12 +276,12 @@ class Editor(Widget):
247
276
 
248
277
  if under:
249
278
  painted = head + CURSOR_MARKER + inverse(under) + "".join(after)
250
- return truncate_to_width(painted, inner)
279
+ return truncate_to_width(painted, room)
251
280
 
252
281
  # At end of line the cursor is an appended cell, which costs a column
253
282
  # the text did not need. Without trimming for it the line comes out one
254
283
  # cell too wide and the renderer refuses to draw the frame.
255
- head = truncate_to_width(head, max(0, inner - 1))
284
+ head = truncate_to_width(head, max(0, room - 1))
256
285
  return head + CURSOR_MARKER + inverse(" ")
257
286
 
258
287
  # -- editing helpers ---------------------------------------------------
@@ -417,8 +417,13 @@ def _unicode_key(code: int, shifted: int | None, base: int | None, mods: list[st
417
417
  return Key(_with_modifiers(_CTRL_PUNCTUATION[char], rest))
418
418
  return Key(_with_modifiers("space" if char == " " else char.lower(), mods))
419
419
 
420
- # alt without ctrl. The legacy encoding sends ESC then the shifted
421
- # character, so alt+shift+b has always been alt+B.
420
+ # super, hyper and meta have no legacy encoding to agree with, so shift is
421
+ # named like ctrl's: cmd+shift+z is super+shift+z, as a user writes it.
422
+ if set(chording) - {"alt"}:
423
+ return Key(_with_modifiers("space" if char == " " else char.lower(), mods))
424
+
425
+ # alt alone. The legacy encoding sends ESC then the shifted character, so
426
+ # alt+shift+b has always been alt+B.
422
427
  if "shift" in mods:
423
428
  char = chr(shifted) if shifted else char.upper()
424
429
  mods = [mod for mod in mods if mod != "shift"]
@@ -24,7 +24,7 @@ Two conventions worth naming, both taken from pi:
24
24
 
25
25
  from __future__ import annotations
26
26
 
27
- from hx.term.ansi import fill_line, hyperlink, wrap
27
+ from hx.term.ansi import fill_line, hyperlink, open_styles, wrap
28
28
  from hx.term.component import Widget
29
29
  from hx.term.width import cell_width, truncate_to_width
30
30
 
@@ -38,6 +38,10 @@ HR_MAX = 80
38
38
  """A rule spanning a very wide terminal reads as a divider in a book, not a
39
39
  paragraph break. Capping it keeps it proportionate to the text."""
40
40
 
41
+ MIN_COLUMN = 3
42
+ """The narrowest a table column is squeezed to. Below this a table is drawn as
43
+ records instead - see :meth:`_Renderer._stacked_table`."""
44
+
41
45
 
42
46
  class Painter:
43
47
  """How each markdown element is coloured.
@@ -294,30 +298,61 @@ class _Renderer:
294
298
  return
295
299
 
296
300
  count = max(len(row) for row in rows)
297
- widths = [
298
- max((cell_width(row[i]) for row in rows if i < len(row)), default=0)
299
- for i in range(count)
300
- ]
301
- # Shrink to fit rather than overflowing the terminal.
301
+ rows = [row + [""] * (count - len(row)) for row in rows]
302
+ widths = [max(cell_width(row[i]) for row in rows) for i in range(count)]
303
+ # Narrowed to fit, widest column first, and the cells wrapped inside
304
+ # it. Truncating instead dropped the end of every long cell without a
305
+ # mark, so a pasted table echoed back missing text the model was sent.
302
306
  budget = self.width - indent - (3 * count + 1)
303
- while sum(widths) > budget and max(widths) > 3:
307
+ if budget < MIN_COLUMN * count:
308
+ self._stacked_table(rows, header, indent)
309
+ return
310
+ while sum(widths) > budget:
304
311
  widths[widths.index(max(widths))] -= 1
305
312
 
306
- def line(row: list[str]) -> str:
307
- cells = []
308
- for i in range(count):
309
- cell = row[i] if i < len(row) else ""
310
- cell = truncate_to_width(cell, widths[i])
311
- cells.append(cell + " " * (widths[i] - cell_width(cell)))
312
- return "│ " + " │ ".join(cells) + " │"
313
+ body = [
314
+ [_cell_lines(cell, width) for cell, width in zip(row, widths, strict=True)]
315
+ for row in rows
316
+ ]
317
+ # Once a row takes more than one line, only a rule says where the next
318
+ # one starts.
319
+ ruled = any(max(map(len, row)) > 1 for row in body[header:])
320
+
321
+ def divider() -> str:
322
+ line = "├─" + "─┼─".join("─" * w for w in widths) + "─┤"
323
+ return self.painter.paint("md_code_block_border", line)
324
+
325
+ self.blank()
326
+ for index, row in enumerate(body):
327
+ if index and (index == header or (index > header and ruled)):
328
+ self.emit(divider(), indent)
329
+ for depth in range(max(map(len, row))):
330
+ cells = []
331
+ for lines, width in zip(row, widths, strict=True):
332
+ piece = lines[depth] if depth < len(lines) else ""
333
+ cells.append(piece + " " * (width - cell_width(piece)))
334
+ self.emit("│ " + " │ ".join(cells) + " │", indent)
335
+ self.blank()
313
336
 
314
- divider = "├─" + "─┼─".join("─" * w for w in widths) + "─┤"
337
+ def _stacked_table(self, rows: list[list[str]], header: int, indent: int) -> None:
338
+ """A table too wide for the terminal, one record at a time.
315
339
 
340
+ Every column at its narrowest would still overflow, and a grid of
341
+ three-cell slivers is not readable. Each row becomes ``header: value``
342
+ lines instead, the records a rule apart.
343
+ """
344
+ names = rows[header - 1] if header else [""] * len(rows[0])
316
345
  self.blank()
317
- for index, row in enumerate(rows):
318
- if index == header and header:
319
- self.emit(self.painter.paint("md_code_block_border", divider), indent)
320
- self.emit(line(row), indent)
346
+ for index, row in enumerate(rows[header:]):
347
+ if index:
348
+ span = min(self.width - indent, HR_MAX)
349
+ self.emit(self.painter.paint("md_code_block_border", "─" * span), indent)
350
+ for name, cell in zip(names, row, strict=True):
351
+ label = self.painter.paint("md_heading", name + ":", bold=True) if name else ""
352
+ lines = self.wrapped(f"{label} {cell}" if label else cell, indent, 2)
353
+ for position, line in enumerate(lines):
354
+ # Continuation hangs under the value, clear of the name.
355
+ self.emit(line, indent + (2 if position else 0))
321
356
  self.blank()
322
357
 
323
358
  # -- inline ------------------------------------------------------------
@@ -359,6 +394,18 @@ class _Renderer:
359
394
  return str(getattr(node, "content", "") or "") or self.inline(node)
360
395
 
361
396
 
397
+ def _cell_lines(text: str, width: int) -> list[str]:
398
+ """One table cell wrapped to its column.
399
+
400
+ Wrapping reopens on each line whatever styling was open at the break; each
401
+ line here also closes it, so a link or a bold phrase broken across lines
402
+ stays inside its own cell instead of running on into the next.
403
+ """
404
+ # Truncated because wrap only overflows for a glyph wider than the column.
405
+ lines = [truncate_to_width(piece, width) for piece in wrap(text, width)]
406
+ return [line + open_styles(line)[1] for line in lines]
407
+
408
+
362
409
  class Markdown(Widget):
363
410
  """A block of markdown as a component."""
364
411
 
@@ -87,7 +87,7 @@ it to an application that asks - which is how shift+enter survives tmux."""
87
87
 
88
88
  _RESIZE_SIGNALS = (signal.SIGWINCH, signal.SIGCONT)
89
89
  """SIGWINCH is not delivered while the process is stopped, so a resize during
90
- ``ctrl+z`` is only discoverable on the way back - which is what SIGCONT is
90
+ a suspend is only discoverable on the way back - which is what SIGCONT is
91
91
  doing in a list of resize signals."""
92
92
 
93
93
 
@@ -758,7 +758,7 @@ class HXSession:
758
758
  self.runner.request_immediate_render()
759
759
 
760
760
  def _suspend(self) -> None:
761
- """``ctrl+z``. Raw mode means the terminal will not do this for us."""
761
+ """``app.suspend``. Raw mode means the terminal will not do this for us."""
762
762
  self.runner.suspend()
763
763
 
764
764
  def _terminal_rows(self) -> int:
@@ -310,12 +310,24 @@ class Header(Widget):
310
310
  lines = [title]
311
311
 
312
312
  if not self._expanded:
313
- lines.append(fg("muted", "An agent harness. Ask a question, or start with /help."))
314
- lines.append(fg("dim", f"{KEYMAP.primary('app.tools.expand')} shows every key."))
313
+ from hx.term.ansi import wrap
314
+
315
+ # Prose, so wrapped between words on a narrow terminal rather than
316
+ # cut off mid-word the way the key table's columns are.
317
+ inner = max(1, width - 2)
318
+ lines += wrap(
319
+ fg("muted", "An agent harness. Ask a question, or start with /help."), inner
320
+ )
321
+ lines += wrap(
322
+ fg("dim", f"{KEYMAP.primary('app.tools.expand')} shows every key."), inner
323
+ )
315
324
  else:
316
325
  rows = [
317
326
  (fg("dim", KEYMAP.text(binding.id)), fg("muted", binding.description))
318
327
  for binding in KEYMAP.bindings.values()
328
+ # An action with no key, like suspend by default, has nothing
329
+ # to show here but a blank where its key would be.
330
+ if KEYMAP.keys_for(binding.id)
319
331
  ]
320
332
  from hx.tui.format import columns
321
333
 
@@ -69,6 +69,10 @@ _KITTY: dict[str, str] = {
69
69
  # character (flag 4, alternate keys), and ctrl+shift.
70
70
  "ctrl+_": "\x1b[45:95;6u",
71
71
  "ctrl+shift+z": "\x1b[122:90;6u",
72
+ # cmd is the protocol's super. Only a terminal that leaves cmd to the
73
+ # program sends these (kitty, WezTerm); the rest keep it for their menus.
74
+ "cmd+shift+z": "\x1b[122:90;10u",
75
+ **{f"cmd+{c}": f"\x1b[{ord(c)};9u" for c in "abcdefghijklmnopqrstuvwxyz"},
72
76
  **{f"alt+{c}": f"\x1b[{ord(c)};3u" for c in "bfdy"},
73
77
  **{f"ctrl+{c}": f"\x1b[{ord(c)};5u" for c in "abcdefghijklnopqrstuvwxyz"},
74
78
  }