hx-cli 0.2.11__tar.gz → 0.2.12__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.11 → hx_cli-0.2.12}/CHANGELOG.md +20 -1
- {hx_cli-0.2.11 → hx_cli-0.2.12}/PKG-INFO +9 -3
- {hx_cli-0.2.11 → hx_cli-0.2.12}/README.md +8 -2
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/__init__.py +1 -1
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/cli.py +18 -25
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/loop.py +31 -17
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/session.py +18 -5
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/title.py +14 -1
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/app.py +4 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/commands.py +1 -1
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/runtime.py +29 -7
- {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/stub.py +26 -2
- {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/test_commands.py +172 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/.gitignore +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/examples/hooks/check.sh +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/install.sh +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/pyproject.toml +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/agents/subagent.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/auth/__init__.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/auth/oauth/__init__.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/auth/oauth/browser.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/auth/oauth/callback.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/auth/oauth/codex.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/auth/oauth/devin.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/auth/oauth/pkce.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/auth/resolve.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/auth/store.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/config.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/checkpoints.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/compaction.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/context.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/events.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/images.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/messages.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/usage.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/docs.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/git.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/hooks/__init__.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/hooks/engine.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/hooks/spec.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/keys.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/mcp/client.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/mcp/manager.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/mcp/oauth.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/net.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/paths.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/permissions/engine.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/permissions/parser.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/base.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/codex.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/codex_catalogue.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/devin.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/devin_catalogue.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/devin_wire.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/models.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/openrouter.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/protowire.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/registry.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/responses_codec.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/__init__.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/ansi.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/buffer.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/component.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/demo.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/editor.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/keydecode.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/loop.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/markdown.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/modifiers.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/primitives.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/sanitize.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/screen.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/syntax.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/terminal.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/undo.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/width.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/word_nav.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/anchors.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/base.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/bash.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/edit.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/glob.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/grep.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/output.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/read.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/symbols.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/task.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/websearch.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/write.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/trace.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/clipboard.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/format.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/fuzzy.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/glyphs.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/killring.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/limits.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/paint.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/renderers.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/roles.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/theme.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/theme_json.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/themes/ansi.json +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/themes/dark.json +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/themes/light.json +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/views/__init__.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/views/blocks.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/views/dialog.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/views/login.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/views/permission.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/views/pickers.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/views/prompt.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/views/status.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/views/transcript.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/__init__.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/conftest.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/__init__.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/conftest.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/fixtures/echo_server.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/report.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/terminal.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/test_chat.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/test_cli.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/test_extend.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/test_input.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/test_print.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/test_tools.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/test_live.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/test_live_codex.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/test_live_devin.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/test_live_tavily.py +0 -0
- {hx_cli-0.2.11 → hx_cli-0.2.12}/uv.lock +0 -0
|
@@ -28,6 +28,24 @@ the project follows [semantic versioning](https://semver.org/).
|
|
|
28
28
|
|
|
29
29
|
---
|
|
30
30
|
|
|
31
|
+
## [0.2.12] - 2026-10-01
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- `/exit` and `ctrl+d` return the shell at once. They used to wait up to 10
|
|
36
|
+
seconds on a model call renaming the session; sessions are no longer renamed
|
|
37
|
+
on the way out, in the TUI or with `hx -p`.
|
|
38
|
+
- A name set with `/title` is no longer replaced by a generated one, including
|
|
39
|
+
one that was still being generated when you set it.
|
|
40
|
+
- The session name is generated from the opening and the latest stretch of the
|
|
41
|
+
conversation, not only its first 2000 characters.
|
|
42
|
+
- A session left before its first name came back is named from your first
|
|
43
|
+
message, instead of being listed by `/resume` with no name.
|
|
44
|
+
- A session left with `/resume` into another directory is renamed for what it
|
|
45
|
+
became, by the HX that starts there, in the background.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
31
49
|
## [0.2.11] - 2026-10-01
|
|
32
50
|
|
|
33
51
|
### Fixed
|
|
@@ -776,7 +794,8 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
|
|
|
776
794
|
- `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
|
|
777
795
|
self-update, and `install.sh` bootstrapping uv with a pinned Python.
|
|
778
796
|
|
|
779
|
-
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.
|
|
797
|
+
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.12...HEAD
|
|
798
|
+
[0.2.12]: https://github.com/aletisunil/hx/compare/v0.2.11...v0.2.12
|
|
780
799
|
[0.2.11]: https://github.com/aletisunil/hx/compare/v0.2.10...v0.2.11
|
|
781
800
|
[0.2.10]: https://github.com/aletisunil/hx/compare/v0.2.9...v0.2.10
|
|
782
801
|
[0.2.9]: https://github.com/aletisunil/hx/compare/v0.2.8...v0.2.9
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hx-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.12
|
|
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
|
|
@@ -709,8 +709,14 @@ After the first exchange, HX asks the model for a short name for the session and
|
|
|
709
709
|
writes it to `~/.hx/sessions/<id>/meta.json`, so `/resume` lists work rather
|
|
710
710
|
than timestamps. It is one small call — cap 32 output tokens, `models.title_model`
|
|
711
711
|
if you want a cheaper model for it — and it is counted in `/cost` like any other.
|
|
712
|
-
If the call fails the session is still named,
|
|
713
|
-
|
|
712
|
+
If the call fails, or you leave before it is back, the session is still named,
|
|
713
|
+
from your first message.
|
|
714
|
+
|
|
715
|
+
The live session is never renamed while it runs, and nothing is renamed on
|
|
716
|
+
`/exit`, which returns your shell at once. A session you leave with `/clear` or
|
|
717
|
+
`/resume` - including a `/resume` into another directory - is renamed in the
|
|
718
|
+
background for what it became. `/title <text>` renames it yourself, and a name
|
|
719
|
+
you chose is never replaced.
|
|
714
720
|
|
|
715
721
|
`/resume` lists every session except the one you are in: this directory's
|
|
716
722
|
first, then other directories', each row naming where it lives. A session from
|
|
@@ -669,8 +669,14 @@ After the first exchange, HX asks the model for a short name for the session and
|
|
|
669
669
|
writes it to `~/.hx/sessions/<id>/meta.json`, so `/resume` lists work rather
|
|
670
670
|
than timestamps. It is one small call — cap 32 output tokens, `models.title_model`
|
|
671
671
|
if you want a cheaper model for it — and it is counted in `/cost` like any other.
|
|
672
|
-
If the call fails the session is still named,
|
|
673
|
-
|
|
672
|
+
If the call fails, or you leave before it is back, the session is still named,
|
|
673
|
+
from your first message.
|
|
674
|
+
|
|
675
|
+
The live session is never renamed while it runs, and nothing is renamed on
|
|
676
|
+
`/exit`, which returns your shell at once. A session you leave with `/clear` or
|
|
677
|
+
`/resume` - including a `/resume` into another directory - is renamed in the
|
|
678
|
+
background for what it became. `/title <text>` renames it yourself, and a name
|
|
679
|
+
you chose is never replaced.
|
|
674
680
|
|
|
675
681
|
`/resume` lists every session except the one you are in: this directory's
|
|
676
682
|
first, then other directories', each row naming where it lives. A session from
|
|
@@ -609,6 +609,8 @@ def run_tui_command(parsed: ParsedArgs) -> int:
|
|
|
609
609
|
from hx.auth.resolve import MissingCredential
|
|
610
610
|
from hx.tui.app import run_tui
|
|
611
611
|
|
|
612
|
+
# Taken before anything starts, so no tool, MCP server or nested hx inherits it.
|
|
613
|
+
rename_left = os.environ.pop(RENAME_LEFT_ENV, None)
|
|
612
614
|
try:
|
|
613
615
|
resume = _resume_target(parsed)
|
|
614
616
|
moved = _adopt_session_directory(parsed, resume)
|
|
@@ -648,10 +650,8 @@ def run_tui_command(parsed: ParsedArgs) -> int:
|
|
|
648
650
|
notices=runtime.notices,
|
|
649
651
|
checkpoints=runtime.checkpoints,
|
|
650
652
|
tracker=runtime.tracker,
|
|
653
|
+
rename_left=rename_left,
|
|
651
654
|
)
|
|
652
|
-
# The TUI is down but the provider is not, which is the one moment
|
|
653
|
-
# the whole session exists and nothing is competing for the screen.
|
|
654
|
-
await _rename_closed_session(runtime.loop)
|
|
655
655
|
finally:
|
|
656
656
|
runtime.bus.close()
|
|
657
657
|
# Everything, not only the provider: a relaunch replaces this
|
|
@@ -661,7 +661,8 @@ def run_tui_command(parsed: ParsedArgs) -> int:
|
|
|
661
661
|
|
|
662
662
|
asyncio.run(main_async())
|
|
663
663
|
if relaunch is not None:
|
|
664
|
-
|
|
664
|
+
left = runtime.loop.session
|
|
665
|
+
_relaunch(relaunch, left=left.meta.session_id if left.messages else None)
|
|
665
666
|
return 0
|
|
666
667
|
|
|
667
668
|
|
|
@@ -724,9 +725,19 @@ def relaunch_argv(argv: list[str], session_id: str) -> list[str]:
|
|
|
724
725
|
return [*kept, "resume", session_id]
|
|
725
726
|
|
|
726
727
|
|
|
727
|
-
|
|
728
|
+
RENAME_LEFT_ENV = "HX_RENAME_LEFT_SESSION"
|
|
729
|
+
"""The session a relaunch left behind, handed to the HX that replaces it.
|
|
730
|
+
|
|
731
|
+
``/clear`` and ``/resume`` rename the session the user leaves, in the
|
|
732
|
+
background. A relaunch cannot: ``exec`` ends this process before the call is
|
|
733
|
+
back. So the new process does it, as detached as it is everywhere else."""
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _relaunch(session_id: str, *, left: str | None = None) -> None:
|
|
728
737
|
"""Replace this process with HX resumed on ``session_id``.
|
|
729
738
|
|
|
739
|
+
``left`` is the session being left, for the new process to rename.
|
|
740
|
+
|
|
730
741
|
``exec`` rather than a child: the terminal, the pid and the shell's job
|
|
731
742
|
control stay exactly as they were, and there is no parent left waiting.
|
|
732
743
|
|
|
@@ -738,30 +749,13 @@ def _relaunch(session_id: str) -> None:
|
|
|
738
749
|
"""
|
|
739
750
|
prefix = sys.orig_argv[: len(sys.orig_argv) - len(sys.argv) + 1]
|
|
740
751
|
command = [sys.executable, *prefix[1:], *relaunch_argv(sys.argv[1:], session_id)]
|
|
752
|
+
if left is not None:
|
|
753
|
+
os.environ[RENAME_LEFT_ENV] = left
|
|
741
754
|
sys.stdout.flush()
|
|
742
755
|
sys.stderr.flush()
|
|
743
756
|
os.execv(sys.executable, command)
|
|
744
757
|
|
|
745
758
|
|
|
746
|
-
RENAME_TIMEOUT_SECONDS = 10.0
|
|
747
|
-
"""A session name is not worth making the user wait for. Exit wins the tie."""
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
async def _rename_closed_session(loop: Any) -> None:
|
|
751
|
-
"""Re-name the session now that it is over, if the work moved on.
|
|
752
|
-
|
|
753
|
-
The first name is written after one exchange and never revisited, so
|
|
754
|
-
``/resume`` ends up listing opening questions. Bounded and swallowed: this
|
|
755
|
-
runs while the user is waiting for their shell prompt back.
|
|
756
|
-
"""
|
|
757
|
-
try:
|
|
758
|
-
await asyncio.wait_for(loop.retitle_session(), timeout=RENAME_TIMEOUT_SECONDS)
|
|
759
|
-
except Exception:
|
|
760
|
-
# Including the timeout. A rename that cannot happen is not an error the
|
|
761
|
-
# user needs at the moment they are leaving; the old name still stands.
|
|
762
|
-
return
|
|
763
|
-
|
|
764
|
-
|
|
765
759
|
def run_print_command(parsed: ParsedArgs) -> int:
|
|
766
760
|
"""Headless single-prompt run. Streams assistant text to stdout and tool
|
|
767
761
|
activity to stderr, so stdout stays pipeable."""
|
|
@@ -828,7 +822,6 @@ def run_print_command(parsed: ParsedArgs) -> int:
|
|
|
828
822
|
print(f"[mcp] {status.name} unavailable: {status.error}", file=sys.stderr)
|
|
829
823
|
try:
|
|
830
824
|
result = await runtime.loop.run(parsed.prompt, images)
|
|
831
|
-
await _rename_closed_session(runtime.loop)
|
|
832
825
|
finally:
|
|
833
826
|
await asyncio.sleep(0.05)
|
|
834
827
|
runtime.bus.close()
|
|
@@ -352,7 +352,8 @@ class AgentLoop:
|
|
|
352
352
|
moment ``run`` returns - a background task would simply be killed.
|
|
353
353
|
|
|
354
354
|
Naming must never cost the user a turn, so every failure falls back to
|
|
355
|
-
the first thing they said
|
|
355
|
+
the first thing they said - including leaving before the call is back,
|
|
356
|
+
which would otherwise list the session in ``/resume`` with no name.
|
|
356
357
|
"""
|
|
357
358
|
if self.origin is not None or self.session.meta.title or self._cancelled:
|
|
358
359
|
return
|
|
@@ -361,37 +362,50 @@ class AgentLoop:
|
|
|
361
362
|
if messages is None:
|
|
362
363
|
return
|
|
363
364
|
|
|
364
|
-
title = await self._ask_for_title(messages, self.session)
|
|
365
365
|
from hx.core.title import fallback_title
|
|
366
366
|
|
|
367
|
+
try:
|
|
368
|
+
title = await self._ask_for_title(messages, self.session)
|
|
369
|
+
except asyncio.CancelledError:
|
|
370
|
+
if not self.session.meta.title:
|
|
371
|
+
self.session.set_title(fallback_title(messages))
|
|
372
|
+
raise
|
|
373
|
+
# The user may have named it with /title while the call was out; the
|
|
374
|
+
# turn is over by then, and their name stands.
|
|
375
|
+
if self.session.meta.title:
|
|
376
|
+
return
|
|
377
|
+
|
|
367
378
|
self.session.set_title(title or fallback_title(messages))
|
|
368
379
|
|
|
369
|
-
async def retitle_session(self,
|
|
370
|
-
"""Rename a session for what it turned into, as it
|
|
380
|
+
async def retitle_session(self, target: Session) -> None:
|
|
381
|
+
"""Rename a session for what it turned into, as the user leaves it.
|
|
371
382
|
|
|
372
383
|
The first name is written after one exchange, so it describes an opening
|
|
373
|
-
question.
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
title alone rather than replacing something specific with a guess.
|
|
384
|
+
question. ``/clear`` and ``/resume`` both leave a session behind, and
|
|
385
|
+
that is the moment to name it for what it became - detached, so nobody
|
|
386
|
+
waits on it. The live session is never renamed while it runs.
|
|
387
|
+
|
|
388
|
+
Only when the conversation changed since the name was written, never
|
|
389
|
+
over a name the user chose, and never at the cost of the name already
|
|
390
|
+
there: a rename that fails leaves the old title alone rather than
|
|
391
|
+
replacing something specific with a guess.
|
|
382
392
|
"""
|
|
383
393
|
if self.origin is not None:
|
|
384
394
|
return
|
|
385
395
|
|
|
386
|
-
|
|
396
|
+
if target.meta.title_pinned:
|
|
397
|
+
return
|
|
387
398
|
messages = self._nameable_messages(target)
|
|
388
399
|
if messages is None:
|
|
389
400
|
return
|
|
390
|
-
|
|
401
|
+
prompt_count = target.meta.prompt_count
|
|
402
|
+
if prompt_count == target.meta.title_prompt_count:
|
|
391
403
|
return
|
|
392
404
|
|
|
393
|
-
|
|
394
|
-
|
|
405
|
+
title = await self._ask_for_title(messages, target)
|
|
406
|
+
# The user may have named it while the call was out; their name stands.
|
|
407
|
+
if title and not target.meta.title_pinned:
|
|
408
|
+
target.set_title(title, prompt_count=prompt_count)
|
|
395
409
|
|
|
396
410
|
def _nameable_messages(self, session: Session) -> list[Message] | None:
|
|
397
411
|
"""The transcript a name is derived from, or ``None`` if there is none."""
|
|
@@ -39,9 +39,12 @@ class SessionMeta:
|
|
|
39
39
|
updated_at: float
|
|
40
40
|
model: str
|
|
41
41
|
title: str | None = None
|
|
42
|
-
|
|
43
|
-
"""How
|
|
44
|
-
session can tell
|
|
42
|
+
title_prompt_count: int = 0
|
|
43
|
+
"""How many prompts the session held when the title was written, so the
|
|
44
|
+
session can tell when the conversation has moved on from its name."""
|
|
45
|
+
title_pinned: bool = False
|
|
46
|
+
"""The user named the session with ``/title``. Their name is never replaced
|
|
47
|
+
by a generated one."""
|
|
45
48
|
message_count: int = 0
|
|
46
49
|
"""Every message record: the user's prompts, the assistant's replies, and
|
|
47
50
|
the tool-result messages that ride the user role to match the provider wire
|
|
@@ -166,14 +169,24 @@ class Session:
|
|
|
166
169
|
self._pending.append({"kind": "environment", "data": asdict(environment)})
|
|
167
170
|
self.flush()
|
|
168
171
|
|
|
169
|
-
def set_title(
|
|
172
|
+
def set_title(
|
|
173
|
+
self, title: str, *, pinned: bool = False, prompt_count: int | None = None
|
|
174
|
+
) -> None:
|
|
170
175
|
"""Name the session for ``/resume``.
|
|
171
176
|
|
|
177
|
+
``pinned`` marks a name the user chose, which generated names never
|
|
178
|
+
replace. ``prompt_count`` is how much of the conversation the name
|
|
179
|
+
describes, when that is less than the whole of it - a name generated in
|
|
180
|
+
the background describes the transcript as it was when it was asked for.
|
|
181
|
+
|
|
172
182
|
``updated_at`` is deliberately left alone: it orders the picker and must
|
|
173
183
|
keep reflecting real activity, not the moment a name was written.
|
|
174
184
|
"""
|
|
175
185
|
self.meta.title = title.strip() or None
|
|
176
|
-
self.meta.
|
|
186
|
+
self.meta.title_pinned = pinned and self.meta.title is not None
|
|
187
|
+
self.meta.title_prompt_count = (
|
|
188
|
+
self.meta.prompt_count if prompt_count is None else prompt_count
|
|
189
|
+
)
|
|
177
190
|
self._write_meta()
|
|
178
191
|
|
|
179
192
|
def record_usage(self, usage: TurnUsage) -> None:
|
|
@@ -27,6 +27,10 @@ TITLE_MAX_TOKENS = 32
|
|
|
27
27
|
MAX_TITLE_CHARS = 50
|
|
28
28
|
"""A session row is read at a glance, in a list. Longer names stop being names."""
|
|
29
29
|
TRANSCRIPT_CHARS = 2_000
|
|
30
|
+
"""How much of the conversation the naming call sees."""
|
|
31
|
+
OPENING_CHARS = 500
|
|
32
|
+
"""Of that, how much is the opening. The rest is the latest stretch: a session
|
|
33
|
+
renamed for what it turned into has to be shown what it turned into."""
|
|
30
34
|
|
|
31
35
|
TITLE_PROMPT = """\
|
|
32
36
|
Name this coding session so it can be recognised in a list of sessions.
|
|
@@ -49,7 +53,7 @@ def build_title_request(messages: list[Message], model: str) -> ProviderRequest:
|
|
|
49
53
|
from hx.core.context import AssembledContext, PromptSection
|
|
50
54
|
from hx.providers.base import ProviderRequest
|
|
51
55
|
|
|
52
|
-
transcript = render_transcript(messages)
|
|
56
|
+
transcript = _excerpt(render_transcript(messages))
|
|
53
57
|
context = AssembledContext(
|
|
54
58
|
system=[PromptSection("system", "You name things briefly and precisely.")],
|
|
55
59
|
messages=[
|
|
@@ -83,6 +87,15 @@ async def generate_title(
|
|
|
83
87
|
return clean_title("".join(parts)) or None, usage
|
|
84
88
|
|
|
85
89
|
|
|
90
|
+
def _excerpt(transcript: str) -> str:
|
|
91
|
+
"""The opening and the latest stretch of a transcript too long to send whole."""
|
|
92
|
+
if len(transcript) <= TRANSCRIPT_CHARS:
|
|
93
|
+
return transcript
|
|
94
|
+
opening = transcript[:OPENING_CHARS]
|
|
95
|
+
latest = transcript[-(TRANSCRIPT_CHARS - OPENING_CHARS) :]
|
|
96
|
+
return f"{opening}\n[... earlier conversation omitted ...]\n{latest}"
|
|
97
|
+
|
|
98
|
+
|
|
86
99
|
def clean_title(raw: str) -> str:
|
|
87
100
|
"""Strip the decoration models add to a name, and cap its length.
|
|
88
101
|
|
|
@@ -31,12 +31,15 @@ async def run_tui(
|
|
|
31
31
|
notices: list[str] | None = None,
|
|
32
32
|
checkpoints: Any = None,
|
|
33
33
|
tracker: Any = None,
|
|
34
|
+
rename_left: str | None = None,
|
|
34
35
|
) -> str | None:
|
|
35
36
|
"""Run the interactive session.
|
|
36
37
|
|
|
37
38
|
The signature is the boundary ``hx.cli`` calls, and it did not change as
|
|
38
39
|
the frontend underneath it was replaced. Returns the id of a session to
|
|
39
40
|
relaunch into, when ``/resume`` picked one from another directory.
|
|
41
|
+
``rename_left`` is the session such a relaunch left behind, to be renamed
|
|
42
|
+
in the background for what it became.
|
|
40
43
|
"""
|
|
41
44
|
from hx.tui.runtime import run_session
|
|
42
45
|
|
|
@@ -54,4 +57,5 @@ async def run_tui(
|
|
|
54
57
|
notices=notices,
|
|
55
58
|
checkpoints=checkpoints,
|
|
56
59
|
tracker=tracker,
|
|
60
|
+
rename_left=rename_left,
|
|
57
61
|
)
|
|
@@ -504,7 +504,7 @@ async def cmd_title(ctx: CommandContext, args: str) -> None:
|
|
|
504
504
|
"info" if current else "warning",
|
|
505
505
|
)
|
|
506
506
|
return
|
|
507
|
-
session.set_title(args.strip())
|
|
507
|
+
session.set_title(args.strip(), pinned=True)
|
|
508
508
|
ctx.app.notice(f"Session title: {session.meta.title}", "success")
|
|
509
509
|
|
|
510
510
|
|
|
@@ -45,7 +45,7 @@ from hx.tui.views.prompt import Prompt
|
|
|
45
45
|
from hx.tui.views.transcript import Session
|
|
46
46
|
|
|
47
47
|
if TYPE_CHECKING:
|
|
48
|
-
from collections.abc import Sequence
|
|
48
|
+
from collections.abc import Coroutine, Sequence
|
|
49
49
|
|
|
50
50
|
from hx.config import Settings
|
|
51
51
|
from hx.core.events import EventBus
|
|
@@ -136,7 +136,7 @@ class HXSession:
|
|
|
136
136
|
self._queued: list[UserTurn] = []
|
|
137
137
|
self._background: set[asyncio.Task[Any]] = set()
|
|
138
138
|
"""Detached work - session renaming - held so it is not garbage
|
|
139
|
-
collected mid-flight."""
|
|
139
|
+
collected mid-flight, and cancelled on the way out."""
|
|
140
140
|
self._mouse = False
|
|
141
141
|
self._clear_armed = False
|
|
142
142
|
"""Set by one ctrl+c on an empty, idle prompt; a second one exits."""
|
|
@@ -185,10 +185,14 @@ class HXSession:
|
|
|
185
185
|
events = asyncio.create_task(self._consume_events())
|
|
186
186
|
spinner = asyncio.create_task(self._spin())
|
|
187
187
|
branch = asyncio.create_task(self._watch_branch())
|
|
188
|
+
if left := self.extra.get("rename_left"):
|
|
189
|
+
self._detach(self._rename_left_behind(left))
|
|
188
190
|
try:
|
|
189
191
|
await self.runner.run()
|
|
190
192
|
finally:
|
|
191
|
-
|
|
193
|
+
# Detached work included: renaming a session left with /clear is not
|
|
194
|
+
# worth the user waiting for their shell prompt. Its old name stands.
|
|
195
|
+
for task in (events, spinner, branch, self._turn, self._side, *self._background):
|
|
192
196
|
if task is not None and not task.done():
|
|
193
197
|
task.cancel()
|
|
194
198
|
with contextlib.suppress(asyncio.CancelledError):
|
|
@@ -489,9 +493,29 @@ class HXSession:
|
|
|
489
493
|
"""
|
|
490
494
|
if not session.messages:
|
|
491
495
|
return
|
|
492
|
-
|
|
496
|
+
self._detach(self.loop.retitle_session(session))
|
|
497
|
+
|
|
498
|
+
async def _rename_left_behind(self, session_id: str) -> None:
|
|
499
|
+
"""Rename the session a ``/resume`` into another directory left behind.
|
|
500
|
+
|
|
501
|
+
The process that left it was replaced before it could; see
|
|
502
|
+
:data:`hx.cli.RENAME_LEFT_ENV`. A session that cannot be read is simply
|
|
503
|
+
not renamed - its old name still stands.
|
|
504
|
+
"""
|
|
505
|
+
from hx.core.session import load_session
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
session = await asyncio.to_thread(load_session, session_id)
|
|
509
|
+
except Exception:
|
|
510
|
+
return
|
|
511
|
+
if session.messages:
|
|
512
|
+
await self.loop.retitle_session(session)
|
|
513
|
+
|
|
514
|
+
def _detach(self, work: Coroutine[Any, Any, None]) -> asyncio.Task[None]:
|
|
515
|
+
task = asyncio.create_task(work)
|
|
493
516
|
self._background.add(task)
|
|
494
517
|
task.add_done_callback(self._background.discard)
|
|
518
|
+
return task
|
|
495
519
|
|
|
496
520
|
def start_new_session(self) -> None:
|
|
497
521
|
"""Fresh transcript, same directory. The old session stays on disk."""
|
|
@@ -1139,9 +1163,7 @@ class HXSession:
|
|
|
1139
1163
|
self._notice(f"Could not attach the image: {error}", "error")
|
|
1140
1164
|
self.runner.request_immediate_render()
|
|
1141
1165
|
|
|
1142
|
-
|
|
1143
|
-
self._background.add(task)
|
|
1144
|
-
task.add_done_callback(self._background.discard)
|
|
1166
|
+
self._detach(attach())
|
|
1145
1167
|
|
|
1146
1168
|
async def _attach_images(self, paths: list[Path] | None) -> None:
|
|
1147
1169
|
"""Read, normalise and attach, off the event loop.
|
|
@@ -188,6 +188,9 @@ class Stub:
|
|
|
188
188
|
self.title_requests: list[Request] = []
|
|
189
189
|
self.unexpected: list[str] = []
|
|
190
190
|
self.title = "E2E session"
|
|
191
|
+
self.title_hold: threading.Event | None = None
|
|
192
|
+
"""When set, a naming call is answered only once this event is - a slow
|
|
193
|
+
title model, caught with the call still out."""
|
|
191
194
|
self.models = list(CATALOGUE)
|
|
192
195
|
self.model_fetches = 0
|
|
193
196
|
self._closing = threading.Event()
|
|
@@ -237,6 +240,19 @@ class Stub:
|
|
|
237
240
|
self._arrived.wait(remaining)
|
|
238
241
|
return list(self.requests)
|
|
239
242
|
|
|
243
|
+
def wait_for_title_requests(self, count: int, timeout: float = 15.0) -> list[Request]:
|
|
244
|
+
"""Block until ``count`` session-naming requests have arrived."""
|
|
245
|
+
deadline = time.monotonic() + timeout
|
|
246
|
+
with self._arrived:
|
|
247
|
+
while len(self.title_requests) < count:
|
|
248
|
+
remaining = deadline - time.monotonic()
|
|
249
|
+
if remaining <= 0:
|
|
250
|
+
raise AssertionError(
|
|
251
|
+
f"expected {count} title request(s), got {len(self.title_requests)}"
|
|
252
|
+
)
|
|
253
|
+
self._arrived.wait(remaining)
|
|
254
|
+
return list(self.title_requests)
|
|
255
|
+
|
|
240
256
|
def log(self) -> list[dict[str, Any]]:
|
|
241
257
|
"""What the report records: each request's model, last user text and tools."""
|
|
242
258
|
return [
|
|
@@ -275,9 +291,17 @@ class Stub:
|
|
|
275
291
|
return
|
|
276
292
|
request = Request(body=body, headers=dict(self.headers.items()))
|
|
277
293
|
if request.system == TITLE_SYSTEM:
|
|
278
|
-
with stub.
|
|
294
|
+
with stub._arrived:
|
|
279
295
|
stub.title_requests.append(request)
|
|
280
|
-
|
|
296
|
+
stub._arrived.notify_all()
|
|
297
|
+
self._stream(
|
|
298
|
+
Reply(
|
|
299
|
+
text=stub.title,
|
|
300
|
+
prompt_tokens=300,
|
|
301
|
+
completion_tokens=5,
|
|
302
|
+
hold=stub.title_hold,
|
|
303
|
+
)
|
|
304
|
+
)
|
|
281
305
|
return
|
|
282
306
|
if unanswered := _unanswered_tool_calls(request.messages):
|
|
283
307
|
# Every upstream rejects a tool call left without its
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import re
|
|
7
|
+
import threading
|
|
7
8
|
import time
|
|
8
9
|
|
|
9
10
|
from tests.e2e.conftest import HX, SANDBOX, SANDBOX_STATUS
|
|
@@ -224,6 +225,177 @@ def test_title_command(hx: HX, stub: Stub) -> None:
|
|
|
224
225
|
assert hx.sessions()[-1]["title"] == "Release planning"
|
|
225
226
|
|
|
226
227
|
|
|
228
|
+
def test_session_is_named_once_and_exit_does_not_wait(hx: HX, stub: Stub) -> None:
|
|
229
|
+
"""The session is named once, after its first exchange, and never renamed while it
|
|
230
|
+
runs. /exit returns the shell at once, even with a rename of a left session still out."""
|
|
231
|
+
parser = hx.project / "parser.py"
|
|
232
|
+
parser.write_text(
|
|
233
|
+
"".join(f"def rule_{n}(tokens):\n return tokens[{n}:]\n\n" for n in range(80))
|
|
234
|
+
)
|
|
235
|
+
stub.title = "Parser rules overview"
|
|
236
|
+
stub.script(
|
|
237
|
+
call("Read", file_path=str(parser)),
|
|
238
|
+
say("It is a table of slicing rules."),
|
|
239
|
+
say("rule_3 drops the first three tokens."),
|
|
240
|
+
say("Yes, every rule slices."),
|
|
241
|
+
say("Added --verbose."),
|
|
242
|
+
say("Tests pass."),
|
|
243
|
+
)
|
|
244
|
+
term = hx.tui()
|
|
245
|
+
term.submit("what does parser.py do")
|
|
246
|
+
term.wait_for("It is a table of slicing rules.")
|
|
247
|
+
asked = stub.wait_for_title_requests(1)[0].last_user_text()
|
|
248
|
+
assert "what does parser.py do" in asked, "the opening is shown"
|
|
249
|
+
assert "It is a table of slicing rules." in asked, "so is how the exchange ended"
|
|
250
|
+
assert "earlier conversation omitted" in asked, "the long middle is cut, and says so"
|
|
251
|
+
assert hx.sessions()[-1]["title"] == "Parser rules overview"
|
|
252
|
+
|
|
253
|
+
stub.title = "Never asked for"
|
|
254
|
+
for prompt, reply in (
|
|
255
|
+
("what does rule_3 do", "rule_3 drops the first three tokens."),
|
|
256
|
+
("do they all slice", "Yes, every rule slices."),
|
|
257
|
+
("now add a --verbose flag to the CLI", "Added --verbose."),
|
|
258
|
+
("run the tests", "Tests pass."),
|
|
259
|
+
):
|
|
260
|
+
term.submit(prompt)
|
|
261
|
+
term.wait_for(reply)
|
|
262
|
+
term.settle(0.5)
|
|
263
|
+
assert len(stub.title_requests) == 1, "the live session is not renamed while it runs"
|
|
264
|
+
assert hx.sessions()[-1]["title"] == "Parser rules overview"
|
|
265
|
+
|
|
266
|
+
# Leaving it with /clear renames it for what it became, in the background.
|
|
267
|
+
# A slow title model holds that call; /exit must not wait on it.
|
|
268
|
+
stub.title_hold = threading.Event()
|
|
269
|
+
term.submit("/clear")
|
|
270
|
+
term.wait_for("New session started.")
|
|
271
|
+
stub.wait_for_title_requests(2)
|
|
272
|
+
term.type("/exit")
|
|
273
|
+
started = time.monotonic()
|
|
274
|
+
term.press("enter")
|
|
275
|
+
assert term.wait_exit(timeout=5) == 0
|
|
276
|
+
took = time.monotonic() - started
|
|
277
|
+
hx.note(f"/exit with a rename in flight returned in {took:.2f}s")
|
|
278
|
+
assert took < 1.0, f"/exit took {took:.2f}s"
|
|
279
|
+
term.snapshot("shell prompt back")
|
|
280
|
+
assert "Traceback" not in term.scrollback()
|
|
281
|
+
(left,) = [meta for meta in hx.sessions() if meta["prompt_count"] == 5]
|
|
282
|
+
assert left["title"] == "Parser rules overview", "the abandoned rename wrote nothing"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_exit_while_the_session_is_being_named_still_names_it(hx: HX, stub: Stub) -> None:
|
|
286
|
+
"""Leaving with the naming call still out names the session from the first prompt,
|
|
287
|
+
rather than leaving /resume a row with no name at all."""
|
|
288
|
+
stub.title_hold = threading.Event()
|
|
289
|
+
stub.title = "Never arrives"
|
|
290
|
+
stub.script(say("The retry wraps the request, not the session."))
|
|
291
|
+
term = hx.tui()
|
|
292
|
+
term.submit("why does the login test flake on CI")
|
|
293
|
+
term.wait_for("The retry wraps the request, not the session.")
|
|
294
|
+
stub.wait_for_title_requests(1)
|
|
295
|
+
term.type("/exit")
|
|
296
|
+
started = time.monotonic()
|
|
297
|
+
term.press("enter")
|
|
298
|
+
assert term.wait_exit(timeout=5) == 0
|
|
299
|
+
took = time.monotonic() - started
|
|
300
|
+
hx.note(f"/exit with the first naming call in flight returned in {took:.2f}s")
|
|
301
|
+
assert took < 1.0, f"/exit took {took:.2f}s"
|
|
302
|
+
assert "Traceback" not in term.scrollback()
|
|
303
|
+
(meta,) = hx.sessions()
|
|
304
|
+
assert meta["title"] == "why does the login test flake on CI"
|
|
305
|
+
assert meta["title_pinned"] is False
|
|
306
|
+
|
|
307
|
+
# Not a name the user chose: leaving it again, having moved on, renames it.
|
|
308
|
+
stub.title_hold = None
|
|
309
|
+
stub.title = "Login test flake on CI"
|
|
310
|
+
stub.script(say("Pinned the session cookie."))
|
|
311
|
+
term = hx.tui("resume")
|
|
312
|
+
term.wait_for("The retry wraps the request, not the session.")
|
|
313
|
+
term.submit("fix it")
|
|
314
|
+
term.wait_for("Pinned the session cookie.")
|
|
315
|
+
term.submit("/clear")
|
|
316
|
+
term.wait_for("New session started.")
|
|
317
|
+
stub.wait_for_title_requests(2)
|
|
318
|
+
term.settle(0.5)
|
|
319
|
+
(meta,) = [meta for meta in hx.sessions() if meta["prompt_count"] == 2]
|
|
320
|
+
assert meta["title"] == "Login test flake on CI"
|
|
321
|
+
term.press("ctrl+d")
|
|
322
|
+
assert term.wait_exit() == 0
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def test_resume_into_another_directory_renames_the_session_left(hx: HX, stub: Stub) -> None:
|
|
326
|
+
"""/resume of a session from another directory relaunches HX there. The session left
|
|
327
|
+
behind is still renamed for what it became - by the new process, in the background."""
|
|
328
|
+
elsewhere = hx.root / "elsewhere"
|
|
329
|
+
elsewhere.mkdir()
|
|
330
|
+
stub.title = "Owl facts"
|
|
331
|
+
stub.script(say("Owls are nocturnal."))
|
|
332
|
+
hx.run("-p", "tell me about owls", cwd=elsewhere, check=True)
|
|
333
|
+
|
|
334
|
+
stub.title = "Parser question"
|
|
335
|
+
stub.script(say("It parses rules."), say("Added --verbose."), say("Tests pass."))
|
|
336
|
+
term = hx.tui()
|
|
337
|
+
for prompt, reply in (
|
|
338
|
+
("what does parser.py do", "It parses rules."),
|
|
339
|
+
("now add a --verbose flag to the CLI", "Added --verbose."),
|
|
340
|
+
("run the tests", "Tests pass."),
|
|
341
|
+
):
|
|
342
|
+
term.submit(prompt)
|
|
343
|
+
term.wait_for(reply)
|
|
344
|
+
stub.wait_for_title_requests(2)
|
|
345
|
+
|
|
346
|
+
stub.title = "Verbose flag for the CLI"
|
|
347
|
+
term.submit("/resume")
|
|
348
|
+
screen = term.wait_for("Resume session")
|
|
349
|
+
assert "Owl facts" in screen
|
|
350
|
+
term.press("enter")
|
|
351
|
+
term.wait_for("Owls are nocturnal.")
|
|
352
|
+
term.snapshot("relaunched into the other directory")
|
|
353
|
+
stub.wait_for_title_requests(3)
|
|
354
|
+
term.settle(0.5)
|
|
355
|
+
(left,) = [meta for meta in hx.sessions() if meta["prompt_count"] == 3]
|
|
356
|
+
assert left["title"] == "Verbose flag for the CLI"
|
|
357
|
+
(resumed,) = [meta for meta in hx.sessions() if meta["cwd"] == str(elsewhere)]
|
|
358
|
+
assert resumed["title"] == "Owl facts", "the live session is not renamed"
|
|
359
|
+
|
|
360
|
+
# The handoff stops at HX: nothing it starts inherits it, so a nested hx
|
|
361
|
+
# cannot rename the session a second time.
|
|
362
|
+
term.submit("!echo left=${HX_RENAME_LEFT_SESSION:-none}")
|
|
363
|
+
term.wait_for("left=none")
|
|
364
|
+
term.press("ctrl+d")
|
|
365
|
+
assert term.wait_exit() == 0
|
|
366
|
+
assert "Traceback" not in term.scrollback()
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_a_title_the_user_chose_is_never_replaced(hx: HX, stub: Stub) -> None:
|
|
370
|
+
"""/title pins the name: neither the first naming, still out when it is set, nor the
|
|
371
|
+
rename on /clear replaces it."""
|
|
372
|
+
stub.title_hold = threading.Event()
|
|
373
|
+
stub.title = "Generated name"
|
|
374
|
+
stub.script(say("Hello."), say("A."), say("B."))
|
|
375
|
+
term = hx.tui()
|
|
376
|
+
term.submit("hi")
|
|
377
|
+
term.wait_for("Hello.")
|
|
378
|
+
stub.wait_for_title_requests(1)
|
|
379
|
+
# The model is still naming the session when the user names it themselves.
|
|
380
|
+
term.submit("/title Release planning")
|
|
381
|
+
term.wait_for("✓ Session title: Release planning")
|
|
382
|
+
stub.title_hold.set()
|
|
383
|
+
stub.title_hold = None
|
|
384
|
+
|
|
385
|
+
for prompt, reply in (("a", "A."), ("b", "B.")):
|
|
386
|
+
term.submit(prompt)
|
|
387
|
+
term.wait_for(reply)
|
|
388
|
+
term.submit("/clear")
|
|
389
|
+
term.wait_for("New session started.")
|
|
390
|
+
term.settle(0.5)
|
|
391
|
+
assert len(stub.title_requests) == 1, "a pinned name is not asked for again"
|
|
392
|
+
term.press("ctrl+d")
|
|
393
|
+
assert term.wait_exit() == 0
|
|
394
|
+
(meta,) = [meta for meta in hx.sessions() if meta["prompt_count"] == 3]
|
|
395
|
+
assert meta["title"] == "Release planning"
|
|
396
|
+
assert meta["title_pinned"] is True
|
|
397
|
+
|
|
398
|
+
|
|
227
399
|
def test_copy_puts_the_last_reply_on_the_clipboard(hx: HX, stub: Stub) -> None:
|
|
228
400
|
"""/copy copies the last answer - not the command's own echo - and says how."""
|
|
229
401
|
stub.script(say("Copy me, please."))
|
|
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
|