hx-cli 0.2.17__tar.gz → 0.2.18__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 (161) hide show
  1. {hx_cli-0.2.17 → hx_cli-0.2.18}/CHANGELOG.md +24 -1
  2. {hx_cli-0.2.17 → hx_cli-0.2.18}/PKG-INFO +1 -1
  3. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/__init__.py +1 -1
  4. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/loop.py +15 -0
  5. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/mcp/client.py +2 -5
  6. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/permissions/engine.py +6 -0
  7. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/edit.py +78 -14
  8. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/glob.py +2 -1
  9. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/grep.py +1 -1
  10. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/read.py +3 -2
  11. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/symbols.py +4 -3
  12. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/write.py +35 -7
  13. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/commands.py +5 -5
  14. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/renderers.py +24 -18
  15. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/views/blocks.py +13 -1
  16. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/views/permission.py +15 -0
  17. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/views/pickers.py +3 -3
  18. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_edits.py +81 -0
  19. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_tools.py +18 -1
  20. {hx_cli-0.2.17 → hx_cli-0.2.18}/.github/workflows/ci.yml +0 -0
  21. {hx_cli-0.2.17 → hx_cli-0.2.18}/.gitignore +0 -0
  22. {hx_cli-0.2.17 → hx_cli-0.2.18}/README.md +0 -0
  23. {hx_cli-0.2.17 → hx_cli-0.2.18}/examples/hooks/check.sh +0 -0
  24. {hx_cli-0.2.17 → hx_cli-0.2.18}/install.sh +0 -0
  25. {hx_cli-0.2.17 → hx_cli-0.2.18}/pyproject.toml +0 -0
  26. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/agents/__init__.py +0 -0
  27. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/agents/definitions.py +0 -0
  28. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/agents/subagent.py +0 -0
  29. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/__init__.py +0 -0
  30. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/claude_code.py +0 -0
  31. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/oauth/__init__.py +0 -0
  32. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/oauth/browser.py +0 -0
  33. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/oauth/callback.py +0 -0
  34. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/oauth/codex.py +0 -0
  35. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/oauth/devin.py +0 -0
  36. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/oauth/pkce.py +0 -0
  37. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/resolve.py +0 -0
  38. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/store.py +0 -0
  39. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/cli.py +0 -0
  40. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/config.py +0 -0
  41. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/__init__.py +0 -0
  42. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/checkpoints.py +0 -0
  43. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/compaction.py +0 -0
  44. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/context.py +0 -0
  45. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/events.py +0 -0
  46. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/images.py +0 -0
  47. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/lateinject.py +0 -0
  48. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/messages.py +0 -0
  49. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/session.py +0 -0
  50. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/title.py +0 -0
  51. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/usage.py +0 -0
  52. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/docs.py +0 -0
  53. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/frontmatter.py +0 -0
  54. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/git.py +0 -0
  55. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/hooks/__init__.py +0 -0
  56. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/hooks/engine.py +0 -0
  57. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/hooks/spec.py +0 -0
  58. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/keys.py +0 -0
  59. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/mcp/__init__.py +0 -0
  60. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/mcp/manager.py +0 -0
  61. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/mcp/oauth.py +0 -0
  62. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/net.py +0 -0
  63. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/paths.py +0 -0
  64. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/permissions/__init__.py +0 -0
  65. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/permissions/parser.py +0 -0
  66. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/permissions/sandbox.py +0 -0
  67. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/__init__.py +0 -0
  68. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/base.py +0 -0
  69. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/claude_code.py +0 -0
  70. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/claude_code_catalogue.py +0 -0
  71. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/claude_code_cli.py +0 -0
  72. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/claude_code_transcript.py +0 -0
  73. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/codex.py +0 -0
  74. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/codex_catalogue.py +0 -0
  75. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/devin.py +0 -0
  76. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/devin_catalogue.py +0 -0
  77. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/devin_wire.py +0 -0
  78. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/models.py +0 -0
  79. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/openrouter.py +0 -0
  80. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/protowire.py +0 -0
  81. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/registry.py +0 -0
  82. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/responses_codec.py +0 -0
  83. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/skills/__init__.py +0 -0
  84. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/skills/loader.py +0 -0
  85. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/skills/runtime.py +0 -0
  86. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/__init__.py +0 -0
  87. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/ansi.py +0 -0
  88. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/buffer.py +0 -0
  89. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/component.py +0 -0
  90. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/demo.py +0 -0
  91. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/editor.py +0 -0
  92. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/keydecode.py +0 -0
  93. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/loop.py +0 -0
  94. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/markdown.py +0 -0
  95. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/modifiers.py +0 -0
  96. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/primitives.py +0 -0
  97. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/sanitize.py +0 -0
  98. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/screen.py +0 -0
  99. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/syntax.py +0 -0
  100. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/terminal.py +0 -0
  101. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/undo.py +0 -0
  102. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/width.py +0 -0
  103. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/word_nav.py +0 -0
  104. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/text.py +0 -0
  105. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/__init__.py +0 -0
  106. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/anchors.py +0 -0
  107. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/base.py +0 -0
  108. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/bash.py +0 -0
  109. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/output.py +0 -0
  110. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/registry.py +0 -0
  111. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/task.py +0 -0
  112. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/todo.py +0 -0
  113. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/websearch.py +0 -0
  114. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/trace.py +0 -0
  115. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/__init__.py +0 -0
  116. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/app.py +0 -0
  117. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/clipboard.py +0 -0
  118. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/format.py +0 -0
  119. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/fuzzy.py +0 -0
  120. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/glyphs.py +0 -0
  121. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/killring.py +0 -0
  122. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/limits.py +0 -0
  123. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/paint.py +0 -0
  124. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/roles.py +0 -0
  125. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/runtime.py +0 -0
  126. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/theme.py +0 -0
  127. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/theme_json.py +0 -0
  128. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/themes/ansi.json +0 -0
  129. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/themes/dark.json +0 -0
  130. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/themes/light.json +0 -0
  131. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/views/__init__.py +0 -0
  132. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/views/dialog.py +0 -0
  133. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/views/login.py +0 -0
  134. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/views/prompt.py +0 -0
  135. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/views/status.py +0 -0
  136. {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/views/transcript.py +0 -0
  137. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/__init__.py +0 -0
  138. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/conftest.py +0 -0
  139. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/__init__.py +0 -0
  140. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/conftest.py +0 -0
  141. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/fake_claude.py +0 -0
  142. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/fixtures/echo_server.py +0 -0
  143. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/report.py +0 -0
  144. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/stub.py +0 -0
  145. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/terminal.py +0 -0
  146. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_chat.py +0 -0
  147. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_claude_code.py +0 -0
  148. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_cli.py +0 -0
  149. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_commands.py +0 -0
  150. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_extend.py +0 -0
  151. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_input.py +0 -0
  152. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_print.py +0 -0
  153. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_queue.py +0 -0
  154. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_shell.py +0 -0
  155. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/test_live.py +0 -0
  156. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/test_live_claude_code.py +0 -0
  157. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/test_live_codex.py +0 -0
  158. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/test_live_devin.py +0 -0
  159. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/test_live_tavily.py +0 -0
  160. {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/tls_proxy.py +0 -0
  161. {hx_cli-0.2.17 → hx_cli-0.2.18}/uv.lock +0 -0
@@ -28,6 +28,28 @@ the project follows [semantic versioning](https://semver.org/).
28
28
 
29
29
  ---
30
30
 
31
+ ## [0.2.18] - 2026-10-18
32
+
33
+ ### Security
34
+
35
+ - Edit and Write no longer leave a private file's new content readable by other users while
36
+ they write it. The temporary file now gets the file's own mode before any content goes in.
37
+
38
+ ### Fixed
39
+
40
+ - An edit through a symlink says which file it changes. The approval reads "config.yml is a
41
+ link to ~/shared/config.yml", and so does the answered approval and the result the model
42
+ gets. A link into a missing directory is refused in words, not with a raw error.
43
+ - The transcript names a symlink by its own name, not by the name of its target.
44
+ - A failed tool's error wraps in the transcript instead of being cut at the window's edge, so
45
+ a reason that comes after a long path stays readable.
46
+ - An edit to a CRLF file that differs only in line endings is refused as identical. It used to
47
+ be reported as applied, with nothing changed.
48
+ - Counts of one read in the singular when a limit leaves something out: "1 more line",
49
+ "1 more match", "1 more result". So do the other counts that still had "1 lines" forms.
50
+
51
+ ---
52
+
31
53
  ## [0.2.17] - 2026-10-18
32
54
 
33
55
  ### Security
@@ -958,7 +980,8 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
958
980
  - `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
959
981
  self-update, and `install.sh` bootstrapping uv with a pinned Python.
960
982
 
961
- [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.17...HEAD
983
+ [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.18...HEAD
984
+ [0.2.18]: https://github.com/aletisunil/hx/compare/v0.2.17...v0.2.18
962
985
  [0.2.17]: https://github.com/aletisunil/hx/compare/v0.2.16...v0.2.17
963
986
  [0.2.16]: https://github.com/aletisunil/hx/compare/v0.2.15...v0.2.16
964
987
  [0.2.15]: https://github.com/aletisunil/hx/compare/v0.2.14...v0.2.15
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hx-cli
3
- Version: 0.2.17
3
+ Version: 0.2.18
4
4
  Summary: HX - an agent harness for the terminal
5
5
  Project-URL: Homepage, https://hx.sunilaleti.dev
6
6
  Project-URL: Documentation, https://hx.sunilaleti.dev
@@ -1,5 +1,5 @@
1
1
  """HX - an agent harness for the terminal."""
2
2
 
3
- __version__ = "0.2.17"
3
+ __version__ = "0.2.18"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -889,6 +889,7 @@ class AgentLoop:
889
889
  description=f"{call.name}({_brief(call.input)})",
890
890
  detail=detail,
891
891
  detail_kind=detail_kind,
892
+ link_target=_link_target(call, self.settings.cwd),
892
893
  origin=self.origin,
893
894
  tool_use_id=call.id,
894
895
  )
@@ -1085,6 +1086,20 @@ def _permission_detail(call: ToolUseBlock, cwd: Path) -> tuple[str, str]:
1085
1086
  return _detailed(call.input), "text"
1086
1087
 
1087
1088
 
1089
+ def _link_target(call: ToolUseBlock, cwd: Path) -> str | None:
1090
+ """Where an Edit or Write lands when the path it names is a symlink."""
1091
+ if call.name not in {"Edit", "Write"} or not (raw_path := call.input.get("file_path")):
1092
+ return None
1093
+ from hx.tools.read import resolve_path
1094
+ from hx.tools.write import link_target
1095
+
1096
+ try:
1097
+ target = link_target(resolve_path(str(raw_path), cwd))
1098
+ except (OSError, RuntimeError): # a link loop, or a directory we cannot see into
1099
+ return None
1100
+ return str(target) if target is not None else None
1101
+
1102
+
1088
1103
  def _detailed(params: dict[str, Any], limit: int = 400) -> str:
1089
1104
  """What the approval prompt shows for a tool with no preview of its own.
1090
1105
 
@@ -20,6 +20,7 @@ import httpx
20
20
 
21
21
  from hx import __version__
22
22
  from hx.net import async_client
23
+ from hx.text import count
23
24
 
24
25
  if TYPE_CHECKING:
25
26
  from hx.mcp.oauth import OAuthSession
@@ -531,11 +532,7 @@ class MCPClient:
531
532
  if result.get("isError"):
532
533
  raise MCPToolError(text or f"{name} failed")
533
534
  if not text:
534
- text = (
535
- f"({len(images)} image{'s' if len(images) != 1 else ''})"
536
- if images
537
- else "(no content)"
538
- )
535
+ text = f"({count(len(images), 'image')})" if images else "(no content)"
539
536
  return MCPToolOutput(text=text, images=images)
540
537
 
541
538
  async def _request(
@@ -76,6 +76,12 @@ class PermissionRequest:
76
76
  that painter strips exactly those lines, so the user was asked to approve a
77
77
  blank space.
78
78
  """
79
+ link_target: str | None = None
80
+ """The file an edit really changes, when the path it names is a symlink.
81
+
82
+ The link can point anywhere, out of the project included, and the diff
83
+ alone does not say where it lands.
84
+ """
79
85
  origin: str | None = None
80
86
  """Which subagent asked, when it was not the main conversation."""
81
87
  tool_use_id: str | None = None
@@ -14,14 +14,16 @@ from __future__ import annotations
14
14
 
15
15
  import asyncio
16
16
  import difflib
17
+ import re
18
+ from collections.abc import Iterator
17
19
  from dataclasses import dataclass
18
- from typing import TYPE_CHECKING, Any
20
+ from typing import TYPE_CHECKING, Any, Literal
19
21
 
20
22
  from hx.text import count
21
23
  from hx.tools import anchors
22
24
  from hx.tools.base import Tool, ToolContext, ToolError, ToolResult
23
25
  from hx.tools.read import FileTracker, hashline_enabled, read_exact, resolve_path
24
- from hx.tools.write import write_atomic
26
+ from hx.tools.write import through_link, write_atomic
25
27
 
26
28
  if TYPE_CHECKING:
27
29
  from hx.core.checkpoints import CheckpointStore
@@ -162,7 +164,7 @@ class EditTool(Tool):
162
164
  diff = unified_diff(before, after, str(path))
163
165
  added, removed = count_changes(diff)
164
166
  return ToolResult(
165
- content=f"Applied {count(applied, 'edit')} to {path}.\n\n{diff}",
167
+ content=f"Applied {count(applied, 'edit')} to {path}{through_link(path)}.\n\n{diff}",
166
168
  summary=f"{path.name} +{added} -{removed}",
167
169
  metadata={"path": str(path), "diff": diff},
168
170
  )
@@ -192,6 +194,14 @@ def apply_edits(content: str, edits: list[EditOp]) -> str:
192
194
  new = _crlf(new)
193
195
  if result.count(old) == 0:
194
196
  old = _crlf(old)
197
+ # Compared again once both are spelled the file's way: an edit that
198
+ # only changed line endings came out identical here, replaced
199
+ # nothing, and was still reported as applied.
200
+ if old == new:
201
+ raise ToolError(
202
+ f"edit {index}: old_string and new_string are identical once their "
203
+ "line endings follow the file's CRLF. Edit keeps a file's line endings."
204
+ )
195
205
 
196
206
  occurrences = result.count(old)
197
207
  if occurrences == 0:
@@ -309,21 +319,75 @@ def parse_edits(params: dict[str, Any]) -> list[EditOp]:
309
319
  ]
310
320
 
311
321
 
312
- def count_changes(diff_text: str) -> tuple[int, int]:
313
- """Added and removed line counts, ignoring the ``+++``/``---`` file headers.
322
+ HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
323
+
324
+
325
+ @dataclass(frozen=True, slots=True)
326
+ class DiffRow:
327
+ """One meaningful line of a unified diff."""
328
+
329
+ kind: Literal["file", "hunk", "line"]
330
+ text: str
331
+ """The file's name, the hunk header, or the line with its ``-``/``+``/space."""
332
+ header: re.Match[str] | None = None
333
+ """For a hunk: its parsed header, which numbers the lines that follow."""
314
334
 
315
- Only lines inside a hunk are counted. Telling the headers apart by their
316
- doubled sign instead skipped every changed line whose own text starts with
317
- ``-`` or ``+`` - a SQL or Lua comment, ``++i``, a markdown rule.
335
+
336
+ def diff_rows(diff_text: str) -> Iterator[DiffRow]:
337
+ """The files, hunks and changed or context lines of a unified diff.
338
+
339
+ A hunk ends where its header says, after its count of old and new lines -
340
+ not at the next line that looks like a header. A changed line can start
341
+ with ``--`` or ``++`` (a SQL comment, ``++i``), so telling headers apart by
342
+ their sign hid those lines; and a hunk that never ended counted the next
343
+ file's ``---``/``+++`` headers as one removed and one added line.
344
+
345
+ Everything between hunks other than a file's header pair - ``diff --git``,
346
+ ``index``, mode lines - is left out.
318
347
  """
348
+ lines = diff_text.splitlines()
349
+ old_left = new_left = 0
350
+ index = 0
351
+ while index < len(lines):
352
+ line = lines[index]
353
+ index += 1
354
+ if header := HUNK_HEADER.match(line):
355
+ old_left = int(header.group(2)) if header.group(2) is not None else 1
356
+ new_left = int(header.group(4)) if header.group(4) is not None else 1
357
+ yield DiffRow("hunk", line, header)
358
+ elif old_left > 0 or new_left > 0:
359
+ if line.startswith("\\"):
360
+ continue # "" belongs to no side
361
+ if line.startswith("-"):
362
+ old_left -= 1
363
+ elif line.startswith("+"):
364
+ new_left -= 1
365
+ else:
366
+ old_left -= 1
367
+ new_left -= 1
368
+ yield DiffRow("line", line)
369
+ elif line.startswith("--- ") and index < len(lines) and lines[index].startswith("+++ "):
370
+ yield DiffRow("file", _file_name(line[4:], lines[index][4:]))
371
+ index += 1
372
+
373
+
374
+ def _file_name(old: str, new: str) -> str:
375
+ """The path a ``---``/``+++`` pair is about: the new one, or the old one for a
376
+ deletion, without git's ``a/``/``b/`` prefixes or a trailing timestamp."""
377
+ old, new = old.split("\t", 1)[0], new.split("\t", 1)[0]
378
+ sides = ((old, "a/"), (new, "b/"))
379
+ if all(side.startswith(prefix) or side == "/dev/null" for side, prefix in sides):
380
+ old, new = (side if side == "/dev/null" else side[2:] for side, _ in sides)
381
+ return old if new == "/dev/null" else new
382
+
383
+
384
+ def count_changes(diff_text: str) -> tuple[int, int]:
385
+ """Added and removed line counts, ignoring the ``+++``/``---`` file headers."""
319
386
  added = removed = 0
320
- in_hunk = False
321
- for line in diff_text.splitlines():
322
- if line.startswith("@@"):
323
- in_hunk = True
324
- elif in_hunk and line.startswith("+"):
387
+ for row in diff_rows(diff_text):
388
+ if row.kind == "line" and row.text.startswith("+"):
325
389
  added += 1
326
- elif in_hunk and line.startswith("-"):
390
+ elif row.kind == "line" and row.text.startswith("-"):
327
391
  removed += 1
328
392
  return added, removed
329
393
 
@@ -59,7 +59,8 @@ class GlobTool(Tool):
59
59
  shown = matches[:limit]
60
60
  body = "\n".join(str(path) for path in shown) or "(no matches)"
61
61
  if len(matches) > limit:
62
- body += f"\n\n... {len(matches) - limit} more matches (raise limit to see them)"
62
+ more = count(len(matches) - limit, "more match", "more matches")
63
+ body += f"\n\n... {more} (raise limit to see them)"
63
64
  return ToolResult(content=body, summary=count(len(matches), "match", "matches"))
64
65
 
65
66
 
@@ -79,7 +79,7 @@ class GrepTool(Tool):
79
79
  shown = lines[:limit]
80
80
  body = "\n".join(shown) or "(no matches)"
81
81
  if len(lines) > limit:
82
- body += f"\n\n... {len(lines) - limit} more results (raise limit to see them)"
82
+ body += f"\n\n... {count(len(lines) - limit, 'more result')} (raise limit to see them)"
83
83
  return ToolResult(content=body, summary=count(len(lines), "result"))
84
84
 
85
85
  def _ripgrep(self, pattern: str, root: Path, mode: str, params: dict[str, Any]) -> list[str]:
@@ -69,7 +69,7 @@ class ReadTool(Tool):
69
69
  return _read_image(path)
70
70
  if is_binary(path):
71
71
  size = path.stat().st_size
72
- raise ToolError(f"{path}: binary file ({size} bytes), not read as text")
72
+ raise ToolError(f"{path}: binary file ({count(size, 'byte')}), not read as text")
73
73
 
74
74
  offset = max(int(params.get("offset") or 1), 1)
75
75
  limit = int(params.get("limit") or DEFAULT_LINE_LIMIT)
@@ -105,7 +105,8 @@ class ReadTool(Tool):
105
105
  remaining = len(lines) - (offset - 1 + len(window))
106
106
  if remaining > 0:
107
107
  rendered += (
108
- f"\n\n... {remaining} more lines. Re-read with offset={offset + len(window)}."
108
+ f"\n\n... {count(remaining, 'more line')}. "
109
+ f"Re-read with offset={offset + len(window)}."
109
110
  )
110
111
 
111
112
  if not window:
@@ -23,6 +23,7 @@ from dataclasses import dataclass
23
23
  from pathlib import Path
24
24
  from typing import TYPE_CHECKING, Any
25
25
 
26
+ from hx.text import count
26
27
  from hx.tools.base import Tool, ToolContext, ToolError, ToolResult
27
28
  from hx.tools.glob import IGNORED_DIRS
28
29
 
@@ -218,7 +219,7 @@ class SymbolsTool(Tool):
218
219
  if not rows:
219
220
  return ToolResult(content=f"{path}: no definitions found", summary="0 symbols")
220
221
  body = f"{path}\n" + "\n".join(rows)
221
- return ToolResult(content=body, summary=f"{len(rows)} symbols")
222
+ return ToolResult(content=body, summary=count(len(rows), "symbol"))
222
223
 
223
224
  def _search(
224
225
  self, mode: str, symbol: str, params: dict[str, Any], ctx: ToolContext, limit: int
@@ -251,8 +252,8 @@ class SymbolsTool(Tool):
251
252
 
252
253
  body = "\n".join(hits)
253
254
  if truncated:
254
- body += f"\n\n... stopped at {limit} hits (raise limit to see more)"
255
- return ToolResult(content=body, summary=f"{len(hits)} hits")
255
+ body += f"\n\n... stopped at {count(limit, 'hit')} (raise limit to see more)"
256
+ return ToolResult(content=body, summary=count(len(hits), "hit"))
256
257
 
257
258
 
258
259
  def _matches(
@@ -88,12 +88,28 @@ class WriteTool(Tool):
88
88
  # than starting another, so "done\n" is one line, not two.
89
89
  lines = len(anchors.split(content)[0])
90
90
  return ToolResult(
91
- content=f"{verb} {path} ({count(lines, 'line')})",
91
+ content=f"{verb} {path}{through_link(path)} ({count(lines, 'line')})",
92
92
  summary=f"{verb} {path.name}",
93
93
  metadata={"path": str(path), "created": not existed},
94
94
  )
95
95
 
96
96
 
97
+ def link_target(path: Path) -> Path | None:
98
+ """The file a write to ``path`` really changes, when ``path`` is a symlink.
99
+
100
+ None for anything else. Callers name it to the user and the model, because
101
+ the link can point anywhere - out of the project included - and a write
102
+ reported against the link's own name hides where it landed.
103
+ """
104
+ return path.resolve() if path.is_symlink() else None
105
+
106
+
107
+ def through_link(path: Path) -> str:
108
+ """`` through the link to <target>`` for a symlink, else nothing."""
109
+ target = link_target(path)
110
+ return f" through the link to {target}" if target is not None else ""
111
+
112
+
97
113
  def write_atomic(path: Path, content: str) -> None:
98
114
  """Write via a temp file in the same directory, then rename.
99
115
 
@@ -103,26 +119,38 @@ def write_atomic(path: Path, content: str) -> None:
103
119
  The rename replaces whatever sits at the name it lands on, so two things
104
120
  are carried over by hand. A symlink is written through - the rename lands
105
121
  on the file it points at, and the link stays a link - rather than replaced
106
- by a regular file while its target goes unchanged. And the file's mode is
107
- copied onto the temp file, which would otherwise turn an executable script
108
- into one that no longer runs. A new file gets the mode the umask gives it.
122
+ by a regular file while its target goes unchanged. A link to a file that
123
+ does not exist yet creates it, the way a shell redirect would, provided
124
+ its directory exists. And the file's mode is copied onto the temp file,
125
+ which would otherwise turn an executable script into one that no longer
126
+ runs. A new file gets the mode the umask gives it.
127
+
128
+ The temp file is created with that mode, not opened wider and narrowed
129
+ after the write: in between, a private file's new content - an ``.env``
130
+ being edited - was readable by every user on the machine, and a crash in
131
+ that window left the readable copy on disk.
109
132
 
110
133
  Text is written as given, with no newline translation: the line endings
111
134
  in ``content`` are the ones the caller chose.
112
135
  """
113
- target = path.resolve() if path.is_symlink() else path
136
+ target = link_target(path) or path
137
+ if target is not path and not target.parent.is_dir():
138
+ raise ToolError(f"{path} is a link to {target}, and {target.parent} does not exist")
114
139
  try:
115
140
  mode: int | None = stat.S_IMODE(target.stat().st_mode)
116
141
  except FileNotFoundError:
117
142
  mode = None
118
143
 
119
144
  tmp = target.with_name(f".{target.name}.{secrets.token_hex(4)}.hx-tmp")
120
- # 0o666 through os.open, so the kernel applies the umask to a new file.
121
- fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666)
145
+ # Through os.open, so the kernel applies the umask on top: the temp file
146
+ # never allows more than the file it replaces, and a new file gets 0o666
147
+ # less the umask.
148
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666 if mode is None else mode)
122
149
  try:
123
150
  with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
124
151
  handle.write(content)
125
152
  if mode is not None:
153
+ # Again, exactly: the umask may have taken bits the file had.
126
154
  os.chmod(tmp, mode)
127
155
  os.replace(tmp, target)
128
156
  except BaseException:
@@ -561,7 +561,7 @@ async def cmd_cost(ctx: CommandContext, args: str) -> None:
561
561
  # message records, and a single prompt produces many of both.
562
562
  scale = count(len(usage.turns), "API request")
563
563
  if prompts:
564
- scale += f" across {prompts} prompt{'s' if prompts != 1 else ''}"
564
+ scale += f" across {count(prompts, 'prompt')}"
565
565
  lines = [
566
566
  f"Session cost: {format_cost(usage.total_cost_usd)} over {scale}",
567
567
  f"Input: {format_tokens(usage.total_input)} uncached",
@@ -921,8 +921,8 @@ async def _run_claude_code_login(ctx: CommandContext, spec: Any) -> None:
921
921
 
922
922
  who = status.account or "your Claude account"
923
923
  plan = f", {status.plan} plan" if status.plan else ""
924
- count = await _refresh_after_login(ctx, spec.id)
925
- offer = f"{count} models available" if count else "Pick a model with /model"
924
+ models = await _refresh_after_login(ctx, spec.id)
925
+ offer = f"{count(models, 'model')} available" if models else "Pick a model with /model"
926
926
  ctx.app.notice(f"Claude Code signed in ({who}{plan}). {offer}.", "success")
927
927
 
928
928
 
@@ -983,8 +983,8 @@ async def _run_oauth_login(ctx: CommandContext, spec: Any) -> None:
983
983
 
984
984
  # The model list is per account, so it only means anything once there is an
985
985
  # account: ask now rather than leaving /model showing the fallback guess.
986
- count = await _refresh_after_login(ctx, spec.id)
987
- offer = f"{count} models available" if count else "Pick a model with /model"
986
+ models = await _refresh_after_login(ctx, spec.id)
987
+ offer = f"{count(models, 'model')} available" if models else "Pick a model with /model"
988
988
  ctx.app.notice(f"{signed_in}. {offer}.", "success")
989
989
 
990
990
 
@@ -30,12 +30,12 @@ from hx.term.sanitize import plain_text
30
30
  from hx.term.syntax import highlight as highlight_source
31
31
  from hx.term.width import cell_width, truncate_to_width
32
32
  from hx.text import count
33
+ from hx.tools.edit import HUNK_HEADER, diff_rows
33
34
  from hx.tools.edit import count_changes as _count_changes
34
35
  from hx.tui.glyphs import BASH, ELLIPSIS, TODO_ACTIVE, TODO_DONE, TODO_PENDING
35
36
  from hx.tui.limits import EXPANDED_MAX, PREVIEW_LINES, RECORD_WIDTH
36
37
  from hx.tui.paint import color_mode, fg
37
38
 
38
- _HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
39
39
  _WORD = re.compile(r"\s+|\w+|[^\w\s]")
40
40
 
41
41
 
@@ -48,9 +48,8 @@ def expand_note(hidden: int) -> str:
48
48
  """
49
49
  from hx.keys import primary_key
50
50
 
51
- plural = "s" if hidden != 1 else ""
52
51
  return (
53
- fg("muted", f"{ELLIPSIS} ({hidden} more line{plural}, ")
52
+ fg("muted", f"{ELLIPSIS} ({count(hidden, 'more line')}, ")
54
53
  + fg("dim", primary_key("app.tools.expand"))
55
54
  + fg("muted", " to expand)")
56
55
  )
@@ -146,18 +145,29 @@ def display_path(raw: Any, cwd: Path) -> str:
146
145
 
147
146
  An absolute path inside a tool header is noise - forty characters of prefix
148
147
  the user already knows, pushing the part they do not know off the edge.
148
+
149
+ The directories are resolved and the name is not, so a symlink is shown by
150
+ its own name. Resolved whole, ``config.yml`` linked to a file in the home
151
+ directory was shown as ``~/shared/config.yml``, a name the model never
152
+ used - and the approval naming the link and its target named one file twice.
149
153
  """
150
154
  if not raw:
151
155
  return ""
152
156
  text = str(raw)
153
157
  try:
154
158
  path = Path(text).expanduser()
159
+ path = path if path.is_absolute() else cwd / path
160
+ named = (
161
+ path.parent.resolve() / path.name
162
+ if path.name not in ("", ".", "..")
163
+ else path.resolve()
164
+ )
155
165
  except (OSError, ValueError):
156
166
  return single_row(text)
157
167
 
158
168
  for base, prefix in ((cwd, ""), (Path.home(), "~/")):
159
169
  try:
160
- return single_row(prefix + str(path.resolve().relative_to(base.resolve())))
170
+ return single_row(prefix + str(named.relative_to(base.resolve())))
161
171
  except (OSError, ValueError):
162
172
  continue
163
173
  return single_row(text)
@@ -299,7 +309,6 @@ def render_diff(diff_text: str) -> list[str]:
299
309
  old_no = new_no = 0
300
310
  pending_removed: list[tuple[int, str]] = []
301
311
  pending_added: list[tuple[int, str]] = []
302
- in_hunk = False
303
312
 
304
313
  def flush() -> None:
305
314
  if len(pending_removed) == 1 and len(pending_added) == 1:
@@ -315,20 +324,18 @@ def render_diff(diff_text: str) -> list[str]:
315
324
  pending_removed.clear()
316
325
  pending_added.clear()
317
326
 
318
- for line in diff_text.splitlines():
319
- hunk = _HUNK.match(line)
320
- if not hunk and not in_hunk:
321
- # The ``---``/``+++`` file headers. Skipped by position rather than
322
- # by their doubled sign, which also hid every changed line whose own
323
- # text starts with ``--`` or ``++`` - a SQL comment, ``++i``.
327
+ # File headers and anything else between hunks are left out by diff_rows:
328
+ # the approval or the edit header above already names the file.
329
+ for row in diff_rows(diff_text):
330
+ if row.kind == "file":
324
331
  continue
325
- if hunk:
326
- in_hunk = True
332
+ if row.header is not None:
327
333
  flush()
328
- old_no, new_no = int(hunk.group(1)), int(hunk.group(3))
334
+ old_no, new_no = int(row.header.group(1)), int(row.header.group(3))
329
335
  if out:
330
336
  out.append(fg("diff_context", ELLIPSIS))
331
337
  continue
338
+ line = row.text
332
339
  if line.startswith("-"):
333
340
  pending_removed.append((old_no, line[1:].expandtabs(4)))
334
341
  old_no += 1
@@ -351,7 +358,7 @@ def looks_like_diff(text: str) -> bool:
351
358
  prefix alone meant YAML front matter and a markdown rule were run through
352
359
  the diff painter, which strips exactly those lines and emits nothing.
353
360
  """
354
- return any(_HUNK.match(line) for line in text.splitlines())
361
+ return any(HUNK_HEADER.match(line) for line in text.splitlines())
355
362
 
356
363
 
357
364
  #: Owned by the tools layer, which produces the diffs. The TUI may depend on
@@ -434,8 +441,7 @@ class WriteRenderer(ToolRenderer):
434
441
  header = fg("tool_title", "write ", bold=True) + fg("accent", path)
435
442
  content = str(call.params.get("content") or "")
436
443
  if content:
437
- count = len(content.splitlines())
438
- header += fg("dim", f" {count} line{'s' if count != 1 else ''}")
444
+ header += fg("dim", f" {count(len(content.splitlines()), 'line')}")
439
445
  return header
440
446
 
441
447
  def body(self, call: ToolCall) -> list[str]:
@@ -500,7 +506,7 @@ class BashRenderer(ToolRenderer):
500
506
  first, rest = self._command_lines(call)
501
507
  header = fg("accent", BASH, bold=True) + fg("tool_title", first or ELLIPSIS, bold=True)
502
508
  if rest:
503
- header += fg("muted", f" +{len(rest)} line{'s' if len(rest) != 1 else ''}")
509
+ header += fg("muted", f" +{count(len(rest), 'line')}")
504
510
  if timeout := call.params.get("timeout"):
505
511
  header += fg("muted", f" (timeout {timeout}s)")
506
512
  if call.params.get("run_in_background"):
@@ -16,6 +16,7 @@ from collections.abc import Sequence
16
16
  from pathlib import Path
17
17
  from typing import TYPE_CHECKING, Any
18
18
 
19
+ from hx.term.ansi import wrap
19
20
  from hx.term.component import Widget
20
21
  from hx.term.markdown import render_markdown
21
22
  from hx.term.primitives import Box, HangingText, Lines
@@ -155,6 +156,10 @@ def _stream_tail(text: str) -> str:
155
156
  return tail[tail.find("\n") + 1 :]
156
157
 
157
158
 
159
+ BODY_INDENT = 2
160
+ """Cells a tool block's body sits in from its marker, under the header's text."""
161
+
162
+
158
163
  class ToolBlock(Widget):
159
164
  """One tool call: a header, optionally some output, tinted by its state.
160
165
 
@@ -271,9 +276,16 @@ class ToolBlock(Widget):
271
276
 
272
277
  renderer = renderer_for(self._call.name)
273
278
  lines = [fg(marker_role, f"{marker} ") + renderer.header(self._call)]
279
+ body = renderer.body(self._call)
280
+ if state == "error":
281
+ # An error is prose, and wraps like prose. Cut at the edge the way
282
+ # code is, "<long path> is a link to <long path>, and ... does not
283
+ # exist" lost exactly the part that said what was wrong.
284
+ room = max(1, width - 2 - BODY_INDENT) # the box's padding, then the indent
285
+ body = [piece for line in body for piece in wrap(line, room)]
274
286
  # The body is indented to sit under the header's text rather than under
275
287
  # its marker, so the block has one left edge instead of two.
276
- lines += [f" {line}" for line in renderer.body(self._call)]
288
+ lines += [" " * BODY_INDENT + line for line in body]
277
289
  if self._call.interrupted:
278
290
  took = self._call.duration_ms
279
291
  lines.append(
@@ -184,6 +184,19 @@ class PermissionPrompt(Widget, Framed):
184
184
  return [*lines[:PREVIEW_LINES], expand_note(len(lines) - PREVIEW_LINES)]
185
185
 
186
186
  def _detail_lines(self) -> list[str]:
187
+ if link := self._link():
188
+ return [fg("warning", f"{link[0]} is a link to {link[1]}"), "", *self._body_lines()]
189
+ return self._body_lines()
190
+
191
+ def _link(self) -> tuple[str, str] | None:
192
+ """The path an edit names and the file it really changes, when they differ."""
193
+ target = getattr(self.request, "link_target", None)
194
+ path = (self.request.params or {}).get("file_path")
195
+ if not target or not path:
196
+ return None
197
+ return plain_text(display_path(path, self.cwd)), plain_text(display_path(target, self.cwd))
198
+
199
+ def _body_lines(self) -> list[str]:
187
200
  # Sanitized before anything reads it. This is the one block on screen
188
201
  # whose whole job is to show the user what is about to happen, so text
189
202
  # that can move the cursor or repaint the line is the last thing it
@@ -216,6 +229,8 @@ class PermissionPrompt(Widget, Framed):
216
229
  already knows, and the record is cut before the part they do not.
217
230
  """
218
231
  specifier = self.request.specifier
232
+ if link := self._link():
233
+ return f"{link[0]} → {link[1]}"
219
234
  if specifier and specifier == (self.request.params or {}).get("file_path"):
220
235
  return plain_text(display_path(specifier, self.cwd))
221
236
  return plain_text(str(specifier or self.request.description))
@@ -21,6 +21,7 @@ from hx.core.usage import format_tokens
21
21
  from hx.paths import tilde
22
22
  from hx.term.component import Widget
23
23
  from hx.term.primitives import Lines, Rule, Spacer, Text
24
+ from hx.text import count
24
25
  from hx.tui.format import columns, one_line
25
26
  from hx.tui.fuzzy import filter_items
26
27
  from hx.tui.glyphs import CURRENT, CURSOR, GUTTER
@@ -344,7 +345,7 @@ class ModelPicker(Picker):
344
345
  (values.index(value) for value in (selected, self.current) if value in values),
345
346
  min(self.selected, max(0, len(values) - 1)),
346
347
  )
347
- self.status = ("success", f"Refreshed {len(models)} models.")
348
+ self.status = ("success", f"Refreshed {count(len(models), 'model')}.")
348
349
  self.invalidate()
349
350
  self._redraw()
350
351
 
@@ -578,8 +579,7 @@ def _size(meta: Any) -> str:
578
579
  prompts = getattr(meta, "prompt_count", 0)
579
580
  if not prompts:
580
581
  return f"{meta.message_count} msgs"
581
- unit = "prompt" if prompts == 1 else "prompts"
582
- return f"{prompts} {unit} · {meta.message_count} msgs"
582
+ return f"{count(prompts, 'prompt')} · {meta.message_count} msgs"
583
583
 
584
584
 
585
585
  __all__ = [