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.
Files changed (148) hide show
  1. {hx_cli-0.2.11 → hx_cli-0.2.12}/CHANGELOG.md +20 -1
  2. {hx_cli-0.2.11 → hx_cli-0.2.12}/PKG-INFO +9 -3
  3. {hx_cli-0.2.11 → hx_cli-0.2.12}/README.md +8 -2
  4. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/__init__.py +1 -1
  5. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/cli.py +18 -25
  6. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/loop.py +31 -17
  7. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/session.py +18 -5
  8. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/title.py +14 -1
  9. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/app.py +4 -0
  10. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/commands.py +1 -1
  11. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/runtime.py +29 -7
  12. {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/stub.py +26 -2
  13. {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/test_commands.py +172 -0
  14. {hx_cli-0.2.11 → hx_cli-0.2.12}/.github/workflows/ci.yml +0 -0
  15. {hx_cli-0.2.11 → hx_cli-0.2.12}/.gitignore +0 -0
  16. {hx_cli-0.2.11 → hx_cli-0.2.12}/examples/hooks/check.sh +0 -0
  17. {hx_cli-0.2.11 → hx_cli-0.2.12}/install.sh +0 -0
  18. {hx_cli-0.2.11 → hx_cli-0.2.12}/pyproject.toml +0 -0
  19. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/agents/__init__.py +0 -0
  20. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/agents/definitions.py +0 -0
  21. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/agents/subagent.py +0 -0
  22. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/auth/__init__.py +0 -0
  23. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/auth/oauth/__init__.py +0 -0
  24. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/auth/oauth/browser.py +0 -0
  25. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/auth/oauth/callback.py +0 -0
  26. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/auth/oauth/codex.py +0 -0
  27. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/auth/oauth/devin.py +0 -0
  28. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/auth/oauth/pkce.py +0 -0
  29. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/auth/resolve.py +0 -0
  30. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/auth/store.py +0 -0
  31. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/config.py +0 -0
  32. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/__init__.py +0 -0
  33. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/checkpoints.py +0 -0
  34. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/compaction.py +0 -0
  35. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/context.py +0 -0
  36. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/events.py +0 -0
  37. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/images.py +0 -0
  38. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/lateinject.py +0 -0
  39. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/messages.py +0 -0
  40. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/core/usage.py +0 -0
  41. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/docs.py +0 -0
  42. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/frontmatter.py +0 -0
  43. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/git.py +0 -0
  44. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/hooks/__init__.py +0 -0
  45. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/hooks/engine.py +0 -0
  46. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/hooks/spec.py +0 -0
  47. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/keys.py +0 -0
  48. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/mcp/__init__.py +0 -0
  49. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/mcp/client.py +0 -0
  50. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/mcp/manager.py +0 -0
  51. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/mcp/oauth.py +0 -0
  52. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/net.py +0 -0
  53. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/paths.py +0 -0
  54. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/permissions/__init__.py +0 -0
  55. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/permissions/engine.py +0 -0
  56. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/permissions/parser.py +0 -0
  57. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/permissions/sandbox.py +0 -0
  58. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/__init__.py +0 -0
  59. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/base.py +0 -0
  60. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/codex.py +0 -0
  61. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/codex_catalogue.py +0 -0
  62. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/devin.py +0 -0
  63. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/devin_catalogue.py +0 -0
  64. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/devin_wire.py +0 -0
  65. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/models.py +0 -0
  66. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/openrouter.py +0 -0
  67. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/protowire.py +0 -0
  68. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/registry.py +0 -0
  69. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/providers/responses_codec.py +0 -0
  70. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/skills/__init__.py +0 -0
  71. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/skills/loader.py +0 -0
  72. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/skills/runtime.py +0 -0
  73. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/__init__.py +0 -0
  74. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/ansi.py +0 -0
  75. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/buffer.py +0 -0
  76. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/component.py +0 -0
  77. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/demo.py +0 -0
  78. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/editor.py +0 -0
  79. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/keydecode.py +0 -0
  80. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/loop.py +0 -0
  81. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/markdown.py +0 -0
  82. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/modifiers.py +0 -0
  83. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/primitives.py +0 -0
  84. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/sanitize.py +0 -0
  85. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/screen.py +0 -0
  86. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/syntax.py +0 -0
  87. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/terminal.py +0 -0
  88. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/undo.py +0 -0
  89. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/width.py +0 -0
  90. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/term/word_nav.py +0 -0
  91. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/__init__.py +0 -0
  92. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/anchors.py +0 -0
  93. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/base.py +0 -0
  94. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/bash.py +0 -0
  95. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/edit.py +0 -0
  96. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/glob.py +0 -0
  97. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/grep.py +0 -0
  98. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/output.py +0 -0
  99. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/read.py +0 -0
  100. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/registry.py +0 -0
  101. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/symbols.py +0 -0
  102. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/task.py +0 -0
  103. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/todo.py +0 -0
  104. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/websearch.py +0 -0
  105. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tools/write.py +0 -0
  106. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/trace.py +0 -0
  107. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/__init__.py +0 -0
  108. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/clipboard.py +0 -0
  109. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/format.py +0 -0
  110. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/fuzzy.py +0 -0
  111. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/glyphs.py +0 -0
  112. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/killring.py +0 -0
  113. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/limits.py +0 -0
  114. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/paint.py +0 -0
  115. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/renderers.py +0 -0
  116. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/roles.py +0 -0
  117. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/theme.py +0 -0
  118. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/theme_json.py +0 -0
  119. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/themes/ansi.json +0 -0
  120. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/themes/dark.json +0 -0
  121. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/themes/light.json +0 -0
  122. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/views/__init__.py +0 -0
  123. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/views/blocks.py +0 -0
  124. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/views/dialog.py +0 -0
  125. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/views/login.py +0 -0
  126. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/views/permission.py +0 -0
  127. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/views/pickers.py +0 -0
  128. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/views/prompt.py +0 -0
  129. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/views/status.py +0 -0
  130. {hx_cli-0.2.11 → hx_cli-0.2.12}/src/hx/tui/views/transcript.py +0 -0
  131. {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/__init__.py +0 -0
  132. {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/conftest.py +0 -0
  133. {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/__init__.py +0 -0
  134. {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/conftest.py +0 -0
  135. {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/fixtures/echo_server.py +0 -0
  136. {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/report.py +0 -0
  137. {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/terminal.py +0 -0
  138. {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/test_chat.py +0 -0
  139. {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/test_cli.py +0 -0
  140. {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/test_extend.py +0 -0
  141. {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/test_input.py +0 -0
  142. {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/test_print.py +0 -0
  143. {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/e2e/test_tools.py +0 -0
  144. {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/test_live.py +0 -0
  145. {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/test_live_codex.py +0 -0
  146. {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/test_live_devin.py +0 -0
  147. {hx_cli-0.2.11 → hx_cli-0.2.12}/tests/test_live_tavily.py +0 -0
  148. {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.11...HEAD
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.11
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, from your first message. `/title
713
- <text>` renames it.
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, from your first message. `/title
673
- <text>` renames it.
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
@@ -1,5 +1,5 @@
1
1
  """HX - an agent harness for the terminal."""
2
2
 
3
- __version__ = "0.2.11"
3
+ __version__ = "0.2.12"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -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
- _relaunch(relaunch)
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
- def _relaunch(session_id: str) -> None:
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, session: Session | None = None) -> None:
370
- """Rename a session for what it turned into, as it closes.
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. Two hours later it is the wrong label on the row the user has
374
- to recognise in ``/resume``, and the list gives them no way to know that.
375
-
376
- ``session`` names the one to rename, for the cases where it is no longer
377
- the live one - ``/clear`` and ``/resume`` both leave a session behind.
378
-
379
- Only when the transcript grew since the name was written, and never at
380
- the cost of the name already there: a rename that fails leaves the old
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
- target = session if session is not None else self.session
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
- if len(target.messages) <= target.meta.title_message_count:
401
+ prompt_count = target.meta.prompt_count
402
+ if prompt_count == target.meta.title_prompt_count:
391
403
  return
392
404
 
393
- if title := await self._ask_for_title(messages, target):
394
- target.set_title(title)
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
- title_message_count: int = 0
43
- """How long the transcript was when the title was written, so closing the
44
- session can tell whether the name still describes it."""
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(self, title: str) -> None:
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.title_message_count = len(self.messages)
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)[:TRANSCRIPT_CHARS]
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
- for task in (events, spinner, branch, self._turn, self._side):
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
- task = asyncio.create_task(self.loop.retitle_session(session))
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
- task = asyncio.create_task(attach())
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._lock:
294
+ with stub._arrived:
279
295
  stub.title_requests.append(request)
280
- self._stream(Reply(text=stub.title, prompt_tokens=300, completion_tokens=5))
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