feat: add keybinding v2

refs #154
refs #201
refs #202
refs #219
This commit is contained in:
Ogulcan Celik
2026-05-20 18:55:41 +03:00
parent be66f33d2a
commit 836fd7e585
28 changed files with 2727 additions and 1844 deletions
+27 -3
View File
@@ -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
+3
View File
@@ -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
+22 -16
View File
@@ -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
+5
View File
@@ -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/"
}
@@ -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
@@ -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"
```
@@ -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.
+88 -11
View File
@@ -17,6 +17,7 @@ SECTION_RE = re.compile(r"^##\s+(?:\[(?P<bracketed>[^\]]+)\]|(?P<plain>.+?))\s*$
VERSION_WITH_DATE_RE = re.compile(r"^(?P<version>.+?)\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",
+78
View File
@@ -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(
{
+12 -11
View File
@@ -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!(),
}
}
+40 -10
View File
@@ -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);
+457 -268
View File
File diff suppressed because it is too large Load Diff
+165 -9
View File
@@ -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();
+22 -17
View File
@@ -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);
+9 -77
View File
@@ -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(),
+115 -1
View File
@@ -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<CommandOutcome> {
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<i32> {
}
}
fn run_config_command(args: &[String]) -> std::io::Result<i32> {
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<i32> {
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::<toml::Value>() {
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::<toml::Value>() {
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");
+7 -5
View File
@@ -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
}
+94
View File
@@ -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::<toml::Value>(&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);
}
}
+1012 -1156
View File
File diff suppressed because it is too large Load Diff
+108 -79
View File
@@ -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(),
}
+52 -34
View File
@@ -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 <subcommand> ...");
println!(" herdr workspace <subcommand> ...");
println!(" herdr tab <subcommand> ...");
println!(" herdr agent <subcommand> ...");
@@ -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 <subcommand>",
"Workspace helpers over the socket API",
@@ -409,6 +426,7 @@ fn main() -> io::Result<()> {
"remote-client-bridge",
"update",
"status",
"config",
"workspace",
"pane",
"wait",
+163
View File
@@ -284,6 +284,7 @@ fn prepare_remote_herdr(target: &str) -> io::Result<RemoteHerdr> {
)));
}
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<Option<String>> {
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<String> {
let mut value = content.parse::<toml::Value>().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<String> {
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<bool> {
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<bool> {
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 {
+54 -9
View File
@@ -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::<String>();
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"));
}
}
+66 -77
View File
@@ -14,8 +14,31 @@ use super::widgets::{
};
use crate::app::AppState;
fn optional_keybind_label(label: &Option<String>) -> 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::<Vec<_>>()
.join(" / ")
}
} else {
bindings
.iter()
.map(|binding| binding.label.clone())
.collect::<Vec<_>>()
.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));
+56 -9
View File
@@ -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),
]);
+1 -1
View File
@@ -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(
+4 -1
View File
@@ -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)),
+9 -1
View File
@@ -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..];
}