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