hx-cli 0.2.14__tar.gz → 0.2.16__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.14 → hx_cli-0.2.16}/CHANGELOG.md +39 -1
- {hx_cli-0.2.14 → hx_cli-0.2.16}/PKG-INFO +8 -2
- {hx_cli-0.2.14 → hx_cli-0.2.16}/README.md +7 -1
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/__init__.py +1 -1
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/claude_code.py +18 -2
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/cli.py +3 -1
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/loop.py +36 -10
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/net.py +60 -7
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/permissions/engine.py +3 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/registry.py +15 -14
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/ansi.py +7 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/glyphs.py +4 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/runtime.py +41 -14
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/views/blocks.py +18 -7
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/views/transcript.py +8 -4
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/fake_claude.py +8 -2
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/terminal.py +5 -1
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/test_chat.py +3 -2
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/test_claude_code.py +81 -4
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/test_cli.py +3 -4
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/test_commands.py +30 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/test_tools.py +115 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/test_live_claude_code.py +74 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/tls_proxy.py +41 -1
- {hx_cli-0.2.14 → hx_cli-0.2.16}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/.gitignore +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/examples/hooks/check.sh +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/install.sh +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/pyproject.toml +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/agents/subagent.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/oauth/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/oauth/browser.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/oauth/callback.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/oauth/codex.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/oauth/devin.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/oauth/pkce.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/resolve.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/store.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/config.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/checkpoints.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/compaction.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/context.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/events.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/images.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/messages.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/session.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/title.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/usage.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/docs.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/git.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/hooks/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/hooks/engine.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/hooks/spec.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/keys.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/mcp/client.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/mcp/manager.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/mcp/oauth.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/paths.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/permissions/parser.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/base.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/claude_code.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/claude_code_catalogue.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/claude_code_cli.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/claude_code_transcript.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/codex.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/codex_catalogue.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/devin.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/devin_catalogue.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/devin_wire.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/models.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/openrouter.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/protowire.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/responses_codec.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/buffer.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/component.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/demo.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/editor.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/keydecode.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/loop.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/markdown.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/modifiers.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/primitives.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/sanitize.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/screen.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/syntax.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/terminal.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/undo.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/width.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/word_nav.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/anchors.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/base.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/bash.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/edit.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/glob.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/grep.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/output.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/read.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/symbols.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/task.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/websearch.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/write.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/trace.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/app.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/clipboard.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/commands.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/format.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/fuzzy.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/killring.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/limits.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/paint.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/renderers.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/roles.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/theme.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/theme_json.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/themes/ansi.json +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/themes/dark.json +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/themes/light.json +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/views/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/views/dialog.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/views/login.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/views/permission.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/views/pickers.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/views/prompt.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/views/status.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/conftest.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/__init__.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/conftest.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/fixtures/echo_server.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/report.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/stub.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/test_extend.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/test_input.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/test_print.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/test_queue.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/test_live.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/test_live_codex.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/test_live_devin.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/test_live_tavily.py +0 -0
- {hx_cli-0.2.14 → hx_cli-0.2.16}/uv.lock +0 -0
|
@@ -28,6 +28,42 @@ the project follows [semantic versioning](https://semver.org/).
|
|
|
28
28
|
|
|
29
29
|
---
|
|
30
30
|
|
|
31
|
+
## [0.2.16] - 2026-10-13
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- The "Working…" status and the tool spinners now keep moving while a tool runs. They
|
|
36
|
+
used to vanish or freeze from the moment the model asked for a tool until its next reply,
|
|
37
|
+
so a long command looked like an idle session. The clock now counts the whole turn, not
|
|
38
|
+
only the time since the last model reply.
|
|
39
|
+
- A tool call waiting on an approval shows `?` and does not spin, and its "Took" counts
|
|
40
|
+
only the run, not the time you spent deciding. A denied call never ran, so it shows no
|
|
41
|
+
"Took" at all.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## [0.2.15] - 2026-10-04
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- `/login` and `hx auth login` list the accounts as OpenAI, Claude, OpenRouter, Devin. At the
|
|
50
|
+
`hx auth login` menu, Enter still picks OpenRouter.
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- `/login claude-code` behind a network whose TLS inspection signs with a certificate not
|
|
55
|
+
valid for servers ("unsupported certificate purpose") now gives Claude Code's reason and
|
|
56
|
+
the fix: have the network fixed or exempted, or set `HX_SSL_NO_VERIFY=1` meanwhile. It
|
|
57
|
+
used to give the whole sign-in URL and no fix. Turns and HX's own requests that fail the
|
|
58
|
+
same way give the same advice, not "add the CA", which is trusted already.
|
|
59
|
+
- A `HX_CA_BUNDLE`, `SSL_CERT_FILE` or `REQUESTS_CA_BUNDLE` that names a file holding no
|
|
60
|
+
certificates no longer crashes HX at startup. HX says so and verifies against the system
|
|
61
|
+
trust store, and does not hand the file to Claude Code.
|
|
62
|
+
- A wrapped error notice no longer shows a bright green background under every line after
|
|
63
|
+
the first.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
31
67
|
## [0.2.14] - 2026-10-04
|
|
32
68
|
|
|
33
69
|
### Added
|
|
@@ -879,7 +915,9 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
|
|
|
879
915
|
- `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
|
|
880
916
|
self-update, and `install.sh` bootstrapping uv with a pinned Python.
|
|
881
917
|
|
|
882
|
-
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.
|
|
918
|
+
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.16...HEAD
|
|
919
|
+
[0.2.16]: https://github.com/aletisunil/hx/compare/v0.2.15...v0.2.16
|
|
920
|
+
[0.2.15]: https://github.com/aletisunil/hx/compare/v0.2.14...v0.2.15
|
|
883
921
|
[0.2.14]: https://github.com/aletisunil/hx/compare/v0.2.13...v0.2.14
|
|
884
922
|
[0.2.13]: https://github.com/aletisunil/hx/compare/v0.2.12...v0.2.13
|
|
885
923
|
[0.2.12]: https://github.com/aletisunil/hx/compare/v0.2.11...v0.2.12
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hx-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.16
|
|
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
|
|
@@ -335,7 +335,13 @@ export HX_CA_BUNDLE=/path/to/corp-ca.pem # SSL_CERT_FILE and REQUESTS_CA_BUNDL
|
|
|
335
335
|
`HX_SSL_NO_VERIFY=1` turns verification off entirely; it prints a warning at
|
|
336
336
|
startup every session, because it is not a fix. A TLS failure is reported in
|
|
337
337
|
one line - `/model` and the startup notice both name the certificate problem
|
|
338
|
-
rather than reporting an empty catalogue with no cause.
|
|
338
|
+
rather than reporting an empty catalogue with no cause. A file that holds no
|
|
339
|
+
certificates is ignored, with a startup notice that says so.
|
|
340
|
+
|
|
341
|
+
"Unsupported certificate purpose" (or "unsuitable") is different: the CA is
|
|
342
|
+
trusted, but the proxy signs with a certificate that is not valid for servers,
|
|
343
|
+
so no CA setting fixes it. The network has to fix that certificate or exempt
|
|
344
|
+
the service from inspection; until then only `HX_SSL_NO_VERIFY=1` gets through.
|
|
339
345
|
|
|
340
346
|
The same settings cover the `claude-code` route, whose requests Claude Code
|
|
341
347
|
makes with its own TLS stack: HX passes `HX_CA_BUNDLE` on to it as
|
|
@@ -294,7 +294,13 @@ export HX_CA_BUNDLE=/path/to/corp-ca.pem # SSL_CERT_FILE and REQUESTS_CA_BUNDL
|
|
|
294
294
|
`HX_SSL_NO_VERIFY=1` turns verification off entirely; it prints a warning at
|
|
295
295
|
startup every session, because it is not a fix. A TLS failure is reported in
|
|
296
296
|
one line - `/model` and the startup notice both name the certificate problem
|
|
297
|
-
rather than reporting an empty catalogue with no cause.
|
|
297
|
+
rather than reporting an empty catalogue with no cause. A file that holds no
|
|
298
|
+
certificates is ignored, with a startup notice that says so.
|
|
299
|
+
|
|
300
|
+
"Unsupported certificate purpose" (or "unsuitable") is different: the CA is
|
|
301
|
+
trusted, but the proxy signs with a certificate that is not valid for servers,
|
|
302
|
+
so no CA setting fixes it. The network has to fix that certificate or exempt
|
|
303
|
+
the service from inspection; until then only `HX_SSL_NO_VERIFY=1` gets through.
|
|
298
304
|
|
|
299
305
|
The same settings cover the `claude-code` route, whose requests Claude Code
|
|
300
306
|
makes with its own TLS stack: HX passes `HX_CA_BUNDLE` on to it as
|
|
@@ -290,6 +290,22 @@ def login_terminal() -> int:
|
|
|
290
290
|
|
|
291
291
|
|
|
292
292
|
_URL = re.compile(r"https://\S+")
|
|
293
|
+
_PASTE_PROMPT = re.compile(r"^.*Paste code[^>]*>\s*")
|
|
294
|
+
_FAILED = re.compile(r"^Login failed:\s*", re.IGNORECASE)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _failure(output: list[str]) -> str | None:
|
|
298
|
+
"""Why ``claude auth login`` failed: its last line of its own, ``unsupported
|
|
299
|
+
certificate purpose`` from ``Login failed: unsupported certificate purpose``.
|
|
300
|
+
|
|
301
|
+
Not the URL it printed before - the screen already showed that, and it is
|
|
302
|
+
wider than the notice - nor the paste prompt the reason is printed after.
|
|
303
|
+
"""
|
|
304
|
+
for line in reversed(output):
|
|
305
|
+
line = _FAILED.sub("", _PASTE_PROMPT.sub("", line)).strip()
|
|
306
|
+
if line and not _URL.search(line):
|
|
307
|
+
return line
|
|
308
|
+
return None
|
|
293
309
|
|
|
294
310
|
|
|
295
311
|
class LoginFailed(Exception):
|
|
@@ -361,8 +377,8 @@ async def login_interactive(interaction: LoginInteraction) -> LoginStatus:
|
|
|
361
377
|
forget_status()
|
|
362
378
|
|
|
363
379
|
if code != 0:
|
|
364
|
-
|
|
365
|
-
raise LoginFailed(f"`claude auth login` failed: {
|
|
380
|
+
reason = _failure(output) or f"exit {code}"
|
|
381
|
+
raise LoginFailed(net.with_tls_hint(f"`claude auth login` failed: {reason}"))
|
|
366
382
|
current = await status_async(fresh=True)
|
|
367
383
|
if current is None or not current.logged_in:
|
|
368
384
|
raise LoginFailed("Claude Code still reports no login.")
|
|
@@ -1432,8 +1432,10 @@ def _choose_provider() -> str:
|
|
|
1432
1432
|
print("Sign in to:")
|
|
1433
1433
|
for index, spec in enumerate(registry.SPECS, start=1):
|
|
1434
1434
|
print(f" {index} {spec.label}")
|
|
1435
|
+
# Enter still means OpenRouter, wherever it sits in the list.
|
|
1436
|
+
default = str(next(i for i, s in enumerate(registry.SPECS, start=1) if s.id == OPENROUTER))
|
|
1435
1437
|
try:
|
|
1436
|
-
choice = input("Choice [
|
|
1438
|
+
choice = input(f"Choice [{default}]: ").strip() or default
|
|
1437
1439
|
except (EOFError, KeyboardInterrupt):
|
|
1438
1440
|
print()
|
|
1439
1441
|
return OPENROUTER
|
|
@@ -75,6 +75,30 @@ class _Steered(Exception):
|
|
|
75
75
|
"""Internal: this stream was cancelled to deliver a steer, not to stop."""
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
class _Stopwatch:
|
|
79
|
+
"""How long one tool call has run, for its "Took".
|
|
80
|
+
|
|
81
|
+
Started once the call is cleared to run, and zero until then. The time a
|
|
82
|
+
person spent on an approval is not the tool's: a three-second command
|
|
83
|
+
answered after a minute's thought said it took a minute, and a denied one
|
|
84
|
+
that never ran said it took as long as the user took to say no.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
__slots__ = ("_started",)
|
|
88
|
+
|
|
89
|
+
def __init__(self) -> None:
|
|
90
|
+
self._started: float | None = None
|
|
91
|
+
|
|
92
|
+
def start(self) -> None:
|
|
93
|
+
self._started = time.monotonic()
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def ms(self) -> float:
|
|
97
|
+
if self._started is None:
|
|
98
|
+
return 0.0
|
|
99
|
+
return (time.monotonic() - self._started) * 1000
|
|
100
|
+
|
|
101
|
+
|
|
78
102
|
@dataclass(slots=True)
|
|
79
103
|
class TurnResult:
|
|
80
104
|
stop_reason: StopReason
|
|
@@ -712,7 +736,7 @@ class AgentLoop:
|
|
|
712
736
|
self, call: ToolUseBlock, results: dict[str, ToolResultBlock]
|
|
713
737
|
) -> ToolResultBlock:
|
|
714
738
|
"""Run one call. Interrupted, it leaves its answer in ``results``."""
|
|
715
|
-
|
|
739
|
+
clock = _Stopwatch()
|
|
716
740
|
|
|
717
741
|
# PreToolUse runs before the call is announced. A hook that rewrites the
|
|
718
742
|
# input must not leave the transcript showing a command that never ran,
|
|
@@ -730,7 +754,7 @@ class AgentLoop:
|
|
|
730
754
|
)
|
|
731
755
|
streamed: list[str] = []
|
|
732
756
|
try:
|
|
733
|
-
return await self._run_announced(call, pre,
|
|
757
|
+
return await self._run_announced(call, pre, clock, streamed, results)
|
|
734
758
|
except asyncio.CancelledError:
|
|
735
759
|
if call.id in results:
|
|
736
760
|
# The tool had finished and only its hooks were cut off.
|
|
@@ -743,7 +767,7 @@ class AgentLoop:
|
|
|
743
767
|
ToolCallFinished(
|
|
744
768
|
tool_use_id=call.id,
|
|
745
769
|
is_error=True,
|
|
746
|
-
duration_ms=
|
|
770
|
+
duration_ms=clock.ms,
|
|
747
771
|
summary="interrupted",
|
|
748
772
|
interrupted=True,
|
|
749
773
|
)
|
|
@@ -754,7 +778,7 @@ class AgentLoop:
|
|
|
754
778
|
self,
|
|
755
779
|
call: ToolUseBlock,
|
|
756
780
|
pre: HookOutcome,
|
|
757
|
-
|
|
781
|
+
clock: _Stopwatch,
|
|
758
782
|
streamed: list[str],
|
|
759
783
|
results: dict[str, ToolResultBlock],
|
|
760
784
|
) -> ToolResultBlock:
|
|
@@ -772,7 +796,7 @@ class AgentLoop:
|
|
|
772
796
|
ToolCallFinished(
|
|
773
797
|
tool_use_id=call.id,
|
|
774
798
|
is_error=True,
|
|
775
|
-
duration_ms=
|
|
799
|
+
duration_ms=clock.ms,
|
|
776
800
|
summary="blocked by hook",
|
|
777
801
|
detail=refusal.content,
|
|
778
802
|
)
|
|
@@ -785,12 +809,13 @@ class AgentLoop:
|
|
|
785
809
|
ToolCallFinished(
|
|
786
810
|
tool_use_id=call.id,
|
|
787
811
|
is_error=True,
|
|
788
|
-
duration_ms=
|
|
812
|
+
duration_ms=clock.ms,
|
|
789
813
|
summary="denied",
|
|
790
814
|
detail=denied.content,
|
|
791
815
|
)
|
|
792
816
|
)
|
|
793
817
|
return denied
|
|
818
|
+
clock.start()
|
|
794
819
|
|
|
795
820
|
def progress(chunk: str) -> None:
|
|
796
821
|
streamed.append(chunk)
|
|
@@ -812,12 +837,12 @@ class AgentLoop:
|
|
|
812
837
|
except asyncio.CancelledError:
|
|
813
838
|
# The tool finished and its effects are real: its own result stands,
|
|
814
839
|
# rather than telling the model a write that happened never did.
|
|
815
|
-
results[call.id] = self._finish(call, result, HookOutcome(),
|
|
840
|
+
results[call.id] = self._finish(call, result, HookOutcome(), clock)
|
|
816
841
|
raise
|
|
817
|
-
return self._finish(call, result, post,
|
|
842
|
+
return self._finish(call, result, post, clock)
|
|
818
843
|
|
|
819
844
|
def _finish(
|
|
820
|
-
self, call: ToolUseBlock, result: ToolResult, post: HookOutcome,
|
|
845
|
+
self, call: ToolUseBlock, result: ToolResult, post: HookOutcome, clock: _Stopwatch
|
|
821
846
|
) -> ToolResultBlock:
|
|
822
847
|
"""Announce a call the tool finished, and its result for the model."""
|
|
823
848
|
content = result.content
|
|
@@ -830,7 +855,7 @@ class AgentLoop:
|
|
|
830
855
|
ToolCallFinished(
|
|
831
856
|
tool_use_id=call.id,
|
|
832
857
|
is_error=result.is_error or post.blocked,
|
|
833
|
-
duration_ms=
|
|
858
|
+
duration_ms=clock.ms,
|
|
834
859
|
summary=result.summary or ("error" if result.is_error else "done"),
|
|
835
860
|
detail=content if result.is_error or post.blocked else "",
|
|
836
861
|
metadata=dict(result.metadata),
|
|
@@ -863,6 +888,7 @@ class AgentLoop:
|
|
|
863
888
|
detail=detail,
|
|
864
889
|
detail_kind=detail_kind,
|
|
865
890
|
origin=self.origin,
|
|
891
|
+
tool_use_id=call.id,
|
|
866
892
|
)
|
|
867
893
|
allowed, reason = await self.permissions.request(
|
|
868
894
|
request, on_ask=lambda: self._announce_ask(call.id, request)
|
|
@@ -41,11 +41,38 @@ _TRUTHY = frozenset({"1", "true", "yes", "on"})
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def ca_bundle() -> Path | None:
|
|
44
|
-
"""The CA bundle the environment names, if it points at a
|
|
44
|
+
"""The CA bundle the environment names, if it points at a file of certificates."""
|
|
45
|
+
named = _named_bundle()
|
|
46
|
+
if named is None or _load_error(named[1]) is not None:
|
|
47
|
+
return None
|
|
48
|
+
return named[1]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _named_bundle() -> tuple[str, Path] | None:
|
|
52
|
+
"""The first of :data:`CA_BUNDLE_VARS` that names a real file, and that file."""
|
|
45
53
|
for name in CA_BUNDLE_VARS:
|
|
46
54
|
raw = (os.environ.get(name) or "").strip()
|
|
47
55
|
if raw and Path(raw).expanduser().is_file():
|
|
48
|
-
return Path(raw).expanduser()
|
|
56
|
+
return name, Path(raw).expanduser()
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load_error(path: Path) -> str | None:
|
|
61
|
+
"""Why ``path`` cannot serve as a CA bundle - a DER file, a key, an empty
|
|
62
|
+
file - or ``None`` when it can. Asked again only when the file changes."""
|
|
63
|
+
try:
|
|
64
|
+
stat = path.stat()
|
|
65
|
+
except OSError as exc:
|
|
66
|
+
return exc.strerror or str(exc)
|
|
67
|
+
return _load_error_of(path, stat.st_mtime_ns, stat.st_size)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@lru_cache(maxsize=4)
|
|
71
|
+
def _load_error_of(path: Path, mtime_ns: int, size: int) -> str | None:
|
|
72
|
+
try:
|
|
73
|
+
ssl.create_default_context(cafile=str(path))
|
|
74
|
+
except (ssl.SSLError, OSError, ValueError) as exc:
|
|
75
|
+
return _flatten(getattr(exc, "reason", None) or str(exc))
|
|
49
76
|
return None
|
|
50
77
|
|
|
51
78
|
|
|
@@ -80,6 +107,15 @@ def tls_notice() -> str | None:
|
|
|
80
107
|
"""One line for the startup transcript when TLS is not stock."""
|
|
81
108
|
if verification_disabled():
|
|
82
109
|
return f"{NO_VERIFY_VAR} is set: TLS certificates are not being verified."
|
|
110
|
+
named = _named_bundle()
|
|
111
|
+
if named is not None and (error := _load_error(named[1])) is not None:
|
|
112
|
+
# Ignored, as OpenSSL and Node ignore a CA file they cannot read: failing
|
|
113
|
+
# to start would cost every route, the ones this file is not for too.
|
|
114
|
+
name, path = named
|
|
115
|
+
return (
|
|
116
|
+
f"{name} names {path}, which holds no usable CA certificates ({error}). "
|
|
117
|
+
f"Verifying TLS against the system trust store instead."
|
|
118
|
+
)
|
|
83
119
|
bundle = ca_bundle()
|
|
84
120
|
return f"Verifying TLS against {bundle}." if bundle is not None else None
|
|
85
121
|
|
|
@@ -108,13 +144,29 @@ _CERT_FAILURE = re.compile(
|
|
|
108
144
|
r"certificate verif"
|
|
109
145
|
r"|self[- ]signed certificate"
|
|
110
146
|
r"|unable to (?:get local issuer|verify the first) certificate"
|
|
111
|
-
r"|UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT|SELF_SIGNED_CERT_IN_CHAIN"
|
|
147
|
+
r"|UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT|SELF_SIGNED_CERT_IN_CHAIN"
|
|
148
|
+
r"|certificate purpose|INVALID_PURPOSE",
|
|
112
149
|
re.IGNORECASE,
|
|
113
150
|
)
|
|
114
151
|
|
|
152
|
+
_WRONG_PURPOSE = re.compile(r"(?:unsupported|unsuitable) certificate purpose|INVALID_PURPOSE")
|
|
153
|
+
"""A chain the client does trust, but with a certificate in it - typically the
|
|
154
|
+
issuing CA of a network that intercepts TLS - that may not serve as a TLS
|
|
155
|
+
server's: OpenSSL says "unsuitable", the BoringSSL in Claude Code "unsupported"."""
|
|
156
|
+
|
|
115
157
|
|
|
116
|
-
def tls_hint() -> str:
|
|
117
|
-
"""What to do about a certificate the client would not
|
|
158
|
+
def tls_hint(reason: str = "") -> str:
|
|
159
|
+
"""What to do about a certificate the client would not accept.
|
|
160
|
+
|
|
161
|
+
``reason`` is the client's own words, when there are any: a certificate of
|
|
162
|
+
the wrong purpose is already trusted, so adding its CA again changes nothing.
|
|
163
|
+
"""
|
|
164
|
+
if _WRONG_PURPOSE.search(reason):
|
|
165
|
+
return (
|
|
166
|
+
"Your network intercepts TLS with a certificate that is not valid for servers. "
|
|
167
|
+
"Ask its administrator to fix that certificate or to exclude this service from "
|
|
168
|
+
f"inspection; until then, {NO_VERIFY_VAR}=1 turns verification off."
|
|
169
|
+
)
|
|
118
170
|
bundle = ca_bundle()
|
|
119
171
|
if bundle is not None:
|
|
120
172
|
return (
|
|
@@ -130,7 +182,7 @@ def tls_hint() -> str:
|
|
|
130
182
|
def with_tls_hint(message: str) -> str:
|
|
131
183
|
"""``message``, followed by :func:`tls_hint` when it reports a certificate
|
|
132
184
|
failure - in any client's words: OpenSSL's, Node's, Claude Code's."""
|
|
133
|
-
hint = tls_hint()
|
|
185
|
+
hint = tls_hint(message)
|
|
134
186
|
if not _CERT_FAILURE.search(message) or hint in message:
|
|
135
187
|
return message
|
|
136
188
|
return f"{message.rstrip().rstrip('.')}. {hint}"
|
|
@@ -156,7 +208,8 @@ def describe(exc: BaseException) -> str:
|
|
|
156
208
|
or getattr(err, "reason", None)
|
|
157
209
|
or "certificate verify failed"
|
|
158
210
|
)
|
|
159
|
-
|
|
211
|
+
reason = _flatten(reason)
|
|
212
|
+
return f"TLS certificate verification failed ({reason}). {tls_hint(reason)}"
|
|
160
213
|
if isinstance(err, ssl.SSLError):
|
|
161
214
|
return f"TLS handshake failed: {_flatten(err.reason or str(err))}."
|
|
162
215
|
if isinstance(exc, httpx.TimeoutException):
|
|
@@ -78,6 +78,9 @@ class PermissionRequest:
|
|
|
78
78
|
"""
|
|
79
79
|
origin: str | None = None
|
|
80
80
|
"""Which subagent asked, when it was not the main conversation."""
|
|
81
|
+
tool_use_id: str | None = None
|
|
82
|
+
"""The call waiting on this, so the UI can show that call as waiting
|
|
83
|
+
rather than running. None for a request no tool call made - a `!` line."""
|
|
81
84
|
|
|
82
85
|
def all_specifiers(self) -> tuple[str, ...]:
|
|
83
86
|
"""Everything a rule has to be matched against for this one call."""
|
|
@@ -145,13 +145,7 @@ def _build_claude_code(
|
|
|
145
145
|
|
|
146
146
|
|
|
147
147
|
SPECS: tuple[ProviderSpec, ...] = (
|
|
148
|
-
|
|
149
|
-
id=OPENROUTER,
|
|
150
|
-
label="OpenRouter API key",
|
|
151
|
-
kind=AuthKind.API_KEY,
|
|
152
|
-
namespace=None,
|
|
153
|
-
build=_build_openrouter,
|
|
154
|
-
),
|
|
148
|
+
# In the order /login and `hx auth login` offer them.
|
|
155
149
|
ProviderSpec(
|
|
156
150
|
id=CODEX,
|
|
157
151
|
label="OpenAI (ChatGPT Plus/Pro)",
|
|
@@ -159,13 +153,6 @@ SPECS: tuple[ProviderSpec, ...] = (
|
|
|
159
153
|
namespace=f"{CODEX}/",
|
|
160
154
|
build=_build_codex,
|
|
161
155
|
),
|
|
162
|
-
ProviderSpec(
|
|
163
|
-
id=DEVIN,
|
|
164
|
-
label="Devin (personal or Enterprise)",
|
|
165
|
-
kind=AuthKind.SUBSCRIPTION,
|
|
166
|
-
namespace=f"{DEVIN}/",
|
|
167
|
-
build=_build_devin,
|
|
168
|
-
),
|
|
169
156
|
ProviderSpec(
|
|
170
157
|
id=CLAUDE_CODE,
|
|
171
158
|
label="Claude (Pro/Max, via Claude Code)",
|
|
@@ -173,6 +160,20 @@ SPECS: tuple[ProviderSpec, ...] = (
|
|
|
173
160
|
namespace=f"{CLAUDE_CODE}/",
|
|
174
161
|
build=_build_claude_code,
|
|
175
162
|
),
|
|
163
|
+
ProviderSpec(
|
|
164
|
+
id=OPENROUTER,
|
|
165
|
+
label="OpenRouter API key",
|
|
166
|
+
kind=AuthKind.API_KEY,
|
|
167
|
+
namespace=None,
|
|
168
|
+
build=_build_openrouter,
|
|
169
|
+
),
|
|
170
|
+
ProviderSpec(
|
|
171
|
+
id=DEVIN,
|
|
172
|
+
label="Devin (personal or Enterprise)",
|
|
173
|
+
kind=AuthKind.SUBSCRIPTION,
|
|
174
|
+
namespace=f"{DEVIN}/",
|
|
175
|
+
build=_build_devin,
|
|
176
|
+
),
|
|
176
177
|
)
|
|
177
178
|
|
|
178
179
|
BY_ID: dict[str, ProviderSpec] = {spec.id: spec for spec in SPECS}
|
|
@@ -266,6 +266,13 @@ def active_background(text: str) -> str:
|
|
|
266
266
|
# Truncated. Nothing legible to re-open, and consuming the
|
|
267
267
|
# rest would be a guess at parameters that never arrived.
|
|
268
268
|
break
|
|
269
|
+
elif code == 38 and index + 1 < len(codes):
|
|
270
|
+
# A foreground's parameters are not codes: the 102 of
|
|
271
|
+
# 38;2;204;102;102 would otherwise open a bright green background.
|
|
272
|
+
span = 3 if codes[index + 1] == 5 else 5 if codes[index + 1] == 2 else 0
|
|
273
|
+
if not span or index + span > len(codes):
|
|
274
|
+
break
|
|
275
|
+
index += span - 1
|
|
269
276
|
index += 1
|
|
270
277
|
return current
|
|
271
278
|
|
|
@@ -18,6 +18,10 @@ TOOL_DONE = "●"
|
|
|
18
18
|
TOOL_FAILED = "✗"
|
|
19
19
|
"""A tool call failed, or a notice reports an error."""
|
|
20
20
|
|
|
21
|
+
TOOL_AWAITING = "?"
|
|
22
|
+
"""A tool call held at an approval. Not the spinner: nothing is running until
|
|
23
|
+
the user answers, and a spinner says something is."""
|
|
24
|
+
|
|
21
25
|
TODO_PENDING = "○"
|
|
22
26
|
"""A todo not started. Only that - a running tool uses the spinner."""
|
|
23
27
|
|
|
@@ -122,6 +122,10 @@ class HXSession:
|
|
|
122
122
|
)
|
|
123
123
|
|
|
124
124
|
self._tools: dict[str, ToolBlock] = {}
|
|
125
|
+
self._awaiting: set[str] = set()
|
|
126
|
+
"""Calls held at an approval. Kept by id rather than set on the block,
|
|
127
|
+
because the approval is asked before ``ToolCallStarted`` comes off the
|
|
128
|
+
bus and the block that will show the call does not exist yet."""
|
|
125
129
|
self._pending: list[PermissionPrompt] = []
|
|
126
130
|
"""Approvals waiting on the user, oldest first.
|
|
127
131
|
|
|
@@ -204,10 +208,13 @@ class HXSession:
|
|
|
204
208
|
if not self._turn_started:
|
|
205
209
|
continue
|
|
206
210
|
elapsed = time.monotonic() - self._turn_started
|
|
207
|
-
|
|
211
|
+
# A frame only when something moved. Held at an approval nothing
|
|
212
|
+
# does, and a frame per tick then is output for its own sake.
|
|
213
|
+
moved = self.view.dock.working.tick(elapsed)
|
|
208
214
|
for block in self._tools.values():
|
|
209
|
-
block.tick()
|
|
210
|
-
|
|
215
|
+
moved = block.tick() or moved
|
|
216
|
+
if moved:
|
|
217
|
+
self.runner.request_render()
|
|
211
218
|
|
|
212
219
|
async def _watch_branch(self) -> None:
|
|
213
220
|
"""Keep the branch in the status bar current across external checkouts."""
|
|
@@ -876,6 +883,8 @@ class HXSession:
|
|
|
876
883
|
)
|
|
877
884
|
self.view.transcript.append(prompt)
|
|
878
885
|
self._pending.append(prompt)
|
|
886
|
+
call_id = getattr(request, "tool_use_id", None)
|
|
887
|
+
self._hold_call(call_id, held=True)
|
|
879
888
|
self._keyboard_owner()
|
|
880
889
|
# The turn is blocked on a person, so the indicator must stop claiming
|
|
881
890
|
# the tool is running.
|
|
@@ -892,15 +901,28 @@ class HXSession:
|
|
|
892
901
|
finally:
|
|
893
902
|
if prompt in self._pending:
|
|
894
903
|
self._pending.remove(prompt)
|
|
904
|
+
self._hold_call(call_id, held=False)
|
|
895
905
|
# Hands the keyboard to whoever is next, so the block below stops
|
|
896
906
|
# saying it is waiting the moment this one is answered.
|
|
897
907
|
self._keyboard_owner()
|
|
898
908
|
# Only the last one out hands the indicator back: with another
|
|
899
|
-
# approval still open the turn is still blocked on a person.
|
|
900
|
-
|
|
901
|
-
|
|
909
|
+
# approval still open the turn is still blocked on a person. And
|
|
910
|
+
# only to a turn: a `!` line asks here too, with nothing running.
|
|
911
|
+
if not self._pending and self._turn_started:
|
|
912
|
+
self.view.dock.working.start(time.monotonic() - self._turn_started)
|
|
902
913
|
self.runner.request_immediate_render()
|
|
903
914
|
|
|
915
|
+
def _hold_call(self, tool_use_id: str | None, *, held: bool) -> None:
|
|
916
|
+
"""Show a call as waiting on its approval, or as running again."""
|
|
917
|
+
if tool_use_id is None:
|
|
918
|
+
return
|
|
919
|
+
if held:
|
|
920
|
+
self._awaiting.add(tool_use_id)
|
|
921
|
+
else:
|
|
922
|
+
self._awaiting.discard(tool_use_id)
|
|
923
|
+
if (block := self._tools.get(tool_use_id)) is not None:
|
|
924
|
+
block.set_awaiting(held)
|
|
925
|
+
|
|
904
926
|
def _keyboard_owner(self) -> PermissionPrompt | None:
|
|
905
927
|
"""The oldest approval still waiting on an answer.
|
|
906
928
|
|
|
@@ -1243,6 +1265,15 @@ class HXSession:
|
|
|
1243
1265
|
self.runner.request_immediate_render()
|
|
1244
1266
|
|
|
1245
1267
|
async def _run_turn(self, text: str, images: Sequence[ImageBlock] = ()) -> None:
|
|
1268
|
+
# Working from here to the end of the task, not from TurnStarted to
|
|
1269
|
+
# TurnFinished: the loop publishes that pair around each model call, and
|
|
1270
|
+
# the tools between two calls are the turn too. Driven by the events,
|
|
1271
|
+
# the status went the moment the model asked for a tool, every spinner
|
|
1272
|
+
# froze while the tool ran, and the clock restarted at the next call.
|
|
1273
|
+
self._turn_started = time.monotonic()
|
|
1274
|
+
self.view.dock.working.start()
|
|
1275
|
+
self.prompt.set_running(True, enter_steers=self._enter_steers)
|
|
1276
|
+
self.runner.request_immediate_render()
|
|
1246
1277
|
try:
|
|
1247
1278
|
await self.loop.run(text, images)
|
|
1248
1279
|
except asyncio.CancelledError:
|
|
@@ -1262,6 +1293,7 @@ class HXSession:
|
|
|
1262
1293
|
finally:
|
|
1263
1294
|
self._turn_started = 0.0
|
|
1264
1295
|
self.view.dock.working.stop()
|
|
1296
|
+
self.prompt.set_running(False)
|
|
1265
1297
|
self.runner.request_immediate_render()
|
|
1266
1298
|
|
|
1267
1299
|
# Only on a turn that ended on its own: an interrupt re-raises above
|
|
@@ -1277,16 +1309,14 @@ class HXSession:
|
|
|
1277
1309
|
async def _consume_events(self) -> None:
|
|
1278
1310
|
transcript = self.view.transcript
|
|
1279
1311
|
status = self.view.dock.status
|
|
1280
|
-
working = self.view.dock.working
|
|
1281
1312
|
|
|
1282
1313
|
async for event in self.bus.subscribe():
|
|
1283
1314
|
match event:
|
|
1284
1315
|
case ev.TurnStarted():
|
|
1316
|
+
# One model call, not the user's turn: the working status
|
|
1317
|
+
# belongs to ``_run_turn``. A new call starts new blocks.
|
|
1285
1318
|
self._assistant = None
|
|
1286
1319
|
self._thinking = None
|
|
1287
|
-
self._turn_started = time.monotonic()
|
|
1288
|
-
working.start()
|
|
1289
|
-
self.prompt.set_running(True, enter_steers=self._enter_steers)
|
|
1290
1320
|
case ev.TextDelta():
|
|
1291
1321
|
if self._assistant is None:
|
|
1292
1322
|
self._assistant = AssistantMessage()
|
|
@@ -1306,6 +1336,7 @@ class HXSession:
|
|
|
1306
1336
|
)
|
|
1307
1337
|
)
|
|
1308
1338
|
self._tools[event.tool_use_id] = started
|
|
1339
|
+
started.set_awaiting(event.tool_use_id in self._awaiting)
|
|
1309
1340
|
# A silent tool is held back rather than dropped: it is
|
|
1310
1341
|
# silent because something else draws its result, and if it
|
|
1311
1342
|
# fails there is no result and nothing else to draw. The
|
|
@@ -1368,10 +1399,6 @@ class HXSession:
|
|
|
1368
1399
|
)
|
|
1369
1400
|
case ev.ErrorRaised():
|
|
1370
1401
|
self._notice(event.message, "error")
|
|
1371
|
-
case ev.TurnFinished():
|
|
1372
|
-
self._turn_started = 0.0
|
|
1373
|
-
working.stop()
|
|
1374
|
-
self.prompt.set_running(False)
|
|
1375
1402
|
|
|
1376
1403
|
self.runner.request_render()
|
|
1377
1404
|
|
|
@@ -20,7 +20,7 @@ from hx.term.component import Widget
|
|
|
20
20
|
from hx.term.markdown import render_markdown
|
|
21
21
|
from hx.term.primitives import Box, HangingText, Lines
|
|
22
22
|
from hx.term.sanitize import plain_text
|
|
23
|
-
from hx.tui.glyphs import IMAGE, NOTICE, SPINNER, TOOL_DONE, TOOL_FAILED
|
|
23
|
+
from hx.tui.glyphs import IMAGE, NOTICE, SPINNER, TOOL_AWAITING, TOOL_DONE, TOOL_FAILED
|
|
24
24
|
from hx.tui.limits import PREVIEW_LINES, STREAM_TAIL
|
|
25
25
|
from hx.tui.paint import ThemePainter, fg, tint
|
|
26
26
|
from hx.tui.renderers import (
|
|
@@ -163,7 +163,7 @@ class ToolBlock(Widget):
|
|
|
163
163
|
for the red one without reading any of it.
|
|
164
164
|
"""
|
|
165
165
|
|
|
166
|
-
__slots__ = ("_call", "_frame", "_stream", "_stream_dirty", "_streamed")
|
|
166
|
+
__slots__ = ("_awaiting", "_call", "_frame", "_stream", "_stream_dirty", "_streamed")
|
|
167
167
|
|
|
168
168
|
def __init__(self, call: ToolCall) -> None:
|
|
169
169
|
super().__init__()
|
|
@@ -173,6 +173,8 @@ class ToolBlock(Widget):
|
|
|
173
173
|
"""What the tool has streamed, raw; the output is its sanitized form."""
|
|
174
174
|
self._streamed = 0
|
|
175
175
|
self._stream_dirty = False
|
|
176
|
+
self._awaiting = False
|
|
177
|
+
"""Held at an approval: not running yet, so it does not spin."""
|
|
176
178
|
|
|
177
179
|
@property
|
|
178
180
|
def call(self) -> ToolCall:
|
|
@@ -230,10 +232,18 @@ class ToolBlock(Widget):
|
|
|
230
232
|
self._stream, self._streamed, self._stream_dirty = [raw], len(raw), False
|
|
231
233
|
self._call = replace(self._call, output=plain_text(raw))
|
|
232
234
|
|
|
233
|
-
def tick(self) ->
|
|
234
|
-
"""Advance the spinner
|
|
235
|
-
if
|
|
236
|
-
|
|
235
|
+
def tick(self) -> bool:
|
|
236
|
+
"""Advance the spinner while the call runs. Whether anything changed."""
|
|
237
|
+
if self._call.finished or self._awaiting:
|
|
238
|
+
return False
|
|
239
|
+
self._frame = (self._frame + 1) % len(SPINNER)
|
|
240
|
+
self.invalidate()
|
|
241
|
+
return True
|
|
242
|
+
|
|
243
|
+
def set_awaiting(self, awaiting: bool) -> None:
|
|
244
|
+
"""Whether the call is held at an approval rather than running."""
|
|
245
|
+
if awaiting != self._awaiting:
|
|
246
|
+
self._awaiting = awaiting
|
|
237
247
|
self.invalidate()
|
|
238
248
|
|
|
239
249
|
def toggle(self) -> None:
|
|
@@ -242,7 +252,7 @@ class ToolBlock(Widget):
|
|
|
242
252
|
@property
|
|
243
253
|
def _state(self) -> str:
|
|
244
254
|
if not self._call.finished:
|
|
245
|
-
return "running"
|
|
255
|
+
return "awaiting" if self._awaiting else "running"
|
|
246
256
|
if self._call.interrupted:
|
|
247
257
|
return "interrupted"
|
|
248
258
|
return "error" if self._call.is_error else "done"
|
|
@@ -252,6 +262,7 @@ class ToolBlock(Widget):
|
|
|
252
262
|
state = self._state
|
|
253
263
|
marker, marker_role, background = {
|
|
254
264
|
"running": (SPINNER[self._frame], "accent", "tool_pending_bg"),
|
|
265
|
+
"awaiting": (TOOL_AWAITING, "warning", "tool_pending_bg"),
|
|
255
266
|
"done": (TOOL_DONE, "success", "tool_success_bg"),
|
|
256
267
|
"error": (TOOL_FAILED, "error", "tool_error_bg"),
|
|
257
268
|
# Stopped, not failed: the warning the "Interrupted" line is drawn in.
|