hx-cli 0.2.7__tar.gz → 0.2.8__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.8}/CHANGELOG.md +28 -1
- {hx_cli-0.2.7 → hx_cli-0.2.8}/PKG-INFO +12 -6
- {hx_cli-0.2.7 → hx_cli-0.2.8}/README.md +11 -5
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/__init__.py +1 -1
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/keys.py +24 -9
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/ansi.py +66 -1
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/editor.py +56 -27
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/keydecode.py +7 -2
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/markdown.py +66 -19
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/terminal.py +1 -1
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/runtime.py +1 -1
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/views/status.py +14 -2
- {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/terminal.py +4 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/test_input.py +185 -16
- {hx_cli-0.2.7 → hx_cli-0.2.8}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/.gitignore +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/examples/hooks/check.sh +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/install.sh +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/pyproject.toml +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/agents/subagent.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/auth/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/auth/oauth/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/auth/oauth/browser.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/auth/oauth/callback.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/auth/oauth/codex.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/auth/oauth/devin.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/auth/oauth/pkce.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/auth/resolve.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/auth/store.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/cli.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/config.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/checkpoints.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/compaction.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/context.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/events.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/images.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/loop.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/messages.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/session.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/title.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/core/usage.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/docs.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/git.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/hooks/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/hooks/engine.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/hooks/spec.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/mcp/client.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/mcp/manager.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/mcp/oauth.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/net.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/paths.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/permissions/engine.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/permissions/parser.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/base.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/codex.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/codex_catalogue.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/devin.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/devin_catalogue.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/devin_wire.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/models.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/openrouter.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/protowire.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/registry.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/providers/responses_codec.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/buffer.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/component.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/demo.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/loop.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/modifiers.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/primitives.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/sanitize.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/screen.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/syntax.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/undo.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/width.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/term/word_nav.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/anchors.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/base.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/bash.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/edit.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/glob.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/grep.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/output.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/read.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/symbols.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/task.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/websearch.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tools/write.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/trace.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/app.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/clipboard.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/commands.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/format.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/fuzzy.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/glyphs.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/killring.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/limits.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/paint.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/renderers.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/roles.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/theme.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/theme_json.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/themes/ansi.json +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/themes/dark.json +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/themes/light.json +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/views/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/views/blocks.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/views/dialog.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/views/login.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/views/permission.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/views/pickers.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/views/prompt.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/src/hx/tui/views/transcript.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/conftest.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/__init__.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/conftest.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/fixtures/echo_server.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/report.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/stub.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/test_chat.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/test_cli.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/test_commands.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/test_extend.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/test_print.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/e2e/test_tools.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/test_live.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/test_live_codex.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/test_live_devin.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/tests/test_live_tavily.py +0 -0
- {hx_cli-0.2.7 → hx_cli-0.2.8}/uv.lock +0 -0
|
@@ -28,6 +28,32 @@ the project follows [semantic versioning](https://semver.org/).
|
|
|
28
28
|
|
|
29
29
|
---
|
|
30
30
|
|
|
31
|
+
## [0.2.8] - 2026-09-27
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- `ctrl+z` undoes in the prompt and `ctrl+shift+z` redoes; `cmd+z` and
|
|
36
|
+
`cmd+shift+z` do the same in terminals that pass cmd through (kitty,
|
|
37
|
+
WezTerm). Apple's Terminal sends `ctrl+shift+z` as `ctrl+z`, so there it
|
|
38
|
+
undoes. Suspend no longer has a default key: bind `app.suspend` in
|
|
39
|
+
`keybindings.json`, which now also accepts `cmd+` and `option+`.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- A markdown table wider than the terminal - pasted into the prompt or in a
|
|
44
|
+
reply - wraps its cells inside their columns instead of cutting them off,
|
|
45
|
+
so every word shows; in a terminal too narrow for its columns it is drawn as
|
|
46
|
+
`header: value` records. A bold phrase or link that wraps keeps its style
|
|
47
|
+
on the next line.
|
|
48
|
+
- A long draft in the prompt wraps between words instead of splitting them
|
|
49
|
+
across rows; only a word longer than the row still breaks inside it. The
|
|
50
|
+
cursor after the last character of a full row no longer hides that
|
|
51
|
+
character.
|
|
52
|
+
- The welcome lines wrap on a narrow terminal instead of being cut off
|
|
53
|
+
mid-word.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
31
57
|
## [0.2.7] - 2026-09-27
|
|
32
58
|
|
|
33
59
|
### Added
|
|
@@ -699,7 +725,8 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
|
|
|
699
725
|
- `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
|
|
700
726
|
self-update, and `install.sh` bootstrapping uv with a pinned Python.
|
|
701
727
|
|
|
702
|
-
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.
|
|
728
|
+
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.8...HEAD
|
|
729
|
+
[0.2.8]: https://github.com/aletisunil/hx/compare/v0.2.7...v0.2.8
|
|
703
730
|
[0.2.7]: https://github.com/aletisunil/hx/compare/v0.2.6...v0.2.7
|
|
704
731
|
[0.2.6]: https://github.com/aletisunil/hx/compare/v0.2.5...v0.2.6
|
|
705
732
|
[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.8
|
|
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
|
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -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))
|
|
@@ -54,11 +54,12 @@ class LayoutLine:
|
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
def layout(text: str, width: int, cursor: int) -> list[LayoutLine]:
|
|
57
|
-
"""Map the buffer onto visual rows, wrapping to ``width
|
|
57
|
+
"""Map the buffer onto visual rows, wrapping to ``width`` between words.
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
The rows of a line are exactly its text, cut up: nothing is added or
|
|
60
|
+
dropped at a break, so a buffer offset maps onto a row and a column by
|
|
61
|
+
counting alone. That is why the space a line breaks on stays at the end
|
|
62
|
+
of its row - see :func:`_wrap_words` - rather than being swallowed.
|
|
62
63
|
"""
|
|
63
64
|
if width < 1:
|
|
64
65
|
width = 1
|
|
@@ -66,42 +67,66 @@ def layout(text: str, width: int, cursor: int) -> list[LayoutLine]:
|
|
|
66
67
|
rows: list[LayoutLine] = []
|
|
67
68
|
offset = 0
|
|
68
69
|
for logical in text.split("\n"):
|
|
69
|
-
chunks =
|
|
70
|
-
for chunk in chunks:
|
|
70
|
+
chunks = _wrap_words(logical, width)
|
|
71
|
+
for index, chunk in enumerate(chunks):
|
|
71
72
|
length = len(chunk)
|
|
72
73
|
column: int | None = None
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
column = cell_width(chunk[: cursor - offset])
|
|
74
|
+
# A cursor at a break belongs to the row it continues onto: what
|
|
75
|
+
# is typed there lands at the start of that row, so the cursor is
|
|
76
|
+
# drawn there too. Past the last row there is nothing to continue.
|
|
77
|
+
last = index == len(chunks) - 1
|
|
78
|
+
if offset <= cursor < offset + length or (cursor == offset + length and last):
|
|
79
|
+
column = cell_width(chunk[: cursor - offset])
|
|
80
80
|
rows.append(LayoutLine(chunk, column))
|
|
81
81
|
offset += length
|
|
82
82
|
offset += 1 # the newline itself
|
|
83
83
|
return rows
|
|
84
84
|
|
|
85
85
|
|
|
86
|
-
def
|
|
87
|
-
|
|
86
|
+
def _wrap_words(line: str, width: int) -> list[str]:
|
|
87
|
+
"""Break a logical line into rows of at most ``width`` cells, between words.
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
A row ends after the last space that fits. The space the break falls on
|
|
90
|
+
may hang one cell past ``width`` - a word that exactly fills a row keeps
|
|
91
|
+
its space rather than pushing it to the front of the next one - which is
|
|
92
|
+
what the editor's right-hand padding is for. A word too long for any row
|
|
93
|
+
breaks where it hits the edge, as does a run that is nothing but spaces.
|
|
94
|
+
"""
|
|
92
95
|
if not line:
|
|
93
96
|
return [""]
|
|
97
|
+
clusters = list(grapheme_clusters(line))
|
|
94
98
|
rows: list[str] = []
|
|
95
|
-
|
|
99
|
+
start = 0
|
|
96
100
|
used = 0
|
|
97
|
-
|
|
101
|
+
# Where the row may end: just past a space with a word before it.
|
|
102
|
+
fold: int | None = None
|
|
103
|
+
worded = False
|
|
104
|
+
index = 0
|
|
105
|
+
while index < len(clusters):
|
|
106
|
+
cluster = clusters[index]
|
|
98
107
|
step = cell_width(cluster)
|
|
99
|
-
if used + step > width and
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
108
|
+
if used + step > width and index > start:
|
|
109
|
+
if cluster == " " and worded and used == width:
|
|
110
|
+
end = index + 1 # hang it
|
|
111
|
+
elif fold is not None:
|
|
112
|
+
end = fold
|
|
113
|
+
else:
|
|
114
|
+
end = index
|
|
115
|
+
rows.append("".join(clusters[start:end]))
|
|
116
|
+
start, fold, worded = end, None, False
|
|
117
|
+
used = sum(cell_width(c) for c in clusters[start:index])
|
|
118
|
+
worded = any(c != " " for c in clusters[start:index])
|
|
119
|
+
if end > index:
|
|
120
|
+
index = end
|
|
121
|
+
continue
|
|
122
|
+
if cluster == " ":
|
|
123
|
+
if worded:
|
|
124
|
+
fold = index + 1
|
|
125
|
+
else:
|
|
126
|
+
worded = True
|
|
103
127
|
used += step
|
|
104
|
-
|
|
128
|
+
index += 1
|
|
129
|
+
rows.append("".join(clusters[start:]))
|
|
105
130
|
return rows
|
|
106
131
|
|
|
107
132
|
|
|
@@ -224,6 +249,10 @@ class Editor(Widget):
|
|
|
224
249
|
placeholder = not self.buffer.text and self.placeholder
|
|
225
250
|
if row.cursor_column is None:
|
|
226
251
|
return truncate_to_width(row.text, inner)
|
|
252
|
+
# The cursor may sit one cell past the text column - on a space hanging
|
|
253
|
+
# off a full row, or after the last character of one - and it is drawn
|
|
254
|
+
# there, in the right-hand padding, rather than over the text.
|
|
255
|
+
room = inner + min(1, self._padding_x)
|
|
227
256
|
|
|
228
257
|
clusters = list(grapheme_clusters(row.text))
|
|
229
258
|
consumed = 0
|
|
@@ -247,12 +276,12 @@ class Editor(Widget):
|
|
|
247
276
|
|
|
248
277
|
if under:
|
|
249
278
|
painted = head + CURSOR_MARKER + inverse(under) + "".join(after)
|
|
250
|
-
return truncate_to_width(painted,
|
|
279
|
+
return truncate_to_width(painted, room)
|
|
251
280
|
|
|
252
281
|
# At end of line the cursor is an appended cell, which costs a column
|
|
253
282
|
# the text did not need. Without trimming for it the line comes out one
|
|
254
283
|
# cell too wide and the renderer refuses to draw the frame.
|
|
255
|
-
head = truncate_to_width(head, max(0,
|
|
284
|
+
head = truncate_to_width(head, max(0, room - 1))
|
|
256
285
|
return head + CURSOR_MARKER + inverse(" ")
|
|
257
286
|
|
|
258
287
|
# -- editing helpers ---------------------------------------------------
|
|
@@ -417,8 +417,13 @@ def _unicode_key(code: int, shifted: int | None, base: int | None, mods: list[st
|
|
|
417
417
|
return Key(_with_modifiers(_CTRL_PUNCTUATION[char], rest))
|
|
418
418
|
return Key(_with_modifiers("space" if char == " " else char.lower(), mods))
|
|
419
419
|
|
|
420
|
-
#
|
|
421
|
-
#
|
|
420
|
+
# super, hyper and meta have no legacy encoding to agree with, so shift is
|
|
421
|
+
# named like ctrl's: cmd+shift+z is super+shift+z, as a user writes it.
|
|
422
|
+
if set(chording) - {"alt"}:
|
|
423
|
+
return Key(_with_modifiers("space" if char == " " else char.lower(), mods))
|
|
424
|
+
|
|
425
|
+
# alt alone. The legacy encoding sends ESC then the shifted character, so
|
|
426
|
+
# alt+shift+b has always been alt+B.
|
|
422
427
|
if "shift" in mods:
|
|
423
428
|
char = chr(shifted) if shifted else char.upper()
|
|
424
429
|
mods = [mod for mod in mods if mod != "shift"]
|
|
@@ -24,7 +24,7 @@ Two conventions worth naming, both taken from pi:
|
|
|
24
24
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
|
|
27
|
-
from hx.term.ansi import fill_line, hyperlink, wrap
|
|
27
|
+
from hx.term.ansi import fill_line, hyperlink, open_styles, wrap
|
|
28
28
|
from hx.term.component import Widget
|
|
29
29
|
from hx.term.width import cell_width, truncate_to_width
|
|
30
30
|
|
|
@@ -38,6 +38,10 @@ HR_MAX = 80
|
|
|
38
38
|
"""A rule spanning a very wide terminal reads as a divider in a book, not a
|
|
39
39
|
paragraph break. Capping it keeps it proportionate to the text."""
|
|
40
40
|
|
|
41
|
+
MIN_COLUMN = 3
|
|
42
|
+
"""The narrowest a table column is squeezed to. Below this a table is drawn as
|
|
43
|
+
records instead - see :meth:`_Renderer._stacked_table`."""
|
|
44
|
+
|
|
41
45
|
|
|
42
46
|
class Painter:
|
|
43
47
|
"""How each markdown element is coloured.
|
|
@@ -294,30 +298,61 @@ class _Renderer:
|
|
|
294
298
|
return
|
|
295
299
|
|
|
296
300
|
count = max(len(row) for row in rows)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
#
|
|
301
|
+
rows = [row + [""] * (count - len(row)) for row in rows]
|
|
302
|
+
widths = [max(cell_width(row[i]) for row in rows) for i in range(count)]
|
|
303
|
+
# Narrowed to fit, widest column first, and the cells wrapped inside
|
|
304
|
+
# it. Truncating instead dropped the end of every long cell without a
|
|
305
|
+
# mark, so a pasted table echoed back missing text the model was sent.
|
|
302
306
|
budget = self.width - indent - (3 * count + 1)
|
|
303
|
-
|
|
307
|
+
if budget < MIN_COLUMN * count:
|
|
308
|
+
self._stacked_table(rows, header, indent)
|
|
309
|
+
return
|
|
310
|
+
while sum(widths) > budget:
|
|
304
311
|
widths[widths.index(max(widths))] -= 1
|
|
305
312
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
for
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
+
body = [
|
|
314
|
+
[_cell_lines(cell, width) for cell, width in zip(row, widths, strict=True)]
|
|
315
|
+
for row in rows
|
|
316
|
+
]
|
|
317
|
+
# Once a row takes more than one line, only a rule says where the next
|
|
318
|
+
# one starts.
|
|
319
|
+
ruled = any(max(map(len, row)) > 1 for row in body[header:])
|
|
320
|
+
|
|
321
|
+
def divider() -> str:
|
|
322
|
+
line = "├─" + "─┼─".join("─" * w for w in widths) + "─┤"
|
|
323
|
+
return self.painter.paint("md_code_block_border", line)
|
|
324
|
+
|
|
325
|
+
self.blank()
|
|
326
|
+
for index, row in enumerate(body):
|
|
327
|
+
if index and (index == header or (index > header and ruled)):
|
|
328
|
+
self.emit(divider(), indent)
|
|
329
|
+
for depth in range(max(map(len, row))):
|
|
330
|
+
cells = []
|
|
331
|
+
for lines, width in zip(row, widths, strict=True):
|
|
332
|
+
piece = lines[depth] if depth < len(lines) else ""
|
|
333
|
+
cells.append(piece + " " * (width - cell_width(piece)))
|
|
334
|
+
self.emit("│ " + " │ ".join(cells) + " │", indent)
|
|
335
|
+
self.blank()
|
|
313
336
|
|
|
314
|
-
|
|
337
|
+
def _stacked_table(self, rows: list[list[str]], header: int, indent: int) -> None:
|
|
338
|
+
"""A table too wide for the terminal, one record at a time.
|
|
315
339
|
|
|
340
|
+
Every column at its narrowest would still overflow, and a grid of
|
|
341
|
+
three-cell slivers is not readable. Each row becomes ``header: value``
|
|
342
|
+
lines instead, the records a rule apart.
|
|
343
|
+
"""
|
|
344
|
+
names = rows[header - 1] if header else [""] * len(rows[0])
|
|
316
345
|
self.blank()
|
|
317
|
-
for index, row in enumerate(rows):
|
|
318
|
-
if index
|
|
319
|
-
|
|
320
|
-
|
|
346
|
+
for index, row in enumerate(rows[header:]):
|
|
347
|
+
if index:
|
|
348
|
+
span = min(self.width - indent, HR_MAX)
|
|
349
|
+
self.emit(self.painter.paint("md_code_block_border", "─" * span), indent)
|
|
350
|
+
for name, cell in zip(names, row, strict=True):
|
|
351
|
+
label = self.painter.paint("md_heading", name + ":", bold=True) if name else ""
|
|
352
|
+
lines = self.wrapped(f"{label} {cell}" if label else cell, indent, 2)
|
|
353
|
+
for position, line in enumerate(lines):
|
|
354
|
+
# Continuation hangs under the value, clear of the name.
|
|
355
|
+
self.emit(line, indent + (2 if position else 0))
|
|
321
356
|
self.blank()
|
|
322
357
|
|
|
323
358
|
# -- inline ------------------------------------------------------------
|
|
@@ -359,6 +394,18 @@ class _Renderer:
|
|
|
359
394
|
return str(getattr(node, "content", "") or "") or self.inline(node)
|
|
360
395
|
|
|
361
396
|
|
|
397
|
+
def _cell_lines(text: str, width: int) -> list[str]:
|
|
398
|
+
"""One table cell wrapped to its column.
|
|
399
|
+
|
|
400
|
+
Wrapping reopens on each line whatever styling was open at the break; each
|
|
401
|
+
line here also closes it, so a link or a bold phrase broken across lines
|
|
402
|
+
stays inside its own cell instead of running on into the next.
|
|
403
|
+
"""
|
|
404
|
+
# Truncated because wrap only overflows for a glyph wider than the column.
|
|
405
|
+
lines = [truncate_to_width(piece, width) for piece in wrap(text, width)]
|
|
406
|
+
return [line + open_styles(line)[1] for line in lines]
|
|
407
|
+
|
|
408
|
+
|
|
362
409
|
class Markdown(Widget):
|
|
363
410
|
"""A block of markdown as a component."""
|
|
364
411
|
|
|
@@ -87,7 +87,7 @@ it to an application that asks - which is how shift+enter survives tmux."""
|
|
|
87
87
|
|
|
88
88
|
_RESIZE_SIGNALS = (signal.SIGWINCH, signal.SIGCONT)
|
|
89
89
|
"""SIGWINCH is not delivered while the process is stopped, so a resize during
|
|
90
|
-
|
|
90
|
+
a suspend is only discoverable on the way back - which is what SIGCONT is
|
|
91
91
|
doing in a list of resize signals."""
|
|
92
92
|
|
|
93
93
|
|
|
@@ -758,7 +758,7 @@ class HXSession:
|
|
|
758
758
|
self.runner.request_immediate_render()
|
|
759
759
|
|
|
760
760
|
def _suspend(self) -> None:
|
|
761
|
-
"""``
|
|
761
|
+
"""``app.suspend``. Raw mode means the terminal will not do this for us."""
|
|
762
762
|
self.runner.suspend()
|
|
763
763
|
|
|
764
764
|
def _terminal_rows(self) -> int:
|
|
@@ -310,12 +310,24 @@ class Header(Widget):
|
|
|
310
310
|
lines = [title]
|
|
311
311
|
|
|
312
312
|
if not self._expanded:
|
|
313
|
-
|
|
314
|
-
|
|
313
|
+
from hx.term.ansi import wrap
|
|
314
|
+
|
|
315
|
+
# Prose, so wrapped between words on a narrow terminal rather than
|
|
316
|
+
# cut off mid-word the way the key table's columns are.
|
|
317
|
+
inner = max(1, width - 2)
|
|
318
|
+
lines += wrap(
|
|
319
|
+
fg("muted", "An agent harness. Ask a question, or start with /help."), inner
|
|
320
|
+
)
|
|
321
|
+
lines += wrap(
|
|
322
|
+
fg("dim", f"{KEYMAP.primary('app.tools.expand')} shows every key."), inner
|
|
323
|
+
)
|
|
315
324
|
else:
|
|
316
325
|
rows = [
|
|
317
326
|
(fg("dim", KEYMAP.text(binding.id)), fg("muted", binding.description))
|
|
318
327
|
for binding in KEYMAP.bindings.values()
|
|
328
|
+
# An action with no key, like suspend by default, has nothing
|
|
329
|
+
# to show here but a blank where its key would be.
|
|
330
|
+
if KEYMAP.keys_for(binding.id)
|
|
319
331
|
]
|
|
320
332
|
from hx.tui.format import columns
|
|
321
333
|
|
|
@@ -69,6 +69,10 @@ _KITTY: dict[str, str] = {
|
|
|
69
69
|
# character (flag 4, alternate keys), and ctrl+shift.
|
|
70
70
|
"ctrl+_": "\x1b[45:95;6u",
|
|
71
71
|
"ctrl+shift+z": "\x1b[122:90;6u",
|
|
72
|
+
# cmd is the protocol's super. Only a terminal that leaves cmd to the
|
|
73
|
+
# program sends these (kitty, WezTerm); the rest keep it for their menus.
|
|
74
|
+
"cmd+shift+z": "\x1b[122:90;10u",
|
|
75
|
+
**{f"cmd+{c}": f"\x1b[{ord(c)};9u" for c in "abcdefghijklmnopqrstuvwxyz"},
|
|
72
76
|
**{f"alt+{c}": f"\x1b[{ord(c)};3u" for c in "bfdy"},
|
|
73
77
|
**{f"ctrl+{c}": f"\x1b[{ord(c)};5u" for c in "abcdefghijklnopqrstuvwxyz"},
|
|
74
78
|
}
|