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.
- {hx_cli-0.2.15 → hx_cli-0.2.16}/CHANGELOG.md +16 -1
- {hx_cli-0.2.15 → hx_cli-0.2.16}/PKG-INFO +1 -1
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/__init__.py +1 -1
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/loop.py +36 -10
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/permissions/engine.py +3 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/glyphs.py +4 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/runtime.py +41 -14
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/views/blocks.py +18 -7
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/views/transcript.py +8 -4
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/terminal.py +5 -1
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/test_chat.py +3 -2
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/test_tools.py +115 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/.gitignore +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/README.md +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/examples/hooks/check.sh +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/install.sh +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/pyproject.toml +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/agents/subagent.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/claude_code.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/oauth/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/oauth/browser.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/oauth/callback.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/oauth/codex.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/oauth/devin.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/oauth/pkce.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/resolve.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/auth/store.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/cli.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/config.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/checkpoints.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/compaction.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/context.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/events.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/images.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/messages.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/session.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/title.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/core/usage.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/docs.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/git.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/hooks/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/hooks/engine.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/hooks/spec.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/keys.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/mcp/client.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/mcp/manager.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/mcp/oauth.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/net.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/paths.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/permissions/parser.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/base.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/claude_code.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/claude_code_catalogue.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/claude_code_cli.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/claude_code_transcript.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/codex.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/codex_catalogue.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/devin.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/devin_catalogue.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/devin_wire.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/models.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/openrouter.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/protowire.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/registry.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/providers/responses_codec.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/ansi.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/buffer.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/component.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/demo.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/editor.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/keydecode.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/loop.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/markdown.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/modifiers.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/primitives.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/sanitize.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/screen.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/syntax.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/terminal.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/undo.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/width.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/term/word_nav.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/anchors.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/base.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/bash.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/edit.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/glob.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/grep.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/output.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/read.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/symbols.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/task.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/websearch.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tools/write.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/trace.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/app.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/clipboard.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/commands.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/format.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/fuzzy.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/killring.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/limits.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/paint.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/renderers.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/roles.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/theme.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/theme_json.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/themes/ansi.json +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/themes/dark.json +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/themes/light.json +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/views/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/views/dialog.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/views/login.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/views/permission.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/views/pickers.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/views/prompt.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/src/hx/tui/views/status.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/conftest.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/conftest.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/fake_claude.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/fixtures/echo_server.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/report.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/stub.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/test_claude_code.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/test_cli.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/test_commands.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/test_extend.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/test_input.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/test_print.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/e2e/test_queue.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/test_live.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/test_live_claude_code.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/test_live_codex.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/test_live_devin.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/test_live_tavily.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.16}/tests/tls_proxy.py +0 -0
- {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.
|
|
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
|
|
@@ -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)
|
|
@@ -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
|
-
|
|
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.
|
|
@@ -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 =
|
|
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) ->
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
|
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
|