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.
- {hx_cli-0.2.4 → hx_cli-0.2.5}/CHANGELOG.md +29 -1
- {hx_cli-0.2.4 → hx_cli-0.2.5}/PKG-INFO +46 -1
- {hx_cli-0.2.4 → hx_cli-0.2.5}/README.md +44 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/pyproject.toml +1 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/__init__.py +1 -1
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/cli.py +39 -2
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/compaction.py +17 -2
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/context.py +25 -4
- hx_cli-0.2.5/src/hx/core/images.py +394 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/loop.py +25 -11
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/messages.py +80 -4
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/keys.py +3 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/mcp/client.py +28 -8
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/mcp/manager.py +31 -1
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/codex_catalogue.py +12 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/devin.py +14 -1
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/devin_catalogue.py +4 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/devin_wire.py +21 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/models.py +36 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/openrouter.py +35 -1
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/responses_codec.py +29 -6
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/base.py +6 -1
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/bash.py +14 -2
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/read.py +36 -1
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/trace.py +32 -0
- hx_cli-0.2.5/src/hx/tui/clipboard.py +308 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/commands.py +2 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/glyphs.py +3 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/runtime.py +97 -19
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/views/blocks.py +27 -9
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/views/pickers.py +5 -2
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/views/prompt.py +108 -8
- hx_cli-0.2.5/tests/core/test_images.py +281 -0
- hx_cli-0.2.5/tests/core/test_loop_images.py +181 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_steering.py +3 -3
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/mcp/fixtures/echo_server.py +26 -1
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/mcp/test_manager.py +20 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/test_catalogue.py +55 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/test_codex_catalogue.py +7 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/test_devin.py +36 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/test_devin_catalogue.py +23 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/test_openrouter.py +70 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/test_responses_codec.py +34 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_cli.py +49 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_readme.py +1 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_trace.py +23 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tools/test_background.py +14 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tools/test_files.py +33 -0
- hx_cli-0.2.5/tests/tui/test_images.py +319 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/test_session.py +22 -11
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/views/test_prompt.py +9 -9
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/views/test_runtime.py +1 -1
- {hx_cli-0.2.4 → hx_cli-0.2.5}/uv.lock +87 -0
- hx_cli-0.2.4/src/hx/tui/clipboard.py +0 -125
- {hx_cli-0.2.4 → hx_cli-0.2.5}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/.gitignore +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/examples/hooks/check.sh +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/install.sh +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/agents/subagent.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/auth/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/auth/oauth/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/auth/oauth/browser.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/auth/oauth/callback.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/auth/oauth/codex.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/auth/oauth/devin.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/auth/oauth/pkce.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/auth/resolve.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/auth/store.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/config.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/checkpoints.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/events.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/session.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/title.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/core/usage.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/docs.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/git.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/hooks/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/hooks/engine.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/hooks/spec.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/net.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/paths.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/permissions/engine.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/permissions/parser.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/base.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/codex.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/fake.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/protowire.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/providers/registry.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/ansi.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/buffer.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/component.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/demo.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/editor.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/keydecode.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/loop.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/markdown.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/primitives.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/sanitize.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/screen.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/syntax.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/terminal.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/undo.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/width.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/term/word_nav.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/anchors.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/edit.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/glob.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/grep.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/output.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/symbols.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/task.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/websearch.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tools/write.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/app.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/format.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/fuzzy.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/killring.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/limits.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/paint.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/renderers.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/roles.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/theme.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/theme_json.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/themes/ansi.json +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/themes/dark.json +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/themes/light.json +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/views/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/views/dialog.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/views/login.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/views/permission.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/views/status.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/src/hx/tui/views/transcript.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/agents/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/agents/test_subagent.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/auth/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/auth/test_codex_oauth.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/auth/test_devin_oauth.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/auth/test_pkce.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/auth/test_resolve.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/auth/test_store.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/conftest.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_checkpoints.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_compaction.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_context.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_lateinject.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_loop.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_rewind.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_session.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_settings_layers.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_settings_migration.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_title.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/core/test_usage.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/hooks/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/hooks/test_engine.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/hooks/test_example_check.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/mcp/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/permissions/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/permissions/test_engine.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/permissions/test_parser.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/permissions/test_sandbox.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/test_codex.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/test_protowire.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/providers/test_registry.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/skills/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/skills/test_loader.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/conftest.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/test_ansi.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/test_keydecode.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/test_loop.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/test_markdown.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/test_primitives.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/test_sanitize.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/test_screen.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/test_terminal.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/term/test_width.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_docs.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_git.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_install.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_integration.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_live.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_live_codex.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_live_devin.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_live_tavily.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_net.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/test_paths.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tools/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tools/test_bash.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tools/test_hashline.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tools/test_output.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tools/test_symbols.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tools/test_todo.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tools/test_websearch.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/support.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/test_autocomplete.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/test_clipboard.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/test_effort.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/test_keys.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/test_login.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/test_renderers.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/test_theme_json.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/test_trace_command.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/views/__init__.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/views/test_blocks.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/views/test_permission.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/views/test_pickers.py +0 -0
- {hx_cli-0.2.4 → hx_cli-0.2.5}/tests/tui/views/test_status.py +0 -0
- {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.
|
|
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.
|
|
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:
|
|
@@ -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.
|
|
177
|
-
|
|
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.
|
|
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.
|
|
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:
|