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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
back automatically. Available on supporting bridges, for interact connections only.
([#368](https://github.com/devswha/herdr-web-ui/pull/368) by @WOULDU-pres)

- The terminal's key bar on a touch screen has an **Alt** key beside Ctrl. Like Ctrl it holds for
one key: Alt then a letter, Backspace or Enter sends ESC before it, and Alt then an arrow sends
Alt+arrow. **Settings → Appearance → Key bar** turns Alt off or adds Shift+Tab, Home/End,
PgUp/PgDn, Ctrl+D, Ctrl+Z, `|`, `~` and `/`, each in a fixed place in the row. Esc, Tab, Ctrl,
the arrows and Ctrl+C stay as they were. ([#487](https://github.com/devswha/herdr-web-ui/pull/487))

### Fixed
- On macOS, a gjc pane's chat finds its session from the terminal breadcrumb gjc leaves. Before,
the process start time it is matched by was read as UTC, so on any other time zone every fresh
Expand Down
4 changes: 3 additions & 1 deletion docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,9 @@ 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, arrows, Ctrl+C).
- The terminal gets a key bar above the keyboard (Esc, Tab, Ctrl, Alt, arrows, Ctrl+C).
**Settings → Appearance → Key bar** adds or removes Alt, Shift+Tab, Home/End, PgUp/PgDn,
Ctrl+D, Ctrl+Z, `|`, `~` and `/`.
- Dragging the terminal scrolls the real herdr pane.
- **Settings → Phone → Keep screen on** keeps the screen awake while a terminal or chat
pane is open. It is off by default, releases when the app is hidden, and resumes when
Expand Down
8 changes: 8 additions & 0 deletions docs/terminal-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ Control sequences are counted as discarded, never saved for later execution. No
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 always has Esc, Tab, Ctrl, the arrows and ^C. Settings → Appearance → Key bar adds
Alt (on by default), Shift+Tab, Home/End, PgUp/PgDn, ^D, ^Z, `|`, `~` and `/`, each in a fixed
place in the row. Ctrl and Alt are one-shot: an armed Alt puts ESC before the next character
(Alt+Backspace, Alt+Enter) and adds the Alt modifier to an arrow, Home, End or Page key
(`CSI 1;3D`); armed together they send ESC and the control code. A paste or a report the
terminal answers with passes through and leaves Alt armed. Neither applies to the input line
or the chat composer, which send their text as typed.

On macOS, Cmd+Left and Cmd+Right in direct typing send Ctrl+A and Ctrl+E, moving to the
beginning and end of the input line in shells and agents that use those bindings.
Pending IME text is sent first. Additional modifiers retain xterm's behavior; the input line
Expand Down
49 changes: 47 additions & 2 deletions src/components/KeyBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Keyboard } from "lucide-react";

import "./KeyBar.css";

import type { KeyBarKey } from "../lib/keys.ts";
import type { KeyBarExtra, KeyBarKey } from "../lib/keys.ts";
import { useT } from "../lib/i18n.ts";

export type { KeyBarKey };
Expand All @@ -14,6 +14,10 @@ export interface KeyBarProps {
onKey: (key: KeyBarKey) => void;
ctrlArmed: boolean;
onToggleCtrl: () => void;
altArmed: boolean;
onToggleAlt: () => void;
/** the optional keys chosen in Settings, drawn in their fixed places */
extras: readonly KeyBarExtra[];
/** on a touch screen: whether the keyboard types straight into the terminal (else the input line) */
directTyping?: boolean;
onToggleDirect?: () => void;
Expand Down Expand Up @@ -79,13 +83,27 @@ export const ARROWS: ReadonlyArray<{ key: KeyBarKey; label: string; direction: D
{ key: "ArrowRight", label: "Right", direction: "right" },
];

/** Settings' name for each optional key: its cap, and a spoken name where the cap does not read as one. */
export const EXTRA_KEY_CAPS: Record<KeyBarExtra, { cap: string; label?: string }> = {
alt: { cap: "Alt" },
"shift-tab": { cap: "⇧Tab", label: "Shift Tab" },
"home-end": { cap: "Home End" },
"page-up-down": { cap: "PgUp PgDn", label: "Page up and Page down" },
"ctrl-d": { cap: "^D", label: "Control D" },
"ctrl-z": { cap: "^Z", label: "Control Z" },
pipe: { cap: "|" },
tilde: { cap: "~" },
slash: { cap: "/" },
};

/**
* Touch key bar under the terminal. Keys are tabIndex -1 on purpose: they exist
* for touch, a hardware keyboard already has all of them. Hence role="group", not
* toolbar: a toolbar promises arrow-key navigation between items, which these skip.
*/
export function KeyBar({ onKey, ctrlArmed, onToggleCtrl, directTyping, onToggleDirect, disabled }: KeyBarProps) {
export function KeyBar({ onKey, ctrlArmed, onToggleCtrl, altArmed, onToggleAlt, extras, directTyping, onToggleDirect, disabled }: KeyBarProps) {
const t = useT();
const has = (extra: KeyBarExtra): boolean => extras.includes(extra);
return (
<div className="key-bar" role="group" aria-label={t("Terminal keys")}>
{/* first: on a narrow cover screen the row scrolls, and the mode toggle must not be the key cut off */}
Expand All @@ -100,17 +118,44 @@ export function KeyBar({ onKey, ctrlArmed, onToggleCtrl, directTyping, onToggleD
<Key disabled={disabled} dataKey="Tab" onPress={() => onKey("Tab")}>
Tab
</Key>
{has("shift-tab") && (
<Key disabled={disabled} dataKey="BackTab" label={t("Shift Tab")} onPress={() => onKey("BackTab")}>
⇧Tab
</Key>
)}
<Key disabled={disabled} dataKey="Control" pressed={ctrlArmed} onPress={onToggleCtrl}>
Ctrl
</Key>
{has("alt") && (
<Key disabled={disabled} dataKey="Alt" pressed={altArmed} onPress={onToggleAlt}>
Alt
</Key>
)}
{ARROWS.map((arrow) => (
<Key disabled={disabled} key={arrow.key} dataKey={arrow.key} label={t(arrow.label)} onPress={() => onKey(arrow.key)}>
<Chevron direction={arrow.direction} />
</Key>
))}
{has("home-end") && (
<>
<Key disabled={disabled} dataKey="Home" onPress={() => onKey("Home")}>Home</Key>
<Key disabled={disabled} dataKey="End" onPress={() => onKey("End")}>End</Key>
</>
)}
{has("page-up-down") && (
<>
<Key disabled={disabled} dataKey="PageUp" label={t("Page up")} onPress={() => onKey("PageUp")}>PgUp</Key>
<Key disabled={disabled} dataKey="PageDown" label={t("Page down")} onPress={() => onKey("PageDown")}>PgDn</Key>
</>
)}
<Key disabled={disabled} dataKey="ctrl-c" label={t("Control C")} onPress={() => onKey("ctrl-c")}>
^C
</Key>
{has("ctrl-d") && <Key disabled={disabled} dataKey="ctrl-d" label={t("Control D")} onPress={() => onKey("ctrl-d")}>^D</Key>}
{has("ctrl-z") && <Key disabled={disabled} dataKey="ctrl-z" label={t("Control Z")} onPress={() => onKey("ctrl-z")}>^Z</Key>}
{has("pipe") && <Key disabled={disabled} dataKey="pipe" onPress={() => onKey("pipe")}>|</Key>}
{has("tilde") && <Key disabled={disabled} dataKey="tilde" onPress={() => onKey("tilde")}>~</Key>}
{has("slash") && <Key disabled={disabled} dataKey="slash" onPress={() => onKey("slash")}>/</Key>}
</div>
);
}
25 changes: 24 additions & 1 deletion src/components/PaneTerminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import "@xterm/xterm/css/xterm.css";
import "./PaneTerminal.css";

import { HerdrSocket } from "../lib/ws.ts";
import { controlCode, ctrlEnterSequence, isPrintable, keySequence, modifyOtherKeysLevel, type KeyBarKey } from "../lib/keys.ts";
import { altSequence, controlCode, ctrlEnterSequence, isPrintable, keySequence, modifyOtherKeysLevel, type KeyBarKey } from "../lib/keys.ts";
import { EMPTY_DRAFT, applyToDraft, draftIsEmpty, type InputDraft } from "../lib/draft.ts";
import { messageQueues } from "../lib/messageQueue.ts";
import { heldCountShown, heldOpenAtFold, heldOpenOnFocus, heldRefocusDue, heldRowError, heldRowsFold, heldRowsHidden, heldToggleShown, SHORT_PHONE_QUERY } from "../lib/heldRows.ts";
Expand Down Expand Up @@ -163,6 +163,8 @@ export function PaneTerminal({
const setComposing = useCallback((active: boolean) => { composingRef.current = active; setComposingState(active); }, []);
const ctrlRef = useRef(false);
const [ctrlArmed, setCtrlArmed] = useState(false);
const altRef = useRef(false);
const [altArmed, setAltArmed] = useState(false);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// observe mode: the ref is what onData and the resize listeners read mid-stream
const observeRef = useRef(false);
// a mirrored pane (no terminal attach on its PC): the grid is the pane's own in herdr, adopted like an observer's
Expand Down Expand Up @@ -868,6 +870,13 @@ export function PaneTerminal({
setCtrlArmed(false);
input = controlCode(data) ?? data;
}
// after Ctrl, so an armed pair sends ESC + the control code (Ctrl+Alt+key)
const alt = altRef.current ? altSequence(input) : null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (alt !== null) {
altRef.current = false;
setAltArmed(false);
input = alt;
}
if (socket.sendInput(current, input)) return;
// A closed socket, an attachment still opening, or a failed synchronous send:
// keep printable input for explicit review, never replay it automatically.
Expand Down Expand Up @@ -1164,6 +1173,11 @@ export function PaneTerminal({
draftPaneRef.current = paneId;
term.reset();
modifyOtherKeysRef.current = 0;
// a one-shot Ctrl or Alt armed for the pane that was open does not reach the next pane's first key
ctrlRef.current = false;
setCtrlArmed(false);
altRef.current = false;
setAltArmed(false);
if (!paneId) return;
try {
fit?.fit();
Expand Down Expand Up @@ -1207,6 +1221,14 @@ export function PaneTerminal({
if (!inputLineRef.current) termRef.current?.focus();
}, []);

const toggleAlt = useCallback(() => {
if (composingRef.current) return;
const armed = !altRef.current;
altRef.current = armed;
setAltArmed(armed);
if (!inputLineRef.current) termRef.current?.focus();
}, []);

// ask the server for the role change; the role-ack handler applies the local
// consequences (stdin gate, grid adoption or reclamation) once it is confirmed.
// The initial default is skipped: the server already treats fresh connections as interact.
Expand Down Expand Up @@ -1667,6 +1689,7 @@ export function PaneTerminal({
)}
{paneId !== null && !secretActive && !observing && !ended && inputLine && <TerminalInput key={paneId} owner={paneStorageId(machineId, paneId)} onComposing={setComposing} connected={connected && !held} onSend={sendTerminalLine} onEnter={pressEnter} />}
{paneId !== null && !secretActive && !observing && !chatView && <KeyBar disabled={composing} onKey={pressKey} ctrlArmed={ctrlArmed} onToggleCtrl={toggleCtrl}
altArmed={altArmed} onToggleAlt={toggleAlt} extras={settings.keyBarExtras}
{...(coarse ? { directTyping, onToggleDirect: toggleDirect } : {})} />}
</div>
);
Expand Down
33 changes: 33 additions & 0 deletions src/components/SettingsDialog.css
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,39 @@
min-width: 0;
}

/* the key bar's optional keys, drawn as the caps they put in the bar (KeyBar.css) */
.key-bar-extras {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}

.key-bar-extras button {
min-width: var(--touch-target);
height: var(--touch-target);
padding: 0 var(--space-2);
background: transparent;
border: var(--hairline) solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-sm);
white-space: nowrap;
cursor: pointer;
}

@media (hover: hover) {
.key-bar-extras button:hover {
background: var(--bg-hover);
}
}

.key-bar-extras button[aria-pressed="true"] {
background: var(--bg-elevated);
border-color: var(--accent);
color: var(--text-strong);
}

.settings-toggle {
position: relative;
flex: 0 0 auto;
Expand Down
17 changes: 17 additions & 0 deletions src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { useInstallPrompt } from "../lib/install.ts";
import { SHORTCUTS, formatKeys, shortcutKeys, shortcutConflict } from "../lib/shortcuts.ts";
import { CHAT_FONT_MAX, CHAT_FONT_MIN, CHAT_WIDTHS, chatFontSize, DEFAULT_SETTINGS, QUICK_REPLIES_MAX, QUICK_REPLY_MAX_CHARS, TERMINAL_FONT_MAX, TERMINAL_FONT_MIN, TERMINAL_WHEEL_SPEED_MAX, TERMINAL_WHEEL_SPEED_MIN, useSettings, forgetPaneViews } from "../lib/settings.ts";
import { LANGUAGE_NAMES, LANGUAGE_SETTINGS, useT } from "../lib/i18n.ts";
import { KEY_BAR_EXTRAS } from "../lib/keys.ts";
import { EXTRA_KEY_CAPS } from "./KeyBar.tsx";
import { FONT_FAMILY_MAX_CHARS, sanitizeFontFamily } from "../lib/fontFamily.ts";
import type { UpdatesModel } from "../lib/updates.ts";
import type { MachineSettings } from "../../shared/machines.ts";
Expand Down Expand Up @@ -297,6 +299,21 @@ export function SettingsDialog({ open, onClose, actions, updates, auth, herdrVer
<option value="auto">{t("Automatic")}</option><option value="line">{t("Input line")}</option><option value="direct">{t("Direct typing")}</option>
</select>
</div>
<div className="settings-row">
<div><span className="settings-label">{t("Key bar")}</span><span className="settings-description">{t("Extra keys in the bar under the terminal on a touch screen. Esc, Tab, Ctrl, the arrows and ^C are always there.")}</span></div>
</div>
<div className="key-bar-extras" role="group" aria-label={t("Key bar")}>
{KEY_BAR_EXTRAS.map((extra) => {
const { cap, label } = EXTRA_KEY_CAPS[extra];
const on = settings.keyBarExtras.includes(extra);
return (
<button key={extra} type="button" aria-pressed={on} aria-label={label ? t(label) : undefined} title={label ? t(label) : undefined}
onClick={() => update({ keyBarExtras: on ? settings.keyBarExtras.filter((chosen) => chosen !== extra) : [...settings.keyBarExtras, extra] })}>
{cap}
</button>
);
})}
</div>
</section>

<section className="settings-section">
Expand Down
8 changes: 8 additions & 0 deletions src/lib/i18n.ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,14 @@ export const JA: Record<string, string> = {
"Left": "左",
"Right": "右",
"Control C": "Control C",
"Control D": "Control D",
"Control Z": "Control Z",
"Shift Tab": "Shift Tab",
"Page up": "ページアップ",
"Page down": "ページダウン",
"Page up and Page down": "ページアップとページダウン",
"Key bar": "キーバー",
"Extra keys in the bar under the terminal on a touch screen. Esc, Tab, Ctrl, the arrows and ^C are always there.": "タッチ画面でターミナルの下のキーバーに加えるキーです。Esc、Tab、Ctrl、矢印、^C は常にあります。",

// ---- prompt card ----
"Submit": "送信",
Expand Down
8 changes: 8 additions & 0 deletions src/lib/i18n.ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,14 @@ export const KO: Record<string, string> = {
"Left": "왼쪽",
"Right": "오른쪽",
"Control C": "Control C",
"Control D": "Control D",
"Control Z": "Control Z",
"Shift Tab": "Shift Tab",
"Page up": "페이지 업",
"Page down": "페이지 다운",
"Page up and Page down": "페이지 업, 페이지 다운",
"Key bar": "키 바",
"Extra keys in the bar under the terminal on a touch screen. Esc, Tab, Ctrl, the arrows and ^C are always there.": "터치 화면에서 터미널 아래 키 바에 더할 키입니다. Esc, Tab, Ctrl, 화살표, ^C는 항상 있습니다.",

// ---- prompt card ----
"Submit": "제출",
Expand Down
3 changes: 2 additions & 1 deletion src/lib/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ function labelMapKeys(): string[] {
...grab("components/Composer.tsx", "SOURCE_LABEL", value),
...grab("components/DevicesPanel.tsx", "VIA", value),
...grab("components/KeyBar.tsx", "ARROWS", /label: "([^"]+)"/g),
...grab("components/KeyBar.tsx", "EXTRA_KEY_CAPS", /label: "([^"]+)"/g),
...grab("lib/shortcuts.ts", "SHORTCUTS", /label: "([^"]+)"/g),
...grab("lib/bridgeProgress.ts", "STAGES", /label: "([^"]+)"/g),
...grab("lib/updateProgress.ts", "STEPS", /label: "([^"]+)"/g),
Expand Down Expand Up @@ -102,7 +103,7 @@ describe.each(Object.entries(DICTIONARIES))("%s dictionary", (_name, dictionary)

it("is not just the English repeated", () => {
// names and key caps read the same in every language; Chinese UIs leave "Shell" and "Agent" in English
const sameOnPurpose = new Set(["PC {name}", "Control C", "Shell", "Agent", "Catppuccin"]);
const sameOnPurpose = new Set(["PC {name}", "Control C", "Control D", "Control Z", "Shift Tab", "Shell", "Agent", "Catppuccin"]);
const same = Object.entries(dictionary).filter(([en, text]) => en === text && /[a-z]{3}/i.test(en) && !sameOnPurpose.has(en));
expect(same.map(([en]) => en)).toEqual([]);
});
Expand Down
8 changes: 8 additions & 0 deletions src/lib/i18n.zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,14 @@ export const ZH: Record<string, string> = {
"Left": "左",
"Right": "右",
"Control C": "Control C",
"Control D": "Control D",
"Control Z": "Control Z",
"Shift Tab": "Shift Tab",
"Page up": "向上翻页",
"Page down": "向下翻页",
"Page up and Page down": "向上翻页和向下翻页",
"Key bar": "按键栏",
"Extra keys in the bar under the terminal on a touch screen. Esc, Tab, Ctrl, the arrows and ^C are always there.": "触屏上终端下方按键栏中的额外按键。Esc、Tab、Ctrl、方向键和 ^C 始终都在。",

// ---- prompt card ----
"Submit": "提交",
Expand Down
46 changes: 45 additions & 1 deletion src/lib/keys.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { controlCode, ctrlEnterSequence, isPrintable, keySequence, modifyOtherKeysLevel } from "./keys.ts";
import { altSequence, controlCode, ctrlEnterSequence, isPrintable, keySequence, modifyOtherKeysLevel, sanitizeKeyBarExtras } from "./keys.ts";

describe("controlCode", () => {
it("maps letters to their control code regardless of case", () => {
Expand Down Expand Up @@ -50,6 +50,50 @@ describe("keySequence", () => {
expect(keySequence("ArrowUp", true)).toBe("\u001bOA");
expect(keySequence("ArrowLeft", true)).toBe("\u001bOD");
});

it("sends the optional keys as xterm does: Home/End follow DECCKM, the rest are fixed", () => {
expect(keySequence("BackTab", false)).toBe("\u001b[Z");
expect(keySequence("Home", false)).toBe("\u001b[H");
expect(keySequence("End", false)).toBe("\u001b[F");
expect(keySequence("Home", true)).toBe("\u001bOH");
expect(keySequence("End", true)).toBe("\u001bOF");
expect(keySequence("PageUp", true)).toBe("\u001b[5~");
expect(keySequence("PageDown", false)).toBe("\u001b[6~");
expect(keySequence("ctrl-d", false)).toBe("\u0004");
expect(keySequence("ctrl-z", false)).toBe("\u001a");
expect([keySequence("pipe", false), keySequence("tilde", false), keySequence("slash", false)]).toEqual(["|", "~", "/"]);
});
});

describe("altSequence", () => {
it("puts ESC before one character, control characters and IME syllables included", () => {
expect(altSequence("b")).toBe("\u001bb");
expect(altSequence("\u007f")).toBe("\u001b\u007f");
expect(altSequence("\r")).toBe("\u001b\r");
expect(altSequence("\u0003")).toBe("\u001b\u0003");
expect(altSequence("한")).toBe("\u001b한");
expect(altSequence("😀")).toBe("\u001b😀");
});

it("adds the Alt modifier to cursor and editing keys in either cursor mode", () => {
expect(altSequence("\u001b[D")).toBe("\u001b[1;3D");
expect(altSequence("\u001bOA")).toBe("\u001b[1;3A");
expect(altSequence("\u001bOH")).toBe("\u001b[1;3H");
expect(altSequence("\u001b[5~")).toBe("\u001b[5;3~");
});

it("leaves pastes and terminal reports alone, so they do not use up the armed Alt", () => {
for (const data of ["ab", "", "\u001b[12;5R", "\u001b[I", "\u001b[<0;3;4M", "\u001b[200~x\u001b[201~", "\u001b[Z"]) expect(altSequence(data)).toBeNull();
});
});

describe("sanitizeKeyBarExtras", () => {
it("keeps known keys once, in the bar's order, and the default for a missing list", () => {
expect(sanitizeKeyBarExtras(["slash", "alt", "nope", "alt", 3], ["alt"])).toEqual(["alt", "slash"]);
expect(sanitizeKeyBarExtras([], ["alt"])).toEqual([]);
expect(sanitizeKeyBarExtras(undefined, ["alt"])).toEqual(["alt"]);
expect(sanitizeKeyBarExtras("alt", ["alt"])).toEqual(["alt"]);
});
});

describe("modifyOtherKeysLevel", () => {
Expand Down
Loading