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.
- {hx_cli-0.2.17 → hx_cli-0.2.18}/CHANGELOG.md +24 -1
- {hx_cli-0.2.17 → hx_cli-0.2.18}/PKG-INFO +1 -1
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/__init__.py +1 -1
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/loop.py +15 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/mcp/client.py +2 -5
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/permissions/engine.py +6 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/edit.py +78 -14
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/glob.py +2 -1
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/grep.py +1 -1
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/read.py +3 -2
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/symbols.py +4 -3
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/write.py +35 -7
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/commands.py +5 -5
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/renderers.py +24 -18
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/views/blocks.py +13 -1
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/views/permission.py +15 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/views/pickers.py +3 -3
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_edits.py +81 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_tools.py +18 -1
- {hx_cli-0.2.17 → hx_cli-0.2.18}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/.gitignore +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/README.md +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/examples/hooks/check.sh +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/install.sh +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/pyproject.toml +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/agents/subagent.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/__init__.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/claude_code.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/oauth/__init__.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/oauth/browser.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/oauth/callback.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/oauth/codex.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/oauth/devin.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/oauth/pkce.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/resolve.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/auth/store.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/cli.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/config.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/checkpoints.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/compaction.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/context.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/events.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/images.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/messages.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/session.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/title.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/core/usage.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/docs.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/git.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/hooks/__init__.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/hooks/engine.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/hooks/spec.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/keys.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/mcp/manager.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/mcp/oauth.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/net.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/paths.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/permissions/parser.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/base.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/claude_code.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/claude_code_catalogue.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/claude_code_cli.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/claude_code_transcript.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/codex.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/codex_catalogue.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/devin.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/devin_catalogue.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/devin_wire.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/models.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/openrouter.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/protowire.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/registry.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/providers/responses_codec.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/__init__.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/ansi.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/buffer.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/component.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/demo.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/editor.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/keydecode.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/loop.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/markdown.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/modifiers.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/primitives.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/sanitize.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/screen.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/syntax.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/terminal.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/undo.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/width.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/term/word_nav.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/text.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/anchors.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/base.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/bash.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/output.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/task.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tools/websearch.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/trace.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/app.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/clipboard.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/format.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/fuzzy.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/glyphs.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/killring.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/limits.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/paint.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/roles.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/runtime.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/theme.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/theme_json.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/themes/ansi.json +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/themes/dark.json +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/themes/light.json +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/views/__init__.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/views/dialog.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/views/login.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/views/prompt.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/views/status.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/src/hx/tui/views/transcript.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/__init__.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/conftest.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/__init__.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/conftest.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/fake_claude.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/fixtures/echo_server.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/report.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/stub.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/terminal.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_chat.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_claude_code.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_cli.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_commands.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_extend.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_input.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_print.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_queue.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/e2e/test_shell.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/test_live.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/test_live_claude_code.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/test_live_codex.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/test_live_devin.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/test_live_tavily.py +0 -0
- {hx_cli-0.2.17 → hx_cli-0.2.18}/tests/tls_proxy.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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=
|
|
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}
|
|
255
|
-
return ToolResult(content=body, summary=
|
|
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.
|
|
107
|
-
|
|
108
|
-
|
|
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 =
|
|
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
|
-
#
|
|
121
|
-
|
|
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
|
|
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
|
-
|
|
925
|
-
offer = f"{count}
|
|
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
|
-
|
|
987
|
-
offer = f"{count}
|
|
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
|
|
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(
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
326
|
-
in_hunk = True
|
|
332
|
+
if row.header is not None:
|
|
327
333
|
flush()
|
|
328
|
-
old_no, new_no = int(
|
|
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(
|
|
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
|
-
|
|
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)
|
|
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 += [
|
|
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)}
|
|
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
|
-
|
|
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__ = [
|