hx-cli 0.2.4__tar.gz → 0.2.5__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 (229) hide show
  1. {hx_cli-0.2.4 → hx_cli-0.2.5}/CHANGELOG.md +29 -1
  2. {hx_cli-0.2.4 → hx_cli-0.2.5}/PKG-INFO +46 -1
  3. {hx_cli-0.2.4 → hx_cli-0.2.5}/README.md +44 -0
  4. {hx_cli-0.2.4 → hx_cli-0.2.5}/pyproject.toml +1 -0
  5. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/__init__.py +1 -1
  6. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/cli.py +39 -2
  7. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/compaction.py +17 -2
  8. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/context.py +25 -4
  9. hx_cli-0.2.5/src/hx/core/images.py +394 -0
  10. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/loop.py +25 -11
  11. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/messages.py +80 -4
  12. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/keys.py +3 -0
  13. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/mcp/client.py +28 -8
  14. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/mcp/manager.py +31 -1
  15. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/codex_catalogue.py +12 -0
  16. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/devin.py +14 -1
  17. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/devin_catalogue.py +4 -0
  18. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/devin_wire.py +21 -0
  19. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/models.py +36 -0
  20. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/openrouter.py +35 -1
  21. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/responses_codec.py +29 -6
  22. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/base.py +6 -1
  23. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/bash.py +14 -2
  24. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/read.py +36 -1
  25. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/trace.py +32 -0
  26. hx_cli-0.2.5/src/hx/tui/clipboard.py +308 -0
  27. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/commands.py +2 -0
  28. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/glyphs.py +3 -0
  29. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/runtime.py +97 -19
  30. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/views/blocks.py +27 -9
  31. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/views/pickers.py +5 -2
  32. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/views/prompt.py +108 -8
  33. hx_cli-0.2.5/tests/core/test_images.py +281 -0
  34. hx_cli-0.2.5/tests/core/test_loop_images.py +181 -0
  35. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_steering.py +3 -3
  36. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/mcp/fixtures/echo_server.py +26 -1
  37. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/mcp/test_manager.py +20 -0
  38. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/test_catalogue.py +55 -0
  39. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/test_codex_catalogue.py +7 -0
  40. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/test_devin.py +36 -0
  41. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/test_devin_catalogue.py +23 -0
  42. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/test_openrouter.py +70 -0
  43. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/test_responses_codec.py +34 -0
  44. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_cli.py +49 -0
  45. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_readme.py +1 -0
  46. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_trace.py +23 -0
  47. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tools/test_background.py +14 -0
  48. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tools/test_files.py +33 -0
  49. hx_cli-0.2.5/tests/tui/test_images.py +319 -0
  50. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/test_session.py +22 -11
  51. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/views/test_prompt.py +9 -9
  52. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/views/test_runtime.py +1 -1
  53. {hx_cli-0.2.4 → hx_cli-0.2.5}/uv.lock +87 -0
  54. hx_cli-0.2.4/src/hx/tui/clipboard.py +0 -125
  55. {hx_cli-0.2.4 → hx_cli-0.2.5}/.github/workflows/ci.yml +0 -0
  56. {hx_cli-0.2.4 → hx_cli-0.2.5}/.gitignore +0 -0
  57. {hx_cli-0.2.4 → hx_cli-0.2.5}/examples/hooks/check.sh +0 -0
  58. {hx_cli-0.2.4 → hx_cli-0.2.5}/install.sh +0 -0
  59. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/agents/__init__.py +0 -0
  60. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/agents/definitions.py +0 -0
  61. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/agents/subagent.py +0 -0
  62. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/auth/__init__.py +0 -0
  63. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/auth/oauth/__init__.py +0 -0
  64. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/auth/oauth/browser.py +0 -0
  65. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/auth/oauth/callback.py +0 -0
  66. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/auth/oauth/codex.py +0 -0
  67. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/auth/oauth/devin.py +0 -0
  68. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/auth/oauth/pkce.py +0 -0
  69. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/auth/resolve.py +0 -0
  70. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/auth/store.py +0 -0
  71. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/config.py +0 -0
  72. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/__init__.py +0 -0
  73. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/checkpoints.py +0 -0
  74. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/events.py +0 -0
  75. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/lateinject.py +0 -0
  76. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/session.py +0 -0
  77. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/title.py +0 -0
  78. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/usage.py +0 -0
  79. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/docs.py +0 -0
  80. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/frontmatter.py +0 -0
  81. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/git.py +0 -0
  82. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/hooks/__init__.py +0 -0
  83. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/hooks/engine.py +0 -0
  84. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/hooks/spec.py +0 -0
  85. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/mcp/__init__.py +0 -0
  86. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/net.py +0 -0
  87. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/paths.py +0 -0
  88. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/permissions/__init__.py +0 -0
  89. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/permissions/engine.py +0 -0
  90. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/permissions/parser.py +0 -0
  91. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/permissions/sandbox.py +0 -0
  92. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/__init__.py +0 -0
  93. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/base.py +0 -0
  94. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/codex.py +0 -0
  95. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/fake.py +0 -0
  96. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/protowire.py +0 -0
  97. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/registry.py +0 -0
  98. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/skills/__init__.py +0 -0
  99. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/skills/loader.py +0 -0
  100. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/skills/runtime.py +0 -0
  101. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/__init__.py +0 -0
  102. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/ansi.py +0 -0
  103. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/buffer.py +0 -0
  104. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/component.py +0 -0
  105. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/demo.py +0 -0
  106. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/editor.py +0 -0
  107. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/keydecode.py +0 -0
  108. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/loop.py +0 -0
  109. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/markdown.py +0 -0
  110. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/primitives.py +0 -0
  111. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/sanitize.py +0 -0
  112. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/screen.py +0 -0
  113. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/syntax.py +0 -0
  114. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/terminal.py +0 -0
  115. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/undo.py +0 -0
  116. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/width.py +0 -0
  117. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/word_nav.py +0 -0
  118. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/__init__.py +0 -0
  119. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/anchors.py +0 -0
  120. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/edit.py +0 -0
  121. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/glob.py +0 -0
  122. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/grep.py +0 -0
  123. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/output.py +0 -0
  124. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/registry.py +0 -0
  125. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/symbols.py +0 -0
  126. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/task.py +0 -0
  127. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/todo.py +0 -0
  128. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/websearch.py +0 -0
  129. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/write.py +0 -0
  130. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/__init__.py +0 -0
  131. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/app.py +0 -0
  132. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/format.py +0 -0
  133. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/fuzzy.py +0 -0
  134. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/killring.py +0 -0
  135. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/limits.py +0 -0
  136. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/paint.py +0 -0
  137. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/renderers.py +0 -0
  138. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/roles.py +0 -0
  139. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/theme.py +0 -0
  140. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/theme_json.py +0 -0
  141. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/themes/ansi.json +0 -0
  142. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/themes/dark.json +0 -0
  143. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/themes/light.json +0 -0
  144. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/views/__init__.py +0 -0
  145. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/views/dialog.py +0 -0
  146. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/views/login.py +0 -0
  147. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/views/permission.py +0 -0
  148. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/views/status.py +0 -0
  149. {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/views/transcript.py +0 -0
  150. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/__init__.py +0 -0
  151. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/agents/__init__.py +0 -0
  152. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/agents/test_subagent.py +0 -0
  153. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/auth/__init__.py +0 -0
  154. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/auth/test_codex_oauth.py +0 -0
  155. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/auth/test_devin_oauth.py +0 -0
  156. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/auth/test_pkce.py +0 -0
  157. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/auth/test_resolve.py +0 -0
  158. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/auth/test_store.py +0 -0
  159. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/conftest.py +0 -0
  160. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/__init__.py +0 -0
  161. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_checkpoints.py +0 -0
  162. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_compaction.py +0 -0
  163. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_context.py +0 -0
  164. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_lateinject.py +0 -0
  165. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_loop.py +0 -0
  166. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_rewind.py +0 -0
  167. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_session.py +0 -0
  168. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_settings_layers.py +0 -0
  169. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_settings_migration.py +0 -0
  170. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_title.py +0 -0
  171. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_usage.py +0 -0
  172. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/hooks/__init__.py +0 -0
  173. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/hooks/test_engine.py +0 -0
  174. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/hooks/test_example_check.py +0 -0
  175. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/mcp/__init__.py +0 -0
  176. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/permissions/__init__.py +0 -0
  177. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/permissions/test_engine.py +0 -0
  178. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/permissions/test_parser.py +0 -0
  179. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/permissions/test_sandbox.py +0 -0
  180. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/__init__.py +0 -0
  181. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/test_codex.py +0 -0
  182. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/test_protowire.py +0 -0
  183. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/test_registry.py +0 -0
  184. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/skills/__init__.py +0 -0
  185. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/skills/test_loader.py +0 -0
  186. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/__init__.py +0 -0
  187. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/conftest.py +0 -0
  188. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/test_ansi.py +0 -0
  189. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/test_keydecode.py +0 -0
  190. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/test_loop.py +0 -0
  191. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/test_markdown.py +0 -0
  192. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/test_primitives.py +0 -0
  193. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/test_sanitize.py +0 -0
  194. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/test_screen.py +0 -0
  195. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/test_terminal.py +0 -0
  196. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/test_width.py +0 -0
  197. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_docs.py +0 -0
  198. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_git.py +0 -0
  199. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_install.py +0 -0
  200. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_integration.py +0 -0
  201. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_live.py +0 -0
  202. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_live_codex.py +0 -0
  203. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_live_devin.py +0 -0
  204. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_live_tavily.py +0 -0
  205. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_net.py +0 -0
  206. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_paths.py +0 -0
  207. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tools/__init__.py +0 -0
  208. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tools/test_bash.py +0 -0
  209. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tools/test_hashline.py +0 -0
  210. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tools/test_output.py +0 -0
  211. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tools/test_symbols.py +0 -0
  212. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tools/test_todo.py +0 -0
  213. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tools/test_websearch.py +0 -0
  214. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/__init__.py +0 -0
  215. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/support.py +0 -0
  216. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/test_autocomplete.py +0 -0
  217. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/test_clipboard.py +0 -0
  218. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/test_effort.py +0 -0
  219. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/test_keys.py +0 -0
  220. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/test_login.py +0 -0
  221. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/test_renderers.py +0 -0
  222. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/test_theme_json.py +0 -0
  223. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/test_trace_command.py +0 -0
  224. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/views/__init__.py +0 -0
  225. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/views/test_blocks.py +0 -0
  226. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/views/test_permission.py +0 -0
  227. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/views/test_pickers.py +0 -0
  228. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/views/test_status.py +0 -0
  229. {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/views/test_transcript.py +0 -0
@@ -28,6 +28,33 @@ the project follows [semantic versioning](https://semver.org/).
28
28
 
29
29
  ---
30
30
 
31
+ ## [0.2.5] - 2026-09-23
32
+
33
+ ### Added
34
+
35
+ - Images as input. `ctrl+v` attaches the image on the clipboard, dragging an
36
+ image file into the terminal attaches it, and `hx -p "..." --image PATH`
37
+ attaches one to a headless prompt. Each shows in the prompt as
38
+ `[Image #N]`.
39
+ - `Read` returns image files as images, so the model can look at a screenshot
40
+ or mockup it is pointed at with `@path`.
41
+ - Images returned by MCP tools reach the model instead of being dropped.
42
+ - Every route carries images: OpenRouter, the Codex subscription and the Devin
43
+ subscription. Large images are scaled and re-encoded to fit each route's
44
+ limits.
45
+ - A model that does not accept images is warned about when one is attached,
46
+ and is told the image was left out rather than sent a request that fails.
47
+ The `/model` picker marks the models that can see images.
48
+
49
+ ### Fixed
50
+
51
+ - Background jobs are waited for, not only signalled, when a session closes.
52
+ An unwaited subprocess transport was collected after the event loop had
53
+ gone, which raised `Event loop is closed` as an unraisable warning on CI
54
+ runs that otherwise passed.
55
+
56
+ ---
57
+
31
58
  ## [0.2.4] - 2026-09-19
32
59
 
33
60
  ### Added
@@ -565,7 +592,8 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
565
592
  - `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
566
593
  self-update, and `install.sh` bootstrapping uv with a pinned Python.
567
594
 
568
- [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.4...HEAD
595
+ [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.5...HEAD
596
+ [0.2.5]: https://github.com/aletisunil/hx/compare/v0.2.4...v0.2.5
569
597
  [0.2.4]: https://github.com/aletisunil/hx/compare/v0.2.3...v0.2.4
570
598
  [0.2.3]: https://github.com/aletisunil/hx/compare/v0.2.2...v0.2.3
571
599
  [0.2.2]: https://github.com/aletisunil/hx/compare/v0.2.1...v0.2.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hx-cli
3
- Version: 0.2.4
3
+ Version: 0.2.5
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
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.14
18
18
  Requires-Python: >=3.11
19
19
  Requires-Dist: httpx>=0.27
20
20
  Requires-Dist: markdown-it-py>=3.0
21
+ Requires-Dist: pillow>=10.4
21
22
  Requires-Dist: pydantic>=2.7
22
23
  Requires-Dist: pygments>=2.17
23
24
  Requires-Dist: pyyaml>=6.0
@@ -296,6 +297,7 @@ available through OpenRouter.
296
297
  ```sh
297
298
  hx # interactive TUI in the current directory
298
299
  hx -p "explain this repo" # headless: streams to stdout, tool activity to stderr
300
+ hx -p "why?" --image s.png # attach an image to a headless prompt; repeatable
299
301
  hx resume # resume the last session here
300
302
  hx resume <session-id> # resume a specific one
301
303
  hx prompt # print the system prompt this directory would use
@@ -375,6 +377,7 @@ be rebound (see [Keybindings](#keybindings)).
375
377
  | `pgdn` | scroll the transcript down (fullscreen; otherwise your terminal scrolls) |
376
378
  | `ctrl+home` | jump to the start: the viewport in fullscreen, the first message otherwise |
377
379
  | `ctrl+end` | jump back to the newest output |
380
+ | `ctrl+v` | attach the image on the clipboard (see [Images](#images)) |
378
381
  | `@path` | complete a file path |
379
382
  | `!command` | run a shell command directly, no model turn |
380
383
 
@@ -406,6 +409,44 @@ text](#selecting-and-copying-text) for the terminal's own selection.
406
409
  HX does not emit OSC 133 prompt markers yet, so shell-integration features that
407
410
  jump between prompts will not see HX's messages.
408
411
 
412
+ #### Images
413
+
414
+ Give the model a picture three ways, and each lands in the prompt as a token
415
+ such as `[Image #1]`:
416
+
417
+ - **`ctrl+v`** attaches whatever image is on the clipboard: a screenshot copied
418
+ with `cmd+ctrl+shift+4`, an image copied in a browser, or an image file
419
+ copied in Finder or a file manager. Terminals whose own paste sends nothing
420
+ when the clipboard holds a picture are caught too - an empty paste is read as
421
+ a request for the image.
422
+ - **Drag an image file** into the terminal. The terminal pastes its path, and a
423
+ paste that is nothing but paths to image files attaches them instead of
424
+ inserting the text. Quoted, backslash-escaped and `file://` paths all work.
425
+ - **Mention it.** `look at @docs/mockup.png` sends the path as text, and the
426
+ model opens it with `Read`, which returns images as images.
427
+
428
+ The token is the attachment: move it and the text refers to the picture in a
429
+ different place, delete it (one `backspace` takes the whole token) and the
430
+ image is not sent. Sent messages list their images underneath, with size and
431
+ weight. Numbers run for the whole session, so a message recalled from history
432
+ or restored by a rewind still carries its pictures.
433
+
434
+ PNG, JPEG, GIF, WebP, BMP and TIFF are accepted. An image within every route's
435
+ limits goes byte for byte; a larger one is scaled to 2000 px on its long side
436
+ and re-encoded to stay under 5 MB, which is what a retina screenshot usually
437
+ needs. Images an MCP tool returns - a browser screenshot, say - are handled the
438
+ same way rather than dropped.
439
+
440
+ Whether a model can see images comes from the catalogue: OpenRouter's input
441
+ modalities, the Codex catalogue, and Devin's model config. The `/model` picker
442
+ marks the ones that can with `images`. A model that cannot
443
+ is warned about when you attach, and is sent a line saying an image was left
444
+ out, rather than a request that fails. The transcript keeps the image either
445
+ way, so `/model` to one that can see it and ask again.
446
+
447
+ Over SSH the clipboard you copied into is on your own machine, so `ctrl+v`
448
+ says so; copy the file over and drag or paste its path instead.
449
+
409
450
  #### Keybindings
410
451
 
411
452
  Any key can be rebound in `~/.hx/keybindings.json`, keyed by the action ids in
@@ -877,6 +918,10 @@ and is unchanged; anchors are a second shape, not a replacement. Turn the whole
877
918
  thing off with `"tools": {"hashline": false}` and reads go back to plain
878
919
  `cat -n`.
879
920
 
921
+ **Images.** `Read` on a PNG, JPEG, GIF, WebP, BMP or TIFF - or on a file with
922
+ no extension whose bytes are one - returns the image itself for the model to
923
+ look at, normalised the way a pasted one is (see [Images](#images)).
924
+
880
925
  **Symbols** answers structural questions with a parse tree rather than a
881
926
  regex — three modes, `outline` for one file's shape, `definition` for where a
882
927
  name is defined, `references` for where it is used:
@@ -257,6 +257,7 @@ available through OpenRouter.
257
257
  ```sh
258
258
  hx # interactive TUI in the current directory
259
259
  hx -p "explain this repo" # headless: streams to stdout, tool activity to stderr
260
+ hx -p "why?" --image s.png # attach an image to a headless prompt; repeatable
260
261
  hx resume # resume the last session here
261
262
  hx resume <session-id> # resume a specific one
262
263
  hx prompt # print the system prompt this directory would use
@@ -336,6 +337,7 @@ be rebound (see [Keybindings](#keybindings)).
336
337
  | `pgdn` | scroll the transcript down (fullscreen; otherwise your terminal scrolls) |
337
338
  | `ctrl+home` | jump to the start: the viewport in fullscreen, the first message otherwise |
338
339
  | `ctrl+end` | jump back to the newest output |
340
+ | `ctrl+v` | attach the image on the clipboard (see [Images](#images)) |
339
341
  | `@path` | complete a file path |
340
342
  | `!command` | run a shell command directly, no model turn |
341
343
 
@@ -367,6 +369,44 @@ text](#selecting-and-copying-text) for the terminal's own selection.
367
369
  HX does not emit OSC 133 prompt markers yet, so shell-integration features that
368
370
  jump between prompts will not see HX's messages.
369
371
 
372
+ #### Images
373
+
374
+ Give the model a picture three ways, and each lands in the prompt as a token
375
+ such as `[Image #1]`:
376
+
377
+ - **`ctrl+v`** attaches whatever image is on the clipboard: a screenshot copied
378
+ with `cmd+ctrl+shift+4`, an image copied in a browser, or an image file
379
+ copied in Finder or a file manager. Terminals whose own paste sends nothing
380
+ when the clipboard holds a picture are caught too - an empty paste is read as
381
+ a request for the image.
382
+ - **Drag an image file** into the terminal. The terminal pastes its path, and a
383
+ paste that is nothing but paths to image files attaches them instead of
384
+ inserting the text. Quoted, backslash-escaped and `file://` paths all work.
385
+ - **Mention it.** `look at @docs/mockup.png` sends the path as text, and the
386
+ model opens it with `Read`, which returns images as images.
387
+
388
+ The token is the attachment: move it and the text refers to the picture in a
389
+ different place, delete it (one `backspace` takes the whole token) and the
390
+ image is not sent. Sent messages list their images underneath, with size and
391
+ weight. Numbers run for the whole session, so a message recalled from history
392
+ or restored by a rewind still carries its pictures.
393
+
394
+ PNG, JPEG, GIF, WebP, BMP and TIFF are accepted. An image within every route's
395
+ limits goes byte for byte; a larger one is scaled to 2000 px on its long side
396
+ and re-encoded to stay under 5 MB, which is what a retina screenshot usually
397
+ needs. Images an MCP tool returns - a browser screenshot, say - are handled the
398
+ same way rather than dropped.
399
+
400
+ Whether a model can see images comes from the catalogue: OpenRouter's input
401
+ modalities, the Codex catalogue, and Devin's model config. The `/model` picker
402
+ marks the ones that can with `images`. A model that cannot
403
+ is warned about when you attach, and is sent a line saying an image was left
404
+ out, rather than a request that fails. The transcript keeps the image either
405
+ way, so `/model` to one that can see it and ask again.
406
+
407
+ Over SSH the clipboard you copied into is on your own machine, so `ctrl+v`
408
+ says so; copy the file over and drag or paste its path instead.
409
+
370
410
  #### Keybindings
371
411
 
372
412
  Any key can be rebound in `~/.hx/keybindings.json`, keyed by the action ids in
@@ -838,6 +878,10 @@ and is unchanged; anchors are a second shape, not a replacement. Turn the whole
838
878
  thing off with `"tools": {"hashline": false}` and reads go back to plain
839
879
  `cat -n`.
840
880
 
881
+ **Images.** `Read` on a PNG, JPEG, GIF, WebP, BMP or TIFF - or on a file with
882
+ no extension whose bytes are one - returns the image itself for the model to
883
+ look at, normalised the way a pasted one is (see [Images](#images)).
884
+
841
885
  **Symbols** answers structural questions with a parse tree rather than a
842
886
  regex — three modes, `outline` for one file's shape, `definition` for where a
843
887
  name is defined, `references` for where it is used:
@@ -33,6 +33,7 @@ dependencies = [
33
33
  "pygments>=2.17",
34
34
  # CommonMark parsing. The renderer is ours; the parser is not worth writing.
35
35
  "markdown-it-py>=3.0",
36
+ "pillow>=10.4",
36
37
  ]
37
38
 
38
39
  [project.optional-dependencies]
@@ -1,5 +1,5 @@
1
1
  """HX - an agent harness for the terminal."""
2
2
 
3
- __version__ = "0.2.4"
3
+ __version__ = "0.2.5"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -54,6 +54,7 @@ Options:
54
54
  --system-prompt TEXT Replace the system prompt (@path reads a file)
55
55
  --append-system-prompt TEXT
56
56
  Append to the system prompt; repeatable (@path reads a file)
57
+ --image PATH Attach an image to the -p prompt; repeatable
57
58
  """
58
59
 
59
60
 
@@ -69,6 +70,7 @@ class ParsedArgs:
69
70
  rest: tuple[str, ...] = ()
70
71
  overrides: dict[str, Any] | None = None
71
72
  cwd: Path | None = None
73
+ images: tuple[Path, ...] = ()
72
74
 
73
75
 
74
76
  def main(argv: list[str] | None = None) -> int:
@@ -159,6 +161,13 @@ def parse_args(args: list[str]) -> ParsedArgs:
159
161
  raise UsageError("--append-system-prompt requires a value")
160
162
  appends = overrides.setdefault("prompt", {}).setdefault("append", [])
161
163
  appends.append(_prompt_value(args[index]))
164
+ elif arg == "--image":
165
+ index += 1
166
+ if index >= len(args):
167
+ raise UsageError("--image requires a path")
168
+ # Against where the command was typed, not --cwd: that is where
169
+ # the shell completed the path.
170
+ parsed.images = (*parsed.images, Path(args[index]).expanduser().absolute())
162
171
  elif arg.startswith("-"):
163
172
  raise UsageError(f"unknown option {arg}")
164
173
  else:
@@ -175,6 +184,8 @@ def parse_args(args: list[str]) -> ParsedArgs:
175
184
  elif parsed.command != "print":
176
185
  raise UsageError(f"unknown command {head!r}")
177
186
 
187
+ if parsed.images and parsed.command != "print":
188
+ raise UsageError("--image goes with -p; in the TUI, paste or drag the image in")
178
189
  parsed.overrides = overrides or None
179
190
  return parsed
180
191
 
@@ -260,6 +271,12 @@ class Runtime:
260
271
 
261
272
  self.notices.append(f"Model catalogue refresh failed: {describe(exc)}")
262
273
  return
274
+ finally:
275
+ # The loop resolved its model before the refresh, from the cache
276
+ # being replaced. Left alone it would gauge against yesterday's
277
+ # context window and decide image support from a cache that did
278
+ # not record it - until the user happened to switch models.
279
+ self.loop.model_info = self.models.get_or_default(self.loop.model)
263
280
  for label, error in self.models.subscription_errors():
264
281
  self.notices.append(f"Could not list this account's {label} models: {error}")
265
282
 
@@ -631,6 +648,13 @@ def run_print_command(parsed: ParsedArgs) -> int:
631
648
  """Headless single-prompt run. Streams assistant text to stdout and tool
632
649
  activity to stderr, so stdout stays pipeable."""
633
650
  from hx.core import events as ev
651
+ from hx.core.images import ImageError, load_image_file
652
+
653
+ try:
654
+ images = [load_image_file(path) for path in parsed.images]
655
+ except ImageError as exc:
656
+ print(f"error: {exc}", file=sys.stderr)
657
+ return 2
634
658
 
635
659
  try:
636
660
  runtime = build_runtime(parsed, resume=_resume_target(parsed))
@@ -660,13 +684,26 @@ def run_print_command(parsed: ParsedArgs) -> int:
660
684
 
661
685
  renderer = asyncio.create_task(render())
662
686
  await asyncio.sleep(0)
687
+ info = runtime.loop.model_info
688
+ if images and (info is None or not info.supports_images):
689
+ # Print mode does not refresh the catalogue - a scripted run should
690
+ # not wait on it - but withholding an image on the word of a stale
691
+ # cache is worse than the wait.
692
+ await runtime.refresh_models_if_stale()
693
+ info = runtime.loop.model_info
663
694
  for notice in runtime.notices:
664
695
  print(f"[hx] {notice}", file=sys.stderr)
696
+ if images and (info is None or not info.supports_images):
697
+ name = info.name if info is not None else runtime.loop.model
698
+ print(
699
+ f"[hx] {name} does not accept images; it is told they were attached, not shown them",
700
+ file=sys.stderr,
701
+ )
665
702
  for status in await runtime.connect_mcp():
666
703
  if not status.connected:
667
704
  print(f"[mcp] {status.name} unavailable: {status.error}", file=sys.stderr)
668
705
  try:
669
- result = await runtime.loop.run(parsed.prompt)
706
+ result = await runtime.loop.run(parsed.prompt, images)
670
707
  await _rename_closed_session(runtime.loop)
671
708
  finally:
672
709
  await asyncio.sleep(0.05)
@@ -674,7 +711,7 @@ def run_print_command(parsed: ParsedArgs) -> int:
674
711
  await renderer
675
712
  await runtime.aclose()
676
713
 
677
- print()
714
+ print(flush=True)
678
715
  usage = runtime.session.usage
679
716
  print(
680
717
  f"[usage] in={usage.total_input} out={usage.total_output} "
@@ -16,6 +16,7 @@ from dataclasses import dataclass
16
16
  from typing import TYPE_CHECKING, ClassVar
17
17
 
18
18
  from hx.core.messages import (
19
+ ImageBlock,
19
20
  Message,
20
21
  TextBlock,
21
22
  ThinkingBlock,
@@ -173,8 +174,10 @@ class Compactor:
173
174
 
174
175
  def _estimate(self, messages: list[Message]) -> int:
175
176
  if self.context is not None:
176
- return sum(self.context.estimate_tokens(_message_text(m)) for m in messages)
177
- return sum(len(_message_text(m)) // 4 for m in messages)
177
+ return sum(self.context.estimate_message_tokens(m) for m in messages)
178
+ from hx.core.context import message_image_tokens
179
+
180
+ return sum(len(_message_text(m)) // 4 + message_image_tokens(m) for m in messages)
178
181
 
179
182
 
180
183
  def _is_turn_edge(messages: list[Message], index: int) -> bool:
@@ -205,11 +208,23 @@ def render_transcript(messages: list[Message]) -> str:
205
208
  elif isinstance(block, ToolResultBlock):
206
209
  marker = "error" if block.is_error else "result"
207
210
  lines.append(f"[tool {marker}] {_clip(block.content)}")
211
+ lines.extend(f"[tool {marker}] {_image_line(image)}" for image in block.images)
212
+ elif isinstance(block, ImageBlock):
213
+ lines.append(f"[{message.role}] {_image_line(block)}")
208
214
  elif isinstance(block, ThinkingBlock):
209
215
  continue
210
216
  return "\n".join(lines)
211
217
 
212
218
 
219
+ def _image_line(image: ImageBlock) -> str:
220
+ """An image in the summary prompt. The summariser cannot see it, but it can
221
+ record that there was one - "the layout in Image #2" means nothing after
222
+ compaction unless the summary says what Image #2 was of."""
223
+ from hx.core.images import describe
224
+
225
+ return f"(attached an image: {describe(image)})"
226
+
227
+
213
228
  def _mechanical_summary(messages: list[Message]) -> str:
214
229
  """Last-resort digest when no model is available to write one."""
215
230
  users = [m.text() for m in messages if m.role == "user" and m.text()]
@@ -28,7 +28,7 @@ from dataclasses import dataclass, field
28
28
  from pathlib import Path
29
29
  from typing import TYPE_CHECKING, Any, ClassVar
30
30
 
31
- from hx.core.messages import Message
31
+ from hx.core.messages import ImageBlock, Message, ToolResultBlock
32
32
 
33
33
  if TYPE_CHECKING:
34
34
  from hx.config import PromptSettings
@@ -139,7 +139,7 @@ class ContextBuilder:
139
139
  PromptSection(
140
140
  "history",
141
141
  "",
142
- sum(self.estimate_tokens(_message_text(m)) for m in active),
142
+ sum(self.estimate_message_tokens(m) for m in active),
143
143
  ),
144
144
  ]
145
145
 
@@ -177,8 +177,7 @@ class ContextBuilder:
177
177
  self._breakpoint_b = candidate
178
178
  else:
179
179
  below = sum(
180
- self.estimate_tokens(_message_text(m))
181
- for m in messages[self._breakpoint_b : candidate]
180
+ self.estimate_message_tokens(m) for m in messages[self._breakpoint_b : candidate]
182
181
  )
183
182
  if below >= self.BREAKPOINT_HYSTERESIS_TOKENS:
184
183
  self._breakpoint_b = candidate
@@ -196,6 +195,15 @@ class ContextBuilder:
196
195
  """
197
196
  return int(len(text) / self.CHARS_PER_TOKEN) + 1 if text else 0
198
197
 
198
+ def estimate_message_tokens(self, message: Message) -> int:
199
+ """:meth:`estimate_tokens` for a whole message, images included.
200
+
201
+ An image is charged by its pixel count, not its base64 length: a
202
+ screenshot is a megabyte of text to the character estimate and about
203
+ 1,500 tokens to the model.
204
+ """
205
+ return self.estimate_tokens(_message_text(message)) + message_image_tokens(message)
206
+
199
207
 
200
208
  def sort_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
201
209
  """Canonical tool ordering: builtins first, then ``mcp__*``, each by name.
@@ -208,6 +216,19 @@ def sort_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
208
216
  )
209
217
 
210
218
 
219
+ def message_image_tokens(message: Message) -> int:
220
+ """Estimated tokens for every image in a message, tool results' included."""
221
+ from hx.core.images import estimate_tokens
222
+
223
+ total = 0
224
+ for block in message.content:
225
+ if isinstance(block, ImageBlock):
226
+ total += estimate_tokens(block)
227
+ elif isinstance(block, ToolResultBlock):
228
+ total += sum(estimate_tokens(image) for image in block.images)
229
+ return total
230
+
231
+
211
232
  def _message_text(message: Message) -> str:
212
233
  parts: list[str] = []
213
234
  for block in message.content: