hx-cli 0.2.7__tar.gz → 0.2.9__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.7 → hx_cli-0.2.9}/CHANGELOG.md +46 -1
- {hx_cli-0.2.7 → hx_cli-0.2.9}/PKG-INFO +19 -6
- {hx_cli-0.2.7 → hx_cli-0.2.9}/README.md +18 -5
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/__init__.py +1 -1
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/cli.py +16 -5
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/context.py +45 -11
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/loop.py +4 -1
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/keys.py +24 -9
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/paths.py +36 -1
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/ansi.py +66 -1
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/editor.py +56 -27
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/keydecode.py +7 -2
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/markdown.py +66 -19
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/terminal.py +1 -1
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/commands.py +9 -5
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/format.py +0 -15
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/runtime.py +2 -2
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/views/pickers.py +2 -1
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/views/status.py +14 -2
- {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/terminal.py +4 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/test_cli.py +10 -1
- {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/test_commands.py +28 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/test_extend.py +79 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/test_input.py +185 -16
- {hx_cli-0.2.7 → hx_cli-0.2.9}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/.gitignore +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/examples/hooks/check.sh +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/install.sh +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/pyproject.toml +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/agents/subagent.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/auth/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/auth/oauth/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/auth/oauth/browser.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/auth/oauth/callback.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/auth/oauth/codex.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/auth/oauth/devin.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/auth/oauth/pkce.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/auth/resolve.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/auth/store.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/config.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/checkpoints.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/compaction.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/events.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/images.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/messages.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/session.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/title.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/core/usage.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/docs.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/git.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/hooks/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/hooks/engine.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/hooks/spec.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/mcp/client.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/mcp/manager.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/mcp/oauth.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/net.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/permissions/engine.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/permissions/parser.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/base.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/codex.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/codex_catalogue.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/devin.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/devin_catalogue.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/devin_wire.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/models.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/openrouter.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/protowire.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/registry.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/providers/responses_codec.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/buffer.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/component.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/demo.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/loop.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/modifiers.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/primitives.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/sanitize.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/screen.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/syntax.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/undo.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/width.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/term/word_nav.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/anchors.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/base.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/bash.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/edit.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/glob.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/grep.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/output.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/read.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/symbols.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/task.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/websearch.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tools/write.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/trace.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/app.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/clipboard.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/fuzzy.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/glyphs.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/killring.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/limits.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/paint.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/renderers.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/roles.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/theme.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/theme_json.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/themes/ansi.json +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/themes/dark.json +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/themes/light.json +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/views/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/views/blocks.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/views/dialog.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/views/login.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/views/permission.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/views/prompt.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/src/hx/tui/views/transcript.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/conftest.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/conftest.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/fixtures/echo_server.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/report.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/stub.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/test_chat.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/test_print.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/e2e/test_tools.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/test_live.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/test_live_codex.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/test_live_devin.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/tests/test_live_tavily.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.9}/uv.lock +0 -0
|
@@ -28,6 +28,49 @@ the project follows [semantic versioning](https://semver.org/).
|
|
|
28
28
|
|
|
29
29
|
---
|
|
30
30
|
|
|
31
|
+
## [0.2.9] - 2026-09-27
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- `~/.hx/AGENTS.md` holds your own instructions and is loaded into every
|
|
36
|
+
session in every project, ahead of the project's `AGENTS.md`. The first
|
|
37
|
+
session creates it empty, ready to fill in. `hx prompt` and `/prompt` now
|
|
38
|
+
name each `AGENTS.md` in force, and `/prompt` says when one has changed on
|
|
39
|
+
disk since the session started.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- An `AGENTS.md` or system-prompt override that is not valid UTF-8 is skipped
|
|
44
|
+
like an unreadable one, instead of stopping HX from starting.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## [0.2.8] - 2026-09-27
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
|
|
52
|
+
- `ctrl+z` undoes in the prompt and `ctrl+shift+z` redoes; `cmd+z` and
|
|
53
|
+
`cmd+shift+z` do the same in terminals that pass cmd through (kitty,
|
|
54
|
+
WezTerm). Apple's Terminal sends `ctrl+shift+z` as `ctrl+z`, so there it
|
|
55
|
+
undoes. Suspend no longer has a default key: bind `app.suspend` in
|
|
56
|
+
`keybindings.json`, which now also accepts `cmd+` and `option+`.
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- A markdown table wider than the terminal - pasted into the prompt or in a
|
|
61
|
+
reply - wraps its cells inside their columns instead of cutting them off,
|
|
62
|
+
so every word shows; in a terminal too narrow for its columns it is drawn as
|
|
63
|
+
`header: value` records. A bold phrase or link that wraps keeps its style
|
|
64
|
+
on the next line.
|
|
65
|
+
- A long draft in the prompt wraps between words instead of splitting them
|
|
66
|
+
across rows; only a word longer than the row still breaks inside it. The
|
|
67
|
+
cursor after the last character of a full row no longer hides that
|
|
68
|
+
character.
|
|
69
|
+
- The welcome lines wrap on a narrow terminal instead of being cut off
|
|
70
|
+
mid-word.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
31
74
|
## [0.2.7] - 2026-09-27
|
|
32
75
|
|
|
33
76
|
### Added
|
|
@@ -699,7 +742,9 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
|
|
|
699
742
|
- `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
|
|
700
743
|
self-update, and `install.sh` bootstrapping uv with a pinned Python.
|
|
701
744
|
|
|
702
|
-
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.
|
|
745
|
+
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.9...HEAD
|
|
746
|
+
[0.2.9]: https://github.com/aletisunil/hx/compare/v0.2.8...v0.2.9
|
|
747
|
+
[0.2.8]: https://github.com/aletisunil/hx/compare/v0.2.7...v0.2.8
|
|
703
748
|
[0.2.7]: https://github.com/aletisunil/hx/compare/v0.2.6...v0.2.7
|
|
704
749
|
[0.2.6]: https://github.com/aletisunil/hx/compare/v0.2.5...v0.2.6
|
|
705
750
|
[0.2.5]: https://github.com/aletisunil/hx/compare/v0.2.4...v0.2.5
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hx-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.9
|
|
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
|
|
@@ -366,7 +366,6 @@ be rebound (see [Keybindings](#keybindings)).
|
|
|
366
366
|
| `esc` | interrupt the current turn |
|
|
367
367
|
| `ctrl+c` | copy the selected text; with nothing selected, clear the prompt (twice on an empty prompt exits) |
|
|
368
368
|
| `ctrl+d` | exit, when the prompt is empty |
|
|
369
|
-
| `ctrl+z` | suspend to the background |
|
|
370
369
|
| `shift+tab` | cycle permission mode |
|
|
371
370
|
| `ctrl+p` | command palette |
|
|
372
371
|
| `ctrl+l` | model picker |
|
|
@@ -390,8 +389,13 @@ The prompt is a readline-style editor: `ctrl+a`/`ctrl+e` for line start and end,
|
|
|
390
389
|
`ctrl+b`/`ctrl+f` by character, `alt+b`/`alt+f` by word, `ctrl+w` and `alt+d` to
|
|
391
390
|
kill a word, `ctrl+u` and `ctrl+k` to kill to the start or end of a line, then
|
|
392
391
|
`ctrl+y` to yank it back and `alt+y` to walk further down the kill ring.
|
|
393
|
-
`ctrl+
|
|
394
|
-
|
|
392
|
+
`ctrl+z` undoes and `ctrl+shift+z` redoes (`ctrl+_` undoes too). `cmd+z` and
|
|
393
|
+
`cmd+shift+z` work in a terminal that passes cmd through to the program, such
|
|
394
|
+
as kitty or WezTerm; Apple's Terminal, iTerm2 and Ghostty keep cmd+z for their
|
|
395
|
+
own Edit menu. A terminal without the kitty protocol or modifyOtherKeys sends
|
|
396
|
+
`ctrl+shift+z` as `ctrl+z`, so there it undoes. Suspending to the background
|
|
397
|
+
has no key by default - bind `app.suspend` in `keybindings.json` (for example
|
|
398
|
+
to `"ctrl+g"`) to get it back.
|
|
395
399
|
|
|
396
400
|
Typing `/` or `@` opens a completion list above the prompt; `tab` cycles it,
|
|
397
401
|
`enter` accepts, `esc` dismisses.
|
|
@@ -461,8 +465,10 @@ Any key can be rebound in `~/.hx/keybindings.json`, keyed by the action ids in
|
|
|
461
465
|
}
|
|
462
466
|
```
|
|
463
467
|
|
|
464
|
-
|
|
465
|
-
|
|
468
|
+
Modifiers are `ctrl`, `alt` (or `option`), `shift` and `cmd` (or `super`);
|
|
469
|
+
a `cmd` key only arrives from a terminal that passes cmd through. Conflicts and
|
|
470
|
+
unknown action names are reported as a notice at startup rather than being
|
|
471
|
+
silently resolved.
|
|
466
472
|
|
|
467
473
|
### Commands
|
|
468
474
|
|
|
@@ -747,6 +753,7 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
|
|
|
747
753
|
| `~/.hx/keybindings.json` | your key overrides |
|
|
748
754
|
| `~/.hx/system-prompt.md` | your system prompt, replacing the built-in one |
|
|
749
755
|
| `~/.hx/system-prompt-append.md` | text appended to whichever prompt is in force |
|
|
756
|
+
| `~/.hx/AGENTS.md` | your instructions, loaded into every session in every project; created empty |
|
|
750
757
|
| `~/.hx/sessions/` | transcripts, spilled tool output, subagent sessions |
|
|
751
758
|
| `~/.hx/sessions/<id>/trace.html` | where `/trace` writes, unless you name a path |
|
|
752
759
|
| `~/.hx/skills/`, `~/.hx/agents/` | your skills and agents |
|
|
@@ -761,6 +768,12 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
|
|
|
761
768
|
tests, conventions, what not to touch. `/init` writes a first draft. It is
|
|
762
769
|
loaded once per session and frozen, so it costs one prefix, not one per turn.
|
|
763
770
|
|
|
771
|
+
`~/.hx/AGENTS.md` is the same thing for you rather than the project: how you
|
|
772
|
+
like commits written, tools you always want used, in every repository. It is
|
|
773
|
+
loaded first and the project's `AGENTS.md` after it, so where the two disagree
|
|
774
|
+
the project has the last word. The first session creates it empty, ready to
|
|
775
|
+
fill in; empty, it adds nothing. `hx prompt` and `/prompt` list each one in force.
|
|
776
|
+
|
|
764
777
|
---
|
|
765
778
|
|
|
766
779
|
## Safety
|
|
@@ -326,7 +326,6 @@ be rebound (see [Keybindings](#keybindings)).
|
|
|
326
326
|
| `esc` | interrupt the current turn |
|
|
327
327
|
| `ctrl+c` | copy the selected text; with nothing selected, clear the prompt (twice on an empty prompt exits) |
|
|
328
328
|
| `ctrl+d` | exit, when the prompt is empty |
|
|
329
|
-
| `ctrl+z` | suspend to the background |
|
|
330
329
|
| `shift+tab` | cycle permission mode |
|
|
331
330
|
| `ctrl+p` | command palette |
|
|
332
331
|
| `ctrl+l` | model picker |
|
|
@@ -350,8 +349,13 @@ The prompt is a readline-style editor: `ctrl+a`/`ctrl+e` for line start and end,
|
|
|
350
349
|
`ctrl+b`/`ctrl+f` by character, `alt+b`/`alt+f` by word, `ctrl+w` and `alt+d` to
|
|
351
350
|
kill a word, `ctrl+u` and `ctrl+k` to kill to the start or end of a line, then
|
|
352
351
|
`ctrl+y` to yank it back and `alt+y` to walk further down the kill ring.
|
|
353
|
-
`ctrl+
|
|
354
|
-
|
|
352
|
+
`ctrl+z` undoes and `ctrl+shift+z` redoes (`ctrl+_` undoes too). `cmd+z` and
|
|
353
|
+
`cmd+shift+z` work in a terminal that passes cmd through to the program, such
|
|
354
|
+
as kitty or WezTerm; Apple's Terminal, iTerm2 and Ghostty keep cmd+z for their
|
|
355
|
+
own Edit menu. A terminal without the kitty protocol or modifyOtherKeys sends
|
|
356
|
+
`ctrl+shift+z` as `ctrl+z`, so there it undoes. Suspending to the background
|
|
357
|
+
has no key by default - bind `app.suspend` in `keybindings.json` (for example
|
|
358
|
+
to `"ctrl+g"`) to get it back.
|
|
355
359
|
|
|
356
360
|
Typing `/` or `@` opens a completion list above the prompt; `tab` cycles it,
|
|
357
361
|
`enter` accepts, `esc` dismisses.
|
|
@@ -421,8 +425,10 @@ Any key can be rebound in `~/.hx/keybindings.json`, keyed by the action ids in
|
|
|
421
425
|
}
|
|
422
426
|
```
|
|
423
427
|
|
|
424
|
-
|
|
425
|
-
|
|
428
|
+
Modifiers are `ctrl`, `alt` (or `option`), `shift` and `cmd` (or `super`);
|
|
429
|
+
a `cmd` key only arrives from a terminal that passes cmd through. Conflicts and
|
|
430
|
+
unknown action names are reported as a notice at startup rather than being
|
|
431
|
+
silently resolved.
|
|
426
432
|
|
|
427
433
|
### Commands
|
|
428
434
|
|
|
@@ -707,6 +713,7 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
|
|
|
707
713
|
| `~/.hx/keybindings.json` | your key overrides |
|
|
708
714
|
| `~/.hx/system-prompt.md` | your system prompt, replacing the built-in one |
|
|
709
715
|
| `~/.hx/system-prompt-append.md` | text appended to whichever prompt is in force |
|
|
716
|
+
| `~/.hx/AGENTS.md` | your instructions, loaded into every session in every project; created empty |
|
|
710
717
|
| `~/.hx/sessions/` | transcripts, spilled tool output, subagent sessions |
|
|
711
718
|
| `~/.hx/sessions/<id>/trace.html` | where `/trace` writes, unless you name a path |
|
|
712
719
|
| `~/.hx/skills/`, `~/.hx/agents/` | your skills and agents |
|
|
@@ -721,6 +728,12 @@ files load here unchanged. `src/hx/tui/themes/dark.json` is the reference.
|
|
|
721
728
|
tests, conventions, what not to touch. `/init` writes a first draft. It is
|
|
722
729
|
loaded once per session and frozen, so it costs one prefix, not one per turn.
|
|
723
730
|
|
|
731
|
+
`~/.hx/AGENTS.md` is the same thing for you rather than the project: how you
|
|
732
|
+
like commits written, tools you always want used, in every repository. It is
|
|
733
|
+
loaded first and the project's `AGENTS.md` after it, so where the two disagree
|
|
734
|
+
the project has the last word. The first session creates it empty, ready to
|
|
735
|
+
fill in; empty, it adds nothing. `hx prompt` and `/prompt` list each one in force.
|
|
736
|
+
|
|
724
737
|
---
|
|
725
738
|
|
|
726
739
|
## Safety
|
|
@@ -346,7 +346,12 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
|
|
|
346
346
|
from hx.config import load_settings
|
|
347
347
|
from hx.core.checkpoints import CheckpointStore
|
|
348
348
|
from hx.core.compaction import Compactor
|
|
349
|
-
from hx.core.context import
|
|
349
|
+
from hx.core.context import (
|
|
350
|
+
ContextBuilder,
|
|
351
|
+
build_project_context,
|
|
352
|
+
load_instructions,
|
|
353
|
+
load_system_prompt,
|
|
354
|
+
)
|
|
350
355
|
from hx.core.events import EventBus
|
|
351
356
|
from hx.core.lateinject import Injection, InjectionRegistry
|
|
352
357
|
from hx.core.loop import AgentLoop
|
|
@@ -430,6 +435,7 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
|
|
|
430
435
|
settings.cwd,
|
|
431
436
|
keep_recent_turns=settings.context.keep_recent_turns,
|
|
432
437
|
)
|
|
438
|
+
instructions = load_instructions(settings.cwd)
|
|
433
439
|
|
|
434
440
|
checkpoints = CheckpointStore(session, session_checkpoints_dir(session.meta.session_id))
|
|
435
441
|
tools = build_default_registry(shell, jobs, tracker, todos, bus_holder, auth, checkpoints)
|
|
@@ -478,7 +484,8 @@ def build_runtime(parsed: ParsedArgs, *, resume: str | None = None) -> Runtime:
|
|
|
478
484
|
settings=settings,
|
|
479
485
|
model_info=model_info,
|
|
480
486
|
skills_index=build_index(list(skills.values())) or None,
|
|
481
|
-
project_context=build_project_context(settings.cwd),
|
|
487
|
+
project_context=build_project_context(settings.cwd, instructions),
|
|
488
|
+
instructions=instructions,
|
|
482
489
|
hooks=hooks,
|
|
483
490
|
)
|
|
484
491
|
|
|
@@ -842,15 +849,17 @@ def run_print_command(parsed: ParsedArgs) -> int:
|
|
|
842
849
|
def run_prompt_command(parsed: ParsedArgs) -> int:
|
|
843
850
|
"""``hx prompt`` - print the system prompt this directory resolves to.
|
|
844
851
|
|
|
845
|
-
The prompt goes to stdout so it can be piped or diffed; where it came from
|
|
846
|
-
goes to stderr so it never
|
|
852
|
+
The prompt goes to stdout so it can be piped or diffed; where it came from,
|
|
853
|
+
and which AGENTS.md files ride alongside it, goes to stderr so it never
|
|
854
|
+
contaminates that output.
|
|
847
855
|
"""
|
|
848
856
|
from hx.config import load_settings
|
|
849
|
-
from hx.core.context import resolve_system_prompt
|
|
857
|
+
from hx.core.context import load_instructions, resolve_system_prompt
|
|
850
858
|
|
|
851
859
|
try:
|
|
852
860
|
settings = load_settings(parsed.cwd, parsed.overrides)
|
|
853
861
|
resolved = resolve_system_prompt(settings.cwd, settings.prompt)
|
|
862
|
+
instructions = load_instructions(settings.cwd)
|
|
854
863
|
except Exception as exc:
|
|
855
864
|
return _report(exc)
|
|
856
865
|
|
|
@@ -858,6 +867,8 @@ def run_prompt_command(parsed: ParsedArgs) -> int:
|
|
|
858
867
|
print(f"[source] {resolved.source}", file=sys.stderr)
|
|
859
868
|
for append in resolved.appends:
|
|
860
869
|
print(f"[append] {append}", file=sys.stderr)
|
|
870
|
+
for loaded in instructions:
|
|
871
|
+
print(f"[instructions] {loaded.path}", file=sys.stderr)
|
|
861
872
|
return 0
|
|
862
873
|
|
|
863
874
|
|
|
@@ -10,7 +10,7 @@ Layout, in order::
|
|
|
10
10
|
[1] system prompt static for the session
|
|
11
11
|
[2] tool schemas deterministic sort: builtins, then mcp__* alphabetical
|
|
12
12
|
[3] skills index name + description only (progressive disclosure)
|
|
13
|
-
[4] project context
|
|
13
|
+
[4] project context cwd, git branch, top-level listing, ~/.hx/AGENTS.md, AGENTS.md
|
|
14
14
|
--- breakpoint A (static) ---
|
|
15
15
|
[5] conversation history
|
|
16
16
|
--- breakpoint B (rolling, before the last few turns) ---
|
|
@@ -238,11 +238,46 @@ def _message_text(message: Message) -> str:
|
|
|
238
238
|
return "\n".join(parts)
|
|
239
239
|
|
|
240
240
|
|
|
241
|
-
|
|
242
|
-
|
|
241
|
+
@dataclass(slots=True)
|
|
242
|
+
class Instructions:
|
|
243
|
+
"""One AGENTS.md in force: what it is, where it lives, what it says."""
|
|
244
|
+
|
|
245
|
+
heading: str
|
|
246
|
+
path: Path
|
|
247
|
+
text: str
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def load_instructions(cwd: Path) -> list[Instructions]:
|
|
251
|
+
"""Every AGENTS.md in force, least specific first.
|
|
252
|
+
|
|
253
|
+
``~/.hx/AGENTS.md`` follows the user into every project; the project's own
|
|
254
|
+
``AGENTS.md`` comes after it, so where the two disagree the more specific
|
|
255
|
+
one has the last word. A blank or unreadable file is skipped, and running
|
|
256
|
+
from inside ``$HX_HOME`` - where both paths name the same file - loads it once.
|
|
257
|
+
"""
|
|
258
|
+
from hx.paths import project_instructions_file, tilde, user_instructions_file
|
|
259
|
+
|
|
260
|
+
user = user_instructions_file()
|
|
261
|
+
found: list[Instructions] = []
|
|
262
|
+
seen: set[Path] = set()
|
|
263
|
+
for heading, path in (
|
|
264
|
+
(f"User instructions ({tilde(user)})", user),
|
|
265
|
+
("Project instructions (AGENTS.md)", project_instructions_file(cwd)),
|
|
266
|
+
):
|
|
267
|
+
body = _read_prompt_file(path)
|
|
268
|
+
if not body or path.resolve() in seen:
|
|
269
|
+
continue
|
|
270
|
+
seen.add(path.resolve())
|
|
271
|
+
found.append(Instructions(heading, path, body))
|
|
272
|
+
return found
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def build_project_context(cwd: Path, instructions: list[Instructions] | None = None) -> str:
|
|
276
|
+
"""Static per-session project preamble: cwd, git branch, listing, AGENTS.md contents.
|
|
243
277
|
|
|
244
278
|
Computed once at startup and then frozen - refreshing it mid-session would
|
|
245
|
-
invalidate the prefix.
|
|
279
|
+
invalidate the prefix. Pass ``instructions`` to fold in the AGENTS.md files
|
|
280
|
+
already loaded, so the caller can remember exactly which ones are in force.
|
|
246
281
|
"""
|
|
247
282
|
lines = [f"Working directory: {cwd}"]
|
|
248
283
|
|
|
@@ -256,9 +291,8 @@ def build_project_context(cwd: Path) -> str:
|
|
|
256
291
|
if entries:
|
|
257
292
|
lines.append("Top level: " + ", ".join(entries[:60]))
|
|
258
293
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
lines.append(f"\n# Project instructions (AGENTS.md)\n\n{agents_md.read_text()}")
|
|
294
|
+
for loaded in load_instructions(cwd) if instructions is None else instructions:
|
|
295
|
+
lines.append(f"\n# {loaded.heading}\n\n{loaded.text}")
|
|
262
296
|
|
|
263
297
|
return "\n".join(lines)
|
|
264
298
|
|
|
@@ -367,12 +401,12 @@ def resolve_system_prompt(cwd: Path, prompt: PromptSettings | None = None) -> Re
|
|
|
367
401
|
def _read_prompt_file(path: Path) -> str:
|
|
368
402
|
"""Contents of a prompt override file, or ``""`` when it is absent or empty.
|
|
369
403
|
|
|
370
|
-
An unreadable file is treated as absent: a
|
|
371
|
-
optional override must not stop the session from starting.
|
|
404
|
+
An unreadable file - no permission, or not UTF-8 - is treated as absent: a
|
|
405
|
+
problem with an optional override must not stop the session from starting.
|
|
372
406
|
"""
|
|
373
407
|
try:
|
|
374
|
-
return path.read_text().strip() if path.is_file() else ""
|
|
375
|
-
except OSError:
|
|
408
|
+
return path.read_text(encoding="utf-8").strip() if path.is_file() else ""
|
|
409
|
+
except (OSError, UnicodeDecodeError):
|
|
376
410
|
return ""
|
|
377
411
|
|
|
378
412
|
|
|
@@ -18,7 +18,7 @@ import time
|
|
|
18
18
|
from dataclasses import dataclass, replace
|
|
19
19
|
from typing import TYPE_CHECKING, Any, ClassVar
|
|
20
20
|
|
|
21
|
-
from hx.core.context import AssembledContext
|
|
21
|
+
from hx.core.context import AssembledContext, Instructions
|
|
22
22
|
from hx.core.events import (
|
|
23
23
|
CompactionFinished,
|
|
24
24
|
CompactionStarted,
|
|
@@ -103,6 +103,7 @@ class AgentLoop:
|
|
|
103
103
|
active_skills: ActiveSkills | None = None,
|
|
104
104
|
skills_index: str | None = None,
|
|
105
105
|
project_context: str | None = None,
|
|
106
|
+
instructions: list[Instructions] | None = None,
|
|
106
107
|
hooks: HookEngine | None = None,
|
|
107
108
|
) -> None:
|
|
108
109
|
self.provider = provider
|
|
@@ -118,6 +119,8 @@ class AgentLoop:
|
|
|
118
119
|
self.active_skills = active_skills
|
|
119
120
|
self.skills_index = skills_index
|
|
120
121
|
self.project_context = project_context
|
|
122
|
+
self.instructions = instructions or []
|
|
123
|
+
"""The AGENTS.md files folded into ``project_context``, least specific first."""
|
|
121
124
|
self.hooks = hooks
|
|
122
125
|
self._cancelled = False
|
|
123
126
|
self._turn_index = 0
|
|
@@ -71,7 +71,10 @@ def _defaults() -> dict[str, KeyBinding]:
|
|
|
71
71
|
),
|
|
72
72
|
KeyBinding("app.clear", ("ctrl+c",), "Clear the prompt (twice to exit)"),
|
|
73
73
|
KeyBinding("app.exit", ("ctrl+d",), "Exit when the prompt is empty"),
|
|
74
|
-
|
|
74
|
+
# Unbound: ctrl+z is undo, as in every windowed editor. Raw mode means
|
|
75
|
+
# the terminal will not suspend for us, so a user who wants job
|
|
76
|
+
# control back binds this in keybindings.json.
|
|
77
|
+
KeyBinding("app.suspend", (), "Suspend to the background"),
|
|
75
78
|
# Session and mode.
|
|
76
79
|
KeyBinding("app.mode.cycle", ("shift+tab",), "Cycle permission mode"),
|
|
77
80
|
KeyBinding("app.commands", ("ctrl+p",), "Open the command palette"),
|
|
@@ -102,21 +105,31 @@ def _defaults() -> dict[str, KeyBinding]:
|
|
|
102
105
|
KeyBinding("tui.editor.deleteWordForward", ("alt+d",), "Delete the word ahead"),
|
|
103
106
|
KeyBinding("tui.editor.yank", ("ctrl+y",), "Yank the last kill"),
|
|
104
107
|
KeyBinding("tui.editor.yankPop", ("alt+y",), "Cycle back through kills"),
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
KeyBinding("tui.editor.undo", ("ctrl+underscore"
|
|
110
|
-
KeyBinding("tui.editor.redo", ("ctrl+shift+z",), "Redo"),
|
|
108
|
+
# The windowed editor's keys, plus readline's own undo. cmd reaches
|
|
109
|
+
# HX as super, and only from a terminal that passes it through rather
|
|
110
|
+
# than keeping it for its own Edit menu - kitty and WezTerm do. A
|
|
111
|
+
# legacy terminal sends ctrl+shift+z as ctrl+z, so there it undoes.
|
|
112
|
+
KeyBinding("tui.editor.undo", ("ctrl+z", "super+z", "ctrl+underscore"), "Undo"),
|
|
113
|
+
KeyBinding("tui.editor.redo", ("ctrl+shift+z", "super+shift+z"), "Redo"),
|
|
111
114
|
]
|
|
112
115
|
return {binding.id: binding for binding in bindings}
|
|
113
116
|
|
|
114
117
|
|
|
118
|
+
_ALIASES = {"cmd": "super", "command": "super", "option": "alt"}
|
|
119
|
+
"""Modifiers as a Mac user writes them - and as ``/help`` shows them there -
|
|
120
|
+
spelled the way the decoder names them."""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _canonical(key: str) -> str:
|
|
124
|
+
"""``cmd+z`` -> ``super+z``, so a keybindings file can say what it means."""
|
|
125
|
+
return "+".join(_ALIASES.get(part, part) for part in key.split("+"))
|
|
126
|
+
|
|
127
|
+
|
|
115
128
|
def _normalize(value: object) -> tuple[str, ...] | None:
|
|
116
129
|
if isinstance(value, str):
|
|
117
|
-
return (value,)
|
|
130
|
+
return (_canonical(value),)
|
|
118
131
|
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
119
|
-
return tuple(value)
|
|
132
|
+
return tuple(_canonical(item) for item in value)
|
|
120
133
|
return None
|
|
121
134
|
|
|
122
135
|
|
|
@@ -173,6 +186,8 @@ def display_key(key: str) -> str:
|
|
|
173
186
|
part = _DISPLAY.get(part, part)
|
|
174
187
|
if part == "alt" and sys.platform == "darwin":
|
|
175
188
|
part = "option"
|
|
189
|
+
elif part == "super" and sys.platform == "darwin":
|
|
190
|
+
part = "cmd"
|
|
176
191
|
parts.append(part)
|
|
177
192
|
return "+".join(parts)
|
|
178
193
|
|
|
@@ -30,6 +30,20 @@ def user_home() -> Path:
|
|
|
30
30
|
return Path(override).expanduser() if override else Path.home() / ".hx"
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def tilde(path: str | Path) -> str:
|
|
34
|
+
"""``path`` with the home directory written as ``~``.
|
|
35
|
+
|
|
36
|
+
Compared as text, not resolved: a session records the path it resolved
|
|
37
|
+
when it was made, and the directory may be gone by the time it is drawn.
|
|
38
|
+
"""
|
|
39
|
+
text, home = str(path), str(Path.home())
|
|
40
|
+
if text == home:
|
|
41
|
+
return "~"
|
|
42
|
+
if text.startswith(home.rstrip("/") + "/"):
|
|
43
|
+
return "~" + text[len(home.rstrip("/")) :]
|
|
44
|
+
return text
|
|
45
|
+
|
|
46
|
+
|
|
33
47
|
def user_settings_file() -> Path:
|
|
34
48
|
return user_home() / "settings.json"
|
|
35
49
|
|
|
@@ -198,7 +212,28 @@ def project_system_prompt_append_file(cwd: Path | None = None) -> Path:
|
|
|
198
212
|
return project_dir(cwd) / "system-prompt-append.md"
|
|
199
213
|
|
|
200
214
|
|
|
215
|
+
def user_instructions_file() -> Path:
|
|
216
|
+
"""Your ``AGENTS.md``: instructions that follow you into every project."""
|
|
217
|
+
return user_home() / "AGENTS.md"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def project_instructions_file(cwd: Path | None = None) -> Path:
|
|
221
|
+
"""The project's ``AGENTS.md``, at the root of the working directory."""
|
|
222
|
+
return (cwd or Path.cwd()) / "AGENTS.md"
|
|
223
|
+
|
|
224
|
+
|
|
201
225
|
def ensure_user_dirs() -> None:
|
|
202
|
-
"""Create the user-level
|
|
226
|
+
"""Create the user-level skeleton if it does not exist.
|
|
227
|
+
|
|
228
|
+
That includes an empty ``AGENTS.md``, so there is a file to find and fill
|
|
229
|
+
in; empty, it adds nothing to the prompt. An existing one - including a
|
|
230
|
+
symlink, dangling or not - is never touched, and failing to create it never
|
|
231
|
+
stops a session: the file is optional.
|
|
232
|
+
"""
|
|
203
233
|
for path in (user_home(), sessions_dir(), user_skills_dir(), user_agents_dir(), logs_dir()):
|
|
204
234
|
path.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
try:
|
|
236
|
+
with user_instructions_file().open("x"):
|
|
237
|
+
pass
|
|
238
|
+
except OSError:
|
|
239
|
+
pass
|
|
@@ -270,6 +270,71 @@ def active_background(text: str) -> str:
|
|
|
270
270
|
return current
|
|
271
271
|
|
|
272
272
|
|
|
273
|
+
def open_styles(text: str) -> tuple[str, str]:
|
|
274
|
+
"""``(reopen, close)`` for the styling still in force at the end of ``text``.
|
|
275
|
+
|
|
276
|
+
Foreground, bold, italic, underline and a hyperlink - everything but the
|
|
277
|
+
background, which :func:`active_background` carries. A table cell wrapped
|
|
278
|
+
onto a second line needs both halves: ``close`` at its column edge, or a
|
|
279
|
+
link or a bold word runs on into the next cell, and ``reopen`` at the start
|
|
280
|
+
of the next line, or the rest of it arrives unstyled. Each half only
|
|
281
|
+
touches what is actually open, so a caller's own colour around the cell
|
|
282
|
+
survives.
|
|
283
|
+
"""
|
|
284
|
+
foreground = link = ""
|
|
285
|
+
bold = italic = underline = False
|
|
286
|
+
for match in _ANY_ESCAPE.finditer(text):
|
|
287
|
+
escape = match.group()
|
|
288
|
+
if escape.startswith("\x1b]8;"):
|
|
289
|
+
link = escape if escape.split(";", 2)[2].rstrip("\x07\x1b\\") else ""
|
|
290
|
+
continue
|
|
291
|
+
sgr = _SGR_PATTERN.fullmatch(escape)
|
|
292
|
+
if not sgr:
|
|
293
|
+
continue
|
|
294
|
+
codes = [int(code or 0) for code in (sgr.group(1) or "0").split(";")]
|
|
295
|
+
index = 0
|
|
296
|
+
while index < len(codes):
|
|
297
|
+
code = codes[index]
|
|
298
|
+
if code == 0:
|
|
299
|
+
foreground, bold, italic, underline = "", False, False, False
|
|
300
|
+
elif code == 1:
|
|
301
|
+
bold = True
|
|
302
|
+
elif code == 22:
|
|
303
|
+
bold = False
|
|
304
|
+
elif code == 3:
|
|
305
|
+
italic = True
|
|
306
|
+
elif code == 23:
|
|
307
|
+
italic = False
|
|
308
|
+
elif code == 4:
|
|
309
|
+
underline = True
|
|
310
|
+
elif code == 24:
|
|
311
|
+
underline = False
|
|
312
|
+
elif 30 <= code <= 37 or 90 <= code <= 97:
|
|
313
|
+
foreground = f"\x1b[{code}m"
|
|
314
|
+
elif code == 39:
|
|
315
|
+
foreground = ""
|
|
316
|
+
elif code in (38, 48) and index + 1 < len(codes):
|
|
317
|
+
# Skip a colour's parameters so they are not read as codes;
|
|
318
|
+
# a truncated one ends the escape, as in active_background.
|
|
319
|
+
span = 3 if codes[index + 1] == 5 else 5 if codes[index + 1] == 2 else 0
|
|
320
|
+
if not span or index + span > len(codes):
|
|
321
|
+
break
|
|
322
|
+
if code == 38:
|
|
323
|
+
foreground = "\x1b[" + ";".join(map(str, codes[index : index + span])) + "m"
|
|
324
|
+
index += span - 1
|
|
325
|
+
index += 1
|
|
326
|
+
|
|
327
|
+
reopen = close = ""
|
|
328
|
+
if foreground:
|
|
329
|
+
reopen, close = reopen + foreground, close + FG_RESET
|
|
330
|
+
for on, start, end in ((bold, "1", "22"), (italic, "3", "23"), (underline, "4", "24")):
|
|
331
|
+
if on:
|
|
332
|
+
reopen, close = reopen + f"\x1b[{start}m", close + f"\x1b[{end}m"
|
|
333
|
+
if link:
|
|
334
|
+
reopen, close = reopen + link, close + "\x1b]8;;\x07"
|
|
335
|
+
return reopen, close
|
|
336
|
+
|
|
337
|
+
|
|
273
338
|
def fill_line(text: str, width: int, background: str = "") -> str:
|
|
274
339
|
"""One rendered line: padded to ``width``, tinted, and terminated.
|
|
275
340
|
|
|
@@ -360,7 +425,7 @@ def _wrap_one(text: str, width: int) -> list[str]:
|
|
|
360
425
|
emitted = "".join(line[:upto])
|
|
361
426
|
rest = line[upto + 1 :] # drop the space itself
|
|
362
427
|
lines.append(carry + emitted)
|
|
363
|
-
carry = active_background(carry + emitted)
|
|
428
|
+
carry = active_background(carry + emitted) + open_styles(carry + emitted)[0]
|
|
364
429
|
line = rest
|
|
365
430
|
used = sum(
|
|
366
431
|
0 if is_escape else cell_width(chunk) for is_escape, chunk in _tokenize("".join(rest))
|