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
59 changes: 59 additions & 0 deletions agent-notes/fix-keys-mode-never-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# fix/keys-mode-never-types

## Goal
Q (2026-10-24): "Hotkey mode should never ever input the keys into the input field here or in mudlet fix and pr and fix everywhere."
Then: "we're probably gonna want some sort of help mode? for hotkey mode?" and "maybe alt shift f1". Q said "make it so".
Keys mode (Alt+Shift+H) never puts a typed character into the command input, in the web client and in the Mudlet
package (MongooseMoo/mudlet-package, same branch name, worktree `C:/Users/Q/code/mudlet-package-keys-mode`), plus key
help on Alt+Shift+F1, and the spec (`docs/keystrokes.md`) says so.

## State
Code written in both worktrees, not yet committed.
- Client: `src/gmcp/Client/Keystrokes.ts` (never-type, staging pause, key help), `src/stores/keystrokesStore.ts`
(`resumeOnSend`, `pauseKeysMode`, `commandSent`), `src/components/input.tsx` (handleSend calls `commandSent`,
announces "Keys mode on"), `src/reservedShortcuts.ts` (Alt+Shift+F1 reserved), `KeystrokePreferences.tsx` text,
`docs/keystrokes.md`. `npx vitest run src/gmcp/Client/Keystrokes.test.ts`: 33 passed.
- Mudlet: `keys/keys.lua`, `tests/test_keys.lua`, `README.md`. `lua tests/test_keys.lua`: all passed.
Chrome check (2026-10-24, dev server :5179 from this worktree, live MOO, guest Tangra_Guest): the Yahtzee layer
was injected through `mudClient.gmcp.packageHandlers["Client.Keystrokes"]` (payload recorded from the live MOO
2026-10-18), keys pressed through the extension (CDP key events, not OS-level). Keys mode on: `x`, `s`, Space left the
input "" and `s` sent `sheet`. Shift+S then "chance" gave "score chance"; Enter sent it and said "Keys mode on"; `x`
then did not type. Alt+Shift+F1 reached the page; help said "S: Scorecards", "X: not bound"; Escape said "Key help
off". Found "Alt+Shift+H: Alt+Shift+H turns..." doubled the key; reserved keys now say the reason alone.
NVDA and a real OS-level Alt+Shift+F1 not tried.

## Decisions and why
Before: client `handleKeydown` let unbound keys type and let every typing key type once the input had text
(acdc46f5, so a staged `score ` could be finished). Mudlet `install` installed only bound keys; `fire` typed the
letter itself when the line had text.

Q's answers:
- Staged command (autosend false) in keys mode: fills the input, keys mode off ("Keys mode off"); when the player
sends, back on ("Keys mode on"). Cleared if the last modal layer leaves, or on a manual toggle.
- Mudlet Space: package-only fix; spec + README say plain Space still types in Mudlet. No upstream PR.
- Key help: NVDA+1 style on Alt+Shift+F1. Each key says "S: Scorecards", "X: not bound", or the client's reserved
reason; sends nothing. Escape (web) or Alt+Shift+F1 ends it.
- MOO changelog post #2667 says keys type once the box has text; it's a past post, left alone. A new changelog entry
when this ships is MOO-side and Q's call.

Client never-type rule: in keys mode a keydown with a one-character `event.key` and no Ctrl/Alt/Meta is always
preventDefault'ed. Mudlet: in keys mode, no-op tempKeys on every mudlet.key code 0x21..0xff, plain and Shift, below the
real bindings (a matched binding is accepted, not typed: TCommandLine.cpp:682-689).

## Surprises
Mudlet source (Mudlet/Mudlet master via gh api):
- `TCommandLine.cpp:327-335`: plain/Shift Space never reaches key bindings; always types.
- `TCommandLine.cpp:575-586`: plain Escape never reaches key bindings (selects the line). Mudlet key help ends on
Alt+Shift+F1 only.
- `TCommandLine.cpp:693-696`: dead-key/IME input bypasses key bindings. Web client also returns early on
`isComposing`, so IME composition can still type in keys mode in both. Not handled.
- `ctelnet.cpp:1833-1840`: sysDataSendRequest fires for every send, the package's own too; `K.sending` guards it.
- Mudlet key help covers only bound keys and character keys; other unbound keys act as normal.

## Unverified
Whether Chrome, Firefox, NVDA or Mudlet's own shortcuts take Alt+Shift+F1.

## Next step
PRs open: react-client #205 (commit ae36c6a7), mudlet-package #1 (commit 0eed11d; that repo has no CI). Read #205's
CI result: "Tests and production build" passed in 4m18s on ae36c6a7 (run 40135762922); Deploy skipped, as on every
PR. Merge and the Mudlet release (v4, `$client_gui` url) are Q's.
24 changes: 20 additions & 4 deletions docs/keystrokes.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,26 @@ bindings fire only in **keys mode**. Every other binding fires whenever focus al
- When a modal layer arrives, the client says "Scrabble keys available. Alt+Shift+H for keys mode."
It does not switch keys mode on by itself.
- When the last modal layer goes away, keys mode switches off and the client says so.
- In keys mode, keys that no layer binds still type as usual, and Enter still sends.
- In keys mode, a key without Ctrl, Alt or Meta fires only while the command input is empty. Once it
has text (a staged `score `, or anything the player started typing), keys type, so the command can
be finished. Sending clears the input and the keys work again.
- In keys mode a key never types into the command input, whether a layer binds it or not, and
whatever the input holds. Enter still sends, and Backspace, the arrows and Tab still do what they
do when no layer binds them.
- A binding with `autosend` false (a staged `score `) puts its command in the input for the player to
finish. If keys mode is on, it switches off and says "Keys mode off", so the player can type the
rest. When the player sends the command, keys mode switches back on and says "Keys mode on".
- Mudlet's command line always types a plain Space and Shift+Space: it never offers them to key
bindings. In Mudlet, Space types in keys mode and a layer's Space binding does not fire.

## Key help

**Alt+Shift+F1** turns key help on and off. It belongs to the client and no layer can bind it. Turning
it on says "Key help on. Press a key to hear what it does. Escape ends key help."

In key help each key says what it does and does nothing else: "S: Scorecards" for a binding (whether
or not keys mode is on), the reason for a key the client keeps ("Alt+Shift+H turns keys mode on and
off"), "X: not bound" for any other key. Escape or Alt+Shift+F1 ends key help and says "Key help off".

In Mudlet, key help describes the bound keys and the character keys; other keys keep doing what they
do. Mudlet keeps a plain Escape for itself, so there only Alt+Shift+F1 ends key help.

A non-modal layer is announced on arrival as "Helm console keys: 6". A layer leaving is announced as
"Helm console keys removed".
Expand Down
2 changes: 1 addition & 1 deletion src/components/KeystrokePreferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ const KeystrokePreferencesTab: React.FC = () => {
<div>
<p id="keystroke-preferences-help">
Keys mode is {keysMode ? "on" : "off"}. Alt+Shift+H in the command input or output turns it
on and off. To change a key, choose it, press Enter, then press the new key; Escape cancels.
on and off; Alt+Shift+F1 turns key help on and off. To change a key, choose it, press Enter, then press the new key; Escape cancels.
</p>
{layers.length === 0 && <p>No keys yet. A game or console you use adds its keys here.</p>}
{layers.map((layer) => (
Expand Down
3 changes: 3 additions & 0 deletions src/components/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import type React from "react";
import { useRef, useEffect, useCallback } from "react";
import { CommandHistory } from "../CommandHistory";
import "./input.css";
import { announce } from "@react-aria/live-announcer";
import { useInputStore } from "../stores/inputStore";
import { useKeystrokesStore } from "../stores/keystrokesStore";
import { useRoomStore } from "../stores/roomStore";
import { registerCommandInput } from "../inputFocus";
import VoiceCommandButton from "./VoiceCommandButton";
Expand Down Expand Up @@ -120,6 +122,7 @@ const CommandInput = ({ onSend, inputRef }: Props) => {
// From the store, not the render: a voice command sets the text and sends at once.
const currentInput = useInputStore.getState().text;
onSend(currentInput);
if (useKeystrokesStore.getState().commandSent()) announce("Keys mode on", "assertive", 2000);
if (currentInput.trim() && owner) {
commandHistoryRef.current.addCommand(currentInput);
saveHistory();
Expand Down
94 changes: 86 additions & 8 deletions src/gmcp/Client/Keystrokes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,19 +302,51 @@ describe("GMCPClientKeystrokes", () => {
mockAnnounce.mockClear();
});

it("lets typing keys type once the command input has text, so a staged command can be finished", () => {
it("fires typing keys even when the command input has text", () => {
press(document.body, altShiftH);
useInputStore.getState().setText("score ");
useInputStore.getState().setText("half typed");

const typed = press(element("command-input"), { key: "p", code: "KeyP" });
expect(typed.defaultPrevented).toBe(false);
expect(client.sendCommand).not.toHaveBeenCalled();

useInputStore.getState().clear();
press(element("command-input"), { key: "p", code: "KeyP" });
expect(typed.defaultPrevented).toBe(true);
expect(client.sendCommand).toHaveBeenCalledWith("press p on #4936");
});

it("switches keys mode off for a staged command and back on when it is sent", () => {
handler.receiveRegisteredMessage("Layer", {
id: "#7",
label: "Yahtzee",
modal: true,
bindings: [{ key: "s", modifiers: ["shift"], command: "score ", label: "Score", autosend: false }],
});
press(document.body, altShiftH);

press(element("command-input"), { key: "S", code: "KeyS", shiftKey: true });
expect(useInputStore.getState().text).toBe("score ");
expect(useKeystrokesStore.getState().keysMode).toBe(false);
expect(mockAnnounce).toHaveBeenLastCalledWith("Keys mode off", "assertive", 2000);

const typed = press(element("command-input"), { key: "p", code: "KeyP" });
expect(typed.defaultPrevented).toBe(false);

expect(useKeystrokesStore.getState().commandSent()).toBe(true);
expect(useKeystrokesStore.getState().keysMode).toBe(true);
expect(useKeystrokesStore.getState().commandSent()).toBe(false);
});

it("does not turn keys mode back on after a staged command once the modal layers are gone", () => {
handler.receiveRegisteredMessage("Layer", { ...scrabbleLayer, bindings: [
{ key: "s", modifiers: ["shift"], command: "score ", label: "Score", autosend: false },
] });
press(document.body, altShiftH);
press(element("command-input"), { key: "S", code: "KeyS", shiftKey: true });

handler.receiveRegisteredMessage("RemoveLayer", { id: scrabbleLayer.id });

expect(useKeystrokesStore.getState().commandSent()).toBe(false);
expect(useKeystrokesStore.getState().keysMode).toBe(false);
});

it("still fires a Ctrl binding while the command input has text", () => {
handler.receiveRegisteredMessage("Layer", helmLayer);
useInputStore.getState().setText("half typed");
Expand Down Expand Up @@ -358,13 +390,15 @@ describe("GMCPClientKeystrokes", () => {
expect(useKeystrokesStore.getState().keysMode).toBe(false);
});

it("lets unbound keys type and Enter send in keys mode", () => {
it("never lets an unbound key type in keys mode, and lets Enter send", () => {
press(document.body, altShiftH);

const typed = press(element("command-input"), { key: "x", code: "KeyX" });
const space = press(element("command-input"), { key: " ", code: "Space" });
const enter = press(element("command-input"), { key: "Enter", code: "Enter" });

expect(typed.defaultPrevented).toBe(false);
expect(typed.defaultPrevented).toBe(true);
expect(space.defaultPrevented).toBe(true);
expect(enter.defaultPrevented).toBe(false);
expect(client.sendCommand).not.toHaveBeenCalled();
});
Expand All @@ -383,6 +417,50 @@ describe("GMCPClientKeystrokes", () => {
});
});

describe("key help", () => {
const altShiftF1 = { key: "F1", code: "F1", altKey: true, shiftKey: true };

beforeEach(() => {
handler.receiveRegisteredMessage("Layer", scrabbleLayer);
press(document.body, altShiftF1);
});

it("says what each key does and does nothing else", () => {
expect(mockAnnounce).toHaveBeenLastCalledWith(
"Key help on. Press a key to hear what it does. Escape ends key help.", "assertive", 2000);

const bound = press(element("command-input"), { key: "p", code: "KeyP" });
expect(mockAnnounce).toHaveBeenLastCalledWith("P: Place the selected tile", "assertive", 2000);
press(element("command-input"), { key: "x", code: "KeyX" });
expect(mockAnnounce).toHaveBeenLastCalledWith("X: not bound", "assertive", 2000);
press(element("command-input"), altShiftH);
expect(mockAnnounce).toHaveBeenLastCalledWith("Alt+Shift+H turns keys mode on and off", "assertive", 2000);
const enter = press(element("command-input"), { key: "Enter", code: "Enter" });

expect(bound.defaultPrevented).toBe(true);
expect(enter.defaultPrevented).toBe(true);
expect(client.sendCommand).not.toHaveBeenCalled();
expect(useKeystrokesStore.getState().keysMode).toBe(false);
});

it("lets a lone modifier through", () => {
const shift = press(element("command-input"), { key: "Shift", code: "ShiftLeft", shiftKey: true });

expect(shift.defaultPrevented).toBe(false);
});

it("ends with Escape or Alt+Shift+F1", () => {
press(element("command-input"), { key: "Escape", code: "Escape" });
expect(mockAnnounce).toHaveBeenLastCalledWith("Key help off", "assertive", 2000);
press(element("command-input"), { key: "p", code: "KeyP" });
expect(client.sendCommand).not.toHaveBeenCalled();

press(document.body, altShiftF1);
press(document.body, altShiftF1);
expect(mockAnnounce).toHaveBeenLastCalledWith("Key help off", "assertive", 2000);
});
});

it("stops listening on shutdown", () => {
handler.receiveRegisteredMessage("Layer", helmLayer);
handler.shutdown();
Expand Down
65 changes: 56 additions & 9 deletions src/gmcp/Client/Keystrokes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@ import { inbound, outbound } from "../../protocol/messages";
import { GMCPMessage, GMCPPackage } from "../package";
import { useInputStore } from "../../stores/inputStore";
import {
bindingLabel,
findBinding,
resolveLayer,
type KeystrokeBinding,
type KeystrokeLayer,
useKeystrokesStore,
} from "../../stores/keystrokesStore";
import { usePreferences } from "../../stores/preferencesStore";
import { chordsEqual, parseChord } from "../../keystrokeChords";
import { isKeysModeToggle } from "../../reservedShortcuts";
import { chordFromEvent, chordsEqual, formatChord, parseChord } from "../../keystrokeChords";
import { isKeysHelpToggle, isKeysModeToggle, reservedReason } from "../../reservedShortcuts";
import { gmcpJsonMessage } from "../messages";

/** A binding as it arrives on the wire. */
Expand Down Expand Up @@ -113,12 +114,18 @@ function keysWorkAt(target: EventTarget | null): boolean {
return target.closest('.output[role="region"]') !== null && !isTextField(target);
}

/** A key press that would put a character in the command input. */
function typesCharacter(event: KeyboardEvent): boolean {
return event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey;
}

function say(message: string): void {
announce(message, "assertive", 2000);
}

export class GMCPClientKeystrokes extends GMCPClientKeystrokesBase {
private readonly boundKeyDownHandler = (event: KeyboardEvent) => this.handleKeydown(event);
private keysHelp = false;

constructor(client: ConstructorParameters<typeof GMCPClientKeystrokesBase>[0]) {
super(client);
Expand All @@ -139,12 +146,26 @@ export class GMCPClientKeystrokes extends GMCPClientKeystrokesBase {
}

override reset(): void {
this.keysHelp = false;
useKeystrokesStore.getState().clearLayers();
}

private handleKeydown(event: KeyboardEvent): void {
if (event.repeat || event.isComposing || !keysWorkAt(event.target)) return;

if (isKeysHelpToggle(event)) {
event.preventDefault();
event.stopPropagation();
this.keysHelp = !this.keysHelp;
say(this.keysHelp ? "Key help on. Press a key to hear what it does. Escape ends key help." : "Key help off");
return;
}

if (this.keysHelp) {
this.describeKey(event);
return;
}

if (isKeysModeToggle(event)) {
event.preventDefault();
event.stopPropagation();
Expand All @@ -153,19 +174,45 @@ export class GMCPClientKeystrokes extends GMCPClientKeystrokesBase {
}

const { layers, keysMode } = useKeystrokesStore.getState();
// Once the command input has text, typing keys type: a staged command can be finished.
const typingKeysFire = keysMode && useInputStore.getState().text === "";
const found = findBinding(layers, usePreferences.getState().keystrokes, typingKeysFire, event);
if (!found) return;
const found = findBinding(layers, usePreferences.getState().keystrokes, keysMode, event);
// In keys mode a key never types, bound or not.
if (!found && !(keysMode && typesCharacter(event))) return;

event.preventDefault();
event.stopPropagation();
if (!found) return;
const command = this.parseCommand(found.binding.command, useInputStore.getState().text);
if (found.binding.autosend) {
this.client.sendCommand(command);
} else {
useInputStore.getState().setText(command);
return;
}
useInputStore.getState().setText(command);
if (keysMode) {
// The player types the rest; sending it turns keys mode back on.
useKeystrokesStore.getState().pauseKeysMode();
say("Keys mode off");
}
}

/** Key help: says what a key does, and does nothing else. */
private describeKey(event: KeyboardEvent): void {
const chord = chordFromEvent(event);
if (!chord) return;
event.preventDefault();
event.stopPropagation();
if (chord.key === "escape" && chord.modifiers.length === 0) {
this.keysHelp = false;
say("Key help off");
return;
}
const { layers } = useKeystrokesStore.getState();
const found = findBinding(layers, usePreferences.getState().keystrokes, true, event);
const keyName = formatChord(found?.chord ?? chord);
if (found) {
say(`${keyName}: ${bindingLabel(found.binding)}`);
return;
}
say(reservedReason(chord) ?? `${keyName}: not bound`);
}

private toggleKeysMode(): void {
Expand Down Expand Up @@ -220,7 +267,7 @@ export class GMCPClientKeystrokes extends GMCPClientKeystrokesBase {
if (!removed) return undefined;
const modalLeft = useKeystrokesStore.getState().layers.some((layer) => layer.modal);
const keysModeEnds = removed.modal && !modalLeft && store.keysMode;
if (keysModeEnds) store.setKeysMode(false);
if (removed.modal && !modalLeft) store.setKeysMode(false);
if (removed.id === SERVER_KEYS_ID) return keysModeEnds ? "Keys mode off" : undefined;
return keysModeEnds ? `${removed.label} keys removed. Keys mode off` : `${removed.label} keys removed`;
}
Expand Down
Loading
Loading