Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ The app is only a bridge: herdr owns every pty, scrollback and agent state.
- Component CSS is colocated and uses tokens only: no color literals, no `!important`. `DESIGN.md` must match the token values in `src/styles.css`, so update both together (`src/conventions.test.ts` enforces both rules).
- Endless animations use `steps()` or `var(--ease-pulse)` (`src/motion.test.ts` enforces it).
- i18n: the English string is the key and must be a string literal. Every new `t("…")` needs an entry in `src/lib/i18n.ko.ts`, `i18n.ja.ts` and `i18n.zh.ts`; `i18n.test.ts` fails on a missing, unused or untranslated entry.
- Shortcuts are Mod+Shift+key so the pty keeps Ctrl+key. To add one, update `SHORTCUTS`, `KEY_TO_ID` and the switch in `src/lib/shortcuts.ts`.
- Default shortcuts are Mod+Shift+key so the pty keeps Ctrl+key. Explicit device-local overrides retain their exact modifiers and warn about terminal/browser conflicts before saving. To add one, update `SHORTCUTS`, `KEY_TO_ID` and the switch in `src/lib/shortcuts.ts`.
- Icons come from lucide-react only; brand marks live in `AgentMark.tsx`. When icon files change, bump the `?v=` query in `index.html` and `CACHE_NAME` in `public/sw.js` together (`src/conventions.test.ts` holds the icon hash).
- UI wording: "New workspace", not "New session". "Session" means the herdr server session or an agent's history (`src/conventions.test.ts` enforces it).
- There is no linter or formatter. Through `tsconfig.tools.json`, `bun run typecheck` also checks
Expand Down
21 changes: 15 additions & 6 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -835,7 +835,7 @@ One set for both themes: the card is island black wherever it shows.
fitted inside each rendered pane; layout rectangles are never used as PTY dimensions.
- Plain right-click can be passed to a mouse-reporting terminal when enabled for that pane.
Modified clicks, pane frames and non-reporting/read-only terminals still open the menu.
Native app shortcuts stay Mod+Shift in the browser, so Ctrl+key continues to reach the PTY.
Default app shortcuts use Mod+Shift in the browser, so Ctrl+key continues to reach the PTY unless the user explicitly assigns that combination.

### Worktree dialog (`.worktree-modal`)
- From a workspace row's menu, as herdr's prefix+shift+g: **New worktree** asks for the branch
Expand Down Expand Up @@ -1626,12 +1626,21 @@ The values are the ones in `src/styles.css`: `--motion-scale` is the panel motio
sizes its font; landscape does not inflate text. A pressed button dims to `0.55` at once in place
of the amber `:active` box; a list row (sidebar, agent, menu, sheet, Settings page) fills instead.
- `prefers-reduced-motion` is honored. Lucide/inline SVG decoration is hidden from assistive tech.
- Global shortcuts use the convention **Mod+Shift+key**: Mod is Command on Apple platforms and Ctrl
elsewhere. Settings lists the global bindings and allows changing their final key or disabling
them, with duplicate assignments blocked and known browser/OS reservations warned about.
Reservations vary by browser and installed-app mode; a warning is not a guarantee of delivery.
- Default global shortcuts use **Mod+Shift+key**: Mod is Command on Apple platforms and Ctrl
elsewhere. Settings records exact modifier combinations into a preview, with explicit Save/Cancel.
A key dropdown and custom Latin-letter/digit/comma/key-name field with modifier checkboxes work
without a hardware keyboard. Ambiguous symbol and non-Latin manual entries cannot be saved;
the hardware recorder resolves their physical-key identity.
Duplicate exact combinations, including fixed hold-to-dictate, block Save. Hardware recording
also checks other bindings using the captured physical event; modifier edits retain that event
while manual key edits discard it. Manual entry cannot check layout-specific aliases. Defaults
and legacy strings retain physical digit/comma priority; exact objects match first. Restoration and disabling
are separate controls. Expanded editors own their keys before global capture listeners run.
Known browser/OS reservations and text/terminal overrides show warnings. Reservations vary by
browser and installed-app mode; manual editing cannot guarantee delivery.
Bindings belong to this browser, not the remote PC or pane. Hold-to-dictate remains fixed.
- Text-field selection and IME composition keep their keys. Focus-local list/tab navigation and
- Default and legacy-string text-field arrow selection and IME composition keep their keys. Exact object bindings
may replace native text or terminal keys. Focus-local list/tab navigation and
reordering are separate from global bindings; they must not intercept terminal input.
Global actions target the selected pane, and the original handled chord must never also reach its PTY.
Paging scrolls herdr history when present; an explicit valid zero-history result instead sends
Expand Down
5 changes: 5 additions & 0 deletions changelog.d/shortcut-recorder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Changed
- Settings → Shortcuts records exact key and modifier combinations with a preview and explicit
Save/Cancel. A manual key and modifier editor works without a hardware keyboard; defaults,
disabled bindings and existing Mod+Shift settings remain supported. Terminal letter chords
and known browser reservations warn before saving.
1 change: 1 addition & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ bun scripts/sticky-modifiers-regression.ts # mobile held keys through real legac
bun scripts/key-bar-customization-demo-regression.ts # mobile key layout, saved combinations and migration on demo fixtures
bun scripts/key-bar-hold-repeat-regression.ts # phone touch-arrow taps, holds, swipes and sticky Ctrl on demo fixtures
bun scripts/settings-pages-demo-regression.ts # every Settings page on a 390px and a 320px phone, and Back stepping out of the dialog
bun scripts/shortcut-recorder-demo-regression.ts # exact shortcut recording, preview/Save, manual touch editing and composer isolation on fictional demo fixtures
bun scripts/chat-browser-qa.ts # chat lens end to end
bun scripts/output-browser-qa.ts # terminal output flow control end to end
bun scripts/math-browser-qa.ts # chat math: KaTeX loads with the first expression
Expand Down
38 changes: 28 additions & 10 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@ The snapshot is the last one a browser reported to this server, not a list of de

## Keyboard shortcuts

`Mod` is **⌘** on Apple platforms and **Ctrl** elsewhere. Global app shortcuts add Shift, so the terminal keeps its own Ctrl keys. These are the defaults:
`Mod` is **⌘** on Apple platforms and **Ctrl** elsewhere. Default global app shortcuts add Shift, so the terminal keeps its own Ctrl keys. These are the defaults:

| Shortcut | Action |
| --- | --- |
Expand All @@ -496,15 +496,33 @@ The snapshot is the last one a browser reported to this server, not a list of de
| `Mod+Shift+↑` / `↓` | Previous / next pane |
| `Mod+Shift+,` | Settings |

**Settings → Shortcuts** changes the final key of each app binding or turns it off. Settings
are saved in this browser, not on the remote PC. The command palette shows the current bindings
and omits disabled ones. Reset shortcuts restores the defaults. Hold-to-dictate
(`Mod+Shift+Space`) remains fixed.

Known browser/OS reservations are marked, but other combinations can also be intercepted by
your browser, OS or extensions. Installed-app mode may behave differently. While an IME is
composing, keys stay with it. In a text field with text, Mod+Shift+arrows keep their text-selection
behavior; from an empty one, such as the chat's empty message box, they switch panes.
**Settings → Shortcuts** records a key with its exact Ctrl, Command/Meta, Alt and Shift modifiers.
Click the current binding, press a combination, check the preview, then **Save** or **Cancel**.
Nothing changes until Save. Saving an unchanged manual preview keeps the existing binding and its aliases. Escape cancels; plain Tab and Shift+Tab move focus. To bind these
keys, or without a hardware keyboard, choose **Edit**, select a key (or type a Latin letter, ASCII
digit, comma, or named key), and check the modifiers. Accented Latin letters, Home/End,
Page Up/Down and function keys are supported in the manual editor. Symbols and non-Latin
characters whose identity depends on the physical keyboard cannot be entered manually: record
them with your keyboard instead. Recording uses the base digit or comma for shifted symbols,
and the physical Latin-letter position on non-Latin layouts; Latin accented letters keep their
own character. Modifier-only, dead-key and IME events are not recorded.

Settings are saved in this browser, not on the remote PC. Existing saved Mod+Shift bindings keep
working with their original physical digit/comma aliases and strict Mod+Shift matching. New exact
object bindings take precedence and preserve Latin letters even on digit keys. Recording also
checks the physical event against other default and legacy bindings; manual entry has no
physical key code and cannot check layout-specific aliases. The command palette shows current
bindings and omits disabled ones. Each row can restore
its default or turn the binding off; **Reset shortcuts** restores all defaults. Exact duplicate
combinations, including the fixed hold-to-dictate binding (`Mod+Shift+Space`), block Save.

Known browser/OS reservations show a warning, but other combinations can also be intercepted by
your browser, OS or extensions. Some keys never reach the page; manual editing cannot bypass
those reservations. Installed-app mode may behave differently. Bare keys, Shift-only bindings
and Ctrl combinations can replace typing, native text editing or terminal commands; choose them
with care. While an IME is composing, keys stay with it. Default Mod+Shift+arrows keep their
text-selection behavior in text fields with text, as do legacy string overrides; from an empty field they switch panes; new exact object arrow
bindings take precedence.
List and tab controls also have focus-local keys; those do not become global terminal shortcuts.
On a focused tab, Alt+Shift+Left/Right reorders it and Shift+F10 opens its menu. On the terminal
scrollbar, arrows move a line, Page Up/Down a viewport minus the configured overlap, and Home/End the start/end of history.
Expand Down
1 change: 1 addition & 0 deletions scripts/ci-browser.sh
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ run_script scripts/pane-paging-demo-regression.ts
run_script scripts/key-bar-customization-demo-regression.ts
run_script scripts/key-bar-hold-repeat-regression.ts
run_script scripts/settings-pages-demo-regression.ts
run_script scripts/shortcut-recorder-demo-regression.ts
run_script scripts/portal-regression.ts
run_script scripts/deep-link-demo-regression.ts
run_script scripts/chat-history-browser-qa.ts
Expand Down
3 changes: 2 additions & 1 deletion scripts/ci-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ describe("browser lane", () => {
it("summarizes every script and exits zero on success without enabling local evidence", async () => {
const { text, code, evidence } = await lane();
expect(code).toBe(0);
expect(rows(text)).toHaveLength(49);
expect(rows(text)).toHaveLength(50);
expect(rows(text).map((row) => row.script)).toContain("scripts/chat-virtual-browser-qa.ts");
expect(rows(text).map((row) => row.script)).toContain("scripts/pwa-update-browser-qa.ts");
expect(rows(text).map((row) => row.script)).toContain("scripts/portal-regression.ts");
Expand All @@ -78,6 +78,7 @@ describe("browser lane", () => {
expect(rows(text).map((row) => row.script)).toContain("scripts/pending-pane-switch-regression.ts");
expect(rows(text).map((row) => row.script)).toContain("scripts/lan-exposure-notice-regression.ts");
expect(rows(text).map((row) => row.script)).toContain("scripts/chat-fallback-browser-qa.ts");
expect(rows(text).map((row) => row.script)).toContain("scripts/shortcut-recorder-demo-regression.ts");
expect(rows(text).map((row) => row.script)).toContain("scripts/file-preview-text-browser-qa.ts");
expect(rows(text).map((row) => row.script)).toContain("scripts/virtual-source-browser-qa.ts");
expect(rows(text).map((row) => row.script)).toContain("scripts/repo-diff-browser-qa.ts");
Expand Down
29 changes: 16 additions & 13 deletions scripts/key-bar-customization-demo-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,40 +332,43 @@ try {
await closePalette();

dialog = await openShortcutSettings();
let sidebarShortcut = dialog.getByRole("combobox", { name: "Toggle sidebar", exact: true });
await sidebarShortcut.selectOption("x");
await page.waitForFunction((key) => JSON.parse(localStorage.getItem(key)!).shortcutOverrides["toggle-sidebar"] === "x", SETTINGS_KEY, { timeout: 5_000 });
const sidebarGroup = () => dialog.getByRole("group", { name: "Toggle sidebar", exact: true });
let sidebarShortcut = sidebarGroup().getByRole("button", { name: "Record shortcut for Toggle sidebar", exact: true });
const editSidebarShortcut = async (key: string): Promise<void> => {
await sidebarGroup().getByRole("button", { name: "Edit shortcut for Toggle sidebar", exact: true }).click();
await sidebarGroup().getByRole("combobox", { name: "Shortcut key", exact: true }).selectOption(key);
await sidebarGroup().getByRole("button", { name: "Save", exact: true }).click();
};
await editSidebarShortcut("x");
await page.waitForFunction((key) => JSON.parse(localStorage.getItem(key)!).shortcutOverrides["toggle-sidebar"]?.key === "x", SETTINGS_KEY, { timeout: 5_000 });
await closeSettings(page, dialog);
action = await openSidebarAction();
await expectSidebarHint(action, "X");
await closePalette();
await page.reload();
await ready(page);
const savedOverrides = (await settingsOf(page)).shortcutOverrides as Record<string, string | null>;
assert.equal(savedOverrides["toggle-sidebar"], "x", "a shortcut rebinding survives reload");
const savedOverrides = (await settingsOf(page)).shortcutOverrides as Record<string, { key: string } | null>;
assert.equal(savedOverrides["toggle-sidebar"]?.key, "x", "a shortcut rebinding survives reload");
action = await openSidebarAction();
await expectSidebarHint(action, "X");
await closePalette();

dialog = await openShortcutSettings();
sidebarShortcut = dialog.getByRole("combobox", { name: "Toggle sidebar", exact: true });
await sidebarShortcut.selectOption("off");
await sidebarGroup().getByRole("button", { name: "Disable shortcut for Toggle sidebar", exact: true }).click();
await page.waitForFunction((key) => JSON.parse(localStorage.getItem(key)!).shortcutOverrides["toggle-sidebar"] === null, SETTINGS_KEY, { timeout: 5_000 });
await closeSettings(page, dialog);
action = await openSidebarAction();
assert.equal(await action.locator(".palette-shortcut").count(), 0, "disabling a shortcut removes its CommandPalette hint");
await closePalette();

dialog = await openShortcutSettings();
sidebarShortcut = dialog.getByRole("combobox", { name: "Toggle sidebar", exact: true });
assert.match(await sidebarShortcut.locator('option[value="t"]').innerText(), /Reserved/);
await sidebarShortcut.selectOption("t");
await editSidebarShortcut("t");
await dialog.getByText(`Your browser or operating system may intercept ${modifierLabel}+⇧+T.`, { exact: true }).waitFor({ timeout: 5_000 });
assert.equal(((await settingsOf(page)).shortcutOverrides as Record<string, string>)["toggle-sidebar"], "t", "a reservation warns without silently changing the configured key");
assert.equal(((await settingsOf(page)).shortcutOverrides as Record<string, { key: string }>)["toggle-sidebar"]?.key, "t", "a reservation warns without silently changing the configured key");
await dialog.getByRole("button", { name: "Reset shortcuts", exact: true }).click();
await page.waitForFunction((key) => Object.keys(JSON.parse(localStorage.getItem(key)!).shortcutOverrides).length === 0, SETTINGS_KEY, { timeout: 5_000 });
sidebarShortcut = dialog.getByRole("combobox", { name: "Toggle sidebar", exact: true });
await page.waitForFunction(() => (document.querySelector<HTMLSelectElement>('.settings-dialog select[aria-label="Toggle sidebar"]')?.value ?? "") === "default", undefined, { timeout: 5_000 });
sidebarShortcut = sidebarGroup().getByRole("button", { name: "Record shortcut for Toggle sidebar", exact: true });
assert.equal(await sidebarShortcut.innerText(), `${modifierLabel}+⇧+B`);
if (evidence) {
mkdirSync(evidence, { recursive: true });
await sidebarShortcut.scrollIntoViewIfNeeded();
Expand Down
Loading