Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
[#599](https://github.com/devswha/herdr-web-ui/pull/599))

### Changed
- The default mobile terminal key bar puts Esc, Tab and Ctrl+C first, before the held modifiers
and arrows. Saved layouts keep their order. ([#607](https://github.com/devswha/herdr-web-ui/pull/607))
- **Settings → Shortcuts** explains which keys control the app and marks known browser/OS
reservations without changing saved bindings. ([#607](https://github.com/devswha/herdr-web-ui/pull/607))
- **Settings → Terminal → Clipboard from a pane** is on again: vim, tmux and Claude Code copy to
your clipboard from a pane without a trip to Settings. It is also on for anyone 0.4.1 left off
without asking (it saved the switch with any other setting). Turn it off if a pane runs output
Expand All @@ -34,6 +38,9 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
([#610](https://github.com/devswha/herdr-web-ui/pull/610))

### Fixed
- Command palette shortcut hints follow changed bindings and disappear for disabled ones.
App shortcuts also recognize physical letter keys on non-Latin layouts outside IME composition.
([#607](https://github.com/devswha/herdr-web-ui/pull/607))
- macOS Safari direct terminal input preserves Korean syllables when the input method
replaces text without emitting composition events, including after switching from English.
([#524](https://github.com/devswha/herdr-web-ui/pull/524) by @suho-han)
Expand Down
17 changes: 13 additions & 4 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -949,7 +949,7 @@ One set for both themes: the card is island black wherever it shows.
and Escape closes. IME candidate, commit and cancel keys stay with composition. Buttons reached
with Tab keep their native activation, and arrow navigation scrolls the selected result into view.
- Actions cover new workspace, lens/sidebar/theme, settings, notifications, lock and refresh, with
`.kbd` hints where a global shortcut exists.
`.kbd` hints resolved from this browser's shortcut settings; disabled bindings have no hint.

### Settings dialog
- A Settings shortcut opened over a file preview places Settings above it (`--z-modal + 2`, one
Expand All @@ -973,7 +973,7 @@ One set for both themes: the card is island black wherever it shows.
(panes open in, show thinking, chat width, chat font size and family; then **Composer**: Enter
sends, suggestion chip; then **Quick replies**), **Terminal** (font size and family, wheel
speed, input mode, Key bar), **Alerts**, **Voice input**, **Subscription usage**,
**Shortcuts** (the complete platform-resolved list), **Phone & devices** (the phone address,
**Shortcuts** (the platform-resolved global bindings), **Phone & devices** (the phone address,
Keep screen on, Install; then paired devices), **Remote PCs**, **About** (Updates, herdr,
the repository links). A button that points at Updates opens the dialog on About.
- Every page is built from the same parts (`components/SettingsControls.tsx`). A group is an
Expand Down Expand Up @@ -1021,7 +1021,7 @@ One set for both themes: the card is island black wherever it shows.
- xterm has `scrollback: 0`; wheel/touch gestures reach herdr's alternate-screen scrollback. The
mount clips its own gutter and hides the unused xterm scrollbar.
- Terminal banners stack top-right for ended, reconnecting, observe and held-draft review states.
- The mobile key bar is Esc, Tab, sticky Ctrl/Alt/Shift, Enter, arrows and `^C`;
- The mobile key bar starts with Esc, Tab and `^C`, then sticky Ctrl/Alt/Shift, Enter and arrows;
it never steals typing focus. Modifiers remain highlighted until toggled off,
leaving the pane/lens or disconnecting; each exposes `aria-pressed`.
- That is the default row. Settings → Terminal → Key bar can add, remove and reorder every
Expand Down Expand Up @@ -1110,7 +1110,16 @@ One set for both themes: the card is island black wherever it shows.
- Touch targets grow to `--touch-target`; fields stay `--fs-input` where mobile zoom is a risk.
- `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. The settings table is the discoverable source of the complete mapping.
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.
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
reordering are separate from global bindings; they must not intercept terminal input.
Global actions target the selected pane, and a handled shortcut must never also reach its PTY.
- On touch screens app actions stay in buttons and menus, including the palette in More on phones.
The key bar sends terminal input only; it is not an app-action toolbar. Hardware keyboards use
the same global bindings on phones and desktops.
- `document.title` is `<pane title> · herdr` while selected, otherwise `herdr web ui`.

### Accepted debt
Expand Down
18 changes: 16 additions & 2 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ whether the test was sent or failed; a missing subscription offers **Turn alerts

On a phone:
- Agent panes open in the chat.
- The terminal gets a key bar above the keyboard (Esc, Tab, Ctrl, Alt, Shift, Enter, arrows, Ctrl+C).
- The terminal gets a key bar above the keyboard (Esc, Tab, Ctrl+C, Ctrl, Alt, Shift, Enter, arrows).
Ctrl, Alt and Shift stay held until tapped again and combine with each other.
Enable direct terminal typing with the keyboard button to use them with letters and
symbols. Herdr encodes each chord for the program's keyboard protocol; legacy
Expand Down Expand Up @@ -448,7 +448,7 @@ A message holds the event (`install` or `update`), a random ID made on this PC,

## Keyboard shortcuts

`Mod` is **⌘** on macOS and **Ctrl** elsewhere. Every shortcut adds Shift, so the terminal keeps its own Ctrl keys.
`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:

| Shortcut | Action |
| --- | --- |
Expand All @@ -459,6 +459,20 @@ A message holds the event (`install` or `update`), a random ID made on this PC,
| `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 text fields, Mod+Shift+arrows keep their text-selection behavior.
List and tab controls also have focus-local keys; those do not become global terminal shortcuts.

On a phone, use the header and More menu for app actions; Command palette is available there
without a shortcut. The terminal key bar is configured separately under **Settings → Terminal**.
An external keyboard uses the same app bindings as a desktop.

Enter sends and Shift+Enter adds a line. Settings can switch sending to Mod+Enter.

## How it works
Expand Down
Binary file added docs/screenshots/shortcuts/key-bar-phone.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/shortcuts/settings-phone.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 9 additions & 4 deletions docs/terminal-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ user previously chose direct typing; a fine pointer uses direct typing. The key
button switches modes too, on a touch screen only: a desktop changes the mode in Settings.
Settings → Shortcuts can change each app action's Mod+Shift key or return its keys to the
terminal. The hold-to-dictate binding remains fixed. Conflicts include the legacy New workspace
alias, and Reset restores the defaults. Browser-reserved keys still depend on the browser and
installed-app mode.
alias, and Reset restores the defaults. Palette hints reflect the saved bindings and disappear
when a binding is off. Known browser/OS reservations are marked; delivery still depends on the
browser, OS and installed-app mode. These settings belong to this browser, not the remote PC.
Global bindings do not replace focus-local list/tab controls or text-field selection keys.

The input line keeps its unsent text per `paneStorageId(machineId, paneId)`, including across
lens changes and reloads. Storage refusal falls back to memory for view changes. Pending sends
Expand Down Expand Up @@ -48,12 +50,15 @@ left out whole and the notice says some input was left out, also when nothing el
draft is replayed on reconnect. The input-line and chat Send buttons preserve an active composition, and the key bar
waits for composition to finish. Leaving the input clears its composition guard.

The key bar defaults to Esc, Tab, Ctrl, Alt, Shift, Enter, the arrows and ^C.
The key bar defaults to Esc, Tab, ^C, Ctrl, Alt, Shift, Enter and the arrows. Esc, Tab and ^C
come first so they stay reachable without scrolling on narrow phones.
Settings → Terminal → Key bar → Edit key bar opens the complete list: add or remove keys, move each key up or
down, and register a custom combination such as Ctrl+W. The catalog includes editing keys and
F1–F12; a custom combination can use any single printable character, including space and `+`.
Existing extra-key preferences migrate to the same visible order. Restore defaults returns the
original bar; an empty list stays empty. The keyboard mode button stays first on touch screens.
default bar; an empty list stays empty. Saved custom layouts keep their order.
The keyboard mode button stays first on touch screens. App actions belong in the header and
menus, not this terminal-input row.
Ctrl, Alt and Shift stay held until tapped again and can be combined. Removing a modifier button
clears its held state immediately. Ordinary key buttons use the held modifiers, while saved
combinations send exactly their configured modifiers without changing the held state.
Expand Down
144 changes: 142 additions & 2 deletions scripts/key-bar-customization-demo-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const app = mkdtempSync(join(tmpdir(), "herdr-key-bar-demo-"));
const evidence = process.env.UI_EVIDENCE_DIR;
const SETTINGS_KEY = "herdr-web-ui:settings";
const MIGRATED_KEYS = ["direct", "Escape", "Tab", "BackTab", "Control", "Alt", "Shift", "Enter", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown", "ctrl-c", "ctrl-d", "ctrl-z", "pipe", "tilde", "slash"];
const DEFAULT_KEY_BAR_KEYS = ["direct", "Escape", "Tab", "ctrl-c", "Control", "Alt", "Shift", "Enter", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];

interface InputFrame { type: "input" | "keys"; pane_id: string; text?: string; keys?: string[] }
const framesOf = (page: Page): Promise<InputFrame[]> => page.evaluate(() => (window as unknown as { keyBarFrames: InputFrame[] }).keyBarFrames);
Expand Down Expand Up @@ -271,9 +272,148 @@ try {
dialog = await openSettings(page);
await dialog.locator(".key-bar-settings").getByRole("button", { name: "Restore defaults", exact: true }).tap();
await closeSettings(page, dialog);
assert.deepEqual(await keysOf(page), ["direct", "Escape", "Tab", "Control", "Alt", "Shift", "Enter", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "ctrl-c"]);
assert.deepEqual(await keysOf(page), DEFAULT_KEY_BAR_KEYS);
for (const width of [320, 390]) {
await page.setViewportSize({ width, height: 844 });
await settled(page);
const controlC = await page.evaluate(() => {
const bar = document.querySelector<HTMLElement>(".key-bar");
const key = bar?.querySelector<HTMLElement>('[data-key="ctrl-c"]');
if (!bar || !key) return null;
bar.scrollLeft = 0;
const barRect = bar.getBoundingClientRect();
const keyRect = key.getBoundingClientRect();
return {
visibleAtStart: getComputedStyle(bar).display !== "none"
&& keyRect.left >= barRect.left
&& keyRect.right <= barRect.left + bar.clientWidth,
scrollLeft: bar.scrollLeft,
};
});
assert.deepEqual(controlC, { visibleAtStart: true, scrollLeft: 0 }, `Ctrl+C is visible without horizontal scrolling at ${width}px`);
if (evidence && width === 390) {
mkdirSync(evidence, { recursive: true });
await appFaces(page);
await page.screenshot({ path: join(evidence, "key-bar-defaults-390.png") });
}
}

await page.setViewportSize({ width: 1280, height: 844 });
await settled(page);
const palette = page.getByRole("dialog", { name: "Command palette", exact: true });
const openSidebarAction = async (): Promise<Locator> => {
await page.keyboard.press("ControlOrMeta+Shift+k");
await palette.waitFor({ timeout: 5_000 });
await palette.getByRole("searchbox", { name: "Search panes and actions", exact: true }).fill("Toggle sidebar");
const action = palette.getByRole("option").filter({ hasText: "Toggle sidebar" });
await action.waitFor({ state: "visible", timeout: 5_000 });
return action;
};
const closePalette = async (): Promise<void> => {
await page.keyboard.press("Escape");
await palette.waitFor({ state: "hidden", timeout: 5_000 });
};
const modifierLabel = await page.evaluate(() => /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent) ? "⌘" : "Ctrl");
const expectSidebarHint = async (action: Locator, key: string): Promise<void> => {
const hint = action.locator(".palette-shortcut");
await hint.waitFor({ state: "visible", timeout: 5_000 });
assert.equal(await hint.getAttribute("aria-label"), `${modifierLabel} + Shift + ${key}`);
assert.deepEqual(await hint.locator("kbd").allTextContents(), [modifierLabel, "Shift", key]);
};
const openShortcutSettings = async (): Promise<Locator> => {
await page.keyboard.press("ControlOrMeta+Shift+Comma");
const settings = page.getByRole("dialog", { name: "Settings", exact: true });
await settings.waitFor({ timeout: 5_000 });
await openSettingsPage(page, "Shortcuts");
return settings;
};
let action = await openSidebarAction();
await expectSidebarHint(action, "B");
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 });
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");
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 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 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");
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 });
if (evidence) {
mkdirSync(evidence, { recursive: true });
await sidebarShortcut.scrollIntoViewIfNeeded();
await appFaces(page);
await page.screenshot({ path: join(evidence, "shortcuts-settings-defaults.png") });
await page.setViewportSize({ width: 390, height: 844 });
await settled(page);
await dialog.locator(".settings-body").evaluate((node) => { node.scrollTop = 0; });
await page.screenshot({ path: join(evidence, "shortcuts-settings-phone.png") });
await page.setViewportSize({ width: 1280, height: 844 });
await settled(page);
}
await closeSettings(page, dialog);
action = await openSidebarAction();
await expectSidebarHint(action, "B");
if (evidence) {
await appFaces(page);
await page.screenshot({ path: join(evidence, "command-palette-shortcut-defaults.png") });
}
await closePalette();

const dispatchPhysicalK = async (isComposing: boolean): Promise<void> => {
await input.focus();
await input.evaluate((node, composing) => {
const mac = /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent);
node.dispatchEvent(new KeyboardEvent("keydown", {
key: "ㅏ",
code: "KeyK",
ctrlKey: !mac,
metaKey: mac,
shiftKey: true,
isComposing: composing,
bubbles: true,
cancelable: true,
}));
}, isComposing);
};
const beforePhysicalShortcut = (await framesOf(page)).length;
await dispatchPhysicalK(false);
await palette.waitFor({ state: "visible", timeout: 5_000 });
await settled(page);
assert.equal((await framesOf(page)).length, beforePhysicalShortcut, "a non-Latin physical Mod+Shift+K opens the palette without sending terminal input");
await closePalette();
await dispatchPhysicalK(true);
await settled(page);
assert.equal(await palette.count(), 0, "the composing equivalent does not open the command palette");
assert.deepEqual(errors, []);
console.log("PASS an empty key list keeps the keyboard mode toggle usable, persists, and can restore the defaults");
console.log("PASS restoring defaults keeps Ctrl+C visible; shortcut hints update, disable and reset; physical non-Latin Mod+Shift+K opens the palette without terminal input, but composition does not");

// The frames above go to an agent pane, which the demo never types into. Its shell must
// answer a chord as a terminal would: the demo has no herdr to turn the names into keys.
Expand Down
Loading