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.
Files changed (158) hide show
  1. {hx_cli-0.2.14 → hx_cli-0.2.16}/CHANGELOG.md +39 -1
  2. {hx_cli-0.2.14 → hx_cli-0.2.16}/PKG-INFO +8 -2
  3. {hx_cli-0.2.14 → hx_cli-0.2.16}/README.md +7 -1
  4. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/__init__.py +1 -1
  5. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/claude_code.py +18 -2
  6. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/cli.py +3 -1
  7. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/loop.py +36 -10
  8. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/net.py +60 -7
  9. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/permissions/engine.py +3 -0
  10. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/registry.py +15 -14
  11. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/ansi.py +7 -0
  12. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/glyphs.py +4 -0
  13. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/runtime.py +41 -14
  14. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/views/blocks.py +18 -7
  15. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/views/transcript.py +8 -4
  16. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/fake_claude.py +8 -2
  17. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/terminal.py +5 -1
  18. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/test_chat.py +3 -2
  19. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/test_claude_code.py +81 -4
  20. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/test_cli.py +3 -4
  21. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/test_commands.py +30 -0
  22. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/test_tools.py +115 -0
  23. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/test_live_claude_code.py +74 -0
  24. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/tls_proxy.py +41 -1
  25. {hx_cli-0.2.14 → hx_cli-0.2.16}/.github/workflows/ci.yml +0 -0
  26. {hx_cli-0.2.14 → hx_cli-0.2.16}/.gitignore +0 -0
  27. {hx_cli-0.2.14 → hx_cli-0.2.16}/examples/hooks/check.sh +0 -0
  28. {hx_cli-0.2.14 → hx_cli-0.2.16}/install.sh +0 -0
  29. {hx_cli-0.2.14 → hx_cli-0.2.16}/pyproject.toml +0 -0
  30. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/agents/__init__.py +0 -0
  31. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/agents/definitions.py +0 -0
  32. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/agents/subagent.py +0 -0
  33. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/__init__.py +0 -0
  34. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/oauth/__init__.py +0 -0
  35. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/oauth/browser.py +0 -0
  36. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/oauth/callback.py +0 -0
  37. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/oauth/codex.py +0 -0
  38. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/oauth/devin.py +0 -0
  39. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/oauth/pkce.py +0 -0
  40. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/resolve.py +0 -0
  41. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/auth/store.py +0 -0
  42. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/config.py +0 -0
  43. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/__init__.py +0 -0
  44. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/checkpoints.py +0 -0
  45. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/compaction.py +0 -0
  46. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/context.py +0 -0
  47. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/events.py +0 -0
  48. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/images.py +0 -0
  49. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/lateinject.py +0 -0
  50. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/messages.py +0 -0
  51. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/session.py +0 -0
  52. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/title.py +0 -0
  53. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/core/usage.py +0 -0
  54. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/docs.py +0 -0
  55. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/frontmatter.py +0 -0
  56. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/git.py +0 -0
  57. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/hooks/__init__.py +0 -0
  58. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/hooks/engine.py +0 -0
  59. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/hooks/spec.py +0 -0
  60. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/keys.py +0 -0
  61. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/mcp/__init__.py +0 -0
  62. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/mcp/client.py +0 -0
  63. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/mcp/manager.py +0 -0
  64. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/mcp/oauth.py +0 -0
  65. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/paths.py +0 -0
  66. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/permissions/__init__.py +0 -0
  67. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/permissions/parser.py +0 -0
  68. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/permissions/sandbox.py +0 -0
  69. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/__init__.py +0 -0
  70. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/base.py +0 -0
  71. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/claude_code.py +0 -0
  72. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/claude_code_catalogue.py +0 -0
  73. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/claude_code_cli.py +0 -0
  74. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/claude_code_transcript.py +0 -0
  75. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/codex.py +0 -0
  76. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/codex_catalogue.py +0 -0
  77. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/devin.py +0 -0
  78. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/devin_catalogue.py +0 -0
  79. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/devin_wire.py +0 -0
  80. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/models.py +0 -0
  81. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/openrouter.py +0 -0
  82. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/protowire.py +0 -0
  83. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/providers/responses_codec.py +0 -0
  84. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/skills/__init__.py +0 -0
  85. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/skills/loader.py +0 -0
  86. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/skills/runtime.py +0 -0
  87. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/__init__.py +0 -0
  88. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/buffer.py +0 -0
  89. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/component.py +0 -0
  90. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/demo.py +0 -0
  91. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/editor.py +0 -0
  92. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/keydecode.py +0 -0
  93. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/loop.py +0 -0
  94. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/markdown.py +0 -0
  95. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/modifiers.py +0 -0
  96. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/primitives.py +0 -0
  97. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/sanitize.py +0 -0
  98. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/screen.py +0 -0
  99. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/syntax.py +0 -0
  100. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/terminal.py +0 -0
  101. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/undo.py +0 -0
  102. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/width.py +0 -0
  103. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/term/word_nav.py +0 -0
  104. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/__init__.py +0 -0
  105. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/anchors.py +0 -0
  106. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/base.py +0 -0
  107. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/bash.py +0 -0
  108. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/edit.py +0 -0
  109. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/glob.py +0 -0
  110. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/grep.py +0 -0
  111. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/output.py +0 -0
  112. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/read.py +0 -0
  113. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/registry.py +0 -0
  114. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/symbols.py +0 -0
  115. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/task.py +0 -0
  116. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/todo.py +0 -0
  117. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/websearch.py +0 -0
  118. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tools/write.py +0 -0
  119. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/trace.py +0 -0
  120. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/__init__.py +0 -0
  121. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/app.py +0 -0
  122. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/clipboard.py +0 -0
  123. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/commands.py +0 -0
  124. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/format.py +0 -0
  125. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/fuzzy.py +0 -0
  126. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/killring.py +0 -0
  127. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/limits.py +0 -0
  128. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/paint.py +0 -0
  129. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/renderers.py +0 -0
  130. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/roles.py +0 -0
  131. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/theme.py +0 -0
  132. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/theme_json.py +0 -0
  133. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/themes/ansi.json +0 -0
  134. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/themes/dark.json +0 -0
  135. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/themes/light.json +0 -0
  136. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/views/__init__.py +0 -0
  137. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/views/dialog.py +0 -0
  138. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/views/login.py +0 -0
  139. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/views/permission.py +0 -0
  140. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/views/pickers.py +0 -0
  141. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/views/prompt.py +0 -0
  142. {hx_cli-0.2.14 → hx_cli-0.2.16}/src/hx/tui/views/status.py +0 -0
  143. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/__init__.py +0 -0
  144. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/conftest.py +0 -0
  145. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/__init__.py +0 -0
  146. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/conftest.py +0 -0
  147. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/fixtures/echo_server.py +0 -0
  148. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/report.py +0 -0
  149. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/stub.py +0 -0
  150. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/test_extend.py +0 -0
  151. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/test_input.py +0 -0
  152. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/test_print.py +0 -0
  153. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/e2e/test_queue.py +0 -0
  154. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/test_live.py +0 -0
  155. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/test_live_codex.py +0 -0
  156. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/test_live_devin.py +0 -0
  157. {hx_cli-0.2.14 → hx_cli-0.2.16}/tests/test_live_tavily.py +0 -0
  158. {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.14...HEAD
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.14
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
@@ -1,5 +1,5 @@
1
1
  """HX - an agent harness for the terminal."""
2
2
 
3
- __version__ = "0.2.14"
3
+ __version__ = "0.2.16"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -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
- tail = " ".join(line.strip() for line in output[-3:] if line.strip())
365
- raise LoginFailed(f"`claude auth login` failed: {tail or f'exit {code}'}")
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 [1]: ").strip() or "1"
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
- started = time.monotonic()
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, started, streamed, results)
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=(time.monotonic() - started) * 1000,
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
- started: float,
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=(time.monotonic() - started) * 1000,
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=(time.monotonic() - started) * 1000,
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(), started)
840
+ results[call.id] = self._finish(call, result, HookOutcome(), clock)
816
841
  raise
817
- return self._finish(call, result, post, started)
842
+ return self._finish(call, result, post, clock)
818
843
 
819
844
  def _finish(
820
- self, call: ToolUseBlock, result: ToolResult, post: HookOutcome, started: float
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=(time.monotonic() - started) * 1000,
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 real file."""
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 trust."""
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
- return f"TLS certificate verification failed ({_flatten(reason)}). {tls_hint()}"
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
- ProviderSpec(
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
- self.view.dock.working.tick(elapsed)
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
- self.runner.request_render()
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
- if not self._pending:
901
- self.view.dock.working.start()
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) -> None:
234
- """Advance the spinner. Only meaningful while the call is running."""
235
- if not self._call.finished:
236
- self._frame = (self._frame + 1) % len(SPINNER)
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.