hx-cli 0.2.0__tar.gz → 0.2.1__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 (199) hide show
  1. {hx_cli-0.2.0 → hx_cli-0.2.1}/CHANGELOG.md +20 -1
  2. {hx_cli-0.2.0 → hx_cli-0.2.1}/PKG-INFO +1 -1
  3. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/__init__.py +1 -1
  4. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/term/markdown.py +85 -6
  5. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/term/primitives.py +3 -2
  6. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/paint.py +14 -3
  7. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/roles.py +1 -0
  8. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/runtime.py +7 -2
  9. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/theme_json.py +6 -1
  10. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/themes/ansi.json +1 -0
  11. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/themes/dark.json +3 -1
  12. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/themes/light.json +3 -1
  13. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/views/blocks.py +23 -6
  14. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/term/test_markdown.py +62 -0
  15. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tui/views/test_blocks.py +17 -0
  16. {hx_cli-0.2.0 → hx_cli-0.2.1}/.github/workflows/ci.yml +0 -0
  17. {hx_cli-0.2.0 → hx_cli-0.2.1}/.gitignore +0 -0
  18. {hx_cli-0.2.0 → hx_cli-0.2.1}/README.md +0 -0
  19. {hx_cli-0.2.0 → hx_cli-0.2.1}/install.sh +0 -0
  20. {hx_cli-0.2.0 → hx_cli-0.2.1}/pyproject.toml +0 -0
  21. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/agents/__init__.py +0 -0
  22. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/agents/definitions.py +0 -0
  23. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/agents/subagent.py +0 -0
  24. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/auth/__init__.py +0 -0
  25. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/auth/oauth/__init__.py +0 -0
  26. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/auth/oauth/callback.py +0 -0
  27. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/auth/oauth/codex.py +0 -0
  28. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/auth/oauth/pkce.py +0 -0
  29. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/auth/resolve.py +0 -0
  30. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/auth/store.py +0 -0
  31. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/cli.py +0 -0
  32. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/config.py +0 -0
  33. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/core/__init__.py +0 -0
  34. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/core/checkpoints.py +0 -0
  35. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/core/compaction.py +0 -0
  36. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/core/context.py +0 -0
  37. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/core/events.py +0 -0
  38. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/core/lateinject.py +0 -0
  39. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/core/loop.py +0 -0
  40. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/core/messages.py +0 -0
  41. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/core/session.py +0 -0
  42. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/core/title.py +0 -0
  43. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/core/usage.py +0 -0
  44. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/docs.py +0 -0
  45. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/frontmatter.py +0 -0
  46. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/git.py +0 -0
  47. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/keys.py +0 -0
  48. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/mcp/__init__.py +0 -0
  49. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/mcp/client.py +0 -0
  50. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/mcp/manager.py +0 -0
  51. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/net.py +0 -0
  52. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/paths.py +0 -0
  53. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/permissions/__init__.py +0 -0
  54. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/permissions/engine.py +0 -0
  55. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/permissions/parser.py +0 -0
  56. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/permissions/sandbox.py +0 -0
  57. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/providers/__init__.py +0 -0
  58. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/providers/base.py +0 -0
  59. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/providers/codex.py +0 -0
  60. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/providers/codex_catalogue.py +0 -0
  61. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/providers/fake.py +0 -0
  62. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/providers/models.py +0 -0
  63. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/providers/openrouter.py +0 -0
  64. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/providers/registry.py +0 -0
  65. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/providers/responses_codec.py +0 -0
  66. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/skills/__init__.py +0 -0
  67. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/skills/loader.py +0 -0
  68. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/skills/runtime.py +0 -0
  69. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/term/__init__.py +0 -0
  70. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/term/ansi.py +0 -0
  71. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/term/buffer.py +0 -0
  72. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/term/component.py +0 -0
  73. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/term/demo.py +0 -0
  74. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/term/editor.py +0 -0
  75. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/term/keydecode.py +0 -0
  76. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/term/loop.py +0 -0
  77. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/term/sanitize.py +0 -0
  78. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/term/screen.py +0 -0
  79. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/term/syntax.py +0 -0
  80. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/term/terminal.py +0 -0
  81. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/term/undo.py +0 -0
  82. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/term/width.py +0 -0
  83. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/term/word_nav.py +0 -0
  84. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tools/__init__.py +0 -0
  85. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tools/base.py +0 -0
  86. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tools/bash.py +0 -0
  87. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tools/edit.py +0 -0
  88. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tools/glob.py +0 -0
  89. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tools/grep.py +0 -0
  90. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tools/output.py +0 -0
  91. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tools/read.py +0 -0
  92. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tools/registry.py +0 -0
  93. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tools/task.py +0 -0
  94. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tools/todo.py +0 -0
  95. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tools/websearch.py +0 -0
  96. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tools/write.py +0 -0
  97. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/__init__.py +0 -0
  98. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/app.py +0 -0
  99. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/clipboard.py +0 -0
  100. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/commands.py +0 -0
  101. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/format.py +0 -0
  102. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/fuzzy.py +0 -0
  103. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/glyphs.py +0 -0
  104. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/killring.py +0 -0
  105. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/limits.py +0 -0
  106. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/renderers.py +0 -0
  107. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/theme.py +0 -0
  108. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/views/__init__.py +0 -0
  109. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/views/dialog.py +0 -0
  110. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/views/login.py +0 -0
  111. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/views/permission.py +0 -0
  112. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/views/pickers.py +0 -0
  113. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/views/prompt.py +0 -0
  114. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/views/status.py +0 -0
  115. {hx_cli-0.2.0 → hx_cli-0.2.1}/src/hx/tui/views/transcript.py +0 -0
  116. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/__init__.py +0 -0
  117. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/agents/__init__.py +0 -0
  118. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/agents/test_subagent.py +0 -0
  119. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/auth/__init__.py +0 -0
  120. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/auth/test_codex_oauth.py +0 -0
  121. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/auth/test_pkce.py +0 -0
  122. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/auth/test_resolve.py +0 -0
  123. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/auth/test_store.py +0 -0
  124. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/conftest.py +0 -0
  125. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/core/__init__.py +0 -0
  126. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/core/test_checkpoints.py +0 -0
  127. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/core/test_compaction.py +0 -0
  128. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/core/test_context.py +0 -0
  129. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/core/test_lateinject.py +0 -0
  130. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/core/test_loop.py +0 -0
  131. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/core/test_rewind.py +0 -0
  132. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/core/test_session.py +0 -0
  133. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/core/test_settings_layers.py +0 -0
  134. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/core/test_settings_migration.py +0 -0
  135. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/core/test_steering.py +0 -0
  136. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/core/test_title.py +0 -0
  137. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/core/test_usage.py +0 -0
  138. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/mcp/__init__.py +0 -0
  139. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/mcp/fixtures/echo_server.py +0 -0
  140. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/mcp/test_manager.py +0 -0
  141. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/permissions/__init__.py +0 -0
  142. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/permissions/test_engine.py +0 -0
  143. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/permissions/test_parser.py +0 -0
  144. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/permissions/test_sandbox.py +0 -0
  145. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/providers/__init__.py +0 -0
  146. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/providers/test_catalogue.py +0 -0
  147. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/providers/test_codex.py +0 -0
  148. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/providers/test_codex_catalogue.py +0 -0
  149. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/providers/test_openrouter.py +0 -0
  150. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/providers/test_registry.py +0 -0
  151. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/providers/test_responses_codec.py +0 -0
  152. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/skills/__init__.py +0 -0
  153. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/skills/test_loader.py +0 -0
  154. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/term/__init__.py +0 -0
  155. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/term/conftest.py +0 -0
  156. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/term/test_ansi.py +0 -0
  157. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/term/test_keydecode.py +0 -0
  158. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/term/test_loop.py +0 -0
  159. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/term/test_primitives.py +0 -0
  160. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/term/test_sanitize.py +0 -0
  161. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/term/test_screen.py +0 -0
  162. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/term/test_terminal.py +0 -0
  163. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/term/test_width.py +0 -0
  164. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/test_cli.py +0 -0
  165. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/test_docs.py +0 -0
  166. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/test_git.py +0 -0
  167. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/test_install.py +0 -0
  168. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/test_integration.py +0 -0
  169. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/test_live.py +0 -0
  170. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/test_live_codex.py +0 -0
  171. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/test_live_tavily.py +0 -0
  172. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/test_net.py +0 -0
  173. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/test_paths.py +0 -0
  174. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/test_readme.py +0 -0
  175. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tools/__init__.py +0 -0
  176. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tools/test_background.py +0 -0
  177. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tools/test_bash.py +0 -0
  178. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tools/test_files.py +0 -0
  179. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tools/test_output.py +0 -0
  180. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tools/test_todo.py +0 -0
  181. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tools/test_websearch.py +0 -0
  182. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tui/__init__.py +0 -0
  183. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tui/support.py +0 -0
  184. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tui/test_autocomplete.py +0 -0
  185. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tui/test_clipboard.py +0 -0
  186. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tui/test_effort.py +0 -0
  187. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tui/test_keys.py +0 -0
  188. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tui/test_login.py +0 -0
  189. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tui/test_renderers.py +0 -0
  190. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tui/test_session.py +0 -0
  191. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tui/test_theme_json.py +0 -0
  192. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tui/views/__init__.py +0 -0
  193. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tui/views/test_permission.py +0 -0
  194. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tui/views/test_pickers.py +0 -0
  195. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tui/views/test_prompt.py +0 -0
  196. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tui/views/test_runtime.py +0 -0
  197. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tui/views/test_status.py +0 -0
  198. {hx_cli-0.2.0 → hx_cli-0.2.1}/tests/tui/views/test_transcript.py +0 -0
  199. {hx_cli-0.2.0 → hx_cli-0.2.1}/uv.lock +0 -0
@@ -28,6 +28,24 @@ the project follows [semantic versioning](https://semver.org/).
28
28
 
29
29
  ---
30
30
 
31
+ ## [0.2.1] - 2026-09-14
32
+
33
+ ### Changed
34
+
35
+ - Code blocks in a reply are drawn as a block - the code on a band of its own
36
+ colour, syntax highlighted, with the language named at the top - instead of
37
+ printing the ``` fences back out as text. Themes set the band with the new
38
+ `mdCodeBlockBg` role, and a theme that leaves colour to the terminal, as
39
+ `ansi` does, gets a rule above and below instead.
40
+
41
+ ### Fixed
42
+
43
+ - The model's reasoning is shown again. It was collapsed to a bare `Thinking…`
44
+ with no key wired to open it, so the text never reached the screen. `ctrl+o`
45
+ now folds and unfolds it, the same key that expands a tool call.
46
+
47
+ ---
48
+
31
49
  ## [0.2.0] - 2026-09-14
32
50
 
33
51
  ### Changed
@@ -390,7 +408,8 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
390
408
  - `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
391
409
  self-update, and `install.sh` bootstrapping uv with a pinned Python.
392
410
 
393
- [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.0...HEAD
411
+ [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.1...HEAD
412
+ [0.2.1]: https://github.com/aletisunil/hx/compare/v0.2.0...v0.2.1
394
413
  [0.2.0]: https://github.com/aletisunil/hx/compare/v0.1.9...v0.2.0
395
414
  [0.1.9]: https://github.com/aletisunil/hx/compare/v0.1.8...v0.1.9
396
415
  [0.1.8]: https://github.com/aletisunil/hx/compare/v0.1.7...v0.1.8
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hx-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: HX - a terminal coding agent
5
5
  Project-URL: Homepage, https://github.com/aletisunil/hx
6
6
  Project-URL: Issues, https://github.com/aletisunil/hx/issues
@@ -1,5 +1,5 @@
1
1
  """HX - a terminal coding agent."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.1"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -13,6 +13,11 @@ Two conventions worth naming, both taken from pi:
13
13
  hashes are syntax, and leaving them in makes prose look like a source file.
14
14
  H3 and below keep theirs, because by then the level is worth stating and
15
15
  bold-on-bold no longer distinguishes them.
16
+ * **Code blocks lose their fences.** ``` is syntax too, and printing it back
17
+ out tells the reader only that the renderer gave up. The block is a band of
18
+ its own colour instead, with the language named at the top of it - and a rule
19
+ above and below for a theme that has no colour to tint with, which is what
20
+ the ``ansi`` one deliberately is.
16
21
  * **Tables are the one place box-drawing is allowed.** A table without rules is
17
22
  not a table. Everywhere else, a rule above and below - never a rectangle.
18
23
  """
@@ -27,6 +32,7 @@ LIST_INDENT = 4
27
32
  """Columns per nesting level, wide enough that a marker and its text both fit."""
28
33
 
29
34
  CODE_INDENT = " "
35
+ CODE_RULE = "─"
30
36
  QUOTE_RAIL = "│ "
31
37
  HR_MAX = 80
32
38
  """A rule spanning a very wide terminal reads as a divider in a book, not a
@@ -43,6 +49,17 @@ class Painter:
43
49
  def paint(self, role: str, text: str, **kwargs: bool) -> str:
44
50
  return text
45
51
 
52
+ def fill(self, role: str, text: str) -> str | None:
53
+ """``text`` on a background, or ``None`` if this role has none.
54
+
55
+ A theme is allowed to have no opinion about colour - the ``ansi`` one
56
+ deliberately has none, deferring to the terminal's own palette - and a
57
+ band tinted in a colour that resolves to nothing is not a band. So the
58
+ renderer asks rather than assumes, and rules the block instead when the
59
+ answer is no.
60
+ """
61
+ return None
62
+
46
63
  def code(self, source: str, language: str | None) -> list[str]:
47
64
  return source.split("\n")
48
65
 
@@ -126,15 +143,77 @@ class _Renderer:
126
143
 
127
144
  def _fence(self, node: object, indent: int) -> None:
128
145
  language = (node.info or "").strip().split(" ")[0] or None # type: ignore[attr-defined]
129
- fence = self.painter.paint("md_code_block_border", "```" + (language or ""))
146
+ available = max(1, self.width - indent)
147
+ room = max(1, available - 2 * len(CODE_INDENT))
148
+ body = [
149
+ truncate_to_width(line, room)
150
+ for line in self.painter.code(node.content.rstrip("\n"), language) # type: ignore[attr-defined]
151
+ ]
152
+
130
153
  self.blank()
131
- self.emit(fence, indent)
132
- room = max(1, self.width - indent - len(CODE_INDENT))
133
- for line in self.painter.code(node.content.rstrip("\n"), language): # type: ignore[attr-defined]
134
- self.emit(CODE_INDENT + truncate_to_width(line, room), indent)
135
- self.emit(self.painter.paint("md_code_block_border", "```"), indent)
154
+ if self.painter.fill("md_code_block_bg", "") is None:
155
+ self._ruled_block(body, language, available, indent)
156
+ else:
157
+ self._tinted_block(body, language, available, indent)
136
158
  self.blank()
137
159
 
160
+ def _tinted_block(
161
+ self, body: list[str], language: str | None, available: int, indent: int
162
+ ) -> None:
163
+ """Code on a band of its own colour, the language named at the top.
164
+
165
+ The band spans the column rather than the longest line, for the same
166
+ reason a tinted message does: a block whose right edge follows the code
167
+ is a ragged column, not a block. The language label and a blank row
168
+ below the code are the padding - inside the tint, so the band is a
169
+ rectangle rather than a highlighted run of text.
170
+ """
171
+ span = available
172
+ label = truncate_to_width(language or "", max(0, span - 2 * len(CODE_INDENT)))
173
+ head = self.painter.paint("md_code_block", label) if label else ""
174
+ rows = [head, *body, ""]
175
+ for row in rows:
176
+ padding = " " * max(0, span - len(CODE_INDENT) - cell_width(row))
177
+ filled = self.painter.fill("md_code_block_bg", CODE_INDENT + row + padding)
178
+ self.emit(filled if filled is not None else CODE_INDENT + row, indent)
179
+
180
+ def _ruled_block(
181
+ self, body: list[str], language: str | None, available: int, indent: int
182
+ ) -> None:
183
+ """The same block for a theme with no colour of its own to tint with.
184
+
185
+ Capped like a thematic break, but never shorter than the code it
186
+ encloses: a rule the content overhangs reads as a broken block.
187
+ """
188
+ widest = max((cell_width(line) for line in body), default=0) + len(CODE_INDENT)
189
+ span = min(available, max(HR_MAX, widest))
190
+ self.emit(self._code_rule(span, language), indent)
191
+ for line in body:
192
+ self.emit(CODE_INDENT + line, indent)
193
+ self.emit(self.painter.paint("md_code_block_border", CODE_RULE * span), indent)
194
+
195
+ def _code_rule(self, span: int, language: str | None) -> str:
196
+ """The rule opening a code block, carrying the language if there is one.
197
+
198
+ The fence markers themselves are syntax, the same way a heading's
199
+ hashes are: the reader is looking at source either way, and ``` in the
200
+ middle of rendered prose only tells them the renderer gave up. A rule
201
+ says the same thing in the vocabulary the rest of the UI uses, and the
202
+ language sits in it rather than on a line of its own.
203
+ """
204
+ paint = self.painter.paint
205
+ label = truncate_to_width(language or "", max(0, span - 4))
206
+ if not label:
207
+ return paint("md_code_block_border", CODE_RULE * span)
208
+ trail = CODE_RULE * (span - 4 - cell_width(label))
209
+ return (
210
+ paint("md_code_block_border", CODE_RULE * 2)
211
+ + " "
212
+ + paint("md_code_block", label)
213
+ + " "
214
+ + paint("md_code_block_border", trail)
215
+ )
216
+
138
217
  _code_block = _fence
139
218
 
140
219
  def _hr(self, _node: object, indent: int) -> None:
@@ -139,8 +139,9 @@ class Box(Widget):
139
139
 
140
140
  The tint is the only thing marking the block, and it reaches the full width
141
141
  of the terminal so the block reads as a band rather than a ragged column.
142
- Backgrounds mean one of two things in this UI - whose turn this is, or how
143
- a tool call ended - and are never used for emphasis.
142
+ Backgrounds mean one of three things in this UI - whose turn this is, how a
143
+ tool call ended, or that a run of text is code - and are never used for
144
+ emphasis.
144
145
  """
145
146
 
146
147
  __slots__ = ("_padding_x", "_padding_y", "_tint", "children")
@@ -66,9 +66,9 @@ def bg(role: str, text: str) -> str:
66
66
  def tint(role: str) -> Callable[[str], str]:
67
67
  """A background filler, for :class:`~hx.term.primitives.Box`.
68
68
 
69
- Backgrounds mean exactly two things in this UI - whose turn this is, and
70
- how a tool call ended - so there are few of these and they are never used
71
- for emphasis.
69
+ Backgrounds mean exactly three things in this UI - whose turn this is, how
70
+ a tool call ended, and that a run of text is code - so there are few of
71
+ these and they are never used for emphasis.
72
72
  """
73
73
 
74
74
  def paint(line: str) -> str:
@@ -103,6 +103,17 @@ class ThemePainter(Painter):
103
103
  def paint(self, role: str, text: str, **kwargs: bool) -> str:
104
104
  return fg(role, text, **kwargs)
105
105
 
106
+ def fill(self, role: str, text: str) -> str | None:
107
+ """The role as a background, unless the theme leaves it to the terminal.
108
+
109
+ ``ansi_default`` and an empty value both mean "whatever the terminal is
110
+ already using", which as a background is nothing at all.
111
+ """
112
+ value = color(role)
113
+ if not value or value == "ansi_default":
114
+ return None
115
+ return bg(role, text)
116
+
106
117
  def code(self, source: str, language: str | None) -> list[str]:
107
118
  from hx.term.syntax import highlight
108
119
 
@@ -66,6 +66,7 @@ class Palette:
66
66
  md_link_url: str
67
67
  md_code: str
68
68
  md_code_block: str
69
+ md_code_block_bg: str
69
70
  md_code_block_border: str
70
71
  md_quote: str
71
72
  md_quote_border: str
@@ -951,13 +951,18 @@ class HXSession:
951
951
  self._notice(f"Copied to the clipboard ({used}).", "success")
952
952
 
953
953
  def expand_cursored(self) -> None:
954
- """Expand the cursored block, or every tool block when none is."""
954
+ """Expand the cursored block, or every foldable one when none is.
955
+
956
+ Reasoning folds with the tool calls rather than on a key of its own:
957
+ both are the detail under an answer, and one key for "show me the
958
+ detail" is the whole point of having a key for it.
959
+ """
955
960
  cursor = self.view.transcript.cursor
956
961
  if cursor is not None and hasattr(cursor, "toggle"):
957
962
  cursor.toggle()
958
963
  else:
959
964
  for block in self.view.transcript.blocks:
960
- if isinstance(block, ToolBlock):
965
+ if isinstance(block, ToolBlock | ThinkingMessage):
961
966
  block.toggle()
962
967
  self.runner.request_immediate_render()
963
968
 
@@ -40,7 +40,12 @@ _EXPORT_FALLBACKS = {"background": "pageBg", "surface": "cardBg"}
40
40
 
41
41
  #: Roles that borrow another role when absent. Only for roles pi has no concept
42
42
  #: of; a role pi does define must be present or the file is rejected.
43
- _DERIVED = {"panel": "surface", "diff_hunk": "md_link", "user_text": "text"}
43
+ _DERIVED = {
44
+ "panel": "surface",
45
+ "diff_hunk": "md_link",
46
+ "user_text": "text",
47
+ "md_code_block_bg": "surface",
48
+ }
44
49
 
45
50
  _FIELDS = frozenset(f.name for f in fields(Palette)) - {"name", "dark"}
46
51
 
@@ -42,6 +42,7 @@
42
42
  "mdLink": "ansi_blue",
43
43
  "mdLinkUrl": "faint",
44
44
  "mdCode": "ansi_cyan",
45
+ "mdCodeBlockBg": "default",
45
46
  "mdCodeBlock": "ansi_green",
46
47
  "mdCodeBlockBorder": "faint",
47
48
  "mdQuote": "faint",
@@ -20,7 +20,8 @@
20
20
  "toolPendingBg": "#282832",
21
21
  "toolSuccessBg": "#283228",
22
22
  "toolErrorBg": "#3c2828",
23
- "customMsgBg": "#2d2838"
23
+ "customMsgBg": "#2d2838",
24
+ "codeBg": "#22222a"
24
25
  },
25
26
  "colors": {
26
27
  "background": "pageBg",
@@ -59,6 +60,7 @@
59
60
  "mdLink": "#81a2be",
60
61
  "mdLinkUrl": "dimGray",
61
62
  "mdCode": "accent",
63
+ "mdCodeBlockBg": "codeBg",
62
64
  "mdCodeBlock": "green",
63
65
  "mdCodeBlockBorder": "gray",
64
66
  "mdQuote": "gray",
@@ -19,7 +19,8 @@
19
19
  "toolPendingBg": "#e8e8f0",
20
20
  "toolSuccessBg": "#e8f0e8",
21
21
  "toolErrorBg": "#f0e8e8",
22
- "customMsgBg": "#ede7f6"
22
+ "customMsgBg": "#ede7f6",
23
+ "codeBg": "#eceef0"
23
24
  },
24
25
  "colors": {
25
26
  "background": "pageBg",
@@ -58,6 +59,7 @@
58
59
  "mdLink": "blue",
59
60
  "mdLinkUrl": "dimGray",
60
61
  "mdCode": "teal",
62
+ "mdCodeBlockBg": "codeBg",
61
63
  "mdCodeBlock": "green",
62
64
  "mdCodeBlockBorder": "mediumGray",
63
65
  "mdQuote": "mediumGray",
@@ -17,7 +17,7 @@ from typing import Any
17
17
 
18
18
  from hx.term.component import Widget
19
19
  from hx.term.markdown import render_markdown
20
- from hx.term.primitives import Box, HangingText, Lines, Text
20
+ from hx.term.primitives import Box, HangingText, Lines
21
21
  from hx.term.sanitize import plain_text
22
22
  from hx.tui.glyphs import NOTICE, SPINNER, TOOL_DONE, TOOL_FAILED
23
23
  from hx.tui.limits import PREVIEW_LINES
@@ -89,11 +89,18 @@ class AssistantMessage(Widget):
89
89
 
90
90
 
91
91
  class ThinkingMessage(Widget):
92
- """The model's reasoning, in its own muted colour."""
92
+ """The model's reasoning, in its own muted colour.
93
+
94
+ Shown rather than hidden. Reasoning is the part of a turn that explains the
95
+ part you can see, and a reader who does not want it has a key for that -
96
+ ``ctrl+o``, the same one that expands a tool call. A one-line ``Thinking…``
97
+ with no way to open it, which is what this was, is the reasoning simply
98
+ not arriving.
99
+ """
93
100
 
94
101
  __slots__ = ("_collapsed", "_text")
95
102
 
96
- def __init__(self, text: str = "", collapsed: bool = True) -> None:
103
+ def __init__(self, text: str = "", collapsed: bool = False) -> None:
97
104
  super().__init__()
98
105
  self._text = plain_text(text)
99
106
  self._collapsed = collapsed
@@ -107,13 +114,23 @@ class ThinkingMessage(Widget):
107
114
  self._collapsed = collapsed
108
115
  self.invalidate()
109
116
 
117
+ def toggle(self) -> None:
118
+ self.set_collapsed(not self._collapsed)
119
+
120
+ @property
121
+ def text(self) -> str:
122
+ return self._text
123
+
110
124
  def draw(self, width: int) -> list[str]:
111
125
  if not self._text.strip():
112
126
  return []
113
- if self._collapsed:
114
- return Text(fg("thinking", "Thinking…", italic=True)).render(width)
115
127
  lines = [fg("thinking", line, italic=True) for line in self._text.strip().split("\n")]
116
- return Lines(lines).render(width)
128
+ if not self._collapsed:
129
+ return Lines(lines).render(width)
130
+ # Folded by the reader, with the key they just pressed - so the count
131
+ # is what is worth saying here, not the key again.
132
+ note = fg("dim", f" ({len(lines)} lines)") if len(lines) > 1 else ""
133
+ return Lines([fg("thinking", "Thinking…", italic=True) + note]).render(width)
117
134
 
118
135
 
119
136
  class ToolBlock(Widget):
@@ -31,6 +31,14 @@ class Recording(Painter):
31
31
  return source.split("\n")
32
32
 
33
33
 
34
+ class Tinting(Recording):
35
+ """A painter that has a background to offer, as a real theme does."""
36
+
37
+ def fill(self, role: str, text: str) -> str | None:
38
+ self.roles.append(f"fill:{role}")
39
+ return f"<{role}>{text}</{role}>"
40
+
41
+
34
42
  def plain(text: str, width: int = 60) -> list[str]:
35
43
  return [strip_ansi(line) for line in render_markdown(text, width)]
36
44
 
@@ -146,6 +154,60 @@ def test_a_fenced_block_is_highlighted_and_indented() -> None:
146
154
  assert body and strip_ansi(body[0]).startswith(" ")
147
155
 
148
156
 
157
+ def test_a_fenced_block_is_ruled_rather_than_fenced() -> None:
158
+ """``` is syntax, like a heading's hashes: printing it back out tells the
159
+ reader only that the renderer gave up."""
160
+ lines = plain("```json\n{}\n```")
161
+ assert not any("```" in line for line in lines)
162
+ assert lines[0].startswith("──") and lines[-1] == "─" * cell_width(lines[0])
163
+
164
+
165
+ def test_the_language_sits_in_the_opening_rule() -> None:
166
+ painter = Recording()
167
+ out = render_markdown("```json\n{}\n```", 40, painter)
168
+ assert "md_code_block" in painter.roles, "the role finally does something"
169
+ assert "json" in strip_ansi(out[0])
170
+ assert "json" not in strip_ansi(out[-1]), "only the opening rule carries it"
171
+
172
+
173
+ def test_a_block_without_a_language_gets_a_plain_rule() -> None:
174
+ lines = plain("```\nx\n```")
175
+ assert lines[0] == lines[-1] == "─" * cell_width(lines[0])
176
+
177
+
178
+ def test_a_theme_with_a_colour_tints_the_block_instead_of_ruling_it() -> None:
179
+ painter = Tinting()
180
+ out = render_markdown("```json\n{}\n```", 40, painter)
181
+ assert "fill:md_code_block_bg" in painter.roles
182
+ assert not any("─" in line for line in out), "tinted, so no rules"
183
+ assert "json" in out[0], "the language is named at the top of the band"
184
+
185
+
186
+ def test_the_band_spans_the_column_rather_than_the_longest_line() -> None:
187
+ """A block whose right edge follows the code is a ragged column."""
188
+ painter = Tinting()
189
+ out = render_markdown("```\nx\n```", 40, painter)
190
+ inner = [line.split(">", 1)[1].rsplit("</", 1)[0] for line in out]
191
+ assert {cell_width(line) for line in inner} == {40}
192
+
193
+
194
+ def test_a_theme_with_no_colour_of_its_own_falls_back_to_rules() -> None:
195
+ """The ansi theme defers to the terminal's palette, so it has no surface
196
+ colour to tint with - and a band tinted in nothing is not a band."""
197
+ painter = Recording()
198
+ out = render_markdown("```json\n{}\n```", 40, painter)
199
+ assert any("─" in line for line in out)
200
+
201
+
202
+ def test_the_rule_is_never_shorter_than_the_code_it_encloses() -> None:
203
+ """A rule the content overhangs reads as a broken block."""
204
+ code = "x = " + "y" * 90
205
+ lines = plain(f"```python\n{code}\n```", width=120)
206
+ span = cell_width(lines[0])
207
+ assert all(cell_width(line) <= span for line in lines)
208
+ assert span >= cell_width(lines[1])
209
+
210
+
149
211
  def test_a_table_is_the_one_place_box_drawing_is_allowed() -> None:
150
212
  lines = plain("| a | b |\n| - | - |\n| 1 | 2 |")
151
213
  assert any("┼" in line for line in lines)
@@ -80,6 +80,23 @@ def test_thinking_collapses_to_one_line() -> None:
80
80
  assert "reasoning" in " ".join(plain(block.render(40)))
81
81
 
82
82
 
83
+ def test_thinking_is_shown_rather_than_hidden() -> None:
84
+ """The port collapsed it by default and wired no way to open it, so the
85
+ reasoning was simply not arriving."""
86
+ block = ThinkingMessage("first thought\nsecond thought")
87
+ shown = " ".join(plain(block.render(40)))
88
+ assert "first thought" in shown and "second thought" in shown
89
+
90
+
91
+ def test_thinking_folds_with_the_same_key_as_a_tool_call() -> None:
92
+ block = ThinkingMessage("first thought\nsecond thought")
93
+ block.toggle()
94
+ folded = plain(block.render(40))
95
+ assert folded == [" Thinking… (2 lines)"], "folded, and says how much is folded"
96
+ block.toggle()
97
+ assert "second thought" in " ".join(plain(block.render(40)))
98
+
99
+
83
100
  # -- tool calls -------------------------------------------------------------
84
101
 
85
102
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes