hx-cli 0.2.15__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.15 → hx_cli-0.2.16}/CHANGELOG.md +16 -1
  2. {hx_cli-0.2.15 → hx_cli-0.2.16}/PKG-INFO +1 -1
  3. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/__init__.py +1 -1
  4. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/loop.py +36 -10
  5. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/permissions/engine.py +3 -0
  6. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/glyphs.py +4 -0
  7. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/runtime.py +41 -14
  8. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/views/blocks.py +18 -7
  9. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/views/transcript.py +8 -4
  10. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/terminal.py +5 -1
  11. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/test_chat.py +3 -2
  12. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/test_tools.py +115 -0
  13. {hx_cli-0.2.15 → hx_cli-0.2.16}/.github/workflows/ci.yml +0 -0
  14. {hx_cli-0.2.15 → hx_cli-0.2.16}/.gitignore +0 -0
  15. {hx_cli-0.2.15 → hx_cli-0.2.16}/README.md +0 -0
  16. {hx_cli-0.2.15 → hx_cli-0.2.16}/examples/hooks/check.sh +0 -0
  17. {hx_cli-0.2.15 → hx_cli-0.2.16}/install.sh +0 -0
  18. {hx_cli-0.2.15 → hx_cli-0.2.16}/pyproject.toml +0 -0
  19. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/agents/__init__.py +0 -0
  20. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/agents/definitions.py +0 -0
  21. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/agents/subagent.py +0 -0
  22. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/__init__.py +0 -0
  23. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/claude_code.py +0 -0
  24. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/oauth/__init__.py +0 -0
  25. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/oauth/browser.py +0 -0
  26. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/oauth/callback.py +0 -0
  27. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/oauth/codex.py +0 -0
  28. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/oauth/devin.py +0 -0
  29. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/oauth/pkce.py +0 -0
  30. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/resolve.py +0 -0
  31. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/store.py +0 -0
  32. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/cli.py +0 -0
  33. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/config.py +0 -0
  34. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/__init__.py +0 -0
  35. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/checkpoints.py +0 -0
  36. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/compaction.py +0 -0
  37. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/context.py +0 -0
  38. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/events.py +0 -0
  39. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/images.py +0 -0
  40. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/lateinject.py +0 -0
  41. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/messages.py +0 -0
  42. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/session.py +0 -0
  43. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/title.py +0 -0
  44. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/usage.py +0 -0
  45. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/docs.py +0 -0
  46. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/frontmatter.py +0 -0
  47. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/git.py +0 -0
  48. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/hooks/__init__.py +0 -0
  49. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/hooks/engine.py +0 -0
  50. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/hooks/spec.py +0 -0
  51. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/keys.py +0 -0
  52. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/mcp/__init__.py +0 -0
  53. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/mcp/client.py +0 -0
  54. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/mcp/manager.py +0 -0
  55. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/mcp/oauth.py +0 -0
  56. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/net.py +0 -0
  57. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/paths.py +0 -0
  58. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/permissions/__init__.py +0 -0
  59. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/permissions/parser.py +0 -0
  60. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/permissions/sandbox.py +0 -0
  61. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/__init__.py +0 -0
  62. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/base.py +0 -0
  63. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/claude_code.py +0 -0
  64. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/claude_code_catalogue.py +0 -0
  65. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/claude_code_cli.py +0 -0
  66. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/claude_code_transcript.py +0 -0
  67. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/codex.py +0 -0
  68. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/codex_catalogue.py +0 -0
  69. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/devin.py +0 -0
  70. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/devin_catalogue.py +0 -0
  71. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/devin_wire.py +0 -0
  72. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/models.py +0 -0
  73. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/openrouter.py +0 -0
  74. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/protowire.py +0 -0
  75. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/registry.py +0 -0
  76. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/responses_codec.py +0 -0
  77. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/skills/__init__.py +0 -0
  78. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/skills/loader.py +0 -0
  79. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/skills/runtime.py +0 -0
  80. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/__init__.py +0 -0
  81. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/ansi.py +0 -0
  82. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/buffer.py +0 -0
  83. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/component.py +0 -0
  84. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/demo.py +0 -0
  85. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/editor.py +0 -0
  86. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/keydecode.py +0 -0
  87. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/loop.py +0 -0
  88. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/markdown.py +0 -0
  89. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/modifiers.py +0 -0
  90. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/primitives.py +0 -0
  91. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/sanitize.py +0 -0
  92. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/screen.py +0 -0
  93. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/syntax.py +0 -0
  94. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/terminal.py +0 -0
  95. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/undo.py +0 -0
  96. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/width.py +0 -0
  97. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/word_nav.py +0 -0
  98. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/__init__.py +0 -0
  99. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/anchors.py +0 -0
  100. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/base.py +0 -0
  101. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/bash.py +0 -0
  102. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/edit.py +0 -0
  103. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/glob.py +0 -0
  104. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/grep.py +0 -0
  105. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/output.py +0 -0
  106. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/read.py +0 -0
  107. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/registry.py +0 -0
  108. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/symbols.py +0 -0
  109. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/task.py +0 -0
  110. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/todo.py +0 -0
  111. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/websearch.py +0 -0
  112. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/write.py +0 -0
  113. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/trace.py +0 -0
  114. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/__init__.py +0 -0
  115. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/app.py +0 -0
  116. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/clipboard.py +0 -0
  117. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/commands.py +0 -0
  118. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/format.py +0 -0
  119. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/fuzzy.py +0 -0
  120. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/killring.py +0 -0
  121. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/limits.py +0 -0
  122. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/paint.py +0 -0
  123. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/renderers.py +0 -0
  124. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/roles.py +0 -0
  125. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/theme.py +0 -0
  126. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/theme_json.py +0 -0
  127. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/themes/ansi.json +0 -0
  128. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/themes/dark.json +0 -0
  129. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/themes/light.json +0 -0
  130. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/views/__init__.py +0 -0
  131. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/views/dialog.py +0 -0
  132. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/views/login.py +0 -0
  133. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/views/permission.py +0 -0
  134. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/views/pickers.py +0 -0
  135. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/views/prompt.py +0 -0
  136. {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/views/status.py +0 -0
  137. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/__init__.py +0 -0
  138. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/conftest.py +0 -0
  139. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/__init__.py +0 -0
  140. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/conftest.py +0 -0
  141. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/fake_claude.py +0 -0
  142. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/fixtures/echo_server.py +0 -0
  143. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/report.py +0 -0
  144. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/stub.py +0 -0
  145. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/test_claude_code.py +0 -0
  146. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/test_cli.py +0 -0
  147. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/test_commands.py +0 -0
  148. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/test_extend.py +0 -0
  149. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/test_input.py +0 -0
  150. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/test_print.py +0 -0
  151. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/test_queue.py +0 -0
  152. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/test_live.py +0 -0
  153. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/test_live_claude_code.py +0 -0
  154. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/test_live_codex.py +0 -0
  155. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/test_live_devin.py +0 -0
  156. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/test_live_tavily.py +0 -0
  157. {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/tls_proxy.py +0 -0
  158. {hx_cli-0.2.15 → hx_cli-0.2.16}/uv.lock +0 -0
@@ -28,6 +28,20 @@ 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
+
31
45
  ## [0.2.15] - 2026-10-04
32
46
 
33
47
  ### Changed
@@ -901,7 +915,8 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
901
915
  - `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
902
916
  self-update, and `install.sh` bootstrapping uv with a pinned Python.
903
917
 
904
- [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.15...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
905
920
  [0.2.15]: https://github.com/aletisunil/hx/compare/v0.2.14...v0.2.15
906
921
  [0.2.14]: https://github.com/aletisunil/hx/compare/v0.2.13...v0.2.14
907
922
  [0.2.13]: https://github.com/aletisunil/hx/compare/v0.2.12...v0.2.13
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hx-cli
3
- Version: 0.2.15
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
@@ -1,5 +1,5 @@
1
1
  """HX - an agent harness for the terminal."""
2
2
 
3
- __version__ = "0.2.15"
3
+ __version__ = "0.2.16"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -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)
@@ -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."""
@@ -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.
@@ -138,21 +138,25 @@ class WorkingIndicator:
138
138
  def running(self) -> bool:
139
139
  return self._running
140
140
 
141
- def start(self) -> None:
141
+ def start(self, elapsed: float = 0.0) -> None:
142
+ """Show the status, ``elapsed`` seconds into the turn - not zero when it
143
+ comes back after an approval."""
142
144
  self._running = True
143
- self._elapsed = 0.0
145
+ self._elapsed = elapsed
144
146
  self._paint()
145
147
 
146
148
  def stop(self) -> None:
147
149
  self._running = False
148
150
  self.target.set_status("")
149
151
 
150
- def tick(self, elapsed: float) -> None:
152
+ def tick(self, elapsed: float) -> bool:
153
+ """Advance the spinner. Whether anything changed."""
151
154
  if not self._running:
152
- return
155
+ return False
153
156
  self._frame = (self._frame + 1) % len(SPINNER)
154
157
  self._elapsed = elapsed
155
158
  self._paint()
159
+ return True
156
160
 
157
161
  def _paint(self) -> None:
158
162
  from hx.keys import primary_key
@@ -277,7 +277,11 @@ class Terminal:
277
277
  if test(screen):
278
278
  # Matched mid-paint is not matched: a frame is often drawn in
279
279
  # more than one write, so look again once HX has gone quiet.
280
- self.settle(quiet=0.08, timeout=2)
280
+ # Quiet for less than a spinner frame (80ms): while a turn runs
281
+ # HX repaints every frame, and a longer wait is never met - it
282
+ # ran out its timeout, and a tool the test meant to catch
283
+ # running had finished by then.
284
+ self.settle(quiet=0.04, timeout=2)
281
285
  screen = read()
282
286
  if test(screen):
283
287
  return screen
@@ -120,8 +120,9 @@ def test_typing_while_busy_queues_the_message(hx: HX, stub: Stub) -> None:
120
120
  term.submit("first")
121
121
  term.wait_for("Working on")
122
122
  term.submit("second")
123
- term.wait_for("second")
124
- term.settle()
123
+ # Not settle(): the working status repaints every frame while the turn runs,
124
+ # so HX never goes quiet and a settle only ever ran out its timeout.
125
+ term.wait_for(lambda screen: "second" in screen and "⧗1 queued" in screen)
125
126
  term.snapshot("second message queued while the first runs")
126
127
  assert len(stub.requests) == 1
127
128
 
@@ -3,9 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import re
7
+ import time
6
8
 
7
9
  from tests.e2e.conftest import HX
8
10
  from tests.e2e.stub import Stub, ToolCall, call, calls, say
11
+ from tests.e2e.terminal import Terminal
9
12
 
10
13
 
11
14
  def test_write_asks_and_allow_once(hx: HX, stub: Stub) -> None:
@@ -52,6 +55,35 @@ def test_deny_tells_the_model(hx: HX, stub: Stub) -> None:
52
55
  assert stub.requests[1].tool_results()[0] == "Write was not permitted: the user declined"
53
56
 
54
57
 
58
+ def test_a_denied_command_says_nothing_about_time(hx: HX, stub: Stub) -> None:
59
+ """A denied call never ran, so it has no "Took": the time the user spent deciding
60
+ is not the command's. The allowed call beside it still reports its own run."""
61
+ stub.script(
62
+ call("Bash", "First this.", command="touch denied.txt"),
63
+ call("Bash", "Then this.", command="touch allowed.txt && sleep 1"),
64
+ say("Done."),
65
+ )
66
+ term = hx.tui()
67
+ term.submit("make two files")
68
+ term.wait_for("Permission needed")
69
+ time.sleep(3) # the user thinks about it
70
+ term.type("n")
71
+ term.wait_for(lambda screen: "Permission needed" in screen and "allowed.txt" in screen)
72
+ time.sleep(3)
73
+ term.type("y")
74
+ term.wait_for("Done.")
75
+ term.settle()
76
+ term.snapshot("one call denied, one allowed")
77
+
78
+ assert not (hx.project / "denied.txt").exists()
79
+ assert (hx.project / "allowed.txt").exists()
80
+ scrollback = term.scrollback()
81
+ denied = scrollback[scrollback.index("✗ $ touch denied.txt") : scrollback.index("Then this.")]
82
+ assert "Took" not in denied, denied
83
+ took = [float(seconds) for seconds in re.findall(r"Took ([\d.]+)s", scrollback)]
84
+ assert len(took) == 1 and 1 <= took[0] < 2, took
85
+
86
+
55
87
  def test_always_allow_is_remembered(hx: HX, stub: Stub) -> None:
56
88
  """`a` saves a rule under ~/.hx for this project, so the next session does not ask again."""
57
89
  target = hx.project / "notes.txt"
@@ -324,3 +356,86 @@ def test_glob_on_a_bare_directory_pattern_is_a_usable_error(hx: HX, stub: Stub)
324
356
  for answer in (dot, dot_slash, absolute):
325
357
  assert "Error" not in answer and "Unacceptable" not in answer
326
358
  assert "main.py" in real
359
+
360
+
361
+ WORKING = re.compile(r"([⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]) Working… \((\d+)s")
362
+
363
+
364
+ def _working(screen: str) -> tuple[str, int] | None:
365
+ """The spinner frame and the seconds the working status shows, if it is shown."""
366
+ found = WORKING.search(screen)
367
+ return (found[1], int(found[2])) if found else None
368
+
369
+
370
+ def _watch_working(term: Terminal, seconds: float) -> list[tuple[str, int]]:
371
+ """What the working status showed, sampled for ``seconds``."""
372
+ seen: list[tuple[str, int]] = []
373
+ deadline = time.monotonic() + seconds
374
+ while time.monotonic() < deadline:
375
+ if (state := _working(term.text())) is not None:
376
+ seen.append(state)
377
+ time.sleep(0.03)
378
+ return seen
379
+
380
+
381
+ def test_working_status_animates_while_tools_run(hx: HX, stub: Stub) -> None:
382
+ """The working status moves for the whole turn: through each tool the model runs,
383
+ and through the command after an approval. It counts the turn's time, not the time
384
+ since the last model call. While the approval is open nothing spins, and the held
385
+ call's "Took" is its own run, not the user's time to decide. The status goes when
386
+ the turn ends, and a `!` command approved afterwards does not bring it back."""
387
+ stub.script(
388
+ call("Bash", "Looking first.", command="printf 'phase%s\\n' one && sleep 3"),
389
+ call(
390
+ "Bash",
391
+ "Now the change.",
392
+ command="touch made.txt && printf 'phase%s\\n' two && sleep 3",
393
+ ),
394
+ say("All done."),
395
+ )
396
+ term = hx.tui()
397
+ term.submit("look, then change it")
398
+
399
+ term.wait_for("phaseone")
400
+ term.snapshot("a read-only command runs")
401
+ during_read = _watch_working(term, 1.5)
402
+ assert during_read, "the working status is shown while the command runs"
403
+ assert len({frame for frame, _ in during_read}) >= 3, "and its spinner moves"
404
+ assert during_read[-1][1] >= 1, "and its clock counts"
405
+
406
+ screen = term.wait_for("Permission needed")
407
+ assert "? $ touch made.txt" in screen, "the held call is shown waiting, not running"
408
+ # Nothing is animating, so HX goes quiet - a spinner would keep painting.
409
+ started = time.monotonic()
410
+ term.settle(quiet=0.5, timeout=3)
411
+ assert time.monotonic() - started < 1.5, "HX keeps writing while nothing moves"
412
+ term.snapshot("an approval holds the turn")
413
+ assert _working(term.text()) is None, "nothing claims to be working while it waits"
414
+ still = term.text()
415
+ time.sleep(4) # the user thinks about it
416
+ assert term.text() == still
417
+
418
+ term.type("y")
419
+ term.wait_for("phasetwo")
420
+ term.snapshot("the approved command runs")
421
+ during_change = _watch_working(term, 1.5)
422
+ assert during_change, "the working status comes back once the approval is answered"
423
+ assert len({frame for frame, _ in during_change}) >= 3, "and its spinner moves"
424
+ assert during_change[0][1] >= 7, "the clock is the turn's, not reset by each model call"
425
+
426
+ term.wait_for("All done.")
427
+ term.settle()
428
+ term.snapshot("the turn is over")
429
+ screen = term.text()
430
+ assert _working(screen) is None
431
+ assert (hx.project / "made.txt").exists()
432
+ took = [float(seconds) for seconds in re.findall(r"Took ([\d.]+)s", term.scrollback())]
433
+ assert len(took) == 2 and all(3 <= seconds < 4 for seconds in took), took
434
+
435
+ term.submit("!touch made-by-bang.txt")
436
+ term.wait_for("Permission needed")
437
+ term.type("y")
438
+ term.wait_for("allowed once")
439
+ term.settle(quiet=0.5)
440
+ term.snapshot("after an approved ! command")
441
+ assert "Working…" not in term.text(), "a shell line is not a turn"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes