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.
- {hx_cli-0.1.2 → hx_cli-0.1.4}/PKG-INFO +1 -1
- hx_cli-0.1.4/install.sh +136 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/__init__.py +1 -1
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/app.py +12 -9
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/input.py +20 -6
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/permissions/test_sandbox.py +0 -26
- hx_cli-0.1.4/tests/test_install.py +53 -0
- hx_cli-0.1.4/tests/tools/test_output.py +31 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/__snapshots__/test_snapshots/test_a_running_turn.raw +81 -81
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/__snapshots__/test_snapshots/test_a_turn_with_tool_blocks.raw +83 -83
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/__snapshots__/test_snapshots/test_the_completion_popup.raw +80 -80
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/__snapshots__/test_snapshots/test_the_idle_screen.raw +79 -79
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_app.py +83 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_snapshots.py +18 -1
- hx_cli-0.1.2/install.sh +0 -62
- hx_cli-0.1.2/tests/tools/test_output.py +0 -55
- {hx_cli-0.1.2 → hx_cli-0.1.4}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/.gitignore +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/README.md +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/pyproject.toml +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/agents/subagent.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/cli.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/config.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/core/compaction.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/core/context.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/core/events.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/core/loop.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/core/messages.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/core/session.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/core/usage.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/keys.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/mcp/client.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/mcp/manager.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/paths.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/permissions/engine.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/permissions/parser.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/providers/base.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/providers/fake.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/providers/models.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/providers/openrouter.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/base.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/bash.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/edit.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/glob.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/grep.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/output.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/read.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/task.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tools/write.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/clipboard.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/commands.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/fuzzy.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/hx.tcss +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/killring.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/renderers.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/roles.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/theme.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/theme_json.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/themes/ansi.json +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/themes/dark.json +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/themes/light.json +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/__init__.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/autocomplete.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/configure.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/diff.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/frame.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/header.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/hints.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/palette.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/permission.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/rule.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/statusbar.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/todos.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/transcript.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/src/hx/tui/widgets/working.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/__init__.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/agents/__init__.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/agents/test_subagent.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/conftest.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/core/__init__.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/core/test_compaction.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/core/test_context.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/core/test_lateinject.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/core/test_loop.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/core/test_usage.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/mcp/__init__.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/mcp/fixtures/echo_server.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/mcp/test_manager.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/permissions/__init__.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/permissions/test_engine.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/permissions/test_parser.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/providers/__init__.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/providers/test_openrouter.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/skills/__init__.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/skills/test_loader.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/test_cli.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/test_integration.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/test_live.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/test_readme.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tools/__init__.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tools/test_background.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tools/test_bash.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tools/test_files.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tools/test_todo.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/__init__.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_autocomplete.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_clipboard.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_frame.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_keys.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_renderers.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_statusbar.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_theme_json.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/tests/tui/test_transcript.py +0 -0
- {hx_cli-0.1.2 → hx_cli-0.1.4}/uv.lock +0 -0
hx_cli-0.1.4/install.sh
ADDED
|
@@ -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 "$@"
|
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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.
|
|
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
|
|
370
|
-
"""
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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"
|