From 836fd7e585af15cc1ccb0d4f04586ffec28d2a3c Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 20 May 2026 18:55:41 +0300 Subject: [PATCH] feat: add keybinding v2 refs #154 refs #201 refs #202 refs #219 --- .github/workflows/release.yml | 30 +- docs/next/CHANGELOG.md | 3 + docs/next/README.md | 38 +- docs/next/product-announcement.json | 5 + .../website/src/content/docs/concepts.mdx | 6 +- .../src/content/docs/configuration.mdx | 85 +- .../website/src/content/docs/quick-start.mdx | 16 +- scripts/changelog.py | 99 +- scripts/test_changelog.py | 78 + src/app/input/mod.rs | 23 +- src/app/input/modal.rs | 50 +- src/app/input/navigate.rs | 725 ++++-- src/app/input/terminal.rs | 174 +- src/app/mod.rs | 39 +- src/app/state.rs | 86 +- src/cli.rs | 116 +- src/config.rs | 12 +- src/config/io.rs | 94 + src/config/keybinds.rs | 2168 ++++++++--------- src/config/model.rs | 187 +- src/main.rs | 86 +- src/remote.rs | 163 ++ src/ui.rs | 63 +- src/ui/keybind_help.rs | 143 +- src/ui/menus.rs | 65 +- src/ui/onboarding.rs | 2 +- src/ui/panes.rs | 5 +- src/ui/release_notes.rs | 10 +- 28 files changed, 2727 insertions(+), 1844 deletions(-) create mode 100644 docs/next/product-announcement.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c5c84f66..9ec6a4cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,8 +109,21 @@ jobs: name: ${{ matrix.name }} path: ${{ matrix.name }} + validate-release-inputs: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Validate product announcement + run: python3 scripts/changelog.py validate-product-announcement + release: - needs: build + needs: [build, validate-release-inputs] runs-on: ubuntu-latest permissions: contents: write @@ -280,18 +293,29 @@ jobs: GH_TOKEN: ${{ github.token }} run: | VERSION="${GITHUB_REF_NAME#v}" + ANNOUNCEMENT_PATH="$RUNNER_TEMP/product-announcement.json" + ANNOUNCEMENT_ORIGINAL_PATH="$RUNNER_TEMP/product-announcement-original.json" + git show "${GITHUB_REF_NAME}:docs/next/product-announcement.json" > "$ANNOUNCEMENT_PATH" + cp "$ANNOUNCEMENT_PATH" "$ANNOUNCEMENT_ORIGINAL_PATH" + python3 scripts/changelog.py validate-product-announcement --path "$ANNOUNCEMENT_PATH" + CURRENT_VERSION=$(python3 -c 'import json; print(json.load(open("website/latest.json")).get("version", ""))') if [ "$CURRENT_VERSION" = "$VERSION" ]; then echo "website/latest.json is already at v$VERSION" exit 0 fi - python3 scripts/changelog.py sync-latest-json --version "$VERSION" --output website/latest.json + python3 scripts/changelog.py sync-latest-json --version "$VERSION" --output website/latest.json --announcement "$ANNOUNCEMENT_PATH" + if cmp -s "$ANNOUNCEMENT_ORIGINAL_PATH" docs/next/product-announcement.json; then + printf 'null\n' > docs/next/product-announcement.json + else + echo "::warning::docs/next/product-announcement.json changed after $GITHUB_REF_NAME; leaving it unchanged." + fi - name: Commit website latest manifest run: | VERSION="${GITHUB_REF_NAME#v}" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add website/latest.json + git add website/latest.json docs/next/product-announcement.json git diff --cached --quiet || git commit -m "docs: update website manifest for v$VERSION" git push origin master diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 36188a11..6d7094a6 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased ### Added +- Added keybinding v2 with explicit `prefix+...` syntax, array bindings per action, configurable prefix-mode pane focus, tab switching, and direct modified chords for users who opt in. (#154) +- Added `herdr config reset-keys` to back up `config.toml` and remove custom keybindings so built-in v2 defaults apply on restart or config reload. (#154) - Added an integrations tab in settings and first-run onboarding so users can install recommended agent integrations from inside Herdr. - Added `terminal.default_shell` to choose the executable used for new interactive panes. When unset, Herdr still falls back to `$SHELL`, then `/bin/sh`. (#196) - Added native Kiro CLI detection with idle and working state heuristics. (#185) @@ -11,6 +13,7 @@ - Remote clients now bridge local clipboard images into the remote pane by staging them as temporary image files and pasting the remote path, so Claude Code image paste works over `herdr --remote`. (#205) ### Breaking Changes +- Keybindings now use explicit trigger syntax: `prefix+c` means prefix mode, while `ctrl+alt+c` is direct. Bare printable direct bindings such as `new_tab = "c"` are rejected with diagnostics because they intercept normal typing. The default keymap now gives tmux-style tab actions to `prefix+c`, `prefix+n`/`prefix+p`, and `prefix+1..9`, uses `prefix+w` for workspace navigation, and moves pane focus to `prefix+h/j/k/l`. (#154) - The client/server protocol is now version 8. Stop and restart any running v0.5.12 server before attaching with this release. ## [0.5.12] - 2026-05-19 diff --git a/docs/next/README.md b/docs/next/README.md index f32fbca9..402d7d4e 100644 --- a/docs/next/README.md +++ b/docs/next/README.md @@ -53,10 +53,10 @@ herdr session stop work herdr session delete side-project ``` -1. press `n` to create a workspace +1. press `ctrl+b`, then `shift+n` to create a workspace 2. run an agent in the root pane -3. press `ctrl+b` to enter navigate mode -4. use `v` or `-` to split panes, or `c` to create a new tab +3. press `ctrl+b`, then `w` to open workspace navigation +4. use `ctrl+b`, then `v` or `minus` to split panes, or `ctrl+b`, then `c` to create a new tab 5. watch the sidebar for blocked, working, and done states on first run herdr opens a short onboarding flow. after that, restored sessions land in terminal mode; fresh sessions start in **navigate mode**. @@ -201,32 +201,38 @@ see the [integrations docs](https://herdr.dev/docs/integrations/) for setup deta ## keybindings -press `ctrl+b` to enter navigate mode. +press `ctrl+b` to enter prefix mode. default actions are prefix-first and tmux-like: | key | action | |-----|--------| -| `n` | new workspace | -| `shift+n` | rename workspace | -| `shift+d` | close workspace | -| `c` | new tab | -| `v` / `-` | split pane | -| `x` | close pane | -| `b` | toggle sidebar | -| `f` | zoom pane | -| `r` | resize mode | -| `q` | detach (quit client) | +| `prefix+c` | new tab | +| `prefix+n` / `prefix+p` | next / previous tab | +| `prefix+1..9` | switch tab | +| `prefix+w` | workspace navigation | +| `prefix+shift+n` | new workspace | +| `prefix+shift+w` | rename workspace | +| `prefix+shift+d` | close workspace | +| `prefix+h/j/k/l` | focus pane | +| `prefix+v` / `prefix+minus` | split pane | +| `prefix+x` | close pane | +| `prefix+b` | toggle sidebar | +| `prefix+z` | zoom pane | +| `prefix+r` | resize mode | +| `prefix+d` | detach (quit client) | resize mode: `h`/`l` resize width, `j`/`k` resize height, `esc` exit. -custom command keybindings can launch detached shell helpers or temporary panes from prefix mode: +custom command keybindings can launch detached shell helpers or temporary panes: ```toml [[keys.command]] -key = "g" +key = "prefix+g" type = "pane" # "shell" or "pane" command = "lazygit" ``` +if you have old custom keybindings and want the new defaults, run `herdr config reset-keys`. herdr backs up `config.toml`, removes only keybinding config, and uses built-in v2 defaults after restart or config reload. + mouse is supported throughout. full reference: [configuration docs](https://herdr.dev/docs/configuration/). ## configuration diff --git a/docs/next/product-announcement.json b/docs/next/product-announcement.json new file mode 100644 index 00000000..f76743f2 --- /dev/null +++ b/docs/next/product-announcement.json @@ -0,0 +1,5 @@ +{ + "id": "keybinding-v2", + "title": "Keybind Refactor", + "body": "### Breaking change: keybinding syntax changed\n\nHerdr now uses explicit tmux-style keybindings. If you want the new defaults, run `herdr config reset-keys`. Herdr backs up your config first, removes only keybinding settings, and preserves non-key settings.\n\nBefore, many keybinding values implicitly meant \"after pressing the prefix\". Now the binding must say that directly:\n\n- Before: `new_tab = \"c\"`\n- After: `new_tab = \"prefix+c\"`\n\nBindings without `prefix+` are direct terminal-mode shortcuts. Direct shortcuts can intercept keys before shells, editors, tmux, SSH, and terminal apps receive them. If your current config has old bare keybindings, Herdr may warn about them or disable unsafe bindings.\n\nThe new defaults are prefix-first:\n\n- `prefix+c` creates a tab\n- `prefix+n` / `prefix+p` switch tabs\n- `prefix+1..9` switches tabs\n- `prefix+h/j/k/l` focuses panes\n- `prefix+w` opens workspace navigation\n\nPlease read the updated keybinding docs before customizing again:\n\nhttps://herdr.dev/docs/configuration/" +} diff --git a/docs/next/website/src/content/docs/concepts.mdx b/docs/next/website/src/content/docs/concepts.mdx index ea77ac20..b1a7908f 100644 --- a/docs/next/website/src/content/docs/concepts.mdx +++ b/docs/next/website/src/content/docs/concepts.mdx @@ -67,11 +67,11 @@ herdr server stop ## Modes -Herdr has terminal mode and navigate mode. +Herdr has terminal mode, prefix mode, and navigate mode. -Terminal mode sends keys to the focused pane. Navigate mode sends keys to Herdr. +Terminal mode sends keys to the focused pane. Prefix mode waits for one Herdr action after the prefix key. Navigate mode is the persistent workspace navigation surface. -Press the prefix key, default `ctrl+b`, to enter navigate mode. Use navigate mode to create workspaces, split panes, switch tabs, resize, open menus, or detach. +Press the prefix key, default `ctrl+b`, then an action key such as `c` for a new tab or `w` for workspace navigation. ## Mouse UI diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 317dc4d2..bc43d4f0 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -58,72 +58,79 @@ When unset or empty, Herdr uses `$SHELL`, then `/bin/sh`. This is an executable ## Keybindings -Herdr has a prefix mode similar to tmux. The default prefix is `ctrl+b`. +Herdr has a prefix mode similar to tmux. The default prefix is `ctrl+b`. Keybinding strings are explicit: `prefix+n` means press the configured prefix and then `n`; `ctrl+alt+n` is a direct terminal-mode shortcut. A small keybinding override looks like this: ```toml [keys] prefix = "ctrl+b" -new_workspace = "n" -rename_workspace = "shift+n" -close_workspace = "shift+d" -new_tab = "c" -split_vertical = "v" -split_horizontal = "-" -close_pane = "x" -zoom = "f" -resize_mode = "r" -toggle_sidebar = "b" +new_tab = "prefix+c" +next_tab = "prefix+n" +previous_tab = "prefix+p" +focus_pane_left = "prefix+h" +split_horizontal = "prefix+minus" ``` -Optional actions are unset by default. Bind them when you want direct shortcuts: +The default keymap is prefix-first and avoids direct shortcuts that can steal input from shells, editors, tmux, or terminal apps. Common defaults include: ```toml [keys] -detach = "q" -reload_config = "R" -open_notification_target = "o" -previous_workspace = "H" -next_workspace = "L" -previous_agent = "A" -next_agent = "D" -previous_tab = "J" -next_tab = "K" -rename_tab = "T" -close_tab = "W" -rename_pane = "p" -edit_scrollback = "e" -focus_pane_left = "h" -focus_pane_down = "j" -focus_pane_up = "k" -focus_pane_right = "l" +workspace_picker = "prefix+w" +new_workspace = "prefix+shift+n" +rename_workspace = "prefix+shift+w" +close_workspace = "prefix+shift+d" +new_tab = "prefix+c" +previous_tab = "prefix+p" +next_tab = "prefix+n" +switch_tab = "prefix+1..9" +rename_tab = "prefix+shift+t" +close_tab = "prefix+shift+x" +focus_pane_left = "prefix+h" +focus_pane_down = "prefix+j" +focus_pane_up = "prefix+k" +focus_pane_right = "prefix+l" +split_vertical = "prefix+v" +split_horizontal = "prefix+minus" +close_pane = "prefix+x" +zoom = "prefix+z" +resize_mode = "prefix+r" +toggle_sidebar = "prefix+b" ``` -Use the full default config to see every available action. +Optional actions are unset by default. Bind them with `prefix+` for prefix-mode behavior, or with an explicit modified chord when you intentionally want a direct shortcut: -Key strings accept plain keys, modifier combinations such as `ctrl+a`, `shift+n`, `alt+1`, `cmd+k`, and special keys such as `enter`, `tab`, `esc`, `left`, `right`, `up`, and `down`. Plain keys, `ctrl+letter`, Escape, Tab, Enter, and function keys are the most reliable. Alt, Cmd/Super, and punctuation with modifiers depend on your terminal and tmux settings. +```toml +[keys] +previous_workspace = "prefix+shift+left" +next_workspace = "prefix+shift+right" +next_tab = ["prefix+n", "ctrl+alt+]"] +``` + +Key strings accept plain keys, modifier combinations such as `ctrl+a`, `shift+n`, `alt+1`, `cmd+k`, and special keys such as `enter`, `tab`, `esc`, `left`, `right`, `up`, and `down`. Named punctuation such as `minus`, `comma`, `ampersand`, `plus`, and `backtick` is also accepted. Plain direct printable keys such as `n` are unsafe because they intercept typing; use `prefix+n` unless you intentionally want a direct binding. Alt, Cmd/Super, and punctuation with modifiers depend on your terminal and tmux settings. + +If you have old custom keybindings and want the new defaults, run `herdr config reset-keys`. Herdr backs up `config.toml`, removes `[keys]` and `[[keys.command]]`, and uses built-in v2 defaults after restart or `herdr server reload-config`. ## Indexed jumps -Indexed keybindings let you jump directly to visible positions. +Indexed keybindings use `1..9` in normal keybinding fields: ```toml -[keys.indexed] -workspaces = "ctrl+shift" -tabs = "ctrl" -agents = "alt" +[keys] +switch_tab = "prefix+1..9" +switch_workspace = "prefix+shift+1..9" +focus_agent = "prefix+alt+1..9" ``` -These expand over number keys 1 through 9. For example, `tabs = "ctrl"` makes `ctrl+1` through `ctrl+9` switch tabs. +The legacy `[keys.indexed]` table is still parsed for compatibility, but new configs should prefer the explicit action fields. ## Custom command keybindings -Custom prefix-mode commands can run shell helpers from inside Herdr. +Custom commands use the same keybinding syntax. ```toml [[keys.command]] -key = "g" +key = "prefix+g" type = "pane" command = "lazygit" ``` diff --git a/docs/next/website/src/content/docs/quick-start.mdx b/docs/next/website/src/content/docs/quick-start.mdx index fddef6b8..9e2452a7 100644 --- a/docs/next/website/src/content/docs/quick-start.mdx +++ b/docs/next/website/src/content/docs/quick-start.mdx @@ -29,19 +29,21 @@ pi Herdr detects supported agents automatically. The sidebar shows whether each agent is `working`, `blocked`, `done`, or `idle`. -## Navigate +## Keyboard control -Press `ctrl+b` to enter navigate mode. +Press `ctrl+b` to enter prefix mode, then press an action key. Common actions: | Action | Key | | --- | --- | -| Split right | `v` | -| Split down | `-` | -| New tab | `c` | -| New workspace | `n` | -| Detach client | `q` | +| Split right | `prefix+v` | +| Split down | `prefix+minus` | +| New tab | `prefix+c` | +| Next / previous tab | `prefix+n` / `prefix+p` | +| Workspace navigation | `prefix+w` | +| New workspace | `prefix+shift+n` | +| Detach client | `prefix+d` | After detaching, run `herdr` again to reattach to the same session. diff --git a/scripts/changelog.py b/scripts/changelog.py index e930fd82..56838baa 100644 --- a/scripts/changelog.py +++ b/scripts/changelog.py @@ -17,6 +17,7 @@ SECTION_RE = re.compile(r"^##\s+(?:\[(?P[^\]]+)\]|(?P.+?))\s*$ VERSION_WITH_DATE_RE = re.compile(r"^(?P.+?)\s+-\s+\d{4}-\d{2}-\d{2}$") DEFAULT_RELEASE_REPO = "ogulcancelik/herdr" DEFAULT_LATEST_JSON_PATH = Path("website/latest.json") +DEFAULT_PRODUCT_ANNOUNCEMENT_PATH = Path("docs/next/product-announcement.json") PROTOCOL_SOURCE_PATH = Path("src/server/protocol.rs") ASSET_TARGETS = ( "linux-x86_64", @@ -134,7 +135,11 @@ def read_protocol_version(source_path: Path = PROTOCOL_SOURCE_PATH) -> int: def build_latest_json( - version: str, notes: str, assets: dict[str, str], protocol: int | None = None + version: str, + notes: str, + assets: dict[str, str], + protocol: int | None = None, + announcement: dict[str, str] | None = None, ) -> str: normalized_version = normalize_version(version) normalized_notes = notes.strip() @@ -150,15 +155,16 @@ def build_latest_json( ordered_assets = {target: assets[target] for target in ASSET_TARGETS} - return json.dumps( - { - "version": normalized_version, - "protocol": protocol, - "notes": normalized_notes, - "assets": ordered_assets, - }, - indent=2, - ) + "\n" + manifest: dict[str, Any] = { + "version": normalized_version, + "protocol": protocol, + "notes": normalized_notes, + "assets": ordered_assets, + } + if announcement is not None: + manifest["announcement"] = announcement + + return json.dumps(manifest, indent=2) + "\n" def default_release_assets(version: str, repo: str = DEFAULT_RELEASE_REPO) -> dict[str, str]: @@ -284,6 +290,44 @@ def load_json(path: Path) -> dict[str, Any]: return data +def load_product_announcement(path: Path) -> dict[str, str] | None: + try: + content = path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + raise ChangelogError(f"product announcement file not found: {path}") from exc + + try: + data = json.loads(content) + except json.JSONDecodeError as exc: + raise ChangelogError(f"invalid JSON in {path}: {exc}") from exc + + if data is None: + return None + if not isinstance(data, dict): + raise ChangelogError(f"expected announcement object or null in {path}") + + allowed_keys = {"id", "title", "body"} + extra_keys = sorted(set(data) - allowed_keys) + if extra_keys: + raise ChangelogError( + f"announcement in {path} has unsupported field(s): {', '.join(extra_keys)}" + ) + + announcement: dict[str, str] = {} + for key in ("id", "title", "body"): + value = data.get(key) + if not isinstance(value, str) or not value.strip(): + raise ChangelogError(f"announcement in {path} is missing non-empty string field: {key}") + announcement[key] = value.strip() + + if not re.fullmatch(r"[a-z0-9][a-z0-9._-]*", announcement["id"]): + raise ChangelogError( + f"announcement in {path} has invalid id; use lowercase letters, numbers, dots, underscores, or dashes" + ) + + return announcement + + def write_text(path: Path, text: str) -> None: path.write_text(text, encoding="utf-8") @@ -412,10 +456,22 @@ def cmd_sync_latest_json(args: argparse.Namespace) -> int: release_payload = fetch_release_payload(version, args.repo) new_manifest = manifest_from_release_payload(release_payload, version) - output = build_latest_json(version, str(new_manifest["notes"]), dict(new_manifest["assets"])) + announcement_path = Path(args.announcement) + announcement = load_product_announcement(announcement_path) + output = build_latest_json( + version, + str(new_manifest["notes"]), + dict(new_manifest["assets"]), + announcement=announcement, + ) write_text(manifest_path, output) + if announcement is not None: + write_text(announcement_path, "null\n") print(f"updated {manifest_path} from GitHub release v{version}") + if announcement is not None: + print(f"included product announcement from {announcement_path}") + print(f"cleared {announcement_path}") status_lines = git_status_lines(manifest_path) print("files changed:") if status_lines: @@ -432,6 +488,17 @@ def cmd_sync_latest_json(args: argparse.Namespace) -> int: return 0 +def cmd_validate_product_announcement(args: argparse.Namespace) -> int: + announcement = load_product_announcement(Path(args.path)) + if announcement is None: + print(f"product announcement ({args.path}): none") + else: + print( + f"product announcement ({args.path}): {announcement['id']} - {announcement['title']}" + ) + return 0 + + def cmd_verify_release_state(args: argparse.Namespace) -> int: version = normalize_version(args.version) release_payload = fetch_release_payload(version, args.repo) @@ -484,8 +551,18 @@ def build_parser() -> argparse.ArgumentParser: sync_latest_json.add_argument("--version", required=True) sync_latest_json.add_argument("--repo", default=DEFAULT_RELEASE_REPO) sync_latest_json.add_argument("--output", default=str(DEFAULT_LATEST_JSON_PATH)) + sync_latest_json.add_argument("--announcement", default=str(DEFAULT_PRODUCT_ANNOUNCEMENT_PATH)) sync_latest_json.set_defaults(func=cmd_sync_latest_json) + validate_product_announcement = subparsers.add_parser( + "validate-product-announcement", + help="Validate docs/next product announcement JSON", + ) + validate_product_announcement.add_argument( + "--path", default=str(DEFAULT_PRODUCT_ANNOUNCEMENT_PATH) + ) + validate_product_announcement.set_defaults(func=cmd_validate_product_announcement) + verify_release_state = subparsers.add_parser( "verify-release-state", help="Verify GitHub release, local manifest, live manifest, and asset URLs all match", diff --git a/scripts/test_changelog.py b/scripts/test_changelog.py index 5778b6ac..5d9fd506 100644 --- a/scripts/test_changelog.py +++ b/scripts/test_changelog.py @@ -1,16 +1,20 @@ from __future__ import annotations import json +import tempfile import unittest +from pathlib import Path from scripts.changelog import ( ChangelogError, build_latest_json, canonicalize_manifest, + DEFAULT_PRODUCT_ANNOUNCEMENT_PATH, default_release_assets, ensure_manifest_is_outdated, ensure_manifest_matches_expected, extract_section_body, + load_product_announcement, manifest_from_release_payload, prepare_release, read_protocol_version, @@ -76,6 +80,80 @@ class ChangelogScriptTests(unittest.TestCase): }, ) + def test_build_latest_json_embeds_product_announcement(self) -> None: + manifest = json.loads( + build_latest_json( + "0.1.1", + "### Fixed\n- One", + default_release_assets("0.1.1"), + announcement={"id": "keybinding-v2", "title": "Keybind Refactor", "body": "body"}, + ) + ) + + self.assertEqual( + manifest["announcement"], + {"id": "keybinding-v2", "title": "Keybind Refactor", "body": "body"}, + ) + + def write_temp_json(self, content: str) -> Path: + tmp = tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") + with tmp: + tmp.write(content) + return Path(tmp.name) + + def test_checked_in_product_announcement_is_valid_or_null(self) -> None: + self.assertTrue(DEFAULT_PRODUCT_ANNOUNCEMENT_PATH.is_file()) + load_product_announcement(DEFAULT_PRODUCT_ANNOUNCEMENT_PATH) + + def test_load_product_announcement_accepts_null(self) -> None: + path = self.write_temp_json("null\n") + try: + self.assertIsNone(load_product_announcement(path)) + finally: + path.unlink(missing_ok=True) + + def test_load_product_announcement_accepts_valid_object(self) -> None: + path = self.write_temp_json( + json.dumps({"id": "keybinding-v2", "title": "Keybind Refactor", "body": "Body"}) + ) + try: + self.assertEqual( + load_product_announcement(path), + {"id": "keybinding-v2", "title": "Keybind Refactor", "body": "Body"}, + ) + finally: + path.unlink(missing_ok=True) + + def test_load_product_announcement_rejects_missing_file(self) -> None: + path = Path(tempfile.gettempdir()) / "herdr-missing-product-announcement.json" + path.unlink(missing_ok=True) + with self.assertRaisesRegex(ChangelogError, "file not found"): + load_product_announcement(path) + + def test_load_product_announcement_rejects_missing_empty_or_extra_fields(self) -> None: + cases = [ + ({"id": "keybinding-v2", "title": "Keybind Refactor"}, "body"), + ({"id": "", "title": "Keybind Refactor", "body": "Body"}, "id"), + ({"id": "keybinding-v2", "title": "Keybind Refactor", "body": "Body", "cta": "x"}, "unsupported"), + ] + for payload, expected in cases: + path = self.write_temp_json(json.dumps(payload)) + try: + with self.assertRaisesRegex(ChangelogError, expected): + load_product_announcement(path) + finally: + path.unlink(missing_ok=True) + + def test_load_product_announcement_rejects_invalid_id(self) -> None: + path = self.write_temp_json( + json.dumps({"id": "Keybinding V2", "title": "Keybind Refactor", "body": "Body"}) + ) + try: + with self.assertRaisesRegex(ChangelogError, "invalid id"): + load_product_announcement(path) + finally: + path.unlink(missing_ok=True) + def test_manifest_from_release_payload_uses_release_body_and_asset_urls(self) -> None: manifest = manifest_from_release_payload( { diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 4cafc926..245f935b 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -56,23 +56,24 @@ impl App { pub(super) async fn handle_key(&mut self, key: TerminalKey) { match self.state.mode { Mode::Terminal => self.handle_terminal_key(key).await, + Mode::Prefix => self.handle_prefix_key(key), Mode::Navigate => self.handle_navigate_key(key), _ => { - let key = key.as_key_event(); + let key_event = key.as_key_event(); match self.state.mode { - Mode::Onboarding => self.handle_onboarding_key(key), - Mode::ReleaseNotes => self.handle_release_notes_key(key), - Mode::ProductAnnouncement => self.handle_product_announcement_key(key), - Mode::Navigate => unreachable!(), + Mode::Onboarding => self.handle_onboarding_key(key_event), + Mode::ReleaseNotes => self.handle_release_notes_key(key_event), + Mode::ProductAnnouncement => self.handle_product_announcement_key(key_event), + Mode::Prefix | Mode::Navigate => unreachable!(), Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => { - handle_rename_key(&mut self.state, key) + handle_rename_key(&mut self.state, key_event) } Mode::Resize => handle_resize_key(&mut self.state, key), - Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key), - Mode::ContextMenu => handle_context_menu_key(&mut self.state, key), - Mode::Settings => self.handle_settings_key(key), - Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key), - Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key), + Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key_event), + Mode::ContextMenu => handle_context_menu_key(&mut self.state, key_event), + Mode::Settings => self.handle_settings_key(key_event), + Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key_event), + Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key_event), Mode::Terminal => unreachable!(), } } diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index ff5e8582..5b585221 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -2,7 +2,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::layout::{Direction, Rect}; use crate::{ - app::state::{key_matches, AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode}, + app::state::{AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode}, + input::TerminalKey, layout::NavDirection, }; @@ -448,14 +449,12 @@ pub(crate) fn handle_rename_key(state: &mut AppState, key: KeyEvent) { } } -pub(crate) fn handle_resize_key(state: &mut AppState, key: KeyEvent) { +pub(crate) fn handle_resize_key(state: &mut AppState, raw_key: TerminalKey) { + let key = raw_key.as_key_event(); if key.code == KeyCode::Esc || key.code == KeyCode::Enter - || key_matches( - &key, - state.keybinds.resize_mode.0, - state.keybinds.resize_mode.1, - ) + || state.keybinds.resize_mode.matches_prefix_key(raw_key) + || state.keybinds.resize_mode.matches_direct_key(raw_key) { if state.active.is_some() { state.mode = Mode::Terminal; @@ -648,12 +647,43 @@ mod tests { fn custom_resize_key_exits_resize_mode() { let mut state = state_with_workspaces(&["test"]); state.mode = Mode::Resize; - state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty()); - state.keybinds.resize_mode_label = "g".into(); + state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("g"); handle_resize_key( &mut state, - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), + TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()), + ); + + assert_eq!(state.mode, Mode::Terminal); + } + + #[test] + fn direct_resize_key_exits_resize_mode() { + let mut state = state_with_workspaces(&["test"]); + state.mode = Mode::Resize; + state.keybinds.resize_mode = crate::config::ActionKeybinds::direct("ctrl+alt+r"); + + handle_resize_key( + &mut state, + TerminalKey::new( + KeyCode::Char('r'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + ), + ); + + assert_eq!(state.mode, Mode::Terminal); + } + + #[test] + fn resize_key_exit_matches_enhanced_shifted_punctuation() { + let mut state = state_with_workspaces(&["test"]); + state.mode = Mode::Resize; + state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("?"); + + handle_resize_key( + &mut state, + TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) + .with_shifted_codepoint('?' as u32), ); assert_eq!(state.mode, Mode::Terminal); diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index a2967ca3..8bf10832 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -11,7 +11,7 @@ use ratatui::layout::Direction; use crate::{ app::{ - state::{key_matches, AppState, Mode}, + state::{AppState, Mode}, App, }, input::TerminalKey, @@ -20,109 +20,84 @@ use crate::{ pub(crate) fn terminal_direct_navigation_action( state: &AppState, - key: &KeyEvent, + key: TerminalKey, ) -> Option { - if let Some(action) = indexed_navigation_action(state, key) { - return Some(action); - } + action_for_key(state, key, BindingDispatch::Direct) +} - let kb = &state.keybinds; - if kb - .previous_workspace - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::PreviousWorkspace); - } - if kb - .next_workspace - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::NextWorkspace); - } - if kb - .previous_agent - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::PreviousAgent); - } - if kb - .next_agent - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::NextAgent); - } - if kb - .previous_tab - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::PreviousTab); - } - if kb - .next_tab - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::NextTab); - } - if kb - .focus_pane_left - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::FocusPaneLeft); - } - if kb - .focus_pane_down - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::FocusPaneDown); - } - if kb - .focus_pane_up - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::FocusPaneUp); - } - if kb - .focus_pane_right - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::FocusPaneRight); - } - None +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ActionContext { + Direct, + Prefix, + Navigate, } impl App { - pub(crate) fn handle_navigate_key(&mut self, raw_key: TerminalKey) { + pub(crate) fn handle_prefix_key(&mut self, raw_key: TerminalKey) { let key = raw_key.as_key_event(); self.state.update_dismissed = true; - if self.state.is_prefix(&key) { + if self.state.is_prefix_key(raw_key) { if !self.pass_through_key_to_focused_pane(raw_key) { - leave_navigate_mode(&mut self.state); + leave_command_mode(&mut self.state); } return; } if key.code == KeyCode::Esc { - leave_navigate_mode(&mut self.state); + leave_command_mode(&mut self.state); return; } - if let Some(action) = navigate_action_for_key(&self.state, &key) { + if let Some(action) = action_for_key(&self.state, raw_key, BindingDispatch::Prefix) { if action == NavigateAction::EditScrollback { + let previous_mode = self.state.mode; self.launch_focused_scrollback_editor(); + finish_action_context(&mut self.state, ActionContext::Prefix, previous_mode); } else { - execute_navigate_action(&mut self.state, action); + execute_navigate_action_in_context(&mut self.state, action, ActionContext::Prefix); } self.selection_autoscroll_deadline = None; return; } + if let Some(binding) = command_for_key(&self.state, raw_key, BindingDispatch::Prefix) { + self.launch_custom_command(binding, ActionContext::Prefix); + return; + } + + leave_command_mode(&mut self.state); + } + + pub(crate) fn handle_navigate_key(&mut self, raw_key: TerminalKey) { + let key = raw_key.as_key_event(); + self.state.update_dismissed = true; + + if key.code == KeyCode::Esc || self.state.is_prefix_key(raw_key) { + leave_navigate_mode(&mut self.state); + return; + } + if handle_navigate_reserved_key(&mut self.state, key) { return; } - if let Some(binding) = navigate_custom_command_for_key(&self.state, &key) { - self.launch_custom_command(binding); + if let Some(action) = action_for_key(&self.state, raw_key, BindingDispatch::Prefix) { + if action == NavigateAction::EditScrollback { + self.launch_focused_scrollback_editor(); + } else { + execute_navigate_action_in_context( + &mut self.state, + action, + ActionContext::Navigate, + ); + } + self.selection_autoscroll_deadline = None; + return; + } + + if let Some(binding) = command_for_key(&self.state, raw_key, BindingDispatch::Prefix) { + self.launch_custom_command(binding, ActionContext::Navigate); } } @@ -143,7 +118,12 @@ impl App { true } - fn launch_custom_command(&mut self, binding: crate::config::CustomCommandKeybind) { + pub(super) fn launch_custom_command( + &mut self, + binding: crate::config::CustomCommandKeybind, + context: ActionContext, + ) { + let previous_mode = self.state.mode; let previous_toast = self.state.toast.clone(); let result = match binding.action { crate::config::CustomCommandAction::Shell => self.spawn_custom_command(&binding), @@ -152,7 +132,7 @@ impl App { } }; match result { - Ok(()) => leave_navigate_mode(&mut self.state), + Ok(()) => finish_custom_command_context(&mut self.state, context, previous_mode), Err(err) => { self.state.toast = Some(crate::app::state::ToastNotification { kind: crate::app::state::ToastKind::NeedsAttention, @@ -161,6 +141,7 @@ impl App { target: None, }); self.sync_toast_deadline(previous_toast); + finish_custom_command_context(&mut self.state, context, previous_mode); } } } @@ -233,7 +214,7 @@ impl App { Ok(()) } - fn launch_focused_scrollback_editor(&mut self) { + pub(super) fn launch_focused_scrollback_editor(&mut self) { let previous_toast = self.state.toast.clone(); match self.open_focused_scrollback_in_editor() { Ok(()) => self.sync_toast_deadline(previous_toast), @@ -359,25 +340,35 @@ impl App { } } -fn navigate_custom_command_for_key( +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BindingDispatch { + Direct, + Prefix, +} + +pub(crate) fn command_for_key( state: &AppState, - key: &KeyEvent, + key: TerminalKey, + dispatch: BindingDispatch, ) -> Option { state .keybinds .custom_commands .iter() - .find(|binding| key_matches(key, binding.key.0, binding.key.1)) + .find(|binding| match dispatch { + BindingDispatch::Direct => binding.bindings.matches_direct_key(key), + BindingDispatch::Prefix => binding.bindings.matches_prefix_key(key), + }) .cloned() } pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) -> bool { - match key.code { - KeyCode::Char('q') => { - super::modal::request_quit_or_detach(state); - leave_navigate_mode(state); - true - } + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + if !modifiers.is_empty() { + return false; + } + + match code { KeyCode::Enter => { if !state.workspaces.is_empty() { state.switch_workspace(state.selected); @@ -393,14 +384,6 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) } true } - KeyCode::Char('s') => { - super::settings::open_settings(state); - true - } - KeyCode::Char('?') => { - super::modal::open_keybind_help(state); - true - } KeyCode::Up => { if state.selected > 0 { state.selected -= 1; @@ -446,18 +429,20 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) #[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { state.update_dismissed = true; + let terminal_key = TerminalKey::from(key); - if state.is_prefix(&key) || key.code == KeyCode::Esc { + if state.is_prefix_key(terminal_key) || key.code == KeyCode::Esc { leave_navigate_mode(state); return; } - if let Some(action) = navigate_action_for_key(state, &key) { - execute_navigate_action(state, action); + if handle_navigate_reserved_key(state, key) { return; } - let _ = handle_navigate_reserved_key(state, key); + if let Some(action) = action_for_key(state, terminal_key, BindingDispatch::Prefix) { + execute_navigate_action_in_context(state, action, ActionContext::Navigate); + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -468,6 +453,7 @@ pub(crate) enum NavigateAction { SwitchWorkspace(usize), SwitchTab(usize), FocusAgent(usize), + WorkspacePicker, PreviousWorkspace, NextWorkspace, PreviousAgent, @@ -489,158 +475,129 @@ pub(crate) enum NavigateAction { Zoom, EnterResizeMode, ToggleSidebar, + CyclePaneNext, + CyclePanePrevious, + Help, + Settings, + Quit, ReloadConfig, OpenNotificationTarget, Detach, } -fn indexed_navigation_action(state: &AppState, key: &KeyEvent) -> Option { - let KeyCode::Char(c @ '1'..='9') = key.code else { - return None; - }; - let idx = (c as usize) - ('1' as usize); +fn indexed_navigation_action( + state: &AppState, + key: TerminalKey, + dispatch: BindingDispatch, +) -> Option { let kb = &state.keybinds; + let trigger_matches = |binding: &crate::config::IndexedKeybind| match dispatch { + BindingDispatch::Direct => binding.trigger.is_direct(), + BindingDispatch::Prefix => binding.trigger.is_prefix(), + }; - if kb - .indexed_tabs - .is_some_and(|mods| key_matches(key, KeyCode::Char(c), mods)) - { - return Some(NavigateAction::SwitchTab(idx)); + for binding in &kb.switch_tab { + if trigger_matches(binding) { + if let Some(idx) = binding.matched_index(key) { + return Some(NavigateAction::SwitchTab(idx)); + } + } } - if kb - .indexed_workspaces - .is_some_and(|mods| key_matches(key, KeyCode::Char(c), mods)) - { - return Some(NavigateAction::SwitchWorkspace(idx)); + for binding in &kb.switch_workspace { + if trigger_matches(binding) { + if let Some(idx) = binding.matched_index(key) { + return Some(NavigateAction::SwitchWorkspace(idx)); + } + } } - if kb - .indexed_agents - .is_some_and(|mods| key_matches(key, KeyCode::Char(c), mods)) - { - return Some(NavigateAction::FocusAgent(idx)); + for binding in &kb.focus_agent { + if trigger_matches(binding) { + if let Some(idx) = binding.matched_index(key) { + return Some(NavigateAction::FocusAgent(idx)); + } + } } None } -fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option { - if let Some(action) = indexed_navigation_action(state, key) { +fn action_matches( + bindings: &crate::config::ActionKeybinds, + key: TerminalKey, + dispatch: BindingDispatch, +) -> bool { + match dispatch { + BindingDispatch::Direct => bindings.matches_direct_key(key), + BindingDispatch::Prefix => bindings.matches_prefix_key(key), + } +} + +fn action_for_key( + state: &AppState, + key: TerminalKey, + dispatch: BindingDispatch, +) -> Option { + if let Some(action) = indexed_navigation_action(state, key, dispatch) { return Some(action); } let kb = &state.keybinds; - if key_matches(key, kb.new_workspace.0, kb.new_workspace.1) { - return Some(NavigateAction::NewWorkspace); - } - if key_matches(key, kb.rename_workspace.0, kb.rename_workspace.1) { - return Some(NavigateAction::RenameWorkspace); - } - if key_matches(key, kb.close_workspace.0, kb.close_workspace.1) { - return Some(NavigateAction::CloseWorkspace); - } - if kb - .previous_workspace - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::PreviousWorkspace); - } - if kb - .next_workspace - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::NextWorkspace); - } - if kb - .previous_agent - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::PreviousAgent); - } - if kb - .next_agent - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::NextAgent); - } - if key_matches(key, kb.new_tab.0, kb.new_tab.1) { - return Some(NavigateAction::NewTab); - } - if kb - .rename_tab - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::RenameTab); - } - if kb - .previous_tab - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::PreviousTab); - } - if kb - .next_tab - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::NextTab); - } - if kb - .close_tab - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::CloseTab); - } - if kb - .rename_pane - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::RenamePane); - } - if kb - .edit_scrollback - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::EditScrollback); - } - if key_matches(key, kb.split_vertical.0, kb.split_vertical.1) { - return Some(NavigateAction::SplitVertical); - } - if key_matches(key, kb.split_horizontal.0, kb.split_horizontal.1) { - return Some(NavigateAction::SplitHorizontal); - } - if key_matches(key, kb.close_pane.0, kb.close_pane.1) { - return Some(NavigateAction::ClosePane); - } - if key_matches(key, kb.zoom.0, kb.zoom.1) { - return Some(NavigateAction::Zoom); - } - if key_matches(key, kb.resize_mode.0, kb.resize_mode.1) { - return Some(NavigateAction::EnterResizeMode); - } - if key_matches(key, kb.toggle_sidebar.0, kb.toggle_sidebar.1) { - return Some(NavigateAction::ToggleSidebar); - } - if kb - .reload_config - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::ReloadConfig); - } - if kb - .open_notification_target - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::OpenNotificationTarget); - } - if kb - .detach - .is_some_and(|(code, mods)| key_matches(key, code, mods)) - { - return Some(NavigateAction::Detach); + for (bindings, action) in [ + (&kb.help, NavigateAction::Help), + (&kb.settings, NavigateAction::Settings), + (&kb.quit, NavigateAction::Quit), + (&kb.workspace_picker, NavigateAction::WorkspacePicker), + (&kb.new_workspace, NavigateAction::NewWorkspace), + (&kb.rename_workspace, NavigateAction::RenameWorkspace), + (&kb.close_workspace, NavigateAction::CloseWorkspace), + (&kb.previous_workspace, NavigateAction::PreviousWorkspace), + (&kb.next_workspace, NavigateAction::NextWorkspace), + (&kb.previous_agent, NavigateAction::PreviousAgent), + (&kb.next_agent, NavigateAction::NextAgent), + (&kb.new_tab, NavigateAction::NewTab), + (&kb.rename_tab, NavigateAction::RenameTab), + (&kb.previous_tab, NavigateAction::PreviousTab), + (&kb.next_tab, NavigateAction::NextTab), + (&kb.close_tab, NavigateAction::CloseTab), + (&kb.rename_pane, NavigateAction::RenamePane), + (&kb.edit_scrollback, NavigateAction::EditScrollback), + (&kb.focus_pane_left, NavigateAction::FocusPaneLeft), + (&kb.focus_pane_down, NavigateAction::FocusPaneDown), + (&kb.focus_pane_up, NavigateAction::FocusPaneUp), + (&kb.focus_pane_right, NavigateAction::FocusPaneRight), + (&kb.cycle_pane_next, NavigateAction::CyclePaneNext), + (&kb.cycle_pane_previous, NavigateAction::CyclePanePrevious), + (&kb.split_vertical, NavigateAction::SplitVertical), + (&kb.split_horizontal, NavigateAction::SplitHorizontal), + (&kb.close_pane, NavigateAction::ClosePane), + (&kb.zoom, NavigateAction::Zoom), + (&kb.resize_mode, NavigateAction::EnterResizeMode), + (&kb.toggle_sidebar, NavigateAction::ToggleSidebar), + (&kb.reload_config, NavigateAction::ReloadConfig), + ( + &kb.open_notification_target, + NavigateAction::OpenNotificationTarget, + ), + (&kb.detach, NavigateAction::Detach), + ] { + if action_matches(bindings, key, dispatch) { + return Some(action); + } } None } +#[cfg(test)] pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateAction) { + execute_navigate_action_in_context(state, action, ActionContext::Navigate); +} + +pub(super) fn execute_navigate_action_in_context( + state: &mut AppState, + action: NavigateAction, + context: ActionContext, +) { + let previous_mode = state.mode; match action { NavigateAction::NewWorkspace => { state.request_new_workspace = true; @@ -682,6 +639,10 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi leave_navigate_mode(state); } } + NavigateAction::WorkspacePicker => { + state.mobile_switcher_scroll = 0; + state.mode = Mode::Navigate; + } NavigateAction::PreviousWorkspace => { state.previous_workspace(); leave_navigate_mode(state); @@ -699,11 +660,13 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi leave_navigate_mode(state); } NavigateAction::NewTab => { - if state.prompt_new_tab_name { - super::modal::open_new_tab_dialog(state); - } else { - state.request_new_tab = true; - leave_navigate_mode(state); + if state.active.is_some() { + if state.prompt_new_tab_name { + super::modal::open_new_tab_dialog(state); + } else { + state.request_new_tab = true; + leave_navigate_mode(state); + } } } NavigateAction::RenameTab => super::modal::open_rename_active_tab(state, false), @@ -754,6 +717,20 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi state.sidebar_collapsed = !state.sidebar_collapsed; leave_navigate_mode(state); } + NavigateAction::CyclePaneNext => { + state.cycle_pane(false); + leave_navigate_mode(state); + } + NavigateAction::CyclePanePrevious => { + state.cycle_pane(true); + leave_navigate_mode(state); + } + NavigateAction::Help => super::modal::open_keybind_help(state), + NavigateAction::Settings => super::settings::open_settings(state), + NavigateAction::Quit => { + super::modal::request_quit_or_detach(state); + leave_navigate_mode(state); + } NavigateAction::ReloadConfig => { state.request_reload_config = true; leave_navigate_mode(state); @@ -769,6 +746,8 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi leave_navigate_mode(state); } } + + finish_action_context(state, context, previous_mode); } fn leave_navigate_mode(state: &mut AppState) { @@ -777,6 +756,34 @@ fn leave_navigate_mode(state: &mut AppState) { } } +fn finish_action_context(state: &mut AppState, context: ActionContext, previous_mode: Mode) { + if matches!(context, ActionContext::Direct | ActionContext::Prefix) + && state.mode == previous_mode + { + leave_command_mode(state); + } +} + +fn finish_custom_command_context( + state: &mut AppState, + context: ActionContext, + previous_mode: Mode, +) { + if context == ActionContext::Navigate { + leave_navigate_mode(state); + } else { + finish_action_context(state, context, previous_mode); + } +} + +fn leave_command_mode(state: &mut AppState) { + state.mode = if state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; +} + fn write_scrollback_temp_file(content: &str) -> io::Result { let mut last_collision = None; for attempt in 0..16 { @@ -850,8 +857,7 @@ mod tests { #[test] fn custom_rename_key_enters_rename_mode() { let mut state = state_with_workspaces(&["test"]); - state.keybinds.rename_workspace = (KeyCode::Char('g'), KeyModifiers::empty()); - state.keybinds.rename_workspace_label = "g".into(); + state.keybinds.rename_workspace = crate::config::ActionKeybinds::prefix("g"); handle_navigate_key( &mut state, @@ -865,8 +871,7 @@ mod tests { #[test] fn custom_new_workspace_key_requests_and_exits_navigate() { let mut state = state_with_workspaces(&["test"]); - state.keybinds.new_workspace = (KeyCode::Char('g'), KeyModifiers::empty()); - state.keybinds.new_workspace_label = "g".into(); + state.keybinds.new_workspace = crate::config::ActionKeybinds::prefix("g"); handle_navigate_key( &mut state, @@ -880,8 +885,7 @@ mod tests { #[test] fn custom_sidebar_toggle_key_toggles_and_exits_navigate() { let mut state = state_with_workspaces(&["test"]); - state.keybinds.toggle_sidebar = (KeyCode::Char('g'), KeyModifiers::empty()); - state.keybinds.toggle_sidebar_label = "g".into(); + state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("g"); assert!(!state.sidebar_collapsed); handle_navigate_key( @@ -896,8 +900,7 @@ mod tests { #[test] fn custom_resize_key_enters_resize_mode() { let mut state = state_with_workspaces(&["test"]); - state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty()); - state.keybinds.resize_mode_label = "g".into(); + state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("g"); handle_navigate_key( &mut state, @@ -910,8 +913,7 @@ mod tests { #[test] fn custom_reload_config_key_requests_reload_and_exits_navigate() { let mut state = state_with_workspaces(&["test"]); - state.keybinds.reload_config = Some((KeyCode::Char('g'), KeyModifiers::empty())); - state.keybinds.reload_config_label = Some("g".into()); + state.keybinds.reload_config = crate::config::ActionKeybinds::prefix("g"); handle_navigate_key( &mut state, @@ -928,8 +930,7 @@ mod tests { state.active = Some(0); state.selected = 0; state.mode = Mode::Navigate; - state.keybinds.open_notification_target = Some((KeyCode::Char('g'), KeyModifiers::empty())); - state.keybinds.open_notification_target_label = Some("g".into()); + state.keybinds.open_notification_target = crate::config::ActionKeybinds::prefix("g"); let target_workspace_id = state.workspaces[1].id.clone(); let target_pane = state.workspaces[1].tabs[0].root_pane; state.toast = Some(crate::app::state::ToastNotification { @@ -989,12 +990,11 @@ mod tests { #[test] fn terminal_direct_agent_shortcut_maps_to_navigation_action() { let mut state = state_with_workspaces(&["test"]); - state.keybinds.next_agent = Some((KeyCode::Char('a'), KeyModifiers::ALT)); - state.keybinds.next_agent_label = Some("alt+a".into()); + state.keybinds.next_agent = crate::config::ActionKeybinds::direct("alt+a"); let action = terminal_direct_navigation_action( &state, - &KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT), + TerminalKey::new(KeyCode::Char('a'), KeyModifiers::ALT), ); assert_eq!(action, Some(NavigateAction::NextAgent)); @@ -1003,12 +1003,11 @@ mod tests { #[test] fn terminal_direct_focus_pane_shortcut_maps_to_navigation_action() { let mut state = state_with_workspaces(&["test"]); - state.keybinds.focus_pane_left = Some((KeyCode::Left, KeyModifiers::ALT)); - state.keybinds.focus_pane_left_label = Some("alt+left".into()); + state.keybinds.focus_pane_left = crate::config::ActionKeybinds::direct("alt+left"); let action = terminal_direct_navigation_action( &state, - &KeyEvent::new(KeyCode::Left, KeyModifiers::ALT), + TerminalKey::new(KeyCode::Left, KeyModifiers::ALT), ); assert_eq!(action, Some(NavigateAction::FocusPaneLeft)); @@ -1017,17 +1016,202 @@ mod tests { #[test] fn terminal_direct_indexed_tab_shortcut_maps_to_navigation_action() { let mut state = state_with_workspaces(&["test"]); - state.keybinds.indexed_tabs = Some(KeyModifiers::CONTROL); - state.keybinds.indexed_tabs_label = Some("ctrl+1..9".into()); + let config: Config = toml::from_str("[keys]\nswitch_tab = \"ctrl+3\"\n").unwrap(); + state.keybinds.switch_tab = config.keybinds().switch_tab; let action = terminal_direct_navigation_action( &state, - &KeyEvent::new(KeyCode::Char('3'), KeyModifiers::CONTROL), + TerminalKey::new(KeyCode::Char('3'), KeyModifiers::CONTROL), ); assert_eq!(action, Some(NavigateAction::SwitchTab(2))); } + #[tokio::test] + async fn navigate_mode_runs_prefix_action_rhs_without_pressing_prefix_again() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Navigate; + + app.handle_navigate_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::SHIFT)); + + assert!(app.state.request_new_workspace); + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[tokio::test] + async fn prefix_focus_pane_is_one_shot_and_returns_to_terminal() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + let root = app.state.workspaces[0].tabs[0].root_pane; + let right = app.state.workspaces[0].test_split(Direction::Horizontal); + app.state.workspaces[0].layout.focus_pane(right); + app.state.view.pane_infos = app.state.workspaces[0] + .active_tab() + .unwrap() + .layout + .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); + + app.handle_key(TerminalKey::new( + app.state.prefix_code, + app.state.prefix_mods, + )) + .await; + app.handle_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::empty())) + .await; + + assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[tokio::test] + async fn no_op_prefix_action_exits_prefix_mode() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.handle_key(TerminalKey::new( + app.state.prefix_code, + app.state.prefix_mods, + )) + .await; + app.handle_key(TerminalKey::new(KeyCode::Char('o'), KeyModifiers::empty())) + .await; + + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[tokio::test] + async fn unmatched_prefix_rhs_exits_prefix_mode() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.handle_key(TerminalKey::new( + app.state.prefix_code, + app.state.prefix_mods, + )) + .await; + app.handle_key(TerminalKey::new(KeyCode::F(12), KeyModifiers::empty())) + .await; + + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[tokio::test] + async fn prefix_help_matches_enhanced_shifted_question_mark() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.handle_key(TerminalKey::new( + app.state.prefix_code, + app.state.prefix_mods, + )) + .await; + app.handle_key( + TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) + .with_shifted_codepoint('?' as u32), + ) + .await; + + assert_eq!(app.state.mode, Mode::KeybindHelp); + } + + #[test] + fn navigate_mode_help_is_binding_driven() { + let mut state = state_with_workspaces(&["test"]); + state.keybinds.help = crate::config::ActionKeybinds::prefix("f"); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT), + ); + assert_eq!(state.mode, Mode::Navigate); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()), + ); + assert_eq!(state.mode, Mode::KeybindHelp); + } + + #[test] + fn modified_navigate_local_key_can_be_bound_as_prefix_rhs() { + let mut state = state_with_workspaces(&["test"]); + state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("shift+h"); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('H'), KeyModifiers::SHIFT), + ); + + assert!(state.sidebar_collapsed); + } + + #[test] + fn empty_state_new_tab_is_no_op() { + let mut state = crate::app::state::AppState::test_new(); + state.mode = Mode::Prefix; + + execute_navigate_action_in_context( + &mut state, + NavigateAction::NewTab, + ActionContext::Prefix, + ); + + assert_eq!(state.mode, Mode::Navigate); + assert!(!state.creating_new_tab); + assert!(!state.request_new_tab); + assert!(state.workspaces.is_empty()); + } + #[tokio::test] async fn custom_command_runs_from_prefix_key_in_navigate_mode() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -1049,8 +1233,8 @@ mod tests { output_path.display() ); app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - key: (KeyCode::Char('g'), KeyModifiers::empty()), - label: "g".into(), + bindings: crate::config::ActionKeybinds::prefix("g"), + label: "prefix+g".into(), command, action: crate::config::CustomCommandAction::Shell, }]; @@ -1060,7 +1244,7 @@ mod tests { app.state.prefix_mods, )) .await; - assert_eq!(app.state.mode, Mode::Navigate); + assert_eq!(app.state.mode, Mode::Prefix); app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())) .await; @@ -1110,8 +1294,8 @@ mod tests { let output_path = unique_temp_path("custom-pane-command"); let command = format!("printf done > '{}'", output_path.display()); app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { - key: (KeyCode::Char('g'), KeyModifiers::empty()), - label: "g".into(), + bindings: crate::config::ActionKeybinds::prefix("g"), + label: "prefix+g".into(), command, action: crate::config::CustomCommandAction::Pane, }]; @@ -1164,7 +1348,7 @@ mod tests { app.state.workspaces = vec![workspace]; app.state.active = Some(0); app.state.selected = 0; - app.state.mode = Mode::Navigate; + app.state.mode = Mode::Terminal; let output_path = unique_temp_path("edit-scrollback"); let previous_editor = std::env::var_os("EDITOR"); @@ -1172,10 +1356,15 @@ mod tests { "EDITOR", format!("sh -c 'cp \"$1\" {}' sh", output_path.display()), ); - app.state.keybinds.edit_scrollback = Some((KeyCode::Char('g'), KeyModifiers::empty())); - app.state.keybinds.edit_scrollback_label = Some("g".into()); + app.state.keybinds.edit_scrollback = crate::config::ActionKeybinds::prefix("g"); - app.handle_navigate_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())); + app.handle_key(TerminalKey::new( + app.state.prefix_code, + app.state.prefix_mods, + )) + .await; + app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())) + .await; match previous_editor { Some(value) => std::env::set_var("EDITOR", value), @@ -1194,8 +1383,7 @@ mod tests { fn zoom_action_exits_navigate_mode() { let mut state = state_with_workspaces(&["test"]); state.workspaces[0].test_split(Direction::Horizontal); - state.keybinds.zoom = (KeyCode::Char('g'), KeyModifiers::empty()); - state.keybinds.zoom_label = "g".into(); + state.keybinds.zoom = crate::config::ActionKeybinds::prefix("g"); handle_navigate_key( &mut state, @@ -1265,10 +1453,11 @@ mod tests { let mut state = crate::app::state::AppState::test_new(); state.quit_detaches = true; - assert!(handle_navigate_reserved_key( + handle_navigate_key( &mut state, - KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()) - )); + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()), + ); + assert!(state.detach_requested); assert!(!state.should_quit); } diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 12fb67b2..41a62e82 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -34,21 +34,44 @@ impl App { let key_event = key.as_key_event(); - if let Some(action) = super::terminal_direct_navigation_action(&self.state, &key_event) { + if let Some(action) = super::terminal_direct_navigation_action(&self.state, key) { debug!( code = ?key_event.code, modifiers = ?key_event.modifiers, kind = ?key_event.kind, action = ?action, - "intercepted terminal direct navigation key before forwarding to pane" + "intercepted terminal direct keybinding before forwarding to pane" ); - super::navigate::execute_navigate_action(&mut self.state, action); + if action == super::navigate::NavigateAction::EditScrollback { + self.launch_focused_scrollback_editor(); + } else { + super::navigate::execute_navigate_action_in_context( + &mut self.state, + action, + super::navigate::ActionContext::Direct, + ); + } return None; } - if self.state.is_prefix(&key_event) { - self.state.mobile_switcher_scroll = 0; - self.state.mode = Mode::Navigate; + if let Some(binding) = super::navigate::command_for_key( + &self.state, + key, + super::navigate::BindingDispatch::Direct, + ) { + debug!( + code = ?key_event.code, + modifiers = ?key_event.modifiers, + kind = ?key_event.kind, + command = %binding.label, + "intercepted terminal direct custom command before forwarding to pane" + ); + self.launch_custom_command(binding, super::navigate::ActionContext::Direct); + return None; + } + + if self.state.is_prefix_key(key) { + self.state.mode = Mode::Prefix; return None; } @@ -169,7 +192,9 @@ mod tests { use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}; use ratatui::layout::Rect; - use super::super::{app_for_mouse_test, mouse, numbered_lines_bytes}; + use super::super::{ + app_for_mouse_test, mouse, numbered_lines_bytes, unique_temp_path, wait_for_file, + }; use super::*; use crate::{config::Config, workspace::Workspace}; @@ -475,8 +500,7 @@ mod tests { .layout .panes(Rect::new(0, 0, 80, 24)); let focused_before = app.state.workspaces[0].layout.focused(); - app.state.keybinds.focus_pane_left = Some((KeyCode::Char('h'), KeyModifiers::ALT)); - app.state.keybinds.focus_pane_left_label = Some("alt+h".into()); + app.state.keybinds.focus_pane_left = crate::config::ActionKeybinds::direct("alt+h"); app.handle_terminal_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::ALT)) .await; @@ -485,6 +509,138 @@ mod tests { assert_eq!(app.state.mode, Mode::Terminal); } + #[tokio::test] + async fn terminal_direct_edit_scrollback_opens_editor_pane() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + let mut workspace = Workspace::test_new("test"); + let root_pane = workspace.tabs[0].root_pane; + workspace.tabs[0].runtimes.insert( + root_pane, + crate::pane::PaneRuntime::test_with_scrollback_bytes(20, 5, 4096, b"alpha\nbeta\n"), + ); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + let output_path = unique_temp_path("direct-edit-scrollback"); + let previous_editor = std::env::var_os("EDITOR"); + std::env::set_var( + "EDITOR", + format!("sh -c 'cp \"$1\" {}' sh", output_path.display()), + ); + app.state.keybinds.edit_scrollback = crate::config::ActionKeybinds::direct("ctrl+alt+e"); + + app.handle_terminal_key(TerminalKey::new( + KeyCode::Char('e'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + )) + .await; + + match previous_editor { + Some(value) => std::env::set_var("EDITOR", value), + None => std::env::remove_var("EDITOR"), + } + + let content = wait_for_file(&output_path); + assert!(content.contains("alpha")); + assert!(content.contains("beta")); + assert_eq!(app.state.mode, Mode::Terminal); + + let _ = std::fs::remove_file(output_path); + } + + #[tokio::test] + async fn direct_custom_command_runs_before_forwarding_to_pane() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + let output_path = unique_temp_path("direct-custom-command"); + let command = format!("printf direct > '{}'", output_path.display()); + app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"), + label: "ctrl+alt+g".into(), + command, + action: crate::config::CustomCommandAction::Shell, + }]; + + app.handle_terminal_key(TerminalKey::new( + KeyCode::Char('g'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + )) + .await; + + assert_eq!(wait_for_file(&output_path), "direct"); + assert_eq!(app.state.mode, Mode::Terminal); + let _ = std::fs::remove_file(output_path); + } + + #[tokio::test] + async fn direct_custom_pane_command_opens_overlay_pane() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + let (workspace, terminal, runtime) = Workspace::new( + std::env::current_dir().unwrap_or_else(|_| "/".into()), + 24, + 80, + app.state.pane_scrollback_limit_bytes, + app.state.host_terminal_theme, + &app.state.default_shell, + app.event_tx.clone(), + app.render_notify.clone(), + app.render_dirty.clone(), + ) + .expect("workspace should spawn"); + app.state.workspaces = vec![workspace]; + app.state + .terminal_runtimes + .insert(terminal.id.clone(), runtime); + app.state.terminals.insert(terminal.id.clone(), terminal); + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { + bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"), + label: "ctrl+alt+g".into(), + command: "printf direct-pane".into(), + action: crate::config::CustomCommandAction::Pane, + }]; + + app.handle_terminal_key(TerminalKey::new( + KeyCode::Char('g'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + )) + .await; + + assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); + assert!(app.state.workspaces[0].tabs[0].zoomed); + assert_eq!(app.state.mode, Mode::Terminal); + } + #[tokio::test] async fn alt_backspace_is_forwarded_to_focused_pane() { let mut app = app_for_mouse_test(); diff --git a/src/app/mod.rs b/src/app/mod.rs index f33de78f..63bf690d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1048,6 +1048,9 @@ impl App { fn handle_non_terminal_key(&mut self, key: crate::input::TerminalKey) { let key_event = key.as_key_event(); match self.state.mode { + Mode::Prefix => { + self.handle_prefix_key(key); + } Mode::Navigate => { self.handle_navigate_key(key); } @@ -1055,7 +1058,7 @@ impl App { input::handle_rename_key(&mut self.state, key_event); } Mode::Resize => { - input::handle_resize_key(&mut self.state, key_event); + input::handle_resize_key(&mut self.state, key); } Mode::ConfirmClose => { input::handle_confirm_close_key(&mut self.state, key_event); @@ -1104,7 +1107,7 @@ mod tests { use crate::detect::{Agent, AgentState}; use crate::terminal::TerminalRuntime; use crate::workspace::Workspace; - use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; + use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use std::sync::{Mutex, OnceLock}; fn raw_key( @@ -1330,7 +1333,7 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( &path, - "[terminal]\ndefault_shell = \"nu\"\n[keys]\nnew_workspace = \"g\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\n[ui.toast]\ndelivery = \"herdr\"\n", + "[terminal]\ndefault_shell = \"nu\"\n[keys]\nnew_workspace = \"prefix+g\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\n[ui.toast]\ndelivery = \"herdr\"\n", ) .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); @@ -1341,10 +1344,11 @@ mod tests { assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); assert_eq!(app.state.prefix_code, KeyCode::Char('a')); assert_eq!(app.state.prefix_mods, KeyModifiers::CONTROL); - assert_eq!( - app.state.keybinds.new_workspace, - (KeyCode::Char('g'), KeyModifiers::empty()) - ); + assert!(app + .state + .keybinds + .new_workspace + .matches_prefix(&KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()))); assert_eq!( app.state.toast_config.delivery, crate::config::ToastDelivery::Herdr @@ -1523,7 +1527,7 @@ mod tests { let mut app = test_app(); let original_prefix = (app.state.prefix_code, app.state.prefix_mods); - let original_keybinds = app.state.keybinds.new_workspace; + let original_keybinds = app.state.keybinds.new_workspace.clone(); let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); @@ -1556,7 +1560,7 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( &path, - "[keys]\nnew_workspace = \"g\"\n[ui.toast]\ndelivery = \"desktop\"\n", + "[keys]\nnew_workspace = \"prefix+g\"\n[ui.toast]\ndelivery = \"desktop\"\n", ) .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); @@ -1566,10 +1570,11 @@ mod tests { let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); - assert_eq!( - app.state.keybinds.new_workspace, - (KeyCode::Char('g'), KeyModifiers::empty()) - ); + assert!(app + .state + .keybinds + .new_workspace + .matches_prefix(&KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()))); assert_eq!( app.state.toast_config.delivery, crate::config::ToastDelivery::Herdr @@ -1650,7 +1655,7 @@ mod tests { let mut app = test_app(); let original_prefix = (app.state.prefix_code, app.state.prefix_mods); - let original_keybinds = app.state.keybinds.new_workspace; + let original_keybinds = app.state.keybinds.new_workspace.clone(); let original_toast_delivery = app.state.toast_config.delivery; let report = app.reload_config(); @@ -2547,8 +2552,8 @@ mod tests { assert_eq!( app.state.mode, - Mode::Navigate, - "prefix key should enter navigate mode" + Mode::Prefix, + "prefix key should enter prefix mode" ); assert!( !app.state.detach_requested, @@ -2584,7 +2589,7 @@ mod tests { app.state.prefix_mods = KeyModifiers::CONTROL; app.route_client_input(vec![0x0c]); - assert_eq!(app.state.mode, Mode::Navigate); + assert_eq!(app.state.mode, Mode::Prefix); app.route_client_input(vec![0x0c]); assert_eq!(app.state.mode, Mode::Terminal); diff --git a/src/app/state.rs b/src/app/state.rs index 0c38171d..2b078caf 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -590,6 +590,7 @@ pub enum Mode { ReleaseNotes, ProductAnnouncement, Navigate, + Prefix, Terminal, RenameWorkspace, RenameTab, @@ -1023,8 +1024,8 @@ impl AppState { self.mouse_capture || self.focused_pane_requests_mouse_capture() } - pub fn is_prefix(&self, key: &crossterm::event::KeyEvent) -> bool { - key_matches(key, self.prefix_code, self.prefix_mods) + pub fn is_prefix_key(&self, key: crate::input::TerminalKey) -> bool { + crate::config::terminal_key_matches_combo(key, (self.prefix_code, self.prefix_mods)) } pub fn estimate_pane_size(&self) -> (u16, u16) { @@ -1110,23 +1111,16 @@ impl AppState { } } +#[cfg(test)] pub fn key_matches( key: &crossterm::event::KeyEvent, expected_code: KeyCode, expected_mods: KeyModifiers, ) -> bool { - if key.modifiers != expected_mods { - return false; - } - - match (key.code, expected_code) { - (KeyCode::Char(actual), KeyCode::Char(expected)) - if actual.is_ascii_alphabetic() && expected.is_ascii_alphabetic() => - { - actual.eq_ignore_ascii_case(&expected) - } - (actual, expected) => actual == expected, - } + crate::config::terminal_key_matches_combo( + crate::input::TerminalKey::from(*key), + (expected_code, expected_mods), + ) } // --------------------------------------------------------------------------- @@ -1221,69 +1215,7 @@ impl AppState { }, local_sound_playback: false, toast_config: ToastConfig::default(), - keybinds: Keybinds { - new_workspace: (KeyCode::Char('n'), KeyModifiers::empty()), - new_workspace_label: "n".into(), - rename_workspace: (KeyCode::Char('n'), KeyModifiers::SHIFT), - rename_workspace_label: "shift+n".into(), - close_workspace: (KeyCode::Char('d'), KeyModifiers::SHIFT), - close_workspace_label: "shift+d".into(), - detach: None, - detach_label: None, - reload_config: None, - reload_config_label: None, - open_notification_target: None, - open_notification_target_label: None, - previous_workspace: None, - previous_workspace_label: None, - next_workspace: None, - next_workspace_label: None, - previous_agent: None, - previous_agent_label: None, - next_agent: None, - next_agent_label: None, - indexed_tabs: None, - indexed_tabs_label: None, - indexed_workspaces: None, - indexed_workspaces_label: None, - indexed_agents: None, - indexed_agents_label: None, - new_tab: (KeyCode::Char('c'), KeyModifiers::empty()), - new_tab_label: "c".into(), - rename_tab: None, - rename_tab_label: None, - previous_tab: None, - previous_tab_label: None, - next_tab: None, - next_tab_label: None, - close_tab: None, - close_tab_label: None, - rename_pane: None, - rename_pane_label: None, - edit_scrollback: None, - edit_scrollback_label: None, - focus_pane_left: None, - focus_pane_left_label: None, - focus_pane_down: None, - focus_pane_down_label: None, - focus_pane_up: None, - focus_pane_up_label: None, - focus_pane_right: None, - focus_pane_right_label: None, - split_vertical: (KeyCode::Char('v'), KeyModifiers::empty()), - split_vertical_label: "v".into(), - split_horizontal: (KeyCode::Char('-'), KeyModifiers::empty()), - split_horizontal_label: "-".into(), - close_pane: (KeyCode::Char('x'), KeyModifiers::empty()), - close_pane_label: "x".into(), - zoom: (KeyCode::Char('f'), KeyModifiers::empty()), - zoom_label: "f".into(), - resize_mode: (KeyCode::Char('r'), KeyModifiers::empty()), - resize_mode_label: "r".into(), - toggle_sidebar: (KeyCode::Char('b'), KeyModifiers::empty()), - toggle_sidebar_label: "b".into(), - custom_commands: Vec::new(), - }, + keybinds: Keybinds::default(), spinner_tick: 0, palette: Palette::catppuccin(), theme_name: "catppuccin".to_string(), diff --git a/src/cli.rs b/src/cli.rs index 3e07ab2c..e9fe6e81 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,6 @@ use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde::Serialize; @@ -33,6 +33,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result { exit_code } "status" => run_status_command(&args[2..])?, + "config" => run_config_command(&args[2..])?, "workspace" => run_workspace_command(&args[2..])?, "tab" => run_tab_command(&args[2..])?, "agent" => run_agent_command(&args[2..])?, @@ -95,6 +96,114 @@ fn run_status_command(args: &[String]) -> std::io::Result { } } +fn run_config_command(args: &[String]) -> std::io::Result { + let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { + print_config_help(); + return Ok(2); + }; + + match subcommand { + "reset-keys" => config_reset_keys(&args[1..]), + "help" | "--help" | "-h" => { + print_config_help(); + Ok(0) + } + _ => { + print_config_help(); + Ok(2) + } + } +} + +fn config_reset_keys(args: &[String]) -> std::io::Result { + if !args.is_empty() { + eprintln!("usage: herdr config reset-keys"); + return Ok(2); + } + + let path = crate::config::config_path(); + if !path.exists() { + println!( + "No config file found at {}. Built-in v2 keybindings already apply.", + path.display() + ); + return Ok(0); + } + + let content = std::fs::read_to_string(&path)?; + let parsed = match content.parse::() { + Ok(value) => value, + Err(err) => { + eprintln!( + "config file at {} is invalid TOML: {err}. Fix it manually or move it aside to use defaults.", + path.display() + ); + return Ok(1); + } + }; + let Some(table) = parsed.as_table() else { + eprintln!( + "config file at {} is invalid TOML: top-level config must be a table.", + path.display() + ); + return Ok(1); + }; + + if !table.contains_key("keys") { + println!( + "No [keys] config found in {}. Built-in v2 keybindings already apply.", + path.display() + ); + return Ok(0); + } + + let (updated, removed) = crate::config::remove_keybinding_config_sections(&content); + if !removed { + eprintln!( + "could not safely remove keybinding config from {} without rewriting comments; edit the file manually or remove the top-level keys setting.", + path.display() + ); + return Ok(1); + } + if let Err(err) = updated.parse::() { + eprintln!( + "removing keybinding config would make {} invalid TOML: {err}; leaving config unchanged", + path.display() + ); + return Ok(1); + } + + let backup_path = key_config_backup_path(&path); + std::fs::copy(&path, &backup_path)?; + std::fs::write(&path, updated)?; + + println!("Created backup: {}", backup_path.display()); + println!( + "Removed [keys], [keys.indexed], and [[keys.command]] from {}.", + path.display() + ); + println!("Built-in v2 keybindings will apply after Herdr restarts or reloads config."); + println!("If a Herdr server is running, run `herdr server reload-config` to apply this now."); + println!( + "To restore: cp {} {}", + backup_path.display(), + path.display() + ); + Ok(0) +} + +fn key_config_backup_path(path: &std::path::Path) -> std::path::PathBuf { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("config.toml"); + path.with_file_name(format!("{file_name}.bak-keybind-v2-{timestamp}")) +} + #[derive(Debug, Clone, PartialEq, Eq)] enum ServerRuntimeStatus { Running { @@ -2071,6 +2180,11 @@ fn print_status_help() { eprintln!(" herdr status client show local client binary status"); } +fn print_config_help() { + eprintln!("herdr config commands:"); + eprintln!(" herdr config reset-keys back up config.toml and remove custom keybindings"); +} + fn print_workspace_help() { eprintln!("herdr workspace commands:"); eprintln!(" herdr workspace list"); diff --git a/src/config.rs b/src/config.rs index 37ba5bbf..48ebb8f6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,12 +8,14 @@ mod theme; pub use self::{ io::{ - config_diagnostic_summary, config_dir, config_path, load_live_config, remove_section_key, - state_dir, upsert_section_bool, upsert_section_value, + config_diagnostic_summary, config_dir, config_path, load_live_config, + remove_keybinding_config_sections, remove_section_key, state_dir, upsert_section_bool, + upsert_section_value, }, keybinds::{ - format_key_combo, CommandKeybindConfig, CustomCommandAction, CustomCommandKeybind, - Keybinds, LiveKeybindConfig, + format_key_combo, normalize_key_combo, terminal_key_matches_combo, ActionKeybinds, + BindingConfig, CommandKeybindConfig, CustomCommandAction, CustomCommandKeybind, + IndexedKeybind, Keybinds, LiveKeybindConfig, }, model::{ validated_sidebar_bounds, AgentPanelScopeConfig, Config, ConfigReloadReport, @@ -42,7 +44,7 @@ impl Config { self.validated_keybinds().1 } - /// Parsed keybinds for navigate mode actions. + /// Parsed keybinds for Herdr actions. pub fn keybinds(&self) -> Keybinds { self.validated_keybinds().3 } diff --git a/src/config/io.rs b/src/config/io.rs index 07b63d37..12176815 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -293,6 +293,58 @@ pub fn remove_section_key(content: &str, section: &str, key: &str) -> String { result.join("\n") + "\n" } +pub fn remove_keybinding_config_sections(content: &str) -> (String, bool) { + let mut result = Vec::new(); + let mut removed = false; + let mut skipping_key_section = false; + let mut in_table = false; + + for line in content.lines() { + let trimmed = line.trim(); + + if let Some(table_name) = toml_table_header_name(trimmed) { + in_table = true; + skipping_key_section = is_keys_table_name(table_name); + if skipping_key_section { + removed = true; + continue; + } + } else if skipping_key_section || (!in_table && is_top_level_keys_assignment(trimmed)) { + removed = true; + continue; + } + + result.push(line.to_string()); + } + + let mut updated = result.join("\n"); + if content.ends_with('\n') || !updated.is_empty() { + updated.push('\n'); + } + (updated, removed) +} + +fn toml_table_header_name(trimmed: &str) -> Option<&str> { + if let Some(name) = trimmed + .strip_prefix("[[") + .and_then(|value| value.strip_suffix("]]")) + { + return Some(name.trim()); + } + trimmed + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .map(str::trim) +} + +fn is_keys_table_name(name: &str) -> bool { + name == "keys" || name.starts_with("keys.") +} + +fn is_top_level_keys_assignment(trimmed: &str) -> bool { + trimmed.starts_with("keys ") || trimmed.starts_with("keys=") || trimmed.starts_with("keys.") +} + fn upsert_section_raw(content: &str, section: &str, key: &str, value: &str) -> String { let header = format!("[{section}]"); let assignment = format!("{key} = {value}"); @@ -381,4 +433,46 @@ mod tests { assert!(updated.contains("delivery = \"herdr\"")); assert!(updated.contains("[ui.sound]\nenabled = true")); } + + #[test] + fn remove_keybinding_config_sections_removes_keys_tables_only() { + let content = r#"onboarding = false + +[theme] +name = "catppuccin" + +[keys] +prefix = "ctrl+a" +new_tab = "c" + +[[keys.command]] +key = "g" +command = "lazygit" + +[keys.indexed] +tabs = "ctrl" + +[ui] +mouse_capture = false +"#; + + let (updated, removed) = remove_keybinding_config_sections(content); + + assert!(removed); + assert!(updated.contains("onboarding = false")); + assert!(updated.contains("[theme]\nname = \"catppuccin\"")); + assert!(updated.contains("[ui]\nmouse_capture = false")); + assert!(!updated.contains("[keys]")); + assert!(!updated.contains("[[keys.command]]")); + assert!(!updated.contains("[keys.indexed]")); + assert!(toml::from_str::(&updated).is_ok()); + } + + #[test] + fn remove_keybinding_config_sections_reports_noop_without_keys() { + let content = "[ui]\nmouse_capture = true\n"; + let (updated, removed) = remove_keybinding_config_sections(content); + assert!(!removed); + assert_eq!(updated, content); + } } diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 13d65ec3..58c26020 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -1,15 +1,50 @@ +#[cfg(test)] +use crossterm::event::KeyEvent; use crossterm::event::{KeyCode, KeyModifiers}; use serde::Deserialize; use tracing::warn; use super::Config; +use crate::input::TerminalKey; + +pub type KeyCombo = (KeyCode, KeyModifiers); #[derive(Debug)] pub struct LiveKeybindConfig { - pub prefix: (KeyCode, KeyModifiers), + pub prefix: KeyCombo, pub keybinds: Keybinds, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +pub enum BindingConfig { + One(String), + Many(Vec), +} + +impl Default for BindingConfig { + fn default() -> Self { + Self::One(String::new()) + } +} + +impl BindingConfig { + pub fn one(value: impl Into) -> Self { + Self::One(value.into()) + } + + pub fn empty() -> Self { + Self::One(String::new()) + } + + fn values(&self) -> Vec<&str> { + match self { + Self::One(value) => vec![value.as_str()], + Self::Many(values) => values.iter().map(String::as_str).collect(), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum CommandKeybindType { @@ -21,8 +56,8 @@ pub enum CommandKeybindType { #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct CommandKeybindConfig { - /// Navigate-mode key that runs a command after pressing the prefix key. - pub key: String, + /// Key that runs a command. Use `prefix+g` for prefix mode or a modified chord for direct mode. + pub key: BindingConfig, /// Command executed either in the background shell or inside a pane. pub command: String, /// Command execution mode. Default: "shell". @@ -33,7 +68,7 @@ pub struct CommandKeybindConfig { impl Default for CommandKeybindConfig { fn default() -> Self { Self { - key: String::new(), + key: BindingConfig::empty(), command: String::new(), action_type: CommandKeybindType::Shell, } @@ -46,253 +81,273 @@ pub enum CustomCommandAction { Pane, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BindingTrigger { + Direct(KeyCombo), + Prefix(KeyCombo), +} + +impl BindingTrigger { + pub fn combo(self) -> KeyCombo { + match self { + Self::Direct(combo) | Self::Prefix(combo) => combo, + } + } + + pub fn is_direct(self) -> bool { + matches!(self, Self::Direct(_)) + } + + pub fn is_prefix(self) -> bool { + matches!(self, Self::Prefix(_)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedBinding { + pub trigger: BindingTrigger, + pub label: String, +} + +impl ResolvedBinding { + #[cfg(test)] + fn matches_key_event(&self, key: &KeyEvent) -> bool { + key_event_matches_combo(key, self.trigger.combo()) + } + + fn matches_terminal_key(&self, key: TerminalKey) -> bool { + terminal_key_matches_combo(key, self.trigger.combo()) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ActionKeybinds { + pub bindings: Vec, +} + +impl ActionKeybinds { + #[cfg(test)] + pub fn prefix(label: &str) -> Self { + let raw = if label.starts_with("prefix+") { + label.to_string() + } else { + format!("prefix+{label}") + }; + let trigger = parse_binding_string(&raw) + .and_then(|parsed| match parsed { + ParsedBinding::Single(binding) => Some(binding), + ParsedBinding::Range(_) => None, + }) + .expect("prefix binding should parse"); + Self { + bindings: vec![trigger], + } + } + + #[cfg(test)] + pub fn direct(label: &str) -> Self { + let trigger = parse_binding_string(label) + .and_then(|parsed| match parsed { + ParsedBinding::Single(binding) => Some(binding), + ParsedBinding::Range(_) => None, + }) + .expect("direct binding should parse"); + Self { + bindings: vec![trigger], + } + } + + #[cfg(test)] + pub fn matches_prefix(&self, key: &KeyEvent) -> bool { + self.bindings + .iter() + .any(|binding| binding.trigger.is_prefix() && binding.matches_key_event(key)) + } + + pub fn matches_prefix_key(&self, key: TerminalKey) -> bool { + self.bindings + .iter() + .any(|binding| binding.trigger.is_prefix() && binding.matches_terminal_key(key)) + } + + pub fn matches_direct_key(&self, key: TerminalKey) -> bool { + self.bindings + .iter() + .any(|binding| binding.trigger.is_direct() && binding.matches_terminal_key(key)) + } + + pub fn labels(&self) -> Vec { + self.bindings + .iter() + .map(|binding| binding.label.clone()) + .collect() + } + + pub fn label(&self) -> Option { + let labels = self.labels(); + if labels.is_empty() { + None + } else { + Some(labels.join(" / ")) + } + } + + pub fn prefix_rhs_label(&self) -> Option { + let labels: Vec = self + .bindings + .iter() + .filter(|binding| binding.trigger.is_prefix()) + .map(|binding| { + binding + .label + .strip_prefix("prefix+") + .unwrap_or(&binding.label) + .to_string() + }) + .collect(); + if labels.is_empty() { + None + } else { + Some(labels.join(" / ")) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IndexedKeybind { + pub trigger: BindingTrigger, + pub label: String, +} + +impl IndexedKeybind { + pub fn matched_index(&self, key: TerminalKey) -> Option { + let KeyCode::Char(c @ '1'..='9') = key.code else { + return None; + }; + if terminal_key_matches_combo(key, self.trigger.combo()) { + Some((c as usize) - ('1' as usize)) + } else { + None + } + } +} + #[derive(Debug, Clone)] pub struct CustomCommandKeybind { - pub key: (KeyCode, KeyModifiers), + pub bindings: ActionKeybinds, pub label: String, pub command: String, pub action: CustomCommandAction, } -/// Parsed keybinds for navigate mode actions. +/// Parsed keybinds for Herdr actions. #[derive(Debug, Clone)] pub struct Keybinds { - pub new_workspace: (KeyCode, KeyModifiers), - pub new_workspace_label: String, - pub rename_workspace: (KeyCode, KeyModifiers), - pub rename_workspace_label: String, - pub close_workspace: (KeyCode, KeyModifiers), - pub close_workspace_label: String, - pub detach: Option<(KeyCode, KeyModifiers)>, - pub detach_label: Option, - pub reload_config: Option<(KeyCode, KeyModifiers)>, - pub reload_config_label: Option, - pub open_notification_target: Option<(KeyCode, KeyModifiers)>, - pub open_notification_target_label: Option, - pub previous_workspace: Option<(KeyCode, KeyModifiers)>, - pub previous_workspace_label: Option, - pub next_workspace: Option<(KeyCode, KeyModifiers)>, - pub next_workspace_label: Option, - pub previous_agent: Option<(KeyCode, KeyModifiers)>, - pub previous_agent_label: Option, - pub next_agent: Option<(KeyCode, KeyModifiers)>, - pub next_agent_label: Option, - pub indexed_tabs: Option, - pub indexed_tabs_label: Option, - pub indexed_workspaces: Option, - pub indexed_workspaces_label: Option, - pub indexed_agents: Option, - pub indexed_agents_label: Option, - pub new_tab: (KeyCode, KeyModifiers), - pub new_tab_label: String, - pub rename_tab: Option<(KeyCode, KeyModifiers)>, - pub rename_tab_label: Option, - pub previous_tab: Option<(KeyCode, KeyModifiers)>, - pub previous_tab_label: Option, - pub next_tab: Option<(KeyCode, KeyModifiers)>, - pub next_tab_label: Option, - pub close_tab: Option<(KeyCode, KeyModifiers)>, - pub close_tab_label: Option, - pub rename_pane: Option<(KeyCode, KeyModifiers)>, - pub rename_pane_label: Option, - pub edit_scrollback: Option<(KeyCode, KeyModifiers)>, - pub edit_scrollback_label: Option, - pub focus_pane_left: Option<(KeyCode, KeyModifiers)>, - pub focus_pane_left_label: Option, - pub focus_pane_down: Option<(KeyCode, KeyModifiers)>, - pub focus_pane_down_label: Option, - pub focus_pane_up: Option<(KeyCode, KeyModifiers)>, - pub focus_pane_up_label: Option, - pub focus_pane_right: Option<(KeyCode, KeyModifiers)>, - pub focus_pane_right_label: Option, - pub split_vertical: (KeyCode, KeyModifiers), - pub split_vertical_label: String, - pub split_horizontal: (KeyCode, KeyModifiers), - pub split_horizontal_label: String, - pub close_pane: (KeyCode, KeyModifiers), - pub close_pane_label: String, - pub zoom: (KeyCode, KeyModifiers), - pub zoom_label: String, - pub resize_mode: (KeyCode, KeyModifiers), - pub resize_mode_label: String, - pub toggle_sidebar: (KeyCode, KeyModifiers), - pub toggle_sidebar_label: String, + pub help: ActionKeybinds, + pub settings: ActionKeybinds, + pub quit: ActionKeybinds, + pub new_workspace: ActionKeybinds, + pub rename_workspace: ActionKeybinds, + pub close_workspace: ActionKeybinds, + pub workspace_picker: ActionKeybinds, + pub detach: ActionKeybinds, + pub reload_config: ActionKeybinds, + pub open_notification_target: ActionKeybinds, + pub previous_workspace: ActionKeybinds, + pub next_workspace: ActionKeybinds, + pub previous_agent: ActionKeybinds, + pub next_agent: ActionKeybinds, + pub focus_agent: Vec, + pub new_tab: ActionKeybinds, + pub rename_tab: ActionKeybinds, + pub previous_tab: ActionKeybinds, + pub next_tab: ActionKeybinds, + pub switch_tab: Vec, + pub switch_workspace: Vec, + pub close_tab: ActionKeybinds, + pub rename_pane: ActionKeybinds, + pub edit_scrollback: ActionKeybinds, + pub focus_pane_left: ActionKeybinds, + pub focus_pane_down: ActionKeybinds, + pub focus_pane_up: ActionKeybinds, + pub focus_pane_right: ActionKeybinds, + pub cycle_pane_next: ActionKeybinds, + pub cycle_pane_previous: ActionKeybinds, + pub split_vertical: ActionKeybinds, + pub split_horizontal: ActionKeybinds, + pub close_pane: ActionKeybinds, + pub zoom: ActionKeybinds, + pub resize_mode: ActionKeybinds, + pub toggle_sidebar: ActionKeybinds, pub custom_commands: Vec, } +impl Default for Keybinds { + fn default() -> Self { + Config::default().keybinds() + } +} + +#[derive(Clone)] +enum ParsedBinding { + Single(ResolvedBinding), + Range(Vec), +} + +struct BindingRegistry { + prefix_combo: KeyCombo, + direct: std::collections::HashMap, + prefix: std::collections::HashMap, +} + +impl BindingRegistry { + fn new(prefix_combo: KeyCombo) -> Self { + Self { + prefix_combo: normalize_key_combo(prefix_combo), + direct: std::collections::HashMap::new(), + prefix: std::collections::HashMap::new(), + } + } + + fn reserve_direct(&mut self, combo: KeyCombo, field: &str) { + self.direct + .entry(normalize_key_combo(combo)) + .or_insert_with(|| field.to_string()); + } + + fn prefix_rhs_is_reserved(&self, combo: KeyCombo) -> bool { + normalize_key_combo(combo) == self.prefix_combo + } + + fn conflict(&self, binding: &ResolvedBinding) -> Option<&str> { + match binding.trigger { + BindingTrigger::Direct(combo) => self + .direct + .get(&normalize_key_combo(combo)) + .map(String::as_str), + BindingTrigger::Prefix(combo) => self + .prefix + .get(&normalize_key_combo(combo)) + .map(String::as_str), + } + } + + fn register(&mut self, binding: &ResolvedBinding, field: &str) { + match binding.trigger { + BindingTrigger::Direct(combo) => { + self.direct + .insert(normalize_key_combo(combo), field.to_string()); + } + BindingTrigger::Prefix(combo) => { + self.prefix + .insert(normalize_key_combo(combo), field.to_string()); + } + } + } +} + impl Config { - pub(super) fn validated_keybinds( - &self, - ) -> ( - Option, - (KeyCode, KeyModifiers), - Vec, - Keybinds, - ) { - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] - enum BindingScope { - Navigate, - TerminalDirect, - } - - #[derive(Clone)] - struct RequiredBinding<'a> { - scope: BindingScope, - field: &'a str, - label: String, - default_label: &'a str, - value: (KeyCode, KeyModifiers), - default: (KeyCode, KeyModifiers), - } - - struct OptionalBinding { - scopes: Vec, - field: &'static str, - value: Option<(KeyCode, KeyModifiers)>, - label: Option, - } - - struct IndexedBinding { - field: &'static str, - value: Option, - label: Option, - } - - #[derive(Default)] - struct BindingRegistry { - seen: std::collections::HashMap<(BindingScope, KeyCode, KeyModifiers), String>, - } - - impl BindingRegistry { - fn register( - &mut self, - scope: BindingScope, - binding: (KeyCode, KeyModifiers), - field: &str, - ) -> Option { - self.seen - .insert((scope, binding.0, binding.1), field.to_string()) - } - - fn conflict( - &self, - scope: BindingScope, - binding: (KeyCode, KeyModifiers), - ) -> Option<&str> { - self.seen - .get(&(scope, binding.0, binding.1)) - .map(String::as_str) - } - - fn reserve_if_unbound( - &mut self, - scope: BindingScope, - binding: (KeyCode, KeyModifiers), - field: &str, - ) { - self.seen - .entry((scope, binding.0, binding.1)) - .or_insert_with(|| field.to_string()); - } - } - - fn required_binding<'a>( - scope: BindingScope, - field: &'a str, - configured_label: &'a str, - default_label: &'a str, - default: (KeyCode, KeyModifiers), - diagnostics: &mut Vec, - ) -> RequiredBinding<'a> { - let (value, diag) = parse_key_combo_with_diagnostic(configured_label, field, default); - let label = if let Some(diag) = diag { - diagnostics.push(diag); - default_label.to_string() - } else { - configured_label.to_string() - }; - RequiredBinding { - scope, - field, - label, - default_label, - value, - default, - } - } - - fn optional_binding( - scopes: Vec, - field: &'static str, - configured_label: &str, - diagnostics: &mut Vec, - ) -> OptionalBinding { - if configured_label.trim().is_empty() { - return OptionalBinding { - scopes, - field, - value: None, - label: None, - }; - } - match parse_key_combo(configured_label) { - Some(value) => OptionalBinding { - scopes, - field, - value: Some(value), - label: Some(configured_label.to_string()), - }, - None => { - let diag = format!( - "invalid keybinding: {field} = {:?}; disabling binding", - configured_label - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - OptionalBinding { - scopes, - field, - value: None, - label: None, - } - } - } - } - - fn indexed_binding( - field: &'static str, - configured_label: &str, - diagnostics: &mut Vec, - ) -> IndexedBinding { - if configured_label.trim().is_empty() { - return IndexedBinding { - field, - value: None, - label: None, - }; - } - match parse_modifier_combo(configured_label) { - Some(value) => IndexedBinding { - field, - value: Some(value), - label: Some(format!("{}+1..9", configured_label.trim())), - }, - None => { - let diag = format!( - "invalid indexed keybinding: {field} = {:?}; disabling binding", - configured_label - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - IndexedBinding { - field, - value: None, - label: None, - } - } - } - } - + pub(super) fn validated_keybinds(&self) -> (Option, KeyCombo, Vec, Keybinds) { let mut diagnostics = Vec::new(); let (prefix, prefix_diag) = parse_key_combo_with_diagnostic( &self.keys.prefix, @@ -303,394 +358,88 @@ impl Config { warn!(message = %diag, "config diagnostic"); } - let mut bindings = vec![ - required_binding( - BindingScope::Navigate, - "keys.new_workspace", - &self.keys.new_workspace, - "n", - (KeyCode::Char('n'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.rename_workspace", - &self.keys.rename_workspace, - "shift+n", - (KeyCode::Char('n'), KeyModifiers::SHIFT), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.close_workspace", - &self.keys.close_workspace, - "shift+d", - (KeyCode::Char('d'), KeyModifiers::SHIFT), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.new_tab", - &self.keys.new_tab, - "c", - (KeyCode::Char('c'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.split_vertical", - &self.keys.split_vertical, - "v", - (KeyCode::Char('v'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.split_horizontal", - &self.keys.split_horizontal, - "-", - (KeyCode::Char('-'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.close_pane", - &self.keys.close_pane, - "x", - (KeyCode::Char('x'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.zoom", - &self.keys.zoom, - "f", - (KeyCode::Char('f'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.resize_mode", - &self.keys.resize_mode, - "r", - (KeyCode::Char('r'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.toggle_sidebar", - &self.keys.toggle_sidebar, - "b", - (KeyCode::Char('b'), KeyModifiers::empty()), - &mut diagnostics, - ), - ]; + let mut registry = BindingRegistry::new(prefix); + registry.reserve_direct(prefix, "keys.prefix"); - let navigate_scope = || vec![BindingScope::Navigate]; - let terminal_direct_scope = || vec![BindingScope::TerminalDirect]; - let direct_navigation_scopes = - || vec![BindingScope::Navigate, BindingScope::TerminalDirect]; + macro_rules! action { + ($field:literal, $config:expr) => { + parse_action_bindings($field, $config, false, &mut registry, &mut diagnostics) + }; + } + macro_rules! indexed { + ($field:literal, $config:expr) => { + parse_indexed_bindings($field, $config, &mut registry, &mut diagnostics) + }; + } - let mut optional_bindings = vec![ - optional_binding( - navigate_scope(), - "keys.detach", - &self.keys.detach, - &mut diagnostics, - ), - optional_binding( - navigate_scope(), - "keys.reload_config", - &self.keys.reload_config, - &mut diagnostics, - ), - optional_binding( - navigate_scope(), + let mut keybinds = Keybinds { + help: action!("keys.help", &self.keys.help), + settings: action!("keys.settings", &self.keys.settings), + quit: action!("keys.quit", &self.keys.quit), + new_workspace: action!("keys.new_workspace", &self.keys.new_workspace), + rename_workspace: action!("keys.rename_workspace", &self.keys.rename_workspace), + close_workspace: action!("keys.close_workspace", &self.keys.close_workspace), + workspace_picker: action!("keys.workspace_picker", &self.keys.workspace_picker), + detach: action!("keys.detach", &self.keys.detach), + reload_config: action!("keys.reload_config", &self.keys.reload_config), + open_notification_target: action!( "keys.open_notification_target", - &self.keys.open_notification_target, - &mut diagnostics, + &self.keys.open_notification_target ), - optional_binding( - direct_navigation_scopes(), - "keys.previous_workspace", - &self.keys.previous_workspace, - &mut diagnostics, + previous_workspace: action!("keys.previous_workspace", &self.keys.previous_workspace), + next_workspace: action!("keys.next_workspace", &self.keys.next_workspace), + previous_agent: action!("keys.previous_agent", &self.keys.previous_agent), + next_agent: action!("keys.next_agent", &self.keys.next_agent), + focus_agent: indexed!("keys.focus_agent", &self.keys.focus_agent), + new_tab: action!("keys.new_tab", &self.keys.new_tab), + rename_tab: action!("keys.rename_tab", &self.keys.rename_tab), + previous_tab: action!("keys.previous_tab", &self.keys.previous_tab), + next_tab: action!("keys.next_tab", &self.keys.next_tab), + switch_tab: indexed!("keys.switch_tab", &self.keys.switch_tab), + switch_workspace: indexed!("keys.switch_workspace", &self.keys.switch_workspace), + close_tab: action!("keys.close_tab", &self.keys.close_tab), + rename_pane: action!("keys.rename_pane", &self.keys.rename_pane), + edit_scrollback: action!("keys.edit_scrollback", &self.keys.edit_scrollback), + focus_pane_left: action!("keys.focus_pane_left", &self.keys.focus_pane_left), + focus_pane_down: action!("keys.focus_pane_down", &self.keys.focus_pane_down), + focus_pane_up: action!("keys.focus_pane_up", &self.keys.focus_pane_up), + focus_pane_right: action!("keys.focus_pane_right", &self.keys.focus_pane_right), + cycle_pane_next: action!("keys.cycle_pane_next", &self.keys.cycle_pane_next), + cycle_pane_previous: action!( + "keys.cycle_pane_previous", + &self.keys.cycle_pane_previous ), - optional_binding( - direct_navigation_scopes(), - "keys.next_workspace", - &self.keys.next_workspace, - &mut diagnostics, - ), - optional_binding( - direct_navigation_scopes(), - "keys.previous_agent", - &self.keys.previous_agent, - &mut diagnostics, - ), - optional_binding( - direct_navigation_scopes(), - "keys.next_agent", - &self.keys.next_agent, - &mut diagnostics, - ), - optional_binding( - navigate_scope(), - "keys.rename_tab", - &self.keys.rename_tab, - &mut diagnostics, - ), - optional_binding( - direct_navigation_scopes(), - "keys.previous_tab", - &self.keys.previous_tab, - &mut diagnostics, - ), - optional_binding( - direct_navigation_scopes(), - "keys.next_tab", - &self.keys.next_tab, - &mut diagnostics, - ), - optional_binding( - navigate_scope(), - "keys.close_tab", - &self.keys.close_tab, - &mut diagnostics, - ), - optional_binding( - navigate_scope(), - "keys.rename_pane", - &self.keys.rename_pane, - &mut diagnostics, - ), - optional_binding( - navigate_scope(), - "keys.edit_scrollback", - &self.keys.edit_scrollback, - &mut diagnostics, - ), - optional_binding( - terminal_direct_scope(), - "keys.focus_pane_left", - &self.keys.focus_pane_left, - &mut diagnostics, - ), - optional_binding( - terminal_direct_scope(), - "keys.focus_pane_down", - &self.keys.focus_pane_down, - &mut diagnostics, - ), - optional_binding( - terminal_direct_scope(), - "keys.focus_pane_up", - &self.keys.focus_pane_up, - &mut diagnostics, - ), - optional_binding( - terminal_direct_scope(), - "keys.focus_pane_right", - &self.keys.focus_pane_right, - &mut diagnostics, - ), - ]; + split_vertical: action!("keys.split_vertical", &self.keys.split_vertical), + split_horizontal: action!("keys.split_horizontal", &self.keys.split_horizontal), + close_pane: action!("keys.close_pane", &self.keys.close_pane), + zoom: action!("keys.zoom", &self.keys.zoom), + resize_mode: action!("keys.resize_mode", &self.keys.resize_mode), + toggle_sidebar: action!("keys.toggle_sidebar", &self.keys.toggle_sidebar), + custom_commands: Vec::new(), + }; - let mut indexed_bindings = vec![ - indexed_binding( - "keys.indexed.tabs", - &self.keys.indexed.tabs, - &mut diagnostics, - ), - indexed_binding( - "keys.indexed.workspaces", - &self.keys.indexed.workspaces, - &mut diagnostics, - ), - indexed_binding( - "keys.indexed.agents", - &self.keys.indexed.agents, - &mut diagnostics, - ), - ]; + append_legacy_indexed_bindings( + &mut keybinds.switch_tab, + "keys.indexed.tabs", + &self.keys.indexed.tabs, + &mut registry, + &mut diagnostics, + ); + append_legacy_indexed_bindings( + &mut keybinds.switch_workspace, + "keys.indexed.workspaces", + &self.keys.indexed.workspaces, + &mut registry, + &mut diagnostics, + ); + append_legacy_indexed_bindings( + &mut keybinds.focus_agent, + "keys.indexed.agents", + &self.keys.indexed.agents, + &mut registry, + &mut diagnostics, + ); - let mut registry = BindingRegistry::default(); - for binding in &mut bindings { - if let Some(first_field) = registry.conflict(binding.scope, binding.value) { - let diag = format!( - "duplicate keybinding: {} conflicts with {}; using default {}", - binding.field, first_field, binding.default_label - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - binding.value = binding.default; - binding.label = binding.default_label.to_string(); - } - registry.register(binding.scope, binding.value, binding.field); - } - - registry.reserve_if_unbound(BindingScope::TerminalDirect, prefix, "keys.prefix"); - - for binding in &mut optional_bindings { - let Some(value) = binding.value else { - continue; - }; - - let conflict = binding - .scopes - .iter() - .find_map(|scope| registry.conflict(*scope, value)); - if let Some(first_field) = conflict { - let diag = format!( - "duplicate keybinding: {} conflicts with {}; disabling binding", - binding.field, first_field - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - binding.value = None; - binding.label = None; - continue; - } - for scope in &binding.scopes { - registry.register(*scope, value, binding.field); - } - } - - registry.reserve_if_unbound(BindingScope::Navigate, prefix, "keys.prefix"); - for (field, binding) in [ - ("navigate.quit", (KeyCode::Char('q'), KeyModifiers::empty())), - ( - "navigate.open_workspace", - (KeyCode::Enter, KeyModifiers::empty()), - ), - ( - "navigate.settings", - (KeyCode::Char('s'), KeyModifiers::empty()), - ), - ( - "navigate.keybind_help", - (KeyCode::Char('?'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_up", - (KeyCode::Up, KeyModifiers::empty()), - ), - ( - "navigate.workspace_down", - (KeyCode::Down, KeyModifiers::empty()), - ), - ( - "navigate.focus_left", - (KeyCode::Char('h'), KeyModifiers::empty()), - ), - ( - "navigate.focus_down", - (KeyCode::Char('j'), KeyModifiers::empty()), - ), - ( - "navigate.focus_up", - (KeyCode::Char('k'), KeyModifiers::empty()), - ), - ( - "navigate.focus_right", - (KeyCode::Char('l'), KeyModifiers::empty()), - ), - ( - "navigate.arrow_left", - (KeyCode::Left, KeyModifiers::empty()), - ), - ( - "navigate.arrow_right", - (KeyCode::Right, KeyModifiers::empty()), - ), - ("navigate.tab_next", (KeyCode::Tab, KeyModifiers::empty())), - ( - "navigate.tab_prev", - (KeyCode::BackTab, KeyModifiers::empty()), - ), - ( - "navigate.workspace_1", - (KeyCode::Char('1'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_2", - (KeyCode::Char('2'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_3", - (KeyCode::Char('3'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_4", - (KeyCode::Char('4'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_5", - (KeyCode::Char('5'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_6", - (KeyCode::Char('6'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_7", - (KeyCode::Char('7'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_8", - (KeyCode::Char('8'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_9", - (KeyCode::Char('9'), KeyModifiers::empty()), - ), - ("navigate.back", (KeyCode::Esc, KeyModifiers::empty())), - ] { - registry.reserve_if_unbound(BindingScope::Navigate, binding, field); - } - - for binding in &mut indexed_bindings { - let Some(modifiers) = binding.value else { - continue; - }; - - let mut conflict = None; - 'indexes: for idx in 1..=9 { - let key = ( - KeyCode::Char(char::from_digit(idx, 10).unwrap_or('1')), - modifiers, - ); - for scope in [BindingScope::Navigate, BindingScope::TerminalDirect] { - if let Some(first_field) = registry.conflict(scope, key) { - conflict = Some(first_field.to_string()); - break 'indexes; - } - } - } - - if let Some(first_field) = conflict { - let diag = format!( - "duplicate indexed keybinding: {} conflicts with {}; disabling binding", - binding.field, first_field - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - binding.value = None; - binding.label = None; - continue; - } - - for idx in 1..=9 { - let key = ( - KeyCode::Char(char::from_digit(idx, 10).unwrap_or('1')), - modifiers, - ); - registry.register(BindingScope::Navigate, key, binding.field); - registry.register(BindingScope::TerminalDirect, key, binding.field); - } - } - - let mut custom_commands = Vec::new(); for (index, command) in self.keys.command.iter().enumerate() { let key_field = format!("keys.command[{index}].key"); let command_field = format!("keys.command[{index}].command"); @@ -703,108 +452,246 @@ impl Config { continue; } - let Some(binding) = parse_key_combo(&command.key) else { - let diag = format!( - "invalid keybinding: {} = {:?}; disabling custom command", - key_field, command.key - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - continue; - }; - - if let Some(first_field) = registry.conflict(BindingScope::Navigate, binding) { - let diag = format!( - "duplicate custom keybinding: {} conflicts with {}; disabling custom command", - key_field, first_field - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); + let bindings = parse_action_bindings_owned( + &key_field, + &command.key, + false, + &mut registry, + &mut diagnostics, + ); + if bindings.bindings.is_empty() { continue; } - registry.register(BindingScope::Navigate, binding, &key_field); let action = match command.action_type { CommandKeybindType::Shell => CustomCommandAction::Shell, CommandKeybindType::Pane => CustomCommandAction::Pane, }; - custom_commands.push(CustomCommandKeybind { - key: binding, - label: format_key_combo(binding), + let label = bindings.label().unwrap_or_else(|| "unset".to_string()); + keybinds.custom_commands.push(CustomCommandKeybind { + bindings, + label, command: command.command.clone(), action, }); } - let keybinds = Keybinds { - new_workspace: bindings[0].value, - new_workspace_label: bindings[0].label.clone(), - rename_workspace: bindings[1].value, - rename_workspace_label: bindings[1].label.clone(), - close_workspace: bindings[2].value, - close_workspace_label: bindings[2].label.clone(), - detach: optional_bindings[0].value, - detach_label: optional_bindings[0].label.clone(), - reload_config: optional_bindings[1].value, - reload_config_label: optional_bindings[1].label.clone(), - open_notification_target: optional_bindings[2].value, - open_notification_target_label: optional_bindings[2].label.clone(), - previous_workspace: optional_bindings[3].value, - previous_workspace_label: optional_bindings[3].label.clone(), - next_workspace: optional_bindings[4].value, - next_workspace_label: optional_bindings[4].label.clone(), - previous_agent: optional_bindings[5].value, - previous_agent_label: optional_bindings[5].label.clone(), - next_agent: optional_bindings[6].value, - next_agent_label: optional_bindings[6].label.clone(), - indexed_tabs: indexed_bindings[0].value, - indexed_tabs_label: indexed_bindings[0].label.clone(), - indexed_workspaces: indexed_bindings[1].value, - indexed_workspaces_label: indexed_bindings[1].label.clone(), - indexed_agents: indexed_bindings[2].value, - indexed_agents_label: indexed_bindings[2].label.clone(), - new_tab: bindings[3].value, - new_tab_label: bindings[3].label.clone(), - rename_tab: optional_bindings[7].value, - rename_tab_label: optional_bindings[7].label.clone(), - previous_tab: optional_bindings[8].value, - previous_tab_label: optional_bindings[8].label.clone(), - next_tab: optional_bindings[9].value, - next_tab_label: optional_bindings[9].label.clone(), - close_tab: optional_bindings[10].value, - close_tab_label: optional_bindings[10].label.clone(), - rename_pane: optional_bindings[11].value, - rename_pane_label: optional_bindings[11].label.clone(), - edit_scrollback: optional_bindings[12].value, - edit_scrollback_label: optional_bindings[12].label.clone(), - focus_pane_left: optional_bindings[13].value, - focus_pane_left_label: optional_bindings[13].label.clone(), - focus_pane_down: optional_bindings[14].value, - focus_pane_down_label: optional_bindings[14].label.clone(), - focus_pane_up: optional_bindings[15].value, - focus_pane_up_label: optional_bindings[15].label.clone(), - focus_pane_right: optional_bindings[16].value, - focus_pane_right_label: optional_bindings[16].label.clone(), - split_vertical: bindings[4].value, - split_vertical_label: bindings[4].label.clone(), - split_horizontal: bindings[5].value, - split_horizontal_label: bindings[5].label.clone(), - close_pane: bindings[6].value, - close_pane_label: bindings[6].label.clone(), - zoom: bindings[7].value, - zoom_label: bindings[7].label.clone(), - resize_mode: bindings[8].value, - resize_mode_label: bindings[8].label.clone(), - toggle_sidebar: bindings[9].value, - toggle_sidebar_label: bindings[9].label.clone(), - custom_commands, - }; - (prefix_diag, prefix, diagnostics, keybinds) } } -pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String { +fn parse_action_bindings( + field: &'static str, + config: &BindingConfig, + allow_ranges: bool, + registry: &mut BindingRegistry, + diagnostics: &mut Vec, +) -> ActionKeybinds { + parse_action_bindings_owned(field, config, allow_ranges, registry, diagnostics) +} + +fn parse_action_bindings_owned( + field: &str, + config: &BindingConfig, + allow_ranges: bool, + registry: &mut BindingRegistry, + diagnostics: &mut Vec, +) -> ActionKeybinds { + let mut bindings = Vec::new(); + for raw in config.values() { + let raw = raw.trim(); + if raw.is_empty() { + continue; + } + match parse_binding_string(raw) { + Some(ParsedBinding::Single(binding)) => { + if reject_binding(field, &binding, registry, diagnostics) { + continue; + } + registry.register(&binding, field); + bindings.push(binding); + } + Some(ParsedBinding::Range(_)) if !allow_ranges => { + let diag = format!("range keybinding is only valid for indexed actions: {field} = {raw:?}; disabling binding"); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + } + Some(ParsedBinding::Range(range)) => { + for binding in range { + if reject_binding(field, &binding, registry, diagnostics) { + continue; + } + registry.register(&binding, field); + bindings.push(binding); + } + } + None => { + let diag = format!("invalid keybinding: {field} = {raw:?}; disabling binding"); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + } + } + } + ActionKeybinds { bindings } +} + +fn parse_indexed_bindings( + field: &'static str, + config: &BindingConfig, + registry: &mut BindingRegistry, + diagnostics: &mut Vec, +) -> Vec { + parse_action_bindings(field, config, true, registry, diagnostics) + .bindings + .into_iter() + .filter_map(|binding| { + if matches!(binding.trigger.combo().0, KeyCode::Char('1'..='9')) { + Some(IndexedKeybind { + trigger: binding.trigger, + label: binding.label, + }) + } else { + let diag = format!( + "indexed keybinding must use 1..9: {field} = {:?}; disabling binding", + binding.label + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + None + } + }) + .collect() +} + +fn append_legacy_indexed_bindings( + target: &mut Vec, + field: &'static str, + configured_label: &str, + registry: &mut BindingRegistry, + diagnostics: &mut Vec, +) { + if configured_label.trim().is_empty() { + return; + } + let Some(modifiers) = parse_modifier_combo(configured_label) else { + let diag = format!( + "invalid indexed keybinding: {field} = {configured_label:?}; disabling binding" + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + return; + }; + + for idx in 1..=9 { + let combo = ( + KeyCode::Char(char::from_digit(idx, 10).unwrap_or('1')), + modifiers, + ); + let binding = ResolvedBinding { + trigger: BindingTrigger::Direct(combo), + label: format!("{}+{idx}", configured_label.trim()), + }; + if reject_binding(field, &binding, registry, diagnostics) { + continue; + } + registry.register(&binding, field); + target.push(IndexedKeybind { + trigger: binding.trigger, + label: binding.label, + }); + } +} + +fn reject_binding( + field: &str, + binding: &ResolvedBinding, + registry: &BindingRegistry, + diagnostics: &mut Vec, +) -> bool { + if binding.trigger.is_prefix() && registry.prefix_rhs_is_reserved(binding.trigger.combo()) { + let diag = format!( + "reserved keybinding: {field} = {:?} uses keys.prefix as the prefix-mode key; pressing the prefix twice sends a literal prefix key, so this binding is disabled", + binding.label + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + return true; + } + + if let Some(first_field) = registry.conflict(binding) { + let diag = format!( + "duplicate keybinding: {field} = {:?} conflicts with {}; disabling binding", + binding.label, first_field + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + return true; + } + + if binding.trigger.is_direct() && is_unmodified_printable(binding.trigger.combo()) { + let suggestion = format!("prefix+{}", binding.label); + let diag = format!( + "unsafe direct keybinding: {field} = {:?} would intercept typing; use {:?} to require the prefix; disabling binding", + binding.label, suggestion + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + return true; + } + + false +} + +fn parse_binding_string(raw: &str) -> Option { + let trimmed = raw.trim(); + let (trigger_prefix, body) = if let Some(rest) = trimmed.strip_prefix("prefix+") { + (true, rest) + } else { + (false, trimmed) + }; + + if let Some(range_modifiers) = parse_range_modifiers(body) { + let bindings = (1..=9) + .map(|idx| { + let combo = ( + KeyCode::Char(char::from_digit(idx, 10).unwrap_or('1')), + range_modifiers, + ); + let key_label = format_key_combo(combo); + ResolvedBinding { + trigger: if trigger_prefix { + BindingTrigger::Prefix(combo) + } else { + BindingTrigger::Direct(combo) + }, + label: if trigger_prefix { + format!("prefix+{key_label}") + } else { + key_label + }, + } + }) + .collect(); + return Some(ParsedBinding::Range(bindings)); + } + + let combo = parse_key_combo(body)?; + let label = if trigger_prefix { + format!("prefix+{}", format_key_combo(combo)) + } else { + format_key_combo(combo) + }; + Some(ParsedBinding::Single(ResolvedBinding { + trigger: if trigger_prefix { + BindingTrigger::Prefix(combo) + } else { + BindingTrigger::Direct(combo) + }, + label, + })) +} + +pub fn format_key_combo(binding: KeyCombo) -> String { let (code, modifiers) = binding; let mut parts = Vec::new(); if modifiers.contains(KeyModifiers::CONTROL) { @@ -813,7 +700,7 @@ pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String { if modifiers.contains(KeyModifiers::ALT) { parts.push("alt".to_string()); } - if modifiers.contains(KeyModifiers::SHIFT) { + if modifiers.contains(KeyModifiers::SHIFT) && !matches!(code, KeyCode::BackTab) { parts.push("shift".to_string()); } if modifiers.contains(KeyModifiers::SUPER) { @@ -846,7 +733,7 @@ pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String { return if parts.is_empty() { key } else { - format!("{}+tab", parts.join("+")) + format!("{}+{key}", parts.join("+")) }; } @@ -873,6 +760,23 @@ fn parse_modifier_token(token: &str) -> Option { } } +fn parse_range_modifiers(s: &str) -> Option { + let mut modifiers = KeyModifiers::empty(); + let mut saw_range = false; + for part in s.split('+') { + let trimmed = part.trim(); + if trimmed == "1..9" { + if saw_range { + return None; + } + saw_range = true; + } else { + modifiers |= parse_modifier_token(trimmed)?; + } + } + saw_range.then_some(modifiers) +} + fn parse_modifier_combo(s: &str) -> Option { let mut modifiers = KeyModifiers::empty(); let parts: Vec<&str> = s.split('+').collect(); @@ -895,7 +799,7 @@ fn parse_modifier_combo(s: &str) -> Option { } } -pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> { +pub(super) fn parse_key_combo(s: &str) -> Option { let parts: Vec<&str> = s.split('+').collect(); let mut modifiers = KeyModifiers::empty(); let mut key_str: Option<&str> = None; @@ -907,27 +811,42 @@ pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> { } if let Some(modifier) = parse_modifier_token(trimmed) { modifiers |= modifier; + } else if key_str.is_some() { + return None; } else { - if key_str.is_some() { - return None; - } key_str = Some(trimmed); } } let key_str = key_str?; - let lower = key_str.to_lowercase(); let code = match lower.as_str() { "space" | " " => KeyCode::Char(' '), "enter" | "return" => KeyCode::Enter, "esc" | "escape" => KeyCode::Esc, + "tab" if modifiers.contains(KeyModifiers::SHIFT) => { + modifiers.remove(KeyModifiers::SHIFT); + KeyCode::BackTab + } "tab" => KeyCode::Tab, "backspace" | "bs" => KeyCode::Backspace, "left" => KeyCode::Left, "right" => KeyCode::Right, "up" => KeyCode::Up, "down" => KeyCode::Down, + "minus" => KeyCode::Char('-'), + "comma" => KeyCode::Char(','), + "period" => KeyCode::Char('.'), + "slash" => KeyCode::Char('/'), + "backslash" => KeyCode::Char('\\'), + "quote" => KeyCode::Char('\''), + "double_quote" | "double-quote" => KeyCode::Char('"'), + "semicolon" => KeyCode::Char(';'), + "colon" => KeyCode::Char(':'), + "percent" => KeyCode::Char('%'), + "ampersand" => KeyCode::Char('&'), + "backtick" => KeyCode::Char('`'), + "plus" => KeyCode::Char('+'), s if s.len() == 1 => { let ch = key_str.chars().next().unwrap(); if ch.is_ascii_uppercase() { @@ -941,14 +860,14 @@ pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> { _ => return None, }; - Some((code, modifiers)) + Some(normalize_key_combo((code, modifiers))) } fn parse_key_combo_with_diagnostic( s: &str, field: &str, - fallback: (KeyCode, KeyModifiers), -) -> ((KeyCode, KeyModifiers), Option) { + fallback: KeyCombo, +) -> (KeyCombo, Option) { match parse_key_combo(s) { Some(binding) => (binding, None), None => { @@ -959,13 +878,125 @@ fn parse_key_combo_with_diagnostic( } } +pub fn normalize_key_combo((mut code, mut modifiers): KeyCombo) -> KeyCombo { + if matches!(code, KeyCode::Tab) && modifiers.contains(KeyModifiers::SHIFT) { + code = KeyCode::BackTab; + modifiers.remove(KeyModifiers::SHIFT); + } else if matches!(code, KeyCode::BackTab) { + modifiers.remove(KeyModifiers::SHIFT); + } + (code, modifiers) +} + +#[cfg(test)] +pub fn key_event_matches_combo(key: &KeyEvent, combo: KeyCombo) -> bool { + key_parts_match_combo(key.code, key.modifiers, None, combo) +} + +pub fn terminal_key_matches_combo(key: TerminalKey, combo: KeyCombo) -> bool { + key_parts_match_combo(key.code, key.modifiers, key.shifted_codepoint, combo) +} + +fn key_parts_match_combo( + actual_code: KeyCode, + actual_modifiers: KeyModifiers, + shifted_codepoint: Option, + combo: KeyCombo, +) -> bool { + let (actual_code, actual_modifiers) = normalize_key_combo((actual_code, actual_modifiers)); + let (expected_code, expected_modifiers) = normalize_key_combo(combo); + + if actual_modifiers == expected_modifiers + && key_codes_match(actual_code, expected_code, shifted_codepoint) + { + return true; + } + + let actual_without_shift = actual_modifiers.difference(KeyModifiers::SHIFT); + actual_modifiers.contains(KeyModifiers::SHIFT) + && actual_without_shift == expected_modifiers + && shifted_char_matches_expected(actual_code, shifted_codepoint, expected_code) +} + +fn key_codes_match(actual: KeyCode, expected: KeyCode, shifted_codepoint: Option) -> bool { + match (actual, expected) { + (KeyCode::Char(actual), KeyCode::Char(expected)) + if actual.is_ascii_alphabetic() && expected.is_ascii_alphabetic() => + { + actual.eq_ignore_ascii_case(&expected) + } + (KeyCode::Char(actual), KeyCode::Char(expected)) => { + actual == expected + || shifted_char_matches_expected( + KeyCode::Char(actual), + shifted_codepoint, + KeyCode::Char(expected), + ) + } + (actual, expected) => actual == expected, + } +} + +fn shifted_char_matches_expected( + actual_code: KeyCode, + shifted_codepoint: Option, + expected_code: KeyCode, +) -> bool { + let KeyCode::Char(expected) = expected_code else { + return false; + }; + if shifted_codepoint.and_then(char::from_u32) == Some(expected) { + return true; + } + matches!(actual_code, KeyCode::Char(actual) if actual == expected && is_shifted_punctuation(expected)) +} + +fn is_shifted_punctuation(ch: char) -> bool { + matches!( + ch, + '!' | '@' + | '#' + | '$' + | '%' + | '^' + | '&' + | '*' + | '(' + | ')' + | '_' + | '+' + | '{' + | '}' + | '|' + | ':' + | '"' + | '<' + | '>' + | '?' + | '~' + ) +} + +fn is_unmodified_printable(combo: KeyCombo) -> bool { + matches!(combo.0, KeyCode::Char(ch) if !ch.is_control()) + && combo.1.difference(KeyModifiers::SHIFT).is_empty() +} + #[cfg(test)] mod tests { use super::*; - use crate::config::Config; + use crate::{config::Config, input::TerminalKey}; + + fn binding_triggers(bindings: &ActionKeybinds) -> Vec { + bindings + .bindings + .iter() + .map(|binding| binding.trigger) + .collect() + } #[test] - fn parse_simple_char() { + fn parse_simple_char_combo() { assert_eq!( parse_key_combo("v"), Some((KeyCode::Char('v'), KeyModifiers::empty())) @@ -973,475 +1004,300 @@ mod tests { } #[test] - fn parse_ctrl_combo() { + fn parse_shift_tab_as_backtab() { assert_eq!( - parse_key_combo("ctrl+b"), - Some((KeyCode::Char('b'), KeyModifiers::CONTROL)) + parse_key_combo("shift+tab"), + Some((KeyCode::BackTab, KeyModifiers::empty())) ); } #[test] - fn parse_cmd_combo() { + fn parse_named_punctuation() { assert_eq!( - parse_key_combo("cmd+1"), - Some((KeyCode::Char('1'), KeyModifiers::SUPER)) + parse_key_combo("minus"), + Some((KeyCode::Char('-'), KeyModifiers::empty())) + ); + assert_eq!( + parse_key_combo("comma"), + Some((KeyCode::Char(','), KeyModifiers::empty())) + ); + assert_eq!( + parse_key_combo("ampersand"), + Some((KeyCode::Char('&'), KeyModifiers::empty())) ); } #[test] - fn parse_modifier_combo_for_indexed_bindings() { - assert_eq!( - parse_modifier_combo("ctrl+shift"), - Some(KeyModifiers::CONTROL | KeyModifiers::SHIFT) - ); - assert_eq!(parse_modifier_combo("1"), None); - } - - #[test] - fn parse_special_key() { - assert_eq!( - parse_key_combo("enter"), - Some((KeyCode::Enter, KeyModifiers::empty())) - ); - assert_eq!( - parse_key_combo("tab"), - Some((KeyCode::Tab, KeyModifiers::empty())) - ); - assert_eq!( - parse_key_combo("esc"), - Some((KeyCode::Esc, KeyModifiers::empty())) - ); - assert_eq!( - parse_key_combo("left"), - Some((KeyCode::Left, KeyModifiers::empty())) - ); - assert_eq!( - parse_key_combo("alt+right"), - Some((KeyCode::Right, KeyModifiers::ALT)) - ); - } - - #[test] - fn parse_ctrl_shift() { - assert_eq!( - parse_key_combo("ctrl+shift+a"), - Some(( - KeyCode::Char('a'), - KeyModifiers::CONTROL | KeyModifiers::SHIFT - )) - ); - } - - #[test] - fn parse_f_key() { - assert_eq!( - parse_key_combo("f5"), - Some((KeyCode::F(5), KeyModifiers::empty())) - ); - } - - #[test] - fn parse_punctuation_key() { - assert_eq!( - parse_key_combo("ctrl+`"), - Some((KeyCode::Char('`'), KeyModifiers::CONTROL)) - ); - } - - #[test] - fn uppercase_char_implies_shift() { - assert_eq!( - parse_key_combo("D"), - Some((KeyCode::Char('d'), KeyModifiers::SHIFT)) - ); - } - - #[test] - fn explicit_shift_and_uppercase_do_not_double_apply_shift() { - assert_eq!( - parse_key_combo("shift+D"), - Some((KeyCode::Char('d'), KeyModifiers::SHIFT)) - ); - } - - #[test] - fn invalid_keybinding_is_rejected() { - assert_eq!(parse_key_combo("ctrl+foo+bar"), None); - assert_eq!(parse_key_combo("ctrl+"), None); - } - - #[test] - fn default_keybinds_parse() { - let config = Config::default(); + fn prefix_binding_is_not_direct_binding() { + let config: Config = toml::from_str( + r#" +[keys] +next_tab = "prefix+n" +"#, + ) + .unwrap(); let kb = config.keybinds(); - assert_eq!(kb.new_workspace.0, KeyCode::Char('n')); assert_eq!( - kb.rename_workspace, - (KeyCode::Char('n'), KeyModifiers::SHIFT) + binding_triggers(&kb.next_tab), + vec![BindingTrigger::Prefix(( + KeyCode::Char('n'), + KeyModifiers::empty() + ))] ); - assert_eq!( - kb.close_workspace, - (KeyCode::Char('d'), KeyModifiers::SHIFT) - ); - assert_eq!(kb.detach, None); - assert_eq!(kb.previous_agent, None); - assert_eq!(kb.next_agent, None); - assert_eq!(kb.indexed_tabs, None); - assert_eq!(kb.indexed_workspaces, None); - assert_eq!(kb.indexed_agents, None); - assert_eq!(kb.split_vertical.0, KeyCode::Char('v')); - assert_eq!(kb.split_horizontal.0, KeyCode::Char('-')); - assert_eq!(kb.close_pane.0, KeyCode::Char('x')); - assert_eq!(kb.edit_scrollback, None); - assert_eq!(kb.zoom.0, KeyCode::Char('f')); - assert_eq!(kb.resize_mode.0, KeyCode::Char('r')); - assert_eq!(kb.toggle_sidebar.0, KeyCode::Char('b')); - assert!(kb.custom_commands.is_empty()); } #[test] - fn custom_keybinds_from_toml() { - let toml = r#" + fn array_bindings_allow_prefix_and_modified_direct() { + let config: Config = toml::from_str( + r#" +[keys] +next_tab = ["prefix+n", "ctrl+alt+]"] +"#, + ) + .unwrap(); + let kb = config.keybinds(); + assert_eq!( + binding_triggers(&kb.next_tab), + vec![ + BindingTrigger::Prefix((KeyCode::Char('n'), KeyModifiers::empty())), + BindingTrigger::Direct(( + KeyCode::Char(']'), + KeyModifiers::CONTROL | KeyModifiers::ALT + )), + ] + ); + assert_eq!(kb.next_tab.prefix_rhs_label().as_deref(), Some("n")); + } + + #[test] + fn unsafe_direct_printable_binding_is_disabled_with_diagnostic() { + let config: Config = toml::from_str( + r#" +[keys] +new_tab = "c" +close_tab = "X" +"#, + ) + .unwrap(); + let diagnostics = config.collect_diagnostics(); + let keybinds = config.keybinds(); + assert!(keybinds.new_tab.bindings.is_empty()); + assert!(keybinds.close_tab.bindings.is_empty()); + assert!( + diagnostics + .iter() + .any(|diag| diag.contains("unsafe direct keybinding") + && diag.contains("keys.new_tab")) + ); + assert!(diagnostics.iter().any( + |diag| diag.contains("unsafe direct keybinding") && diag.contains("keys.close_tab") + )); + } + + #[test] + fn shifted_letter_binding_matches_uppercase_key_event() { + let bindings = ActionKeybinds::prefix("shift+n"); + assert!(bindings.matches_prefix(&KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT))); + } + + #[test] + fn shifted_tab_inputs_match_backtab_canonical_binding() { + let bindings = ActionKeybinds::prefix("shift+tab"); + assert!( + bindings.matches_prefix_key(TerminalKey::new(KeyCode::BackTab, KeyModifiers::empty())) + ); + assert!( + bindings.matches_prefix_key(TerminalKey::new(KeyCode::BackTab, KeyModifiers::SHIFT)) + ); + assert!(bindings.matches_prefix_key(TerminalKey::new(KeyCode::Tab, KeyModifiers::SHIFT))); + assert!(!ActionKeybinds::prefix("tab") + .matches_prefix_key(TerminalKey::new(KeyCode::Tab, KeyModifiers::SHIFT))); + assert_eq!( + normalize_key_combo((KeyCode::Tab, KeyModifiers::CONTROL | KeyModifiers::SHIFT)), + (KeyCode::BackTab, KeyModifiers::CONTROL) + ); + } + + #[test] + fn format_modified_backtab_keeps_shift_label() { + assert_eq!( + format_key_combo((KeyCode::BackTab, KeyModifiers::CONTROL)), + "ctrl+shift+tab" + ); + assert_eq!( + format_key_combo((KeyCode::BackTab, KeyModifiers::CONTROL | KeyModifiers::ALT)), + "ctrl+alt+shift+tab" + ); + } + + #[test] + fn shifted_punctuation_matches_enhanced_input() { + let help = ActionKeybinds::prefix("?"); + assert!(help.matches_prefix_key(TerminalKey::new(KeyCode::Char('?'), KeyModifiers::SHIFT))); + assert!(help.matches_prefix_key( + TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) + .with_shifted_codepoint('?' as u32) + )); + + let bang = ActionKeybinds::prefix("!"); + assert!(bang.matches_prefix_key( + TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT) + .with_shifted_codepoint('!' as u32) + )); + } + + #[test] + fn prefix_rhs_equal_to_configured_prefix_is_rejected() { + let config: Config = toml::from_str( + r#" [keys] prefix = "ctrl+a" -new_workspace = "c" -rename_workspace = "shift+r" -close_workspace = "ctrl+d" -split_vertical = "s" -split_horizontal = "shift+s" -close_pane = "ctrl+w" -zoom = "z" -resize_mode = "ctrl+r" -toggle_sidebar = "tab" -previous_agent = "alt+a" -next_agent = "alt+d" -edit_scrollback = "e" -focus_pane_left = "alt+h" -focus_pane_right = "alt+right" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let (code, mods) = config.prefix_key(); - assert_eq!(code, KeyCode::Char('a')); - assert_eq!(mods, KeyModifiers::CONTROL); - - let kb = config.keybinds(); - assert_eq!( - kb.new_workspace, - (KeyCode::Char('c'), KeyModifiers::empty()) - ); - assert_eq!( - kb.rename_workspace, - (KeyCode::Char('r'), KeyModifiers::SHIFT) - ); - assert_eq!( - kb.close_workspace, - (KeyCode::Char('d'), KeyModifiers::CONTROL) - ); - assert_eq!(kb.split_vertical.0, KeyCode::Char('s')); - assert_eq!( - kb.split_horizontal, - (KeyCode::Char('s'), KeyModifiers::SHIFT) - ); - assert_eq!(kb.close_pane, (KeyCode::Char('w'), KeyModifiers::CONTROL)); - assert_eq!(kb.zoom.0, KeyCode::Char('z')); - assert_eq!(kb.resize_mode, (KeyCode::Char('r'), KeyModifiers::CONTROL)); - assert_eq!(kb.toggle_sidebar, (KeyCode::Tab, KeyModifiers::empty())); - assert_eq!( - kb.previous_agent, - Some((KeyCode::Char('a'), KeyModifiers::ALT)) - ); - assert_eq!(kb.next_agent, Some((KeyCode::Char('d'), KeyModifiers::ALT))); - assert_eq!( - kb.edit_scrollback, - Some((KeyCode::Char('e'), KeyModifiers::empty())) - ); - assert_eq!( - kb.focus_pane_left, - Some((KeyCode::Char('h'), KeyModifiers::ALT)) - ); - assert_eq!( - kb.focus_pane_right, - Some((KeyCode::Right, KeyModifiers::ALT)) - ); - assert_eq!(kb.focus_pane_down, None); - assert_eq!(kb.focus_pane_up, None); - } - - #[test] - fn legacy_fullscreen_keybind_alias_maps_to_zoom() { - let toml = r#" -[keys] -fullscreen = "z" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let kb = config.keybinds(); - assert_eq!(kb.zoom.0, KeyCode::Char('z')); - } - - #[test] - fn uppercase_keybind_from_toml_flows_into_shift_combo() { - let toml = r#" -[keys] -close_pane = "X" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let kb = config.keybinds(); - assert_eq!(kb.close_pane, (KeyCode::Char('x'), KeyModifiers::SHIFT)); - } - - #[test] - fn invalid_keybinding_produces_diagnostic_and_falls_back() { - let toml = r#" -[keys] -rename_workspace = "wat" -"#; - let config: Config = toml::from_str(toml).unwrap(); +help = "prefix+ctrl+a" +"#, + ) + .unwrap(); let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics - .iter() - .any(|d| d.contains("keys.rename_workspace"))); - assert_eq!( - kb.rename_workspace, - (KeyCode::Char('n'), KeyModifiers::SHIFT) - ); - assert_eq!(kb.rename_workspace_label, "shift+n"); - } - - #[test] - fn duplicate_keybinding_produces_diagnostic_and_falls_back_later_binding() { - let toml = r#" -[keys] -new_workspace = "g" -rename_workspace = "g" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics - .iter() - .any(|d| d.contains("duplicate keybinding"))); - assert_eq!( - kb.new_workspace, - (KeyCode::Char('g'), KeyModifiers::empty()) - ); - assert_eq!( - kb.rename_workspace, - (KeyCode::Char('n'), KeyModifiers::SHIFT) - ); - assert_eq!(kb.rename_workspace_label, "shift+n"); - } - - #[test] - fn duplicate_optional_keybinding_is_disabled_with_diagnostic() { - let toml = r#" -[keys] -new_workspace = "g" -rename_tab = "g" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics - .iter() - .any(|d| d.contains("keys.rename_tab") && d.contains("disabling binding"))); - assert_eq!( - kb.new_workspace, - (KeyCode::Char('g'), KeyModifiers::empty()) - ); - assert_eq!(kb.rename_tab, None); - } - - #[test] - fn custom_command_keybinds_parse_from_toml() { - let toml = r#" -[[keys.command]] -key = "g" -command = "echo hi" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let kb = config.keybinds(); - - assert_eq!(kb.custom_commands.len(), 1); - assert_eq!( - kb.custom_commands[0].key, - (KeyCode::Char('g'), KeyModifiers::empty()) - ); - assert_eq!(kb.custom_commands[0].label, "g"); - assert_eq!(kb.custom_commands[0].command, "echo hi"); - assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Shell); - } - - #[test] - fn pane_custom_command_keybinds_parse_from_toml() { - let toml = r#" -[[keys.command]] -key = "g" -type = "pane" -command = "lazygit" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let kb = config.keybinds(); - - assert_eq!(kb.custom_commands.len(), 1); - assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Pane); - } - - #[test] - fn custom_command_conflicting_with_builtin_is_disabled_with_diagnostic() { - let toml = r#" -[keys] -new_workspace = "g" - -[[keys.command]] -key = "g" -command = "echo hi" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics.iter().any(|d| { - d.contains("duplicate custom keybinding") - && d.contains("keys.command[0].key") - && d.contains("keys.new_workspace") + assert!(config.keybinds().help.bindings.is_empty()); + assert!(diagnostics.iter().any(|diag| { + diag.contains("reserved keybinding") + && diag.contains("keys.help") + && diag.contains("keys.prefix") })); - assert!(kb.custom_commands.is_empty()); - } - #[test] - fn custom_command_conflicting_with_reserved_navigate_key_is_disabled_with_diagnostic() { - let toml = r#" -[[keys.command]] -key = "q" -command = "echo hi" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics.iter().any(|d| { - d.contains("duplicate custom keybinding") - && d.contains("keys.command[0].key") - && d.contains("navigate.quit") - })); - assert!(kb.custom_commands.is_empty()); - } - - #[test] - fn indexed_keybinds_parse_from_toml() { - let toml = r#" -[keys.indexed] -tabs = "ctrl" -workspaces = "ctrl+shift" -agents = "alt" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let kb = config.keybinds(); - - assert_eq!(kb.indexed_tabs, Some(KeyModifiers::CONTROL)); - assert_eq!( - kb.indexed_workspaces, - Some(KeyModifiers::CONTROL | KeyModifiers::SHIFT) - ); - assert_eq!(kb.indexed_agents, Some(KeyModifiers::ALT)); - assert_eq!(kb.indexed_tabs_label.as_deref(), Some("ctrl+1..9")); - } - - #[test] - fn indexed_keybinding_conflict_disables_later_family() { - let toml = r#" -[keys.indexed] -tabs = "ctrl" -workspaces = "ctrl" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert_eq!(kb.indexed_tabs, Some(KeyModifiers::CONTROL)); - assert_eq!(kb.indexed_workspaces, None); - assert!(diagnostics.iter().any(|d| { - d.contains("duplicate indexed keybinding") - && d.contains("keys.indexed.workspaces") - && d.contains("keys.indexed.tabs") - })); - } - - #[test] - fn terminal_direct_keybinding_conflict_disables_later_binding() { - let toml = r#" + let config: Config = toml::from_str( + r#" [keys] -previous_tab = "alt+h" -focus_pane_left = "alt+h" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert_eq!( - kb.previous_tab, - Some((KeyCode::Char('h'), KeyModifiers::ALT)) - ); - assert_eq!(kb.focus_pane_left, None); - assert!(diagnostics - .iter() - .any(|d| { d.contains("keys.focus_pane_left") && d.contains("keys.previous_tab") })); +prefix = "ctrl+a" +help = "prefix+ctrl+b" +"#, + ) + .unwrap(); + assert!(!config.keybinds().help.bindings.is_empty()); } #[test] - fn terminal_direct_keybinding_conflicting_with_prefix_is_disabled() { - let toml = r#" + fn custom_command_prefix_rhs_equal_to_configured_prefix_is_rejected() { + let config: Config = toml::from_str( + r#" [keys] prefix = "ctrl+b" -previous_tab = "ctrl+b" -"#; - let config: Config = toml::from_str(toml).unwrap(); + +[[keys.command]] +key = "prefix+ctrl+b" +command = "echo no" +"#, + ) + .unwrap(); + let diagnostics = config.collect_diagnostics(); + assert!(config.keybinds().custom_commands.is_empty()); + assert!(diagnostics.iter().any(|diag| { + diag.contains("reserved keybinding") && diag.contains("keys.command[0].key") + })); + } + + #[test] + fn direct_custom_printable_binding_is_rejected_as_unsafe() { + let config: Config = toml::from_str( + r#" +[keys] + +[[keys.command]] +key = "g" +command = "echo no" +"#, + ) + .unwrap(); + let diagnostics = config.collect_diagnostics(); + assert!(config.keybinds().custom_commands.is_empty()); + assert!(diagnostics.iter().any(|diag| { + diag.contains("unsafe direct keybinding") && diag.contains("keys.command[0].key") + })); + } + + #[test] + fn direct_custom_binding_conflicting_with_builtin_is_disabled() { + let config: Config = toml::from_str( + r#" +[keys] +new_tab = "ctrl+alt+g" + +[[keys.command]] +key = "ctrl+alt+g" +command = "echo no" +"#, + ) + .unwrap(); + let diagnostics = config.collect_diagnostics(); + let keybinds = config.keybinds(); + assert!(!keybinds.new_tab.bindings.is_empty()); + assert!(keybinds.custom_commands.is_empty()); + assert!(diagnostics.iter().any(|diag| { + diag.contains("duplicate keybinding") && diag.contains("keys.command[0].key") + })); + } + + #[test] + fn prefixed_indexed_bindings_support_modifiers() { + let config: Config = toml::from_str( + r#" +[keys] +switch_workspace = "prefix+shift+1..9" +"#, + ) + .unwrap(); + let kb = config.keybinds(); + assert_eq!(kb.switch_workspace.len(), 9); + assert_eq!( + kb.switch_workspace[0].trigger, + BindingTrigger::Prefix((KeyCode::Char('1'), KeyModifiers::SHIFT)) + ); + assert_eq!(kb.switch_workspace[0].label, "prefix+shift+1"); + } + + #[test] + fn default_keymap_is_prefix_first_and_tab_centered() { + let kb = Config::default().keybinds(); + assert_eq!( + binding_triggers(&kb.next_tab), + vec![BindingTrigger::Prefix(( + KeyCode::Char('n'), + KeyModifiers::empty() + ))] + ); + assert_eq!( + binding_triggers(&kb.previous_tab), + vec![BindingTrigger::Prefix(( + KeyCode::Char('p'), + KeyModifiers::empty() + ))] + ); + assert_eq!(kb.switch_tab.len(), 9); + assert!(kb + .switch_tab + .iter() + .all(|binding| binding.trigger.is_prefix())); + assert!(kb + .new_tab + .bindings + .iter() + .all(|binding| binding.trigger.is_prefix())); + } + + #[test] + fn duplicate_prefix_binding_disables_later_binding() { + let config: Config = toml::from_str( + r#" +[keys] +next_tab = "prefix+n" +new_workspace = "prefix+n" +"#, + ) + .unwrap(); let diagnostics = config.collect_diagnostics(); let kb = config.keybinds(); - - assert_eq!(kb.previous_tab, None); + assert!(kb.next_tab.bindings.is_empty() || kb.new_workspace.bindings.is_empty()); assert!(diagnostics .iter() - .any(|d| { d.contains("keys.previous_tab") && d.contains("keys.prefix") })); - } - - #[test] - fn live_keybinds_reject_invalid_keybinding() { - let config: Config = toml::from_str( - r#" -[keys] -rename_workspace = "wat" -"#, - ) - .unwrap(); - - let diagnostics = config.live_keybinds().unwrap_err(); - assert!(diagnostics - .iter() - .any(|d| d.contains("keys.rename_workspace"))); - } - - #[test] - fn live_keybinds_ignore_non_key_diagnostics() { - let config: Config = toml::from_str( - r#" -[keys] -new_workspace = "g" - -[ui.sound] -done_path = "sounds/missing.mp3" -"#, - ) - .unwrap(); - - let live = config.live_keybinds().unwrap(); - assert_eq!( - live.keybinds.new_workspace, - (KeyCode::Char('g'), KeyModifiers::empty()) - ); + .any(|diag| diag.contains("duplicate keybinding"))); } } diff --git a/src/config/model.rs b/src/config/model.rs index ff3a7b22..10e80417 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Deserializer, Serialize}; -use super::{CommandKeybindConfig, SoundConfig, ThemeConfig, DEFAULT_SCROLLBACK_LIMIT_BYTES}; +use super::{ + BindingConfig, CommandKeybindConfig, SoundConfig, ThemeConfig, DEFAULT_SCROLLBACK_LIMIT_BYTES, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] #[serde(rename_all = "lowercase")] @@ -90,63 +92,81 @@ pub struct LoadedConfig { #[derive(Debug, Deserialize)] #[serde(default)] pub struct KeysConfig { - /// Prefix key to toggle navigate mode (e.g. "ctrl+b", "f12", "esc"). + /// Prefix key to enter prefix mode (e.g. "ctrl+b", "f12", "esc"). pub prefix: String, - /// Create a new workspace. Default: "n" - pub new_workspace: String, - /// Rename the selected workspace. Default: "shift+n" - pub rename_workspace: String, - /// Close the selected workspace. Default: "shift+d" - pub close_workspace: String, - /// Optional explicit detach shortcut in server/client mode. Unset by default. - pub detach: String, - /// Reload config.toml in the running app/server. Unset by default. - pub reload_config: String, - /// Focus the currently visible notification target. Unset by default. - pub open_notification_target: String, + /// Open keybinding help. Default: "prefix+?" + pub help: BindingConfig, + /// Open settings. Default: "prefix+s" + pub settings: BindingConfig, + /// Quit or detach. Default: "prefix+q" + pub quit: BindingConfig, + /// Create a new workspace. Default: "prefix+shift+n" + pub new_workspace: BindingConfig, + /// Rename the selected workspace. Default: "prefix+shift+w" + pub rename_workspace: BindingConfig, + /// Close the selected workspace. Default: "prefix+shift+d" + pub close_workspace: BindingConfig, + /// Open the workspace navigation surface. Default: "prefix+w" + pub workspace_picker: BindingConfig, + /// Optional explicit detach shortcut in server/client mode. Default: "prefix+d". + pub detach: BindingConfig, + /// Reload config.toml in the running app/server. Default: "prefix+shift+r". + pub reload_config: BindingConfig, + /// Focus the currently visible notification target. Default: "prefix+o". + pub open_notification_target: BindingConfig, /// Select the previous workspace. Unset by default. - pub previous_workspace: String, + pub previous_workspace: BindingConfig, /// Select the next workspace. Unset by default. - pub next_workspace: String, + pub next_workspace: BindingConfig, /// Focus the previous agent shown in the agent panel. Unset by default. - pub previous_agent: String, + pub previous_agent: BindingConfig, /// Focus the next agent shown in the agent panel. Unset by default. - pub next_agent: String, - /// Create a new tab in the active workspace. Default: "c" - pub new_tab: String, - /// Rename the active tab. Unset by default. - pub rename_tab: String, - /// Select the previous tab. Unset by default. - pub previous_tab: String, - /// Select the next tab. Unset by default. - pub next_tab: String, - /// Close the active tab. Unset by default. - pub close_tab: String, - /// Rename the focused pane. Unset by default. - pub rename_pane: String, - /// Open the focused pane scrollback in $EDITOR. Unset by default. - pub edit_scrollback: String, - /// Focus the pane to the left in terminal mode. Unset by default. - pub focus_pane_left: String, - /// Focus the pane below in terminal mode. Unset by default. - pub focus_pane_down: String, - /// Focus the pane above in terminal mode. Unset by default. - pub focus_pane_up: String, - /// Focus the pane to the right in terminal mode. Unset by default. - pub focus_pane_right: String, - /// Split pane vertically (side by side). Default: "v" - pub split_vertical: String, - /// Split pane horizontally (stacked). Default: "-" - pub split_horizontal: String, - /// Close the focused pane. Default: "x" - pub close_pane: String, - /// Toggle zoom for the focused pane. Default: "f" + pub next_agent: BindingConfig, + /// Focus an agent by index 1-9. Unset by default. + pub focus_agent: BindingConfig, + /// Create a new tab in the active workspace. Default: "prefix+c" + pub new_tab: BindingConfig, + /// Rename the active tab. Default: "prefix+shift+t". + pub rename_tab: BindingConfig, + /// Select the previous tab. Default: "prefix+p". + pub previous_tab: BindingConfig, + /// Select the next tab. Default: "prefix+n". + pub next_tab: BindingConfig, + /// Switch to tab 1-9. Default: "prefix+1..9". + pub switch_tab: BindingConfig, + /// Switch to workspace 1-9 from prefix mode. Unset by default. + pub switch_workspace: BindingConfig, + /// Close the active tab. Default: "prefix+shift+x". + pub close_tab: BindingConfig, + /// Rename the focused pane. Default: "prefix+shift+p". + pub rename_pane: BindingConfig, + /// Open the focused pane scrollback in $EDITOR. Default: "prefix+e". + pub edit_scrollback: BindingConfig, + /// Focus the pane to the left. Default: "prefix+h". + pub focus_pane_left: BindingConfig, + /// Focus the pane below. Default: "prefix+j". + pub focus_pane_down: BindingConfig, + /// Focus the pane above. Default: "prefix+k". + pub focus_pane_up: BindingConfig, + /// Focus the pane to the right. Default: "prefix+l". + pub focus_pane_right: BindingConfig, + /// Cycle to the next pane. Default: "prefix+tab". + pub cycle_pane_next: BindingConfig, + /// Cycle to the previous pane. Default: "prefix+shift+tab". + pub cycle_pane_previous: BindingConfig, + /// Split pane vertically (side by side). Default: "prefix+v" + pub split_vertical: BindingConfig, + /// Split pane horizontally (stacked). Default: "prefix+minus" + pub split_horizontal: BindingConfig, + /// Close the focused pane. Default: "prefix+x" + pub close_pane: BindingConfig, + /// Toggle zoom for the focused pane. Default: "prefix+z" #[serde(alias = "fullscreen")] - pub zoom: String, - /// Enter resize mode. Default: "r" - pub resize_mode: String, - /// Toggle sidebar collapse. Default: "b" - pub toggle_sidebar: String, + pub zoom: BindingConfig, + /// Enter resize mode. Default: "prefix+r" + pub resize_mode: BindingConfig, + /// Toggle sidebar collapse. Default: "prefix+b" + pub toggle_sidebar: BindingConfig, /// Optional indexed shortcuts expanded over number keys 1-9. pub indexed: IndexedKeysConfig, /// Prefix-mode custom command bindings. @@ -212,33 +232,42 @@ impl Default for KeysConfig { fn default() -> Self { Self { prefix: "ctrl+b".into(), - new_workspace: "n".into(), - rename_workspace: "shift+n".into(), - close_workspace: "shift+d".into(), - detach: "".into(), - reload_config: "".into(), - open_notification_target: "".into(), - previous_workspace: "".into(), - next_workspace: "".into(), - previous_agent: "".into(), - next_agent: "".into(), - new_tab: "c".into(), - rename_tab: "".into(), - previous_tab: "".into(), - next_tab: "".into(), - close_tab: "".into(), - rename_pane: "".into(), - edit_scrollback: "".into(), - focus_pane_left: "".into(), - focus_pane_down: "".into(), - focus_pane_up: "".into(), - focus_pane_right: "".into(), - split_vertical: "v".into(), - split_horizontal: "-".into(), - close_pane: "x".into(), - zoom: "f".into(), - resize_mode: "r".into(), - toggle_sidebar: "b".into(), + help: BindingConfig::one("prefix+?"), + settings: BindingConfig::one("prefix+s"), + quit: BindingConfig::one("prefix+q"), + new_workspace: BindingConfig::one("prefix+shift+n"), + rename_workspace: BindingConfig::one("prefix+shift+w"), + close_workspace: BindingConfig::one("prefix+shift+d"), + workspace_picker: BindingConfig::one("prefix+w"), + detach: BindingConfig::one("prefix+d"), + reload_config: BindingConfig::one("prefix+shift+r"), + open_notification_target: BindingConfig::one("prefix+o"), + previous_workspace: BindingConfig::empty(), + next_workspace: BindingConfig::empty(), + previous_agent: BindingConfig::empty(), + next_agent: BindingConfig::empty(), + focus_agent: BindingConfig::empty(), + new_tab: BindingConfig::one("prefix+c"), + rename_tab: BindingConfig::one("prefix+shift+t"), + previous_tab: BindingConfig::one("prefix+p"), + next_tab: BindingConfig::one("prefix+n"), + switch_tab: BindingConfig::one("prefix+1..9"), + switch_workspace: BindingConfig::empty(), + close_tab: BindingConfig::one("prefix+shift+x"), + rename_pane: BindingConfig::one("prefix+shift+p"), + edit_scrollback: BindingConfig::one("prefix+e"), + focus_pane_left: BindingConfig::one("prefix+h"), + focus_pane_down: BindingConfig::one("prefix+j"), + focus_pane_up: BindingConfig::one("prefix+k"), + focus_pane_right: BindingConfig::one("prefix+l"), + cycle_pane_next: BindingConfig::one("prefix+tab"), + cycle_pane_previous: BindingConfig::one("prefix+shift+tab"), + split_vertical: BindingConfig::one("prefix+v"), + split_horizontal: BindingConfig::one("prefix+minus"), + close_pane: BindingConfig::one("prefix+x"), + zoom: BindingConfig::one("prefix+z"), + resize_mode: BindingConfig::one("prefix+r"), + toggle_sidebar: BindingConfig::one("prefix+b"), indexed: IndexedKeysConfig::default(), command: Vec::new(), } diff --git a/src/main.rs b/src/main.rs index 0814c09e..2120555e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,56 +81,68 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # default_shell = "" [keys] -# Prefix key to enter navigate mode (default: "ctrl+b") +# Prefix key to enter prefix mode (default: "ctrl+b") # Examples: "ctrl+b", "f12", "esc", "-" -# Accepted syntax: plain keys, ctrl/shift/alt/cmd/super modifiers, and special keys like enter/tab/esc/left/right/up/down -# Most reliable bindings are plain keys, ctrl+letter, esc/tab/enter, and function keys. +# Action bindings use explicit syntax: "prefix+n" requires the prefix; +# "ctrl+alt+n" is a direct terminal-mode shortcut. +# Accepted key syntax: plain keys, ctrl/shift/alt/cmd/super modifiers, and special keys like enter/tab/esc/left/right/up/down. +# Named punctuation such as minus, comma, ampersand, plus, and backtick is also accepted. +# Most reliable direct bindings are ctrl+letter, function keys, and explicit modified chords. # alt+..., cmd/super, and punctuation-with-modifiers may depend on your terminal/tmux setup. # prefix = "ctrl+b" -# Navigate-mode actions -# new_workspace = "n" -# rename_workspace = "shift+n" -# close_workspace = "shift+d" +# Prefix-mode actions +# help = "prefix+?" +# settings = "prefix+s" +# quit = "prefix+q" +# detach = "prefix+d" +# reload_config = "prefix+shift+r" +# open_notification_target = "prefix+o" +# workspace_picker = "prefix+w" +# new_workspace = "prefix+shift+n" +# rename_workspace = "prefix+shift+w" +# close_workspace = "prefix+shift+d" # previous_workspace = "" # optional, unset by default # next_workspace = "" # optional, unset by default # previous_agent = "" # optional, unset by default # next_agent = "" # optional, unset by default -# detach = "" # optional explicit detach shortcut in server/client mode -# reload_config = "" # optional shortcut to reload config.toml without restarting -# open_notification_target = "" # optional shortcut to jump to the visible notification target -# new_tab = "c" -# rename_tab = "" # optional, unset by default -# previous_tab = "" # optional, unset by default -# next_tab = "" # optional, unset by default -# close_tab = "" # optional, unset by default -# rename_pane = "" # optional, unset by default -# edit_scrollback = "" # optional, opens focused pane scrollback in $EDITOR -# focus_pane_left = "" # optional, unset by default -# focus_pane_down = "" # optional, unset by default -# focus_pane_up = "" # optional, unset by default -# focus_pane_right = "" # optional, unset by default -# split_vertical = "v" -# split_horizontal = "-" -# close_pane = "x" -# zoom = "f" # legacy alias: fullscreen -# resize_mode = "r" -# toggle_sidebar = "b" +# focus_agent = "" # optional indexed binding, e.g. "prefix+alt+1..9" +# new_tab = "prefix+c" +# rename_tab = "prefix+shift+t" +# previous_tab = "prefix+p" +# next_tab = "prefix+n" +# switch_tab = "prefix+1..9" +# switch_workspace = "" # optional indexed binding, e.g. "prefix+shift+1..9" +# close_tab = "prefix+shift+x" +# rename_pane = "prefix+shift+p" +# edit_scrollback = "prefix+e" +# focus_pane_left = "prefix+h" +# focus_pane_down = "prefix+j" +# focus_pane_up = "prefix+k" +# focus_pane_right = "prefix+l" +# cycle_pane_next = "prefix+tab" +# cycle_pane_previous = "prefix+shift+tab" +# split_vertical = "prefix+v" +# split_horizontal = "prefix+minus" +# close_pane = "prefix+x" +# zoom = "prefix+z" # legacy alias: fullscreen +# resize_mode = "prefix+r" +# toggle_sidebar = "prefix+b" -# Custom prefix-mode commands. Press prefix, then the configured key. +# Custom commands use the same binding syntax. # type = "shell" runs detached in the background. # type = "pane" opens a temporary pane and closes it when the command exits. # [[keys.command]] -# key = "g" +# key = "prefix+g" # type = "pane" # command = "lazygit" -# Optional modifier-only shortcuts expanded over number keys 1-9. -# Empty means disabled. Examples: "ctrl", "ctrl+shift", "alt". +# Legacy indexed shortcut config is still parsed for compatibility. +# Prefer switch_tab, switch_workspace, and focus_agent for new configs. # [keys.indexed] -# tabs = "" # e.g. "ctrl" makes ctrl+1..9 switch tabs -# workspaces = "" # e.g. "ctrl+shift" makes ctrl+shift+1..9 switch workspaces -# agents = "" # e.g. "alt" makes alt+1..9 focus agent rows +# tabs = "" # e.g. "ctrl" makes ctrl+1..9 switch tabs directly +# workspaces = "" # e.g. "ctrl+shift" makes ctrl+shift+1..9 switch workspaces directly +# agents = "" # e.g. "alt" makes alt+1..9 focus agent rows directly [ui] # Sidebar width (auto-scaled based on workspace names, this sets the default) @@ -304,6 +316,7 @@ fn main() -> io::Result<()> { println!(" herdr update"); println!(" herdr server stop"); println!(" herdr server reload-config"); + println!(" herdr config ..."); println!(" herdr workspace ..."); println!(" herdr tab ..."); println!(" herdr agent ..."); @@ -328,6 +341,10 @@ fn main() -> io::Result<()> { "herdr server reload-config", "Reload config.toml in the running server", ), + ( + "herdr config reset-keys", + "Back up config.toml and remove custom keybindings", + ), ( "herdr workspace ", "Workspace helpers over the socket API", @@ -409,6 +426,7 @@ fn main() -> io::Result<()> { "remote-client-bridge", "update", "status", + "config", "workspace", "pane", "wait", diff --git a/src/remote.rs b/src/remote.rs index 5ab7e6fd..089772b4 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -284,6 +284,7 @@ fn prepare_remote_herdr(target: &str) -> io::Result { ))); } warn_if_remote_bin_not_on_path(target)?; + maybe_copy_local_keybindings_to_remote(target, &remote_herdr)?; Ok(remote_herdr) } @@ -652,6 +653,131 @@ fn confirm_remote_install( Ok(()) } +fn maybe_copy_local_keybindings_to_remote( + target: &str, + remote_herdr: &RemoteHerdr, +) -> io::Result<()> { + let Some(config_toml) = local_keybindings_config_toml()? else { + return Ok(()); + }; + let remote_config_path = remote_config_path(target, remote_herdr)?; + if remote_path_exists(target, &remote_config_path)? { + return Ok(()); + } + if !confirm_remote_keybindings_copy(target)? { + return Ok(()); + } + upload_remote_config(target, &remote_config_path, config_toml.as_bytes()) +} + +fn local_keybindings_config_toml() -> io::Result> { + let path = crate::config::config_path(); + if !path.exists() { + return Ok(None); + } + let content = fs::read_to_string(&path)?; + Ok(local_keybindings_config_toml_from_str(&content)) +} + +fn local_keybindings_config_toml_from_str(content: &str) -> Option { + let mut value = content.parse::().ok()?; + let root = value.as_table_mut()?; + let mut keys = root.remove("keys")?.as_table()?.clone(); + keys.remove("command"); + if keys.is_empty() { + return None; + } + + let mut out = toml::map::Map::new(); + out.insert("keys".to_string(), toml::Value::Table(keys)); + toml::to_string_pretty(&toml::Value::Table(out)).ok() +} + +fn remote_config_path(target: &str, remote_herdr: &RemoteHerdr) -> io::Result { + let command = format!("{} --help", remote_herdr.shell_path); + let output = ssh_output(target, &command)?; + if !output.status.success() { + return Err(command_failed("remote config path probe failed", &output)); + } + let stdout = String::from_utf8_lossy(&output.stdout); + stdout + .lines() + .find_map(|line| line.trim().strip_prefix("Config: ").map(str::to_string)) + .ok_or_else(|| io::Error::other("remote config path probe did not print a Config line")) +} + +fn remote_path_exists(target: &str, path: &str) -> io::Result { + let command = format!("test -e {}", shell_quote(path)); + let output = ssh_output(target, &command)?; + Ok(output.status.success()) +} + +fn confirm_remote_keybindings_copy(target: &str) -> io::Result { + if !io::stdin().is_terminal() { + return Ok(false); + } + eprintln!("remote Herdr config is not present on {target}."); + eprintln!( + "Herdr can copy your local [keys] settings so the remote server uses the same keybindings." + ); + eprintln!("Custom command keybindings are not copied because they run on the remote host."); + eprint!("Copy local Herdr keybindings to {target}? [Y/n] "); + io::stderr().flush()?; + + let mut answer = String::new(); + io::stdin().read_line(&mut answer)?; + let answer = answer.trim().to_ascii_lowercase(); + Ok(!(answer == "n" || answer == "no")) +} + +fn upload_remote_config(target: &str, path: &str, content: &[u8]) -> io::Result<()> { + let script = format!( + r#"dest={} +dir="${{dest%/*}}" +mkdir -p "$dir" +umask 077 +tmp="${{dest}}.tmp.$$" +cat > "$tmp" +mv "$tmp" "$dest" +"#, + shell_quote(path) + ); + + let mut child = Command::new("ssh") + .arg("-T") + .arg(target) + .arg(format!("sh -eu -c {}", shell_quote(&script))) + .stdin(Stdio::piped()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|err| { + io::Error::new( + err.kind(), + format!("failed to start ssh config upload: {err}"), + ) + })?; + + let copy_result = if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(content) + } else { + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "ssh config upload stdin missing", + )) + }; + let status = child.wait()?; + copy_result?; + + if status.success() { + Ok(()) + } else { + Err(io::Error::other(format!( + "remote config upload exited with {status}" + ))) + } +} + fn install_remote_herdr( target: &str, remote_herdr: &RemoteHerdr, @@ -1095,6 +1221,43 @@ mod tests { ); } + #[test] + fn local_keybindings_config_extracts_only_keys_without_commands() { + let toml = r#" +[theme] +name = "one-dark" + +[keys] +prefix = "ctrl+a" +new_tab = ["prefix+c", "ctrl+alt+n"] +next_tab = "prefix+n" + +[keys.indexed] +tabs = "ctrl" + +[[keys.command]] +key = "prefix+g" +type = "pane" +command = "lazygit" +"#; + + let copied = local_keybindings_config_toml_from_str(toml).expect("copied key config"); + assert!(copied.contains("[keys]")); + assert!(copied.contains("prefix = \"ctrl+a\"")); + assert!(copied.contains("prefix+c")); + assert!(copied.contains("ctrl+alt+n")); + assert!(copied.contains("[keys.indexed]")); + assert!(copied.contains("tabs = \"ctrl\"")); + assert!(!copied.contains("one-dark")); + assert!(!copied.contains("lazygit")); + assert!(!copied.contains("[[keys.command]]")); + } + + #[test] + fn local_keybindings_config_returns_none_without_keys() { + assert!(local_keybindings_config_toml_from_str("[theme]\nname = \"one-dark\"\n").is_none()); + } + #[test] fn remote_bridge_command_uses_installed_binary() { let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { diff --git a/src/ui.rs b/src/ui.rs index ed52660e..050cf194 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -23,7 +23,7 @@ use self::dialogs::{render_confirm_close_overlay, render_rename_overlay}; use self::keybind_help::render_keybind_help_overlay; use self::menus::{ render_context_menu, render_global_launcher_menu, render_navigate_overlay, - render_resize_overlay, + render_prefix_overlay, render_resize_overlay, }; use self::mobile::{ compute_mobile_header_hit_areas, is_mobile_width, mobile_switcher_max_scroll_for_height, @@ -311,6 +311,7 @@ pub fn render(app: &AppState, frame: &mut Frame) { render_mobile_panel(app, frame, frame.area()) } Mode::Navigate => render_navigate_overlay(app, frame, terminal_area), + Mode::Prefix => render_prefix_overlay(app, frame, terminal_area), Mode::Resize => render_resize_overlay(app, frame, terminal_area), Mode::ConfirmClose => render_confirm_close_overlay(app, frame, terminal_area), Mode::ContextMenu => { @@ -850,6 +851,22 @@ mod tests { assert_eq!(lines[0].1.spans[1].style.bg, Some(palette.surface0)); } + #[test] + fn release_notes_config_inline_code_uses_nonbreaking_spaces() { + let palette = Palette::catppuccin(); + let lines = release_notes_lines("- After: `new_tab = \"prefix+c\"`", &palette); + + assert_eq!(lines.len(), 1); + assert_eq!( + lines[0].1.spans[2].content.as_ref(), + "new_tab\u{00a0}=\u{00a0}\"prefix+c\"" + ); + assert_eq!( + line_text(&lines[0].1).replace('\u{00a0}', " "), + " • After: new_tab = \"prefix+c\"" + ); + } + #[test] fn release_notes_preview_lines_show_update_steps() { let palette = Palette::catppuccin(); @@ -895,6 +912,28 @@ mod tests { assert_eq!(line_text(&lines[2].1), "▏ second"); } + #[test] + fn prefix_mode_renders_prefix_indicator() { + let mut app = crate::app::state::AppState::test_new(); + app.mode = Mode::Prefix; + app.view.terminal_area = ratatui::layout::Rect::new(0, 0, 60, 4); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(60, 4)) + .expect("test terminal"); + + terminal + .draw(|frame| render_prefix_overlay(&app, frame, app.view.terminal_area)) + .expect("draw prefix overlay"); + + let rendered = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(rendered.contains("PREFIX")); + } + #[test] fn keybind_help_shows_unset_for_optional_actions() { let app = crate::app::state::AppState::test_new(); @@ -917,13 +956,19 @@ mod tests { assert!(workspace_tab.contains(&("unset".to_string(), "next workspace"))); assert!(workspace_tab.contains(&("unset".to_string(), "previous agent"))); assert!(workspace_tab.contains(&("unset".to_string(), "next agent"))); - assert!(workspace_tab.contains(&("unset".to_string(), "rename tab"))); - assert!(workspace_tab.contains(&("unset".to_string(), "previous tab"))); - assert!(workspace_tab.contains(&("unset".to_string(), "next tab"))); - assert!(workspace_tab.contains(&("unset".to_string(), "close tab"))); - assert!(panes.contains(&("unset".to_string(), "focus pane left"))); - assert!(panes.contains(&("unset".to_string(), "focus pane down"))); - assert!(panes.contains(&("unset".to_string(), "focus pane up"))); - assert!(panes.contains(&("unset".to_string(), "focus pane right"))); + assert!(workspace_tab.contains(&("unset".to_string(), "focus agent 1-9"))); + assert!(workspace_tab.contains(&("unset".to_string(), "switch workspace 1-9"))); + assert!(panes + .iter() + .any(|(key, label)| key == "prefix+h" && *label == "focus pane left")); + assert!(panes + .iter() + .any(|(key, label)| key == "prefix+j" && *label == "focus pane down")); + assert!(panes + .iter() + .any(|(key, label)| key == "prefix+k" && *label == "focus pane up")); + assert!(panes + .iter() + .any(|(key, label)| key == "prefix+l" && *label == "focus pane right")); } } diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs index e7a76155..fe3d7582 100644 --- a/src/ui/keybind_help.rs +++ b/src/ui/keybind_help.rs @@ -14,8 +14,31 @@ use super::widgets::{ }; use crate::app::AppState; -fn optional_keybind_label(label: &Option) -> String { - label.clone().unwrap_or_else(|| "unset".to_string()) +fn keybind_label(bindings: &crate::config::ActionKeybinds) -> String { + bindings.label().unwrap_or_else(|| "unset".to_string()) +} + +fn indexed_label(bindings: &[crate::config::IndexedKeybind]) -> String { + if bindings.is_empty() { + "unset".to_string() + } else if bindings.len() == 9 { + let first = &bindings[0].label; + if first.ends_with('1') { + format!("{}1..9", first.trim_end_matches('1')) + } else { + bindings + .iter() + .map(|binding| binding.label.clone()) + .collect::>() + .join(" / ") + } + } else { + bindings + .iter() + .map(|binding| binding.label.clone()) + .collect::>() + .join(" / ") + } } pub(super) fn keybind_help_groups( @@ -29,12 +52,16 @@ pub(super) fn keybind_help_groups( vec![ ( crate::config::format_key_combo((app.prefix_code, app.prefix_mods)), - "navigate mode", + "prefix mode", ), - ("prefix + ?".to_string(), "keybinds"), + (keybind_label(&kb.help), "keybinds"), + (keybind_label(&kb.settings), "settings"), + (keybind_label(&kb.quit), "quit"), + (keybind_label(&kb.detach), "detach from server"), + (keybind_label(&kb.reload_config), "reload config"), ( - optional_keybind_label(&kb.reload_config_label), - "reload config", + keybind_label(&kb.open_notification_target), + "open notification target", ), ], )); @@ -47,85 +74,47 @@ pub(super) fn keybind_help_groups( ("h j k l / arrows".to_string(), "move focus"), ("tab / shift+tab".to_string(), "cycle pane"), ("enter".to_string(), "open workspace"), - ("s".to_string(), "settings"), - ("q".to_string(), "quit"), + ("1..9".to_string(), "switch workspace"), ], )); - let mut workspace_tab = vec![ - (kb.new_workspace_label.clone(), "new workspace"), - (kb.rename_workspace_label.clone(), "rename workspace"), - (kb.close_workspace_label.clone(), "close workspace"), - ( - optional_keybind_label(&kb.open_notification_target_label), - "open notification target", - ), - ( - optional_keybind_label(&kb.previous_workspace_label), - "previous workspace", - ), - ( - optional_keybind_label(&kb.next_workspace_label), - "next workspace", - ), - ( - optional_keybind_label(&kb.indexed_workspaces_label), - "switch workspace 1-9", - ), - ( - optional_keybind_label(&kb.previous_agent_label), - "previous agent", - ), - (optional_keybind_label(&kb.next_agent_label), "next agent"), - ( - optional_keybind_label(&kb.indexed_agents_label), - "focus agent 1-9", - ), - (kb.new_tab_label.clone(), "new tab"), - (optional_keybind_label(&kb.rename_tab_label), "rename tab"), - ( - optional_keybind_label(&kb.previous_tab_label), - "previous tab", - ), - (optional_keybind_label(&kb.next_tab_label), "next tab"), - ( - optional_keybind_label(&kb.indexed_tabs_label), - "switch tab 1-9", - ), - (optional_keybind_label(&kb.close_tab_label), "close tab"), + let workspace_tab = vec![ + (keybind_label(&kb.workspace_picker), "workspace navigation"), + (keybind_label(&kb.new_workspace), "new workspace"), + (keybind_label(&kb.rename_workspace), "rename workspace"), + (keybind_label(&kb.close_workspace), "close workspace"), + (keybind_label(&kb.previous_workspace), "previous workspace"), + (keybind_label(&kb.next_workspace), "next workspace"), + (indexed_label(&kb.switch_workspace), "switch workspace 1-9"), + (keybind_label(&kb.previous_agent), "previous agent"), + (keybind_label(&kb.next_agent), "next agent"), + (indexed_label(&kb.focus_agent), "focus agent 1-9"), + (keybind_label(&kb.new_tab), "new tab"), + (keybind_label(&kb.rename_tab), "rename tab"), + (keybind_label(&kb.previous_tab), "previous tab"), + (keybind_label(&kb.next_tab), "next tab"), + (indexed_label(&kb.switch_tab), "switch tab 1-9"), + (keybind_label(&kb.close_tab), "close tab"), ]; - if let Some(label) = &kb.detach_label { - workspace_tab.insert(3, (label.clone(), "detach from server")); - } groups.push(("workspaces / tabs", workspace_tab)); let panes = vec![ - (kb.split_vertical_label.clone(), "split vertical"), - (kb.split_horizontal_label.clone(), "split horizontal"), - (kb.close_pane_label.clone(), "close pane"), - (optional_keybind_label(&kb.rename_pane_label), "rename pane"), + (keybind_label(&kb.split_vertical), "split vertical"), + (keybind_label(&kb.split_horizontal), "split horizontal"), + (keybind_label(&kb.close_pane), "close pane"), + (keybind_label(&kb.rename_pane), "rename pane"), + (keybind_label(&kb.edit_scrollback), "edit scrollback"), + (keybind_label(&kb.zoom), "zoom pane"), + (keybind_label(&kb.resize_mode), "resize mode"), + (keybind_label(&kb.toggle_sidebar), "toggle sidebar"), + (keybind_label(&kb.focus_pane_left), "focus pane left"), + (keybind_label(&kb.focus_pane_down), "focus pane down"), + (keybind_label(&kb.focus_pane_up), "focus pane up"), + (keybind_label(&kb.focus_pane_right), "focus pane right"), + (keybind_label(&kb.cycle_pane_next), "cycle pane next"), ( - optional_keybind_label(&kb.edit_scrollback_label), - "edit scrollback", - ), - (kb.zoom_label.clone(), "zoom pane"), - (kb.resize_mode_label.clone(), "resize mode"), - (kb.toggle_sidebar_label.clone(), "toggle sidebar"), - ( - optional_keybind_label(&kb.focus_pane_left_label), - "focus pane left", - ), - ( - optional_keybind_label(&kb.focus_pane_down_label), - "focus pane down", - ), - ( - optional_keybind_label(&kb.focus_pane_up_label), - "focus pane up", - ), - ( - optional_keybind_label(&kb.focus_pane_right_label), - "focus pane right", + keybind_label(&kb.cycle_pane_previous), + "cycle pane previous", ), ]; groups.push(("panes", panes)); diff --git a/src/ui/menus.rs b/src/ui/menus.rs index 7a074c68..d17aa897 100644 --- a/src/ui/menus.rs +++ b/src/ui/menus.rs @@ -9,6 +9,12 @@ use ratatui::{ use super::widgets::{panel_contrast_fg, render_panel_shell}; use crate::app::AppState; +fn prefix_rhs_label(bindings: &crate::config::ActionKeybinds) -> String { + bindings + .prefix_rhs_label() + .unwrap_or_else(|| "unset".to_string()) +} + fn render_bottom_bar(frame: &mut Frame, area: Rect, line: Line<'_>, bg: ratatui::style::Color) { frame.render_widget(Clear, area); let buf = frame.buffer_mut(); @@ -18,6 +24,38 @@ fn render_bottom_bar(frame: &mut Frame, area: Rect, line: Line<'_>, bg: ratatui: frame.render_widget(Paragraph::new(line), area); } +pub(super) fn render_prefix_overlay(app: &AppState, frame: &mut Frame, area: Rect) { + let key = Style::default() + .fg(app.palette.accent) + .add_modifier(Modifier::BOLD); + let dim = Style::default().fg(app.palette.overlay0); + let mode_style = Style::default() + .fg(panel_contrast_fg(&app.palette)) + .bg(app.palette.accent) + .add_modifier(Modifier::BOLD); + + let workspace_picker = prefix_rhs_label(&app.keybinds.workspace_picker); + let help = prefix_rhs_label(&app.keybinds.help); + let prefix = crate::config::format_key_combo((app.prefix_code, app.prefix_mods)); + + let line = Line::from(vec![ + Span::styled(" PREFIX ", mode_style), + Span::raw(" "), + Span::styled("esc", key), + Span::styled(" cancel ", dim), + Span::styled(prefix, key), + Span::styled(" send prefix ", dim), + Span::styled(workspace_picker, key), + Span::styled(" workspace nav ", dim), + Span::styled(help, key), + Span::styled(" keybinds", dim), + ]); + + let overlay_y = area.y + area.height.saturating_sub(1); + let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); + render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg); +} + pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { let key = Style::default() .fg(app.palette.accent) @@ -30,6 +68,15 @@ pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: R .add_modifier(Modifier::BOLD); let kb = &app.keybinds; + let new_tab = prefix_rhs_label(&kb.new_tab); + let split_vertical = prefix_rhs_label(&kb.split_vertical); + let split_horizontal = prefix_rhs_label(&kb.split_horizontal); + let close_pane = prefix_rhs_label(&kb.close_pane); + let zoom = prefix_rhs_label(&kb.zoom); + let resize = prefix_rhs_label(&kb.resize_mode); + let help = prefix_rhs_label(&kb.help); + let settings = prefix_rhs_label(&kb.settings); + let quit = prefix_rhs_label(&kb.quit); let line = Line::from(vec![ Span::styled(" NAVIGATE ", mode_style), Span::raw(" "), @@ -39,23 +86,23 @@ pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: R Span::styled(" ws ", dim), Span::styled("⇥", key), Span::styled(" pane ", dim), - Span::styled(kb.new_tab_label.as_str(), key), + Span::styled(new_tab, key), Span::styled(" new tab ", dim), - Span::styled(kb.split_vertical_label.as_str(), key), + Span::styled(split_vertical, key), Span::styled(" split│ ", dim), - Span::styled(kb.split_horizontal_label.as_str(), key), + Span::styled(split_horizontal, key), Span::styled(" split─ ", dim), - Span::styled(kb.close_pane_label.as_str(), key), + Span::styled(close_pane, key), Span::styled(" close ", dim), - Span::styled(kb.zoom_label.as_str(), key), + Span::styled(zoom, key), Span::styled(" zoom ", dim), - Span::styled(kb.resize_mode_label.as_str(), key), + Span::styled(resize, key), Span::styled(" resize ", dim), - Span::styled("?", key), + Span::styled(help, key), Span::styled(" keybinds ", dim), - Span::styled("s", key), + Span::styled(settings, key), Span::styled(" settings ", dim), - Span::styled("q", key), + Span::styled(quit, key), Span::styled(" quit", dim), ]); diff --git a/src/ui/onboarding.rs b/src/ui/onboarding.rs index 104f737f..8829acc7 100644 --- a/src/ui/onboarding.rs +++ b/src/ui/onboarding.rs @@ -78,7 +78,7 @@ fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { .add_modifier(Modifier::BOLD), ), Span::styled( - " enters navigate mode · ", + " enters prefix mode · ", Style::default().fg(app.palette.overlay1), ), Span::styled( diff --git a/src/ui/panes.rs b/src/ui/panes.rs index d9108575..550d34ce 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -355,7 +355,10 @@ fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) { Line::from(vec![ Span::styled(" Press ", Style::default().fg(p.overlay0)), Span::styled( - app.keybinds.new_workspace_label.to_string(), + app.keybinds + .new_workspace + .label() + .unwrap_or_else(|| "unset".to_string()), Style::default().fg(p.accent).add_modifier(Modifier::BOLD), ), Span::styled(" to create one", Style::default().fg(p.overlay0)), diff --git a/src/ui/release_notes.rs b/src/ui/release_notes.rs index 9d069828..e6d20885 100644 --- a/src/ui/release_notes.rs +++ b/src/ui/release_notes.rs @@ -281,7 +281,15 @@ fn release_notes_inline_spans<'a>( let (code, after_end) = after_start.split_at(end); width += code.chars().count(); if !code.is_empty() { - spans.push(Span::styled(code.to_string(), code_style)); + // Keep short config examples together when Paragraph wraps. + // Snippets like `new_tab = "prefix+c"` read poorly when they + // split at the spaces around `=` in narrow announcement modals. + let display_code = if code.contains('=') { + code.replace(' ', "\u{00a0}") + } else { + code.to_string() + }; + spans.push(Span::styled(display_code, code_style)); } remaining = &after_end[1..]; }