diff --git a/agent-notes/fix-keys-mode-never-types.md b/agent-notes/fix-keys-mode-never-types.md new file mode 100644 index 00000000..b0d1fe10 --- /dev/null +++ b/agent-notes/fix-keys-mode-never-types.md @@ -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. diff --git a/docs/keystrokes.md b/docs/keystrokes.md index 74e9eb29..8fc4e604 100644 --- a/docs/keystrokes.md +++ b/docs/keystrokes.md @@ -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". diff --git a/src/components/KeystrokePreferences.tsx b/src/components/KeystrokePreferences.tsx index 40cdd042..3718dffd 100644 --- a/src/components/KeystrokePreferences.tsx +++ b/src/components/KeystrokePreferences.tsx @@ -101,7 +101,7 @@ const KeystrokePreferencesTab: React.FC = () => {

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.

{layers.length === 0 &&

No keys yet. A game or console you use adds its keys here.

} {layers.map((layer) => ( diff --git a/src/components/input.tsx b/src/components/input.tsx index 16c5f1e4..5c1b4741 100644 --- a/src/components/input.tsx +++ b/src/components/input.tsx @@ -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"; @@ -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(); diff --git a/src/gmcp/Client/Keystrokes.test.ts b/src/gmcp/Client/Keystrokes.test.ts index de97319a..8dda3b2c 100644 --- a/src/gmcp/Client/Keystrokes.test.ts +++ b/src/gmcp/Client/Keystrokes.test.ts @@ -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"); @@ -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(); }); @@ -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(); diff --git a/src/gmcp/Client/Keystrokes.ts b/src/gmcp/Client/Keystrokes.ts index e9721c62..9fa44055 100644 --- a/src/gmcp/Client/Keystrokes.ts +++ b/src/gmcp/Client/Keystrokes.ts @@ -3,6 +3,7 @@ import { inbound, outbound } from "../../protocol/messages"; import { GMCPMessage, GMCPPackage } from "../package"; import { useInputStore } from "../../stores/inputStore"; import { + bindingLabel, findBinding, resolveLayer, type KeystrokeBinding, @@ -10,8 +11,8 @@ import { 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. */ @@ -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[0]) { super(client); @@ -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(); @@ -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 { @@ -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`; } diff --git a/src/reservedShortcuts.ts b/src/reservedShortcuts.ts index c09c368e..fea4ae06 100644 --- a/src/reservedShortcuts.ts +++ b/src/reservedShortcuts.ts @@ -22,6 +22,14 @@ export function isKeysModeToggle(event: KeyboardEvent): boolean { return event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey && event.code === "KeyH"; } +// F1 is the same key on every layout; plain F1 stays the games' (rules). +export const KEYS_HELP_CHORD: Chord = { key: "f1", modifiers: ["alt", "shift"] }; + +/** Alt+Shift+F1: key help, where each key says what it does instead of doing it. */ +export function isKeysHelpToggle(event: KeyboardEvent): boolean { + return event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey && event.code === "F1"; +} + export const HEAD_RECENTER_CHORD: Chord = { key: "o", modifiers: ["alt", "shift"] }; /** Alt+Shift+O by physical key: recenters head tracking (src/audio/headTracking.ts). */ @@ -51,6 +59,11 @@ const RESERVED_SHORTCUTS: readonly ReservedShortcut[] = [ modifiers: KEYS_MODE_CHORD.modifiers, reason: "Alt+Shift+H turns keys mode on and off", }, + { + keys: [KEYS_HELP_CHORD.key], + modifiers: KEYS_HELP_CHORD.modifiers, + reason: "Alt+Shift+F1 turns key help on and off", + }, { keys: [HEAD_RECENTER_CHORD.key], modifiers: HEAD_RECENTER_CHORD.modifiers, diff --git a/src/stores/keystrokesStore.ts b/src/stores/keystrokesStore.ts index fc454e5d..d791fc40 100644 --- a/src/stores/keystrokesStore.ts +++ b/src/stores/keystrokesStore.ts @@ -40,6 +40,8 @@ interface KeystrokesState { /** In arrival order; the last one wins a key two layers bind. */ layers: KeystrokeLayer[]; keysMode: boolean; + /** A staged command turned keys mode off; sending it turns keys mode back on. */ + resumeOnSend: boolean; /** Adds or replaces a layer; true when its id is new. */ putLayer: (layer: KeystrokeLayer) => boolean; /** Replaces a layer where it stands, or adds it at the end. */ @@ -47,11 +49,16 @@ interface KeystrokesState { removeLayer: (id: string) => KeystrokeLayer | undefined; clearLayers: () => void; setKeysMode: (on: boolean) => void; + /** Keys mode off until the player sends the command a binding staged. */ + pauseKeysMode: () => void; + /** The player sent a command; true when that turned keys mode back on. */ + commandSent: () => boolean; } export const useKeystrokesStore = create((set, get) => ({ layers: [], keysMode: false, + resumeOnSend: false, putLayer: (layer) => { const others = get().layers.filter((existing) => existing.id !== layer.id); const isNew = others.length === get().layers.length; @@ -71,8 +78,14 @@ export const useKeystrokesStore = create((set, get) => ({ if (removed) set({ layers: get().layers.filter((layer) => layer !== removed) }); return removed; }, - clearLayers: () => set({ layers: [], keysMode: false }), - setKeysMode: (keysMode) => set({ keysMode }), + clearLayers: () => set({ layers: [], keysMode: false, resumeOnSend: false }), + setKeysMode: (keysMode) => set({ keysMode, resumeOnSend: false }), + pauseKeysMode: () => set({ keysMode: false, resumeOnSend: true }), + commandSent: () => { + if (!get().resumeOnSend) return false; + set({ keysMode: true, resumeOnSend: false }); + return true; + }, })); export function bindingLabel(binding: KeystrokeBinding): string {