diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ef7620..a523c36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ All notable changes to Collie are recorded here. The format follows `version` in `herdr-plugin.toml`, `package.json`, and `web/package.json` (enforced by `scripts/check-version.sh`). See [`CLAUDE.md`](./CLAUDE.md) → *Versioning* for the bump policy. +## [0.14.0] - 2026-07-21 + +### Added +- Alt modifier in the nav tray — `alt+` chords now reachable from the phone (#19) — thanks @bnivanov (d1dc947) +- Modifiers combine (checkbox, not radio): `ctrl+shift+p`, `alt+shift+p`, even triple chords (#20) (d1dc947) +- Modifier lock — tap an armed modifier again to keep it armed across presses and Sends; Clear or a third tap releases (#20) (d1dc947) + +### Changed +- HERDR_API.md: multi-modifier chords live-verified in any order against Herdr 0.7.3, cross-confirmed on 0.7.4 by @bnivanov (b505c4e) + ## [0.13.2] - 2026-07-20 ### Fixed diff --git a/HERDR_API.md b/HERDR_API.md index 48d9f4b..35745eb 100644 --- a/HERDR_API.md +++ b/HERDR_API.md @@ -79,6 +79,11 @@ against a real pane). Empirically enumerated against Herdr 0.7.0 — it is **NOT - **Modifier chords (join with `+`):** `ctrl+c`, `ctrl+u`, `ctrl+d`, `ctrl+l`, `ctrl+r`, `shift+tab`, `ctrl+left`, `alt+f`, … Modifiers: `ctrl` / `shift` / `alt` / `cmd` / `super` (case-insensitive). This is the **same grammar as `config.toml [keys]`**. +- **Multi-modifier chords work, in any modifier order** (live-verified 2026-07-20 against 0.7.3 on + a throwaway sandbox pane, with `PageUp` → `invalid_key` in the same run as proof the validator was + active): `ctrl+shift+p` / `shift+ctrl+p`, `alt+shift+p` / `shift+alt+p`, triple + `ctrl+alt+shift+p` / `ctrl+shift+alt+p`, and modifier+special `alt+Up` all ack. Independently + confirmed against 0.7.4 by @bnivanov (issue #20). - **NOT supported** (all return `invalid_key`): tmux-style `C-c` / `BTab`; and the keys `PageUp` `PageDown` `Home` `End` `Insert` `Delete` (in any spelling). There is no forward-delete and no scrollback paging via keys — the web mirror is scrollable instead. diff --git a/herdr-plugin.toml b/herdr-plugin.toml index ca202c0..27aa301 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -1,6 +1,6 @@ id = "herdr.collie" name = "Collie" -version = "0.13.2" +version = "0.14.0" min_herdr_version = "0.7.0" description = "Mobile web UI to monitor and reply to your agent herd, served over Tailscale" platforms = ["linux", "macos"] diff --git a/package.json b/package.json index efcb089..3f3ee53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "collie", - "version": "0.13.2", + "version": "0.14.0", "private": true, "license": "MIT", "description": "Collie — a mobile web UI to monitor and reply to your Herdr agent herd over Tailscale", diff --git a/web/package.json b/web/package.json index c43f118..dfc2b8f 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "collie-web", - "version": "0.13.2", + "version": "0.14.0", "private": true, "license": "MIT", "type": "module", diff --git a/web/src/components/key-queue-strip.tsx b/web/src/components/key-queue-strip.tsx index c3fc1b8..c129e96 100644 --- a/web/src/components/key-queue-strip.tsx +++ b/web/src/components/key-queue-strip.tsx @@ -3,14 +3,15 @@ import { X } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import type { Modifier } from "@/lib/key-queue"; -import { isDangerKey, keyLabel } from "@/lib/key-queue"; +import { isDangerKey, keyLabel, modifierLabel } from "@/lib/key-queue"; // The staging strip that sits at the top of the nav tray while composing: a chip per queued key -// (tap to remove), a ghost chip + one-char input while a modifier is armed, and an explicit Send. +// (tap to remove), a ghost chip + one-char input while any modifier is armed, and an explicit Send. // Presentational only — all state lives in useKeyQueue; every visible string is a plain text node. interface KeyQueueStripProps { queue: string[]; - mod: Modifier | null; + /** The active modifiers in canonical order (pass activeMods from useKeyQueue). */ + mods: readonly Modifier[]; onRemove: (i: number) => void; onClear: () => void; onSend: () => void; @@ -21,7 +22,7 @@ interface KeyQueueStripProps { export function KeyQueueStrip({ queue, - mod, + mods, onRemove, onClear, onSend, @@ -29,10 +30,11 @@ export function KeyQueueStrip({ disabled, }: KeyQueueStripProps) { // Self-guarding: nothing to show unless a modifier is armed or keys are queued. - if (mod === null && queue.length === 0) return null; + if (mods.length === 0 && queue.length === 0) return null; const danger = queue.some(isDangerKey); - const modLabel = mod === "shift" ? "⇧" : mod === "ctrl" ? "Ctrl" : null; + const modsArmed = mods.length > 0; + const modLabels = mods.map(modifierLabel).join(" "); return (
@@ -56,16 +58,16 @@ export function KeyQueueStrip({ ); })} - {/* Modifier armed, no base yet: a ghost chip showing what's awaited (e.g. "Ctrl + …"). */} - {mod !== null && ( + {/* Modifiers armed, no base yet: a ghost chip showing the awaited chord (e.g. "Ctrl ⇧ + …"). */} + {modsArmed && ( - {modLabel} + … + {modLabels} + … )} {/* One-char key input — only while a modifier is armed. Controlled to "" so each keystroke fires onChange and the field stays empty; the model takes the last char typed. */} - {mod !== null && ( + {modsArmed && ( { const shiftBtn = screen.getByRole("button", { name: /Shift/ }); expect(shiftBtn).toHaveAttribute("aria-pressed", "false"); - await user.click(shiftBtn); + await user.click(shiftBtn); // once expect(shiftBtn).toHaveAttribute("aria-pressed", "true"); - // Pressing a key while armed STAGES it (nothing sent yet) and disarms Shift. + // Pressing a key while armed STAGES it (nothing sent yet) and spends the one-shot Shift. await user.click(screen.getByRole("button", { name: /Enter/ })); expect(onSend).not.toHaveBeenCalled(); expect(shiftBtn).toHaveAttribute("aria-pressed", "false"); @@ -192,7 +192,43 @@ describe("NavTray", () => { expect(onSend).not.toHaveBeenCalled(); }); - it("arming Shift then Ctrl leaves only Ctrl armed (radio modifiers)", async () => { + // ── Combinable + lockable modifiers (#19 / #20) ── + + it("the Alt modifier renders alongside Shift and Ctrl", () => { + render(); + expect(screen.getByRole("button", { name: "⇧ Shift" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Ctrl" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Alt" })).toBeInTheDocument(); + }); + + it("tapping a modifier cycles off → once → locked → off (aria-pressed + Lock glyph)", async () => { + const user = userEvent.setup(); + render(); + + const alt = () => screen.getByRole("button", { name: "Alt" }); + const isLocked = () => alt().querySelector(".lucide-lock") !== null; + + // off + expect(alt()).toHaveAttribute("aria-pressed", "false"); + expect(isLocked()).toBe(false); + + // once — armed, no lock glyph yet + await user.click(alt()); + expect(alt()).toHaveAttribute("aria-pressed", "true"); + expect(isLocked()).toBe(false); + + // locked — armed, lock glyph shows + await user.click(alt()); + expect(alt()).toHaveAttribute("aria-pressed", "true"); + expect(isLocked()).toBe(true); + + // off again + await user.click(alt()); + expect(alt()).toHaveAttribute("aria-pressed", "false"); + expect(isLocked()).toBe(false); + }); + + it("modifiers are checkboxes: arming Shift then Ctrl leaves BOTH armed and combines into one chord", async () => { const user = userEvent.setup(); const onSend = vi.fn(); render(); @@ -200,12 +236,63 @@ describe("NavTray", () => { const shiftBtn = screen.getByRole("button", { name: /Shift/ }); const ctrlBtn = screen.getByRole("button", { name: "Ctrl" }); + await user.click(ctrlBtn); await user.click(shiftBtn); + // Both stay armed (not radio) — that's the combine. + expect(ctrlBtn).toHaveAttribute("aria-pressed", "true"); expect(shiftBtn).toHaveAttribute("aria-pressed", "true"); - await user.click(ctrlBtn); - expect(ctrlBtn).toHaveAttribute("aria-pressed", "true"); - expect(shiftBtn).toHaveAttribute("aria-pressed", "false"); + // Ghost chip previews the combined chord in canonical order. + expect(screen.getByText("Ctrl ⇧ + …")).toBeInTheDocument(); + + // Type the base — composes ctrl+shift+p regardless of the shift-then… tap order. + const keyInput = screen.getByRole("textbox", { name: "Type a key to combine" }); + fireEvent.change(keyInput, { target: { value: "p" } }); + expect(screen.getByRole("button", { name: "Remove Ctrl ⇧ P" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Send" })); + expect(onSend).toHaveBeenCalledExactlyOnceWith(["ctrl+shift+p"]); + }); + + it("a locked modifier survives Send — the same chord re-stages without re-arming", async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + render(); + + const ctrlBtn = () => screen.getByRole("button", { name: "Ctrl" }); + await user.click(ctrlBtn()); // once + await user.click(ctrlBtn()); // locked + expect(ctrlBtn().querySelector(".lucide-lock")).not.toBeNull(); + + // Stage ctrl+Tab and send. + await user.click(screen.getByRole("button", { name: "Tab" })); + await user.click(screen.getByRole("button", { name: "Send" })); + expect(onSend).toHaveBeenLastCalledWith(["ctrl+Tab"]); + + // Ctrl is still locked, so tapping Tab again re-stages ctrl+Tab with no re-arm. + expect(ctrlBtn()).toHaveAttribute("aria-pressed", "true"); + await user.click(screen.getByRole("button", { name: "Tab" })); + expect(screen.getByRole("button", { name: "Remove Ctrl Tab" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Send" })); + expect(onSend).toHaveBeenLastCalledWith(["ctrl+Tab"]); + expect(onSend).toHaveBeenCalledTimes(2); // locked chord sent twice, no re-arm between + }); + + it("Clear releases a locked modifier (the one explicit escape hatch)", async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + render(); + + const ctrlBtn = () => screen.getByRole("button", { name: "Ctrl" }); + await user.click(ctrlBtn()); // once + await user.click(ctrlBtn()); // locked + await user.click(screen.getByRole("button", { name: "Tab" })); // stage ctrl+Tab + + await user.click(screen.getByRole("button", { name: "Clear queued keys" })); + expect(screen.queryByRole("button", { name: "Send" })).toBeNull(); // not composing + expect(ctrlBtn()).toHaveAttribute("aria-pressed", "false"); // lock released + expect(ctrlBtn().querySelector(".lucide-lock")).toBeNull(); }); // ── Ctrl presets: immediate two-tap when idle; plain stage when composing ── diff --git a/web/src/components/nav-tray.tsx b/web/src/components/nav-tray.tsx index 6fbf14f..880c458 100644 --- a/web/src/components/nav-tray.tsx +++ b/web/src/components/nav-tray.tsx @@ -1,9 +1,10 @@ import { useState } from "react"; import type { ReactNode } from "react"; -import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, ChevronDown } from "lucide-react"; +import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, ChevronDown, Lock } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; +import type { Modifier } from "@/lib/key-queue"; import { usePendingConfirm } from "@/hooks/use-pending-confirm"; import { useKeyQueue } from "@/hooks/use-key-queue"; import { KeyQueueStrip } from "@/components/key-queue-strip"; @@ -14,10 +15,12 @@ import { KeyQueueStrip } from "@/components/key-queue-strip"; // `pane.send_keys` grammar (see HERDR_API.md): special keys bare, modifier chords joined with "+". // // Two modes, driven by useKeyQueue. When nothing is armed and the queue is empty, a key press fires -// immediately (the classic path). Arm a modifier (⇧ Shift / Ctrl) — or once any key is queued — and -// the tray enters compose mode: presses stage a visible key queue (the strip) that you review and -// Send as ONE call. Herdr rejects a bare "Shift"/"Ctrl" keypress, so the modifiers are one-shot: arm, -// and the next key is composed as `shift+…` / `ctrl+…`, then the modifier disarms. +// immediately (the classic path). Arm one or more modifiers (⇧ Shift / Ctrl / Alt) — or once any key +// is queued — and the tray enters compose mode: presses stage a visible key queue (the strip) that +// you review and Send as ONE call. Herdr rejects a bare "Shift"/"Ctrl"/"Alt" keypress, so modifiers +// only exist as part of a chord. Each modifier is a CHECKBOX that cycles off → once → locked → off: +// tap once for a one-shot (composed into the next staged key, then released), tap again to LOCK it +// armed across presses and Sends, tap a third time to clear. Any subset combines — `ctrl+shift+p`. interface NavTrayProps { onSend: (keys: string[]) => void; @@ -51,7 +54,8 @@ type Tab = "keys" | "digits"; export function NavTray({ onSend, disabled }: NavTrayProps) { const [tab, setTab] = useState("keys"); const [ctrlOpen, setCtrlOpen] = useState(false); - const { queue, mod, composing, arm, press, pushBase, removeAt, clear, take } = useKeyQueue(); + const { queue, mods, activeMods, composing, arm, press, pushBase, removeAt, clear, take } = + useKeyQueue(); const { pending, confirm, reset } = usePendingConfirm(); // danger ctrl two-tap (immediate path only) // Route a key press through the queue: fire immediately when idle, stage when composing. @@ -92,19 +96,26 @@ export function NavTray({ onSend, disabled }: NavTrayProps) { ); - const modBtn = (m: "shift" | "ctrl", label: ReactNode) => ( - - ); + // A modifier button reads its own three-state mode from `mods`: outline when off, filled (default) + // when armed — once OR locked — with a small Lock glyph beside the label to distinguish locked from + // one-shot. Tapping cycles off → once → locked → off. + const modBtn = (m: Modifier, label: ReactNode) => { + const mode = mods[m]; + return ( + + ); + }; return (
@@ -112,7 +123,7 @@ export function NavTray({ onSend, disabled }: NavTrayProps) { both tabs; the review-and-Send surface replaces the old "⇧ armed" hint line. */} - {/* Modifiers (sticky, one-shot, radio): arm one and the next key composes as its chord. - Same pressed styling as everything else (default = armed, outline = idle). */} -
+ {/* Modifiers (checkboxes that cycle off → once → locked → off): arm any subset and the + next key composes as their combined chord. Locked (Lock glyph) stays armed across + presses and Sends. Same pressed styling as everything else (default = armed, outline = + idle). Display order Shift · Ctrl · Alt; compose order is canonical regardless of taps. */} +
{modBtn("shift", "⇧ Shift")} {modBtn("ctrl", "Ctrl")} + {modBtn("alt", "Alt")}
{/* Ctrl presets (collapsed by default; expanding keeps everything inline, never covering diff --git a/web/src/hooks/use-key-queue.test.ts b/web/src/hooks/use-key-queue.test.ts index 834a364..b725ccc 100644 --- a/web/src/hooks/use-key-queue.test.ts +++ b/web/src/hooks/use-key-queue.test.ts @@ -15,55 +15,147 @@ describe("useKeyQueue", () => { expect(result.current.composing).toBe(false); }); - it("stages (does not fire) while composing and disarms the one-shot modifier", () => { + it("arm() cycles a single modifier off → once → locked → off", () => { const { result } = renderHook(() => useKeyQueue()); + expect(result.current.mods.ctrl).toBe("off"); + act(() => result.current.arm("ctrl")); - expect(result.current.mod).toBe("ctrl"); + expect(result.current.mods.ctrl).toBe("once"); expect(result.current.composing).toBe(true); + act(() => result.current.arm("ctrl")); + expect(result.current.mods.ctrl).toBe("locked"); + + act(() => result.current.arm("ctrl")); + expect(result.current.mods.ctrl).toBe("off"); + expect(result.current.composing).toBe(false); + }); + + it("modifiers are checkboxes: arming several combines them into activeMods (canonical order)", () => { + const { result } = renderHook(() => useKeyQueue()); + + act(() => result.current.arm("shift")); // once + act(() => result.current.arm("ctrl")); // once — shift stays armed (not radio) + expect(result.current.mods.shift).toBe("once"); + expect(result.current.mods.ctrl).toBe("once"); + // MODIFIER_ORDER is ctrl, alt, shift regardless of the shift→ctrl tap order. + expect(result.current.activeMods).toEqual(["ctrl", "shift"]); + }); + + it("stages a combined chord and spends the one-shot modifiers after the press", () => { + const { result } = renderHook(() => useKeyQueue()); + + act(() => result.current.arm("ctrl")); + act(() => result.current.arm("shift")); + let r: ReturnType | undefined; act(() => { - r = result.current.press(["Tab"]); + r = result.current.press(["x"]); }); expect(r).toEqual({ mode: "queued" }); - expect(result.current.queue).toEqual(["ctrl+Tab"]); - expect(result.current.mod).toBeNull(); // one-shot consumed + expect(result.current.queue).toEqual(["ctrl+shift+x"]); + // once mods spent → back to off. + expect(result.current.mods.ctrl).toBe("off"); + expect(result.current.mods.shift).toBe("off"); + expect(result.current.activeMods).toEqual([]); + }); - // Queue is non-empty, so a subsequent bare key appends (still composing) rather than firing. + it("a locked modifier survives a press: the same chord re-stages without re-arming", () => { + const { result } = renderHook(() => useKeyQueue()); + + act(() => result.current.arm("ctrl")); // once + act(() => result.current.arm("ctrl")); // locked + expect(result.current.mods.ctrl).toBe("locked"); + + act(() => result.current.press(["p"])); + expect(result.current.queue).toEqual(["ctrl+p"]); + expect(result.current.mods.ctrl).toBe("locked"); // still armed + + act(() => result.current.press(["p"])); // no re-arm needed + expect(result.current.queue).toEqual(["ctrl+p", "ctrl+p"]); + expect(result.current.mods.ctrl).toBe("locked"); + }); + + it("a locked modifier survives take() (Send), so you can stage the same chord again", () => { + const { result } = renderHook(() => useKeyQueue()); + + act(() => result.current.arm("ctrl")); // once + act(() => result.current.arm("ctrl")); // locked + act(() => result.current.press(["p"])); + + let taken: string[] = []; act(() => { - r = result.current.press(["Down"]); + taken = result.current.take(); }); - expect(r).toEqual({ mode: "queued" }); - expect(result.current.queue).toEqual(["ctrl+Tab", "Down"]); + expect(taken).toEqual(["ctrl+p"]); + expect(result.current.queue).toEqual([]); + expect(result.current.mods.ctrl).toBe("locked"); // lock survives Send + expect(result.current.composing).toBe(true); // still composing — mod armed + + act(() => result.current.press(["p"])); + expect(result.current.queue).toEqual(["ctrl+p"]); }); - it("arm() is radio + toggle across the two modifiers", () => { + it("take() spends a once modifier but keeps a locked one", () => { const { result } = renderHook(() => useKeyQueue()); - act(() => result.current.arm("shift")); - expect(result.current.mod).toBe("shift"); + act(() => result.current.arm("ctrl")); // once + act(() => result.current.arm("shift")); // once + act(() => result.current.arm("shift")); // locked + act(() => result.current.press(["x"])); // ctrl spent, shift stays locked + expect(result.current.queue).toEqual(["ctrl+shift+x"]); + expect(result.current.mods.ctrl).toBe("off"); + expect(result.current.mods.shift).toBe("locked"); + }); + + it("clear() releases everything including locked modifiers", () => { + const { result } = renderHook(() => useKeyQueue()); + + act(() => result.current.arm("ctrl")); // once + act(() => result.current.arm("ctrl")); // locked + act(() => result.current.press(["p"])); + expect(result.current.mods.ctrl).toBe("locked"); + + act(() => result.current.clear()); + expect(result.current.queue).toEqual([]); + expect(result.current.mods.ctrl).toBe("off"); + expect(result.current.composing).toBe(false); + }); + + it("stages (does not fire) while composing; a bare key after a chord still appends", () => { + const { result } = renderHook(() => useKeyQueue()); - act(() => result.current.arm("ctrl")); // switches - expect(result.current.mod).toBe("ctrl"); + act(() => result.current.arm("ctrl")); + let r: ReturnType | undefined; + act(() => { + r = result.current.press(["Tab"]); + }); + expect(r).toEqual({ mode: "queued" }); + expect(result.current.queue).toEqual(["ctrl+Tab"]); + expect(result.current.mods.ctrl).toBe("off"); // one-shot consumed - act(() => result.current.arm("ctrl")); // toggles off - expect(result.current.mod).toBeNull(); + // Queue is non-empty, so a subsequent bare key appends (still composing) rather than firing. + act(() => { + r = result.current.press(["Down"]); + }); + expect(r).toEqual({ mode: "queued" }); + expect(result.current.queue).toEqual(["ctrl+Tab", "Down"]); }); - it("pushBase composes the armed mod with a normalised char and disarms", () => { + it("pushBase composes the armed mods with a normalised char and settles", () => { const { result } = renderHook(() => useKeyQueue()); act(() => result.current.arm("ctrl")); act(() => result.current.pushBase("G")); expect(result.current.queue).toEqual(["ctrl+g"]); - expect(result.current.mod).toBeNull(); + expect(result.current.mods.ctrl).toBe("off"); - // Non-printable input is ignored. + // Non-printable input is ignored — nothing pushed, the mod stays armed. act(() => result.current.arm("ctrl")); act(() => result.current.pushBase(" ")); expect(result.current.queue).toEqual(["ctrl+g"]); - expect(result.current.mod).toBe("ctrl"); // still armed — nothing was pushed + expect(result.current.mods.ctrl).toBe("once"); }); it("removeAt / clear edit the queue", () => { @@ -81,18 +173,18 @@ describe("useKeyQueue", () => { expect(result.current.composing).toBe(false); }); - it("take() returns the queue and clears composition state", () => { + it("composing is true when any modifier is armed OR the queue is non-empty", () => { const { result } = renderHook(() => useKeyQueue()); - act(() => result.current.arm("ctrl")); - act(() => result.current.press(["Tab"])); + expect(result.current.composing).toBe(false); - let taken: string[] = []; - act(() => { - taken = result.current.take(); - }); - expect(taken).toEqual(["ctrl+Tab"]); - expect(result.current.queue).toEqual([]); - expect(result.current.mod).toBeNull(); + act(() => result.current.arm("alt")); // armed, empty queue + expect(result.current.composing).toBe(true); + + act(() => result.current.press(["Up"])); // stages alt+Up, alt spent → queue keeps composing on + expect(result.current.activeMods).toEqual([]); + expect(result.current.composing).toBe(true); // queue non-empty + + act(() => result.current.take()); expect(result.current.composing).toBe(false); }); }); diff --git a/web/src/hooks/use-key-queue.ts b/web/src/hooks/use-key-queue.ts index bc4bb07..265e07f 100644 --- a/web/src/hooks/use-key-queue.ts +++ b/web/src/hooks/use-key-queue.ts @@ -1,70 +1,101 @@ -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; -import type { Modifier } from "@/lib/key-queue"; -import { composeKey, normalizeBaseChar } from "@/lib/key-queue"; +import type { Modifier, ModMode } from "@/lib/key-queue"; +import { MODIFIER_ORDER, composeKey, nextModMode, normalizeBaseChar } from "@/lib/key-queue"; + +// Re-exported so consumers can name the arm-state without reaching into lib/key-queue. +export type { ModMode }; // Result of press(): either the keys should fire immediately (nothing is being composed) or they // were staged onto the queue for review-then-send. A discriminated union so the caller branches // cleanly — `if (r.mode === "fire") onSend(r.keys)`. export type PressResult = { mode: "fire"; keys: string[] } | { mode: "queued" }; -// The nav-tray's key-composition state: an armed one-shot modifier plus a visible queue of composed +type ModState = Record; + +const ALL_OFF: ModState = { ctrl: "off", alt: "off", shift: "off" }; + +// After a key is staged (press / pushBase) or the queue is sent (take), every `once` modifier is +// spent → back to off, but `locked` modifiers stay armed. That's the whole point of locking: fire +// the same chord repeatedly without re-arming. +function settleMods(cur: ModState): ModState { + return { + ctrl: cur.ctrl === "once" ? "off" : cur.ctrl, + alt: cur.alt === "once" ? "off" : cur.alt, + shift: cur.shift === "once" ? "off" : cur.shift, + }; +} + +// The nav-tray's key-composition state: a set of armed modifiers plus a visible queue of composed // keys awaiting an explicit Send. Plain useState — no context, no global; instantiate it where the -// keys are pressed. `mod` has radio semantics (arming one disarms the other); the modifier is -// one-shot (consumed by the next key, like the old sticky Shift). "Composing" = a mod is armed OR -// the queue is non-empty; while composing, every press is staged instead of fired. +// keys are pressed. +// +// Modifiers are CHECKBOXES, not radios: each cycles independently off → once → locked → off, so any +// subset can be armed at once and combine into one chord (`ctrl+shift+p`). `once` is the classic +// one-shot (consumed by the next staged key); `locked` survives both a press and a Send, released +// only by cycling it back off or by Clear. "Composing" = any modifier is armed OR the queue is +// non-empty; while composing, every press is staged instead of fired. export function useKeyQueue() { const [queue, setQueue] = useState([]); - const [mod, setMod] = useState(null); + const [mods, setMods] = useState(ALL_OFF); + + // The armed modifiers in canonical order — what composeKey and the strip both consume. + const activeMods = useMemo( + () => MODIFIER_ORDER.filter((m) => mods[m] !== "off"), + [mods], + ); - const composing = mod !== null || queue.length > 0; + const composing = activeMods.length > 0 || queue.length > 0; - // Radio + toggle: arming the already-armed modifier disarms it; arming the other switches. + // Cycle ONE modifier through off → once → locked → off. Independent per modifier (checkbox), so + // arming a second modifier leaves the first alone. const arm = useCallback((m: Modifier) => { - setMod((cur) => (cur === m ? null : m)); + setMods((cur) => ({ ...cur, [m]: nextModMode(cur[m]) })); }, []); // Not composing → hand the keys back to fire immediately. Composing → append each key composed - // with the armed modifier, then disarm it (one-shot). + // with the active modifiers, then settle (spend `once`, keep `locked`). const press = useCallback( (keys: string[]): PressResult => { - if (mod === null && queue.length === 0) return { mode: "fire", keys }; - setQueue((q) => [...q, ...keys.map((k) => composeKey(mod, k))]); - setMod(null); + if (activeMods.length === 0 && queue.length === 0) return { mode: "fire", keys }; + setQueue((q) => [...q, ...keys.map((k) => composeKey(activeMods, k))]); + setMods(settleMods); return { mode: "queued" }; }, - [mod, queue.length], + [activeMods, queue.length], ); - // The one-char key input: normalise the raw input to a base char, compose with the armed mod, stage - // it, and consume the modifier. Ignores non-printable input (returns null from normalizeBaseChar). + // The one-char key input: normalise the raw input to a base char, compose with the active mods, + // stage it, and settle. Ignores non-printable input (returns null from normalizeBaseChar). const pushBase = useCallback( (char: string) => { const base = normalizeBaseChar(char); if (base === null) return; - setQueue((q) => [...q, composeKey(mod, base)]); - setMod(null); + setQueue((q) => [...q, composeKey(activeMods, base)]); + setMods(settleMods); }, - [mod], + [activeMods], ); const removeAt = useCallback((i: number) => { setQueue((q) => q.filter((_, idx) => idx !== i)); }, []); + // The one explicit escape hatch: drop the queue AND release every modifier, including locked. const clear = useCallback(() => { setQueue([]); - setMod(null); + setMods(ALL_OFF); }, []); - // Hand back the queued keys (for Send) and reset composition state. Reads the currently-rendered - // queue — Send is only reachable with a non-empty queue. + // Hand back the queued keys (for Send) and settle the modifiers — `locked` survives the Send so + // you can immediately stage the same chord again without re-arming; `once` is spent. Reads the + // currently-rendered queue — Send is only reachable with a non-empty queue. const take = useCallback((): string[] => { const taken = queue; setQueue([]); - setMod(null); + setMods(settleMods); return taken; }, [queue]); - return { queue, mod, composing, arm, press, pushBase, removeAt, clear, take }; + return { queue, mods, activeMods, composing, arm, press, pushBase, removeAt, clear, take }; } diff --git a/web/src/lib/key-queue.test.ts b/web/src/lib/key-queue.test.ts index c505f55..88a08a7 100644 --- a/web/src/lib/key-queue.test.ts +++ b/web/src/lib/key-queue.test.ts @@ -1,22 +1,46 @@ import { describe, expect, it } from "vitest"; -import { composeKey, isDangerKey, keyLabel, normalizeBaseChar } from "./key-queue"; +import { + composeKey, + isDangerKey, + keyLabel, + modifierLabel, + nextModMode, + normalizeBaseChar, +} from "./key-queue"; describe("composeKey", () => { - it("joins a modifier onto a base with the base verbatim", () => { - expect(composeKey("ctrl", "g")).toBe("ctrl+g"); - expect(composeKey(null, "Down")).toBe("Down"); + it("joins a single modifier onto a base with the base verbatim", () => { + expect(composeKey(["ctrl"], "g")).toBe("ctrl+g"); + expect(composeKey([], "Down")).toBe("Down"); // The exact strings the tray has always sent for shift — case preserved on the wire. - expect(composeKey("shift", "Tab")).toBe("shift+Tab"); - expect(composeKey("shift", "Enter")).toBe("shift+Enter"); - expect(composeKey("shift", "7")).toBe("shift+7"); - expect(composeKey("ctrl", "Left")).toBe("ctrl+Left"); + expect(composeKey(["shift"], "Tab")).toBe("shift+Tab"); + expect(composeKey(["shift"], "Enter")).toBe("shift+Enter"); + expect(composeKey(["shift"], "7")).toBe("shift+7"); + expect(composeKey(["ctrl"], "Left")).toBe("ctrl+Left"); }); - it("passes a base that already contains '+' through unchanged (no stacked modifier)", () => { - expect(composeKey("shift", "ctrl+c")).toBe("ctrl+c"); - expect(composeKey("ctrl", "ctrl+d")).toBe("ctrl+d"); - expect(composeKey(null, "shift+tab")).toBe("shift+tab"); + it("orders combined modifiers by MODIFIER_ORDER regardless of tap order", () => { + expect(composeKey(["ctrl", "shift"], "p")).toBe("ctrl+shift+p"); + expect(composeKey(["shift", "ctrl"], "p")).toBe("ctrl+shift+p"); // same result, any order + expect(composeKey(["alt"], "Up")).toBe("alt+Up"); + expect(composeKey(["shift", "alt", "ctrl"], "p")).toBe("ctrl+alt+shift+p"); // triple + }); + + it("de-dupes repeated modifiers", () => { + expect(composeKey(["ctrl", "ctrl"], "g")).toBe("ctrl+g"); + expect(composeKey(["shift", "ctrl", "shift"], "p")).toBe("ctrl+shift+p"); + }); + + it("empty modifiers returns the base unchanged", () => { + expect(composeKey([], "Up")).toBe("Up"); + expect(composeKey([], "g")).toBe("g"); + }); + + it("passes a base that already contains '+' through unchanged (never stacks onto a preset)", () => { + expect(composeKey(["shift"], "ctrl+c")).toBe("ctrl+c"); + expect(composeKey(["ctrl", "shift"], "ctrl+c")).toBe("ctrl+c"); + expect(composeKey([], "shift+tab")).toBe("shift+tab"); }); }); @@ -30,6 +54,21 @@ describe("keyLabel", () => { expect(keyLabel("ctrl+c")).toBe("Ctrl C"); }); + it("labels multi-modifier chords in leading order", () => { + expect(keyLabel("ctrl+shift+p")).toBe("Ctrl ⇧ P"); + expect(keyLabel("ctrl+alt+shift+p")).toBe("Ctrl Alt ⇧ P"); + expect(keyLabel("alt+Up")).toBe("Alt Up"); + }); + + it("labels the cmd/super modifiers the grammar allows but the tray doesn't surface", () => { + expect(keyLabel("cmd+k")).toBe("Cmd K"); + expect(keyLabel("super+l")).toBe("Super L"); + }); + + it("treats a lone '+' (no leading modifier) as a base char", () => { + expect(keyLabel("+")).toBe("+"); + }); + it("falls back to the token for plain multi-char keys", () => { expect(keyLabel("Down")).toBe("Down"); expect(keyLabel("Tab")).toBe("Tab"); @@ -37,6 +76,22 @@ describe("keyLabel", () => { }); }); +describe("modifierLabel", () => { + it("labels each surfaced modifier", () => { + expect(modifierLabel("ctrl")).toBe("Ctrl"); + expect(modifierLabel("alt")).toBe("Alt"); + expect(modifierLabel("shift")).toBe("⇧"); + }); +}); + +describe("nextModMode", () => { + it("cycles off → once → locked → off", () => { + expect(nextModMode("off")).toBe("once"); + expect(nextModMode("once")).toBe("locked"); + expect(nextModMode("locked")).toBe("off"); + }); +}); + describe("isDangerKey", () => { it("flags the interrupt/suspend/kill chords (ctrl+c IS danger on the queued path)", () => { expect(isDangerKey("ctrl+c")).toBe(true); diff --git a/web/src/lib/key-queue.ts b/web/src/lib/key-queue.ts index f9342e8..5750f4c 100644 --- a/web/src/lib/key-queue.ts +++ b/web/src/lib/key-queue.ts @@ -1,23 +1,62 @@ -// Pure model for the nav-tray key queue: compose a one-shot modifier onto a base key, label a key -// for a chip, flag danger keys, and normalise the one-char key input. No React, no I/O — every -// string this produces is what goes on the wire to Herdr's `pane.send_keys` (see HERDR_API.md). +// Pure model for the nav-tray key queue: compose a set of modifiers onto a base key, label a key +// for a chip, flag danger keys, cycle a modifier's three-state mode, and normalise the one-char key +// input. No React, no I/O — every string this produces is what goes on the wire to Herdr's +// `pane.send_keys` (see HERDR_API.md). -// The two Herdr-verified modifiers Collie surfaces. The grammar also allows `alt`/`cmd`/`super`, and -// multi-modifier chords like `ctrl+shift+x` exist in principle — but multi-mod is UNVERIFIED against -// Herdr, so compose stays single-mod for now. `Modifier | null` (rather than a bare `Modifier`) -// leaves room to widen to a modifier list later without churning every caller. -export type Modifier = "ctrl" | "shift"; +// The modifiers Collie surfaces in the tray. Multi-modifier chords in ANY order — `ctrl+shift+p`, +// the triple `ctrl+alt+shift+p`, `alt+Up` — are now LIVE-VERIFIED against Herdr (0.7.3 sandbox + +// 0.7.4 by the issue reporter), so compose freely combines them. The wire grammar also accepts +// `cmd`/`super`, which we don't surface here (keyLabel still labels them if they ever appear). +export type Modifier = "ctrl" | "alt" | "shift"; -// Join a one-shot modifier onto a base key. Casing is deliberate: the base passes through VERBATIM — -// `("shift","Tab") → "shift+Tab"`, `("ctrl","g") → "ctrl+g"` — because those are the exact strings -// the tray has always sent and Herdr verified (keys are case-insensitive on the wire, so we don't -// rewrite what already works). A base that already carries a "+" is a preset chord (`ctrl+c`, -// `shift+tab`) and passes through untouched, so we never stack a second modifier (no `shift+ctrl+c`). -// A null modifier returns the base unchanged. -export function composeKey(mod: Modifier | null, base: string): string { +// Canonical compose order. The wire accepts modifiers in any order, but we pick ONE so display is +// stable and dedupe is trivial (filtering MODIFIER_ORDER by membership both orders and de-dupes). +export const MODIFIER_ORDER: readonly Modifier[] = ["ctrl", "alt", "shift"]; + +// A modifier's arm state in the tray. Tapping cycles off → once → locked → off: `once` is the +// classic one-shot (consumed by the next staged key), `locked` stays armed across presses and Sends +// until you cycle it back off or Clear the queue. +export type ModMode = "off" | "once" | "locked"; + +// The pure three-state cycle a tap advances a modifier through. Lives here (not in the hook) so it's +// unit-testable without React. +export function nextModMode(m: ModMode): ModMode { + if (m === "off") return "once"; + if (m === "once") return "locked"; + return "off"; +} + +// Join a set of modifiers onto a base key. Casing is deliberate: the base passes through VERBATIM — +// `(["shift"],"Tab") → "shift+Tab"`, `(["ctrl"],"g") → "ctrl+g"` — because those are the exact +// strings the tray has always sent and Herdr verified (keys are case-insensitive on the wire, so we +// don't rewrite what already works). Modifiers are ordered + de-duped by MODIFIER_ORDER so the wire +// string is stable regardless of tap order. A base that already carries a "+" is a preset chord +// (`ctrl+c`, `shift+tab`) and passes through untouched, so we never stack modifiers onto a preset. +// Empty modifiers returns the base unchanged. +export function composeKey(mods: readonly Modifier[], base: string): string { if (base.includes("+")) return base; - if (mod === null) return base; - return `${mod}+${base}`; + if (mods.length === 0) return base; + const ordered = MODIFIER_ORDER.filter((m) => mods.includes(m)); + return `${ordered.join("+")}+${base}`; +} + +// Display label for a single surfaced modifier: `ctrl → "Ctrl"`, `alt → "Alt"`, `shift → "⇧"`. +// Shared by keyLabel and the strip's ghost chip so the mapping lives in one place. +export function modifierLabel(m: Modifier): string { + if (m === "ctrl") return "Ctrl"; + if (m === "alt") return "Alt"; + return "⇧"; // shift +} + +// Label a leading modifier TOKEN off the wire, or null if the token isn't a modifier. Broader than +// modifierLabel: also covers the `cmd`/`super` the grammar allows but we don't surface, so a chord +// that arrives with them still reads nicely. +function leadingModLabel(token: string): string | null { + const lower = token.toLowerCase(); + if (lower === "ctrl" || lower === "alt" || lower === "shift") return modifierLabel(lower); + if (lower === "cmd") return "Cmd"; + if (lower === "super") return "Super"; + return null; } // Friendly display for a base token: special keys get short/glyph labels, a lone printable char is @@ -30,15 +69,23 @@ function baseLabel(base: string): string { return base; } -// Human chip label for a full key token: `"ctrl+g" → "Ctrl G"`, `"shift+Tab" → "⇧ Tab"`, -// `"Escape" → "Esc"`, `"Enter" → "⏎"`, `"g" → "G"`. Total — an unknown token falls back to itself. +// Human chip label for a full key token: `"ctrl+g" → "Ctrl G"`, `"ctrl+shift+p" → "Ctrl ⇧ P"`, +// `"shift+Tab" → "⇧ Tab"`, `"Escape" → "Esc"`, `"g" → "G"`. Consumes LEADING modifier tokens (in +// order) and runs the remainder through baseLabel. Total — `"+"` has no leading modifier so it falls +// straight through to baseLabel ("+"), and an unknown token falls back to itself. export function keyLabel(key: string): string { - const plus = key.indexOf("+"); - if (plus === -1) return baseLabel(key); - const mod = key.slice(0, plus).toLowerCase(); - const base = key.slice(plus + 1); - const modLabel = mod === "shift" ? "⇧" : mod === "ctrl" ? "Ctrl" : mod; - return `${modLabel} ${baseLabel(base)}`; + const tokens = key.split("+"); + const labels: string[] = []; + let i = 0; + while (i < tokens.length) { + const ml = leadingModLabel(tokens[i]); + if (ml === null) break; + labels.push(ml); + i++; + } + const rest = tokens.slice(i).join("+"); + if (rest.length > 0 || labels.length === 0) labels.push(baseLabel(rest)); + return labels.join(" "); } // The chords that interrupt / suspend / kill a running agent. NOTE: ctrl+c counts as danger HERE —