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
15 changes: 13 additions & 2 deletions src/app/useHotkeys.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,22 @@ describe("useHotkeys — Console-only listener (#1218)", () => {
expect(ev.defaultPrevented).toBe(false);
});

it("does not navigate screens from off-Console (F1–F6 are Console-only now)", () => {
it("navigates screens from off-Console via the always-on F-key listener (#2403)", () => {
// #1218 accidentally made F-key screen nav Console-only; the global listener restores it everywhere.
useAppStore.setState({ activeWorkspace: "settings" });
renderHook(() => useHotkeys());

const ev = pressKey({ code: "F1" }); // screen-console hotkey
const ev = pressKey({ code: "F6" }); // screen-github

expect(useAppStore.getState().activeWorkspace).toBe("github");
expect(ev.defaultPrevented).toBe(true);
});

it("F1 no longer navigates — the console screen hotkey was removed (#2403)", () => {
useAppStore.setState({ activeWorkspace: "settings" });
renderHook(() => useHotkeys());

const ev = pressKey({ code: "F1" });

expect(useAppStore.getState().activeWorkspace).toBe("settings");
expect(ev.defaultPrevented).toBe(false);
Expand Down
44 changes: 37 additions & 7 deletions src/app/useHotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ import {
type RebindableId,
} from "@/features/settings";
import { VIEW_ORDER } from "@/app/console/panes/viewDefs";
import type { Workspace } from "@/app/chrome/Rail";

/** If `e` matches a screen-navigation F-key (per the active bindings), return the target workspace, else
* null. Shared by the always-on global screen-nav listener (every page) and the Console pane-hotkey
* effect, so the F-key → screen map lives in exactly one place. */
function screenForEvent(e: KeyboardEvent, bindings: Parameters<typeof matchesBinding>[1]): Workspace | null {
for (const h of SCREEN_HOTKEYS) {
if (matchesBinding(e, bindings, `screen-${h.screen}` as RebindableId)) return h.screen;
}
return null;
}

function keyToTermBytes(e: KeyboardEvent): string | null {
const { key, ctrlKey, altKey, shiftKey } = e;
Expand Down Expand Up @@ -83,6 +94,24 @@ export function useHotkeys() {
const digitBufferRef = useRef("");
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Screen-navigation F-keys switch rail workspaces, so they must fire on EVERY page — NOT just the
// Console (the pane hotkeys below are Console-only per #1218, which accidentally made F-key nav
// Console-only too — #2403). This always-on listener handles them OFF the Console page; on the Console
// page the pane-hotkey effect below handles nav itself (after its broadcast intercept, so an F-key still
// broadcasts in broadcast mode), so this one is gated off-Console to avoid double-firing.
useEffect(() => {
if (activeWorkspace === "console") return;
function onKeyDown(e: KeyboardEvent) {
const t = e.target as HTMLElement;
// Don't yank focus away while the user is typing in a field (matches the Console effect's guard).
if (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable) return;
const navTo = screenForEvent(e, useAppStore.getState().keybindings);
if (navTo) { e.preventDefault(); setWorkspace(navTo); }
}
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
}, [activeWorkspace, setWorkspace]);

useEffect(() => {
// #1218: the hotkeys are Console-only. Don't attach the document listeners at all unless the
// Console page is active AND no pane is maximized — so every other screen, and a maximized
Expand Down Expand Up @@ -263,13 +292,14 @@ export function useHotkeys() {
// Plain typing in inputs is fine; modifier combos still fire
if (inInput && !e.ctrlKey && !e.metaKey && !e.altKey) return;

// Screen navigation (F1–F6 by default, rebindable per screen #773).
for (const h of SCREEN_HOTKEYS) {
if (matchesBinding(e, bindings, `screen-${h.screen}` as RebindableId)) {
e.preventDefault();
setWorkspace(h.screen);
return;
}
// Screen navigation (F-keys, rebindable per screen #773) — handled here too so it works on the
// Console page. It sits AFTER the broadcast intercept above, so in broadcast mode an F-key is sent
// to the panes rather than navigating (unchanged). Off-Console, the always-on effect above owns it.
const navTo = screenForEvent(e, bindings);
if (navTo) {
e.preventDefault();
setWorkspace(navTo);
return;
}

// ── CTRL = SELECT ─────────────────────────────────────────────────────
Expand Down
6 changes: 3 additions & 3 deletions src/features/settings/cards/KeyboardCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,11 @@ describe("KeyboardCard", () => {

it("screen-nav and zoom rows are now rebindable (#773)", () => {
render(<KeyboardCard />);
// Screen nav: "Go to Console" can be rebound.
const navBtn = screen.getByRole("button", { name: /Rebind Go to Console/ });
// Screen nav: "Go to Planner" can be rebound.
const navBtn = screen.getByRole("button", { name: /Rebind Go to Planner/ });
fireEvent.click(navBtn);
fireEvent.keyDown(document, { code: "F9" });
expect(useAppStore.getState().keybindings["screen-console"]).toBe("F9");
expect(useAppStore.getState().keybindings["screen-projects"]).toBe("F9");

// Zoom: "Increase terminal font size" can be rebound.
const zoomBtn = screen.getByRole("button", { name: /Rebind Increase terminal font size/ });
Expand Down
8 changes: 4 additions & 4 deletions src/features/settings/lib/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,16 +116,16 @@ describe("registry wiring", () => {
});

it("screen-nav and zoom are rebindable single chords (#773)", () => {
expect(DEFAULT_BINDINGS["screen-console"]).toBe("F1");
expect(DEFAULT_BINDINGS["screen-projects"]).toBe("F2");
expect(DEFAULT_BINDINGS["screen-github"]).toBe("F6");
expect(DEFAULT_BINDINGS["screen-settings"]).toBe("F8");
expect(DEFAULT_BINDINGS["zoom-in"]).toBe("Ctrl+Equal");
expect(DEFAULT_BINDINGS["zoom-reset"]).toBe("Ctrl+Digit0");
// A bare F-key serializes to just the code, with no modifiers.
expect(eventToChord(ev({ code: "F1" }))).toBe("F1");
expect(matchesBinding(ev({ code: "F1" }), {}, "screen-console")).toBe(true);
expect(eventToChord(ev({ code: "F2" }))).toBe("F2");
expect(matchesBinding(ev({ code: "F2" }), {}, "screen-projects")).toBe(true);
// Conflict detection spans the whole rebindable set, screen + console actions included.
expect(findConflict({}, "screen-automation", "F1")).toBe("screen-console");
expect(findConflict({}, "screen-automation", "F2")).toBe("screen-projects");
});

it("chordToCaps labels the zoom symbol codes", () => {
Expand Down
2 changes: 0 additions & 2 deletions src/features/settings/lib/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ export const REBINDABLE_IDS = [
"clear-input",
"redraw-pane",
"send-all-enter",
"screen-console",
"screen-projects",
"screen-skills",
"screen-automation",
Expand Down Expand Up @@ -52,7 +51,6 @@ export const DEFAULT_BINDINGS: Record<RebindableId, string> = {
"clear-input": "Ctrl+Shift+Backspace",
"redraw-pane": "Ctrl+Shift+KeyR",
"send-all-enter": "Alt+Shift+Enter",
"screen-console": "F1",
"screen-projects": "F2",
"screen-skills": "F3",
"screen-automation": "F4",
Expand Down
5 changes: 3 additions & 2 deletions src/features/settings/lib/shortcuts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ describe("shortcuts registry", () => {
for (const h of SCREEN_HOTKEYS) {
expect(SCREEN_KEY_MAP[h.key]).toBe(h.screen);
}
// Spot-check the canonical F-key bindings.
expect(SCREEN_KEY_MAP.F1).toBe("console");
// Spot-check the canonical F-key bindings. F1 → Console was retired (#2372/#2403).
expect(SCREEN_KEY_MAP.F1).toBeUndefined();
expect(SCREEN_KEY_MAP.F2).toBe("projects");
expect(SCREEN_KEY_MAP.F6).toBe("github");
expect(SCREEN_KEY_MAP.F7).toBe("agents");
expect(SCREEN_KEY_MAP.F8).toBe("settings");
Expand Down
6 changes: 2 additions & 4 deletions src/features/settings/lib/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,9 @@ export interface ScreenHotkey {
label: string;
}

/** F1–F6 navigate the rail screens. The one definition both the handler and the
* reference derive from. */
/** F2–F8 navigate the rail screens. The one definition both the handler and the reference derive from.
* (F1 → Console was removed with the console page's retirement, #2372/#2403.) */
export const SCREEN_HOTKEYS: ScreenHotkey[] = [
{ key: "F1", screen: "console", label: "Console" },
{ key: "F2", screen: "projects", label: "Planner" },
{ key: "F3", screen: "skills", label: "Skills" },
{ key: "F4", screen: "automation", label: "Automations" },
Expand Down Expand Up @@ -107,7 +106,6 @@ export interface ShortcutDef {
/** Single source of truth for every keyboard shortcut in the app. The Keyboard settings page
* derives its reference list from this registry; the `useHotkeys` handler (app shell) consumes it. */
export const SHORTCUT_REGISTRY: ShortcutDef[] = [
{ id: "screen-console", label: "Go to Console", keys: "F1", description: "Switch to the Console screen" },
{ id: "screen-automation", label: "Go to Automations", keys: "F3", description: "Switch to the Automations screen" },
{ id: "screen-github", label: "Go to GitHub", keys: "F4", description: "Switch to the GitHub screen" },
{ id: "screen-projects", label: "Go to Projects", keys: "F5", description: "Switch to the Projects screen" },
Expand Down
Loading