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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+<key>` 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
Expand Down
5 changes: 5 additions & 0 deletions HERDR_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion herdr-plugin.toml
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "collie-web",
"version": "0.13.2",
"version": "0.14.0",
"private": true,
"license": "MIT",
"type": "module",
Expand Down
22 changes: 12 additions & 10 deletions web/src/components/key-queue-strip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,18 +22,19 @@ interface KeyQueueStripProps {

export function KeyQueueStrip({
queue,
mod,
mods,
onRemove,
onClear,
onSend,
onBaseChar,
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 (
<div className="flex flex-wrap items-center gap-1.5 rounded-lg border border-border/60 bg-background/60 p-1.5">
Expand All @@ -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 && (
<span className="inline-flex h-8 items-center rounded-md border border-dashed border-border px-2 text-xs text-muted-foreground">
{modLabel} + …
{modLabels} + …
</span>
)}

{/* 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 && (
<input
type="text"
inputMode="text"
Expand Down
99 changes: 93 additions & 6 deletions web/src/components/nav-tray.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,10 @@ describe("NavTray", () => {
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");
Expand Down Expand Up @@ -192,20 +192,107 @@ 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(<NavTray onSend={vi.fn()} />);
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(<NavTray onSend={vi.fn()} />);

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(<NavTray onSend={onSend} />);

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(<NavTray onSend={onSend} />);

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(<NavTray onSend={onSend} />);

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 ──
Expand Down
60 changes: 37 additions & 23 deletions web/src/components/nav-tray.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -51,7 +54,8 @@ type Tab = "keys" | "digits";
export function NavTray({ onSend, disabled }: NavTrayProps) {
const [tab, setTab] = useState<Tab>("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.
Expand Down Expand Up @@ -92,27 +96,34 @@ export function NavTray({ onSend, disabled }: NavTrayProps) {
</Button>
);

const modBtn = (m: "shift" | "ctrl", label: ReactNode) => (
<Button
type="button"
variant={mod === m ? "default" : "outline"}
size="sm"
disabled={disabled}
onClick={() => arm(m)}
aria-pressed={mod === m}
className="h-10 px-0 text-sm font-medium"
>
{label}
</Button>
);
// 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 (
<Button
type="button"
variant={mode === "off" ? "outline" : "default"}
size="sm"
disabled={disabled}
onClick={() => arm(m)}
aria-pressed={mode !== "off"}
className="h-10 px-0 text-sm font-medium"
>
{mode === "locked" && <Lock className="size-3" />}
{label}
</Button>
);
};

return (
<div className="space-y-2 border-t border-border/60 bg-muted/30 px-3 py-2.5">
{/* Staging strip — visible only while composing (a modifier armed or keys queued). Same on
both tabs; the review-and-Send surface replaces the old "⇧ armed" hint line. */}
<KeyQueueStrip
queue={queue}
mod={mod}
mods={activeMods}
onRemove={removeAt}
onClear={clear}
onSend={sendQueue}
Expand Down Expand Up @@ -172,11 +183,14 @@ export function NavTray({ onSend, disabled }: NavTrayProps) {
Space
</Button>

{/* 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). */}
<div className="grid grid-cols-2 gap-1.5">
{/* 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. */}
<div className="grid grid-cols-3 gap-1.5">
{modBtn("shift", "⇧ Shift")}
{modBtn("ctrl", "Ctrl")}
{modBtn("alt", "Alt")}
</div>

{/* Ctrl presets (collapsed by default; expanding keeps everything inline, never covering
Expand Down
Loading
Loading