hx-cli 0.1.2__tar.gz → 0.1.4__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 (130) hide show
  1. {hx_cli-0.1.2 → hx_cli-0.1.4}/PKG-INFO +1 -1
  2. hx_cli-0.1.4/install.sh +136 -0
  3. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/__init__.py +1 -1
  4. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/app.py +12 -9
  5. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/input.py +20 -6
  6. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/permissions/test_sandbox.py +0 -26
  7. hx_cli-0.1.4/tests/test_install.py +53 -0
  8. hx_cli-0.1.4/tests/tools/test_output.py +31 -0
  9. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/__snapshots__/test_snapshots/test_a_running_turn.raw +81 -81
  10. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/__snapshots__/test_snapshots/test_a_turn_with_tool_blocks.raw +83 -83
  11. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/__snapshots__/test_snapshots/test_the_completion_popup.raw +80 -80
  12. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/__snapshots__/test_snapshots/test_the_idle_screen.raw +79 -79
  13. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_app.py +83 -0
  14. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_snapshots.py +18 -1
  15. hx_cli-0.1.2/install.sh +0 -62
  16. hx_cli-0.1.2/tests/tools/test_output.py +0 -55
  17. {hx_cli-0.1.2 → hx_cli-0.1.4}/.github/workflows/ci.yml +0 -0
  18. {hx_cli-0.1.2 → hx_cli-0.1.4}/.gitignore +0 -0
  19. {hx_cli-0.1.2 → hx_cli-0.1.4}/README.md +0 -0
  20. {hx_cli-0.1.2 → hx_cli-0.1.4}/pyproject.toml +0 -0
  21. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/agents/__init__.py +0 -0
  22. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/agents/definitions.py +0 -0
  23. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/agents/subagent.py +0 -0
  24. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/cli.py +0 -0
  25. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/config.py +0 -0
  26. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/core/__init__.py +0 -0
  27. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/core/compaction.py +0 -0
  28. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/core/context.py +0 -0
  29. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/core/events.py +0 -0
  30. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/core/lateinject.py +0 -0
  31. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/core/loop.py +0 -0
  32. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/core/messages.py +0 -0
  33. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/core/session.py +0 -0
  34. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/core/usage.py +0 -0
  35. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/frontmatter.py +0 -0
  36. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/keys.py +0 -0
  37. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/mcp/__init__.py +0 -0
  38. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/mcp/client.py +0 -0
  39. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/mcp/manager.py +0 -0
  40. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/paths.py +0 -0
  41. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/permissions/__init__.py +0 -0
  42. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/permissions/engine.py +0 -0
  43. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/permissions/parser.py +0 -0
  44. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/permissions/sandbox.py +0 -0
  45. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/providers/__init__.py +0 -0
  46. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/providers/base.py +0 -0
  47. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/providers/fake.py +0 -0
  48. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/providers/models.py +0 -0
  49. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/providers/openrouter.py +0 -0
  50. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/skills/__init__.py +0 -0
  51. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/skills/loader.py +0 -0
  52. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/skills/runtime.py +0 -0
  53. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/__init__.py +0 -0
  54. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/base.py +0 -0
  55. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/bash.py +0 -0
  56. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/edit.py +0 -0
  57. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/glob.py +0 -0
  58. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/grep.py +0 -0
  59. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/output.py +0 -0
  60. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/read.py +0 -0
  61. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/registry.py +0 -0
  62. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/task.py +0 -0
  63. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/todo.py +0 -0
  64. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/write.py +0 -0
  65. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/__init__.py +0 -0
  66. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/clipboard.py +0 -0
  67. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/commands.py +0 -0
  68. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/fuzzy.py +0 -0
  69. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/hx.tcss +0 -0
  70. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/killring.py +0 -0
  71. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/renderers.py +0 -0
  72. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/roles.py +0 -0
  73. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/theme.py +0 -0
  74. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/theme_json.py +0 -0
  75. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/themes/ansi.json +0 -0
  76. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/themes/dark.json +0 -0
  77. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/themes/light.json +0 -0
  78. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/__init__.py +0 -0
  79. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/autocomplete.py +0 -0
  80. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/configure.py +0 -0
  81. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/diff.py +0 -0
  82. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/frame.py +0 -0
  83. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/header.py +0 -0
  84. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/hints.py +0 -0
  85. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/palette.py +0 -0
  86. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/permission.py +0 -0
  87. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/rule.py +0 -0
  88. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/statusbar.py +0 -0
  89. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/todos.py +0 -0
  90. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/transcript.py +0 -0
  91. {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/working.py +0 -0
  92. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/__init__.py +0 -0
  93. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/agents/__init__.py +0 -0
  94. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/agents/test_subagent.py +0 -0
  95. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/conftest.py +0 -0
  96. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/core/__init__.py +0 -0
  97. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/core/test_compaction.py +0 -0
  98. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/core/test_context.py +0 -0
  99. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/core/test_lateinject.py +0 -0
  100. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/core/test_loop.py +0 -0
  101. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/core/test_usage.py +0 -0
  102. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/mcp/__init__.py +0 -0
  103. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/mcp/fixtures/echo_server.py +0 -0
  104. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/mcp/test_manager.py +0 -0
  105. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/permissions/__init__.py +0 -0
  106. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/permissions/test_engine.py +0 -0
  107. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/permissions/test_parser.py +0 -0
  108. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/providers/__init__.py +0 -0
  109. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/providers/test_openrouter.py +0 -0
  110. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/skills/__init__.py +0 -0
  111. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/skills/test_loader.py +0 -0
  112. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/test_cli.py +0 -0
  113. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/test_integration.py +0 -0
  114. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/test_live.py +0 -0
  115. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/test_readme.py +0 -0
  116. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tools/__init__.py +0 -0
  117. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tools/test_background.py +0 -0
  118. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tools/test_bash.py +0 -0
  119. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tools/test_files.py +0 -0
  120. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tools/test_todo.py +0 -0
  121. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/__init__.py +0 -0
  122. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_autocomplete.py +0 -0
  123. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_clipboard.py +0 -0
  124. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_frame.py +0 -0
  125. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_keys.py +0 -0
  126. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_renderers.py +0 -0
  127. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_statusbar.py +0 -0
  128. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_theme_json.py +0 -0
  129. {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_transcript.py +0 -0
  130. {hx_cli-0.1.2 → hx_cli-0.1.4}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hx-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: HX - a terminal coding agent
5
5
  Project-URL: Homepage, https://github.com/aletisunil/hx
6
6
  Project-URL: Issues, https://github.com/aletisunil/hx/issues
@@ -0,0 +1,136 @@
1
+ #!/bin/sh
2
+ # HX installer.
3
+ #
4
+ # curl -fsSL https://raw.githubusercontent.com/aletisunil/hx/main/install.sh | sh
5
+ #
6
+ # Bootstraps `uv` if it is missing, then installs hx as an isolated uv tool with
7
+ # a pinned Python. Idempotent: re-running upgrades in place.
8
+
9
+ set -eu
10
+
11
+ # The PyPI distribution is hx-cli; the command it installs is `hx`.
12
+ HX_PACKAGE="${HX_PACKAGE:-hx-cli}"
13
+ HX_PYTHON="${HX_PYTHON:-3.12}"
14
+ HX_SUPPORT_EMAIL="iam@sunilaleti.dev"
15
+ UV_INSTALLER="https://astral.sh/uv/install.sh"
16
+
17
+ log() { printf '\033[0;36m==>\033[0m %s\n' "$1"; }
18
+ warn() { printf '\033[0;33mwarning:\033[0m %s\n' "$1" >&2; }
19
+ die() {
20
+ printf '\033[0;31merror:\033[0m %s\n' "$1" >&2
21
+ printf 'Need help? Contact \033[1;33m%s\033[0m\n' "$HX_SUPPORT_EMAIL" >&2
22
+ exit 1
23
+ }
24
+
25
+ have() { command -v "$1" >/dev/null 2>&1; }
26
+
27
+ should_animate() {
28
+ [ -t 1 ] && [ -z "${HX_NO_ANIMATION:-}" ] && [ -z "${CI:-}" ]
29
+ }
30
+
31
+ render_logo() {
32
+ padding=$1
33
+ clear_prefix=
34
+ [ "${2:-}" = clear ] && clear_prefix='\033[2K\r'
35
+
36
+ printf '\033[0;36m'
37
+ printf '%b%s%s\n' "$clear_prefix" "$padding" ' _ _ __ __'
38
+ printf '%b%s%s\n' "$clear_prefix" "$padding" '| | | | \ \/ /'
39
+ printf '%b%s%s\n' "$clear_prefix" "$padding" '| |_| | > <'
40
+ printf '%b%s%s\n' "$clear_prefix" "$padding" "| _ | / /\\ \\"
41
+ printf '%b%s%s\n' "$clear_prefix" "$padding" "|_| |_|/_/ \\_\\"
42
+ printf '\033[0m'
43
+ }
44
+
45
+ animate_logo() {
46
+ if ! should_animate; then
47
+ render_logo ''
48
+ return
49
+ fi
50
+
51
+ # Slide in, overshoot slightly, then settle into place.
52
+ render_logo ' ' clear
53
+ sleep 0.07
54
+ printf '\033[5A'
55
+ render_logo ' ' clear
56
+ sleep 0.07
57
+ printf '\033[5A'
58
+ render_logo ' ' clear
59
+ sleep 0.07
60
+ printf '\033[5A'
61
+ render_logo '' clear
62
+ sleep 0.07
63
+ printf '\033[5A'
64
+ render_logo ' ' clear
65
+ sleep 0.07
66
+ printf '\033[5A'
67
+ render_logo '' clear
68
+ }
69
+
70
+ stream_text() {
71
+ message=$1
72
+ printf ' '
73
+ if should_animate; then
74
+ # Word-by-word output keeps the effect portable across macOS and Linux.
75
+ # shellcheck disable=SC2086 # Intentional word splitting for animation.
76
+ for word in $message; do
77
+ printf '%s ' "$word"
78
+ sleep 0.03
79
+ done
80
+ printf '\n'
81
+ else
82
+ printf '%s\n' "$message"
83
+ fi
84
+ }
85
+
86
+ banner() {
87
+ animate_logo
88
+ printf '\n'
89
+ stream_text 'HX is a terminal coding agent that works inside your project.'
90
+ stream_text 'It reads and edits files, runs commands in a sandbox, and handles larger tasks with persistent sessions, skills, tools, and subagents.'
91
+ stream_text 'Connect to models through OpenRouter and see context, cost, and usage for every turn.'
92
+ printf ' Questions or issues? Contact \033[1;33m%s\033[0m\n\n' "$HX_SUPPORT_EMAIL"
93
+ }
94
+
95
+ ensure_uv() {
96
+ if have uv; then
97
+ log "uv found: $(uv --version)"
98
+ return
99
+ fi
100
+ log "Installing uv"
101
+ have curl || have wget || die "need curl or wget to bootstrap uv"
102
+ if have curl; then
103
+ curl -LsSf "$UV_INSTALLER" | sh
104
+ else
105
+ wget -qO- "$UV_INSTALLER" | sh
106
+ fi
107
+ # The uv installer drops binaries here but does not touch the current shell.
108
+ for candidate in "$HOME/.local/bin" "$HOME/.cargo/bin"; do
109
+ [ -x "$candidate/uv" ] && PATH="$candidate:$PATH"
110
+ done
111
+ export PATH
112
+ have uv || die "uv installed but not on PATH; open a new shell and re-run"
113
+ }
114
+
115
+ install_hx() {
116
+ log "Installing $HX_PACKAGE (Python $HX_PYTHON)"
117
+ uv tool install --python "$HX_PYTHON" --force "$HX_PACKAGE"
118
+ }
119
+
120
+ check_path() {
121
+ have hx && return
122
+ bindir="$(uv tool dir --bin 2>/dev/null || echo "$HOME/.local/bin")"
123
+ warn "hx is not on your PATH yet."
124
+ # shellcheck disable=SC2016 # $PATH is literal text for the user to copy.
125
+ printf '\n Add this to your shell profile:\n\n export PATH="%s:$PATH"\n\n' "$bindir"
126
+ }
127
+
128
+ main() {
129
+ banner
130
+ ensure_uv
131
+ install_hx
132
+ check_path
133
+ log "Done. Run 'hx' inside a project directory."
134
+ }
135
+
136
+ main "$@"
@@ -1,5 +1,5 @@
1
1
  """HX - a terminal coding agent."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.4"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -359,16 +359,20 @@ class HXApp(App[None]):
359
359
 
360
360
  async def _run_turn(self, text: str) -> None:
361
361
  prompt_input = self._prompt
362
- prompt_input.set_enabled(False)
362
+ prompt_input.set_running(True)
363
363
  try:
364
364
  await self.loop.run(text)
365
365
  except asyncio.CancelledError:
366
366
  self._transcript.add_notice("interrupted", "warning")
367
367
  finally:
368
- prompt_input.set_enabled(True)
368
+ prompt_input.set_running(False)
369
369
  self._working.stop()
370
370
 
371
371
  if self._queued:
372
+ # This coroutine is still the active Textual worker until it
373
+ # returns, so submit() would otherwise see a running turn and put
374
+ # the same prompt straight back on the queue.
375
+ self._turn_worker = None
372
376
  await self.submit(self._queued.pop(0))
373
377
 
374
378
  def _command_context(self) -> CommandContext:
@@ -498,18 +502,17 @@ class HXApp(App[None]):
498
502
  prompt: escape does that, and a key that sometimes discards a draft and
499
503
  sometimes kills a turn is a key nobody presses confidently.
500
504
 
501
- While a turn *is* running there is no draft to discard and nothing
502
- ambiguous left, so the key means what every terminal user expects it to
503
- mean. Arming the exit there instead would let two presses of the
504
- universal "stop" chord tear down the session mid-answer.
505
+ With an empty prompt during a running turn, the key means what every
506
+ terminal user expects it to mean. A draft still takes priority so it
507
+ can be cleared without interrupting the agent; escape always interrupts.
505
508
  """
506
- if self._turn_worker is not None and not self._turn_worker.is_finished:
507
- await self.action_interrupt()
508
- return
509
509
  if self._prompt.text:
510
510
  self._prompt.clear()
511
511
  self._clear_armed = False
512
512
  return
513
+ if self._turn_worker is not None and not self._turn_worker.is_finished:
514
+ await self.action_interrupt()
515
+ return
513
516
  if self._clear_armed:
514
517
  self.exit()
515
518
  return
@@ -21,6 +21,7 @@ from hx.tui.widgets.autocomplete import Autocomplete, Candidate, Completion
21
21
  MAX_HISTORY = 500
22
22
 
23
23
  PLACEHOLDER = "Ask HX… (/ for commands)"
24
+ RUNNING_PLACEHOLDER = "Ask HX… (Enter to queue)"
24
25
 
25
26
  #: Actions that have to beat TextArea's own bindings. A focused widget wins in
26
27
  #: Textual, so ctrl+c would copy and ctrl+d would delete a character before the
@@ -249,7 +250,18 @@ class PromptInput(TextArea):
249
250
  completion.move(-1 if key == "up" else 1)
250
251
  self._sync_popup()
251
252
  return True
252
- if self._matches(key, "tui.input.complete") or self._matches(key, "tui.input.submit"):
253
+ if self._matches(key, "tui.input.submit"):
254
+ # Once the prompt already contains the highlighted completion,
255
+ # Enter means submit. Treating it as another completion acceptance
256
+ # made exact slash commands such as /clear and /model require a
257
+ # surprising second Enter.
258
+ candidate = completion.current
259
+ if candidate is not None and self.text == completion.prefix + candidate.value:
260
+ self._submit()
261
+ else:
262
+ self.accept_completion()
263
+ return True
264
+ if self._matches(key, "tui.input.complete"):
253
265
  self.accept_completion()
254
266
  return True
255
267
  if self._matches(key, "app.interrupt"):
@@ -366,11 +378,13 @@ class PromptInput(TextArea):
366
378
  return
367
379
  popup.show(self.completion)
368
380
 
369
- def set_enabled(self, enabled: bool) -> None:
370
- """Disabled while a turn streams; queued input is submitted after."""
371
- self.read_only = not enabled
372
- interrupt = KEYMAP.text("app.interrupt")
373
- self.set_placeholder(PLACEHOLDER if enabled else f"{interrupt} to interrupt…")
381
+ def set_running(self, running: bool) -> None:
382
+ """Reflect turn state without locking the user's draft.
383
+
384
+ Submissions made while a turn is running are queued by ``HXApp``. The
385
+ prompt must stay editable so the user can prepare and submit them.
386
+ """
387
+ self.set_placeholder(RUNNING_PLACEHOLDER if running else PLACEHOLDER)
374
388
 
375
389
 
376
390
  class FileCompleter:
@@ -18,7 +18,6 @@ from hx.permissions.sandbox import (
18
18
  Sandbox,
19
19
  SandboxBackend,
20
20
  SandboxPolicy,
21
- build_bwrap_argv,
22
21
  build_seatbelt_profile,
23
22
  default_policy,
24
23
  detect_backend,
@@ -76,15 +75,6 @@ def test_deny_paths_are_resolved_before_emission(tmp_path: Path) -> None:
76
75
  assert str(real.resolve()) in profile
77
76
 
78
77
 
79
- def test_bwrap_argv_unshares_the_network_by_default(project: Path) -> None:
80
- argv = build_bwrap_argv(default_policy(project), ["/bin/sh", "-c", "true"])
81
- assert "--unshare-net" in argv
82
- assert argv[:3] == ["bwrap", "--ro-bind", "/"]
83
- assert "--unshare-net" not in build_bwrap_argv(
84
- default_policy(project, allow_network=True), ["true"]
85
- )
86
-
87
-
88
78
  def test_degrades_visibly_when_no_backend(project: Path, monkeypatch: pytest.MonkeyPatch) -> None:
89
79
  """A sandbox that silently does nothing is worse than a visible absence."""
90
80
  monkeypatch.setattr("platform.system", lambda: "Haiku")
@@ -96,14 +86,6 @@ def test_degrades_visibly_when_no_backend(project: Path, monkeypatch: pytest.Mon
96
86
  assert sandbox.wrap(["echo", "hi"]) == ["echo", "hi"]
97
87
 
98
88
 
99
- @pytest.mark.sandbox
100
- @pytest.mark.skipif(detect_backend() is SandboxBackend.NONE, reason="no sandbox backend")
101
- def test_write_inside_the_project_still_works(project: Path) -> None:
102
- sandbox = Sandbox(default_policy(project))
103
- _run(sandbox, f"echo inside > {project}/ok.txt")
104
- assert (project / "ok.txt").exists()
105
-
106
-
107
89
  @pytest.mark.sandbox
108
90
  @pytest.mark.skipif(detect_backend() is SandboxBackend.NONE, reason="no sandbox backend")
109
91
  def test_write_outside_project_is_blocked(project: Path) -> None:
@@ -142,11 +124,3 @@ def test_denied_paths_cannot_be_read(project: Path) -> None:
142
124
  def test_network_is_blocked_by_default(project: Path) -> None:
143
125
  sandbox = Sandbox(default_policy(project, allow_network=False))
144
126
  assert _run(sandbox, "curl -sS -m 5 https://example.com").returncode != 0
145
-
146
-
147
- @pytest.mark.sandbox
148
- @pytest.mark.skipif(detect_backend() is SandboxBackend.NONE, reason="no sandbox backend")
149
- def test_ordinary_work_is_not_broken(project: Path) -> None:
150
- """A sandbox that blocks real work gets turned off, which protects nothing."""
151
- result = _run(Sandbox(default_policy(project)), "ls / >/dev/null && python3 -c 'print(1+1)'")
152
- assert result.stdout.strip() == "2"
@@ -0,0 +1,53 @@
1
+ """Installer presentation and smoke tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ ROOT = Path(__file__).parent.parent
10
+
11
+
12
+ def test_installer_shows_the_hx_banner_features_and_support_contact(
13
+ tmp_path: Path,
14
+ ) -> None:
15
+ """A successful install should summarize HX and leave a way to get help."""
16
+ fake_bin = tmp_path / "bin"
17
+ fake_bin.mkdir()
18
+
19
+ uv = fake_bin / "uv"
20
+ uv.write_text(
21
+ "#!/bin/sh\n"
22
+ 'case "$1 $2" in\n'
23
+ " '--version ') echo 'uv 0.test' ;;\n"
24
+ " 'tool install') exit 0 ;;\n"
25
+ "esac\n"
26
+ )
27
+ uv.chmod(0o755)
28
+
29
+ hx = fake_bin / "hx"
30
+ hx.write_text("#!/bin/sh\nexit 0\n")
31
+ hx.chmod(0o755)
32
+
33
+ env = os.environ.copy()
34
+ env["PATH"] = f"{fake_bin}:{env['PATH']}"
35
+ result = subprocess.run(
36
+ ["sh", str(ROOT / "install.sh")],
37
+ check=True,
38
+ capture_output=True,
39
+ text=True,
40
+ env=env,
41
+ )
42
+
43
+ assert "|_| |_|/_/ \\_\\" in result.stdout
44
+ assert "HX is a terminal coding agent that works inside your project." in result.stdout
45
+ assert "handles larger tasks with persistent sessions, skills, tools, and subagents" in (
46
+ result.stdout
47
+ )
48
+ assert "see context, cost, and usage for every turn" in result.stdout
49
+ assert "project.\n It reads and edits files, runs commands in a sandbox" in result.stdout
50
+ assert "subagents.\n Connect to models through OpenRouter" in result.stdout
51
+ assert "HX Installer" not in result.stdout
52
+ assert "Questions or issues? Contact \x1b[1;33miam@sunilaleti.dev\x1b[0m" in (result.stdout)
53
+ assert "Done. Run 'hx' inside a project directory." in result.stdout
@@ -0,0 +1,31 @@
1
+ """Tool output capping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from hx.tools.output import cap_output
8
+
9
+
10
+ def test_small_output_passes_through() -> None:
11
+ result = cap_output("hello", session_id="s", tool_use_id="t")
12
+ assert result.text == "hello"
13
+ assert not result.truncated
14
+ assert result.spilled_path is None
15
+
16
+
17
+ def test_spill_path_cannot_escape_the_outputs_directory(hx_home: Path) -> None:
18
+ """Tool ids come from the model and must not be trusted as filenames."""
19
+ from hx.paths import session_outputs_dir
20
+ from hx.tools.output import spill
21
+
22
+ path = spill("body", "sess", "../../escape")
23
+ assert path.parent == session_outputs_dir("sess")
24
+
25
+
26
+ def test_summary_reports_the_first_line_and_a_count() -> None:
27
+ from hx.tools.output import summarize_for_ui
28
+
29
+ assert summarize_for_ui("only line") == "only line"
30
+ assert summarize_for_ui("first\nsecond\nthird") == "first (+2 lines)"
31
+ assert summarize_for_ui(" ") == "no output"