From 097845bff036d2cec1ae73825b9ca8f25367e356 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 11 Aug 2026 09:28:10 +0000 Subject: [PATCH] fix(tui): render platform-aware modifier labels on macOS Key hints resolved modifier tokens through a static, platform-agnostic label map with no `super` entry, so on macOS the shipped `super+v` paste default rendered 'Super+V' (no such key on a Mac) and `alt` always rendered 'Alt' instead of 'Option'. The static /hotkeys navigation rows were hardcoded with macOS 'Option'/'Cmd' names on every platform, so Linux/Windows users saw 'Cmd+Left'. Modifier labels are now platform-aware: on darwin `alt` renders 'Option' and `super` renders 'Cmd'; every other platform keeps 'Alt'/'Super'. The platform is resolved through a single seam (setKeyHintPlatform/keyHintPlatform) mirroring the TUI's setKittyProtocolActive, keeping hint output deterministic in tests without mutating process.platform. The static /hotkeys rows use the same convention and drop the macOS-only Cmd line-start/end fragments (which map to no binding) off darwin. Fixes #8235 --- packages/coding-agent/CHANGELOG.md | 4 ++ .../coding-agent/src/config/keybindings.ts | 61 ++++++++++++++++--- .../src/modes/utils/hotkeys-markdown.ts | 16 +++-- .../test/keybindings-display.test.ts | 33 +++++++++- .../command-controller-hotkeys.test.ts | 26 +++++++- .../test/prompt-action-autocomplete.test.ts | 7 ++- .../test/tools/render-utils.test.ts | 4 +- 7 files changed, 132 insertions(+), 19 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a4d66e8d2..8b0674b1c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed macOS key hints rendering the Linux/Windows modifier names `Alt` and `Super` across every hint surface (`/hotkeys`, status bar, autocomplete, pending-message bar, copy selector, ask dialog): `alt` now renders as `Option` and `super` as `Cmd` on darwin, and the static `/hotkeys` navigation rows are platform-aware instead of hardcoding macOS `Option`/`Cmd` names on every platform ([#8235](https://github.com/can1357/oh-my-pi/issues/8235)). + ## [17.2.12] - 2026-08-08 ### Fixed diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index 6408cfad9..334af0f6a 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -654,12 +654,52 @@ export class KeybindingsManager extends TuiKeybindingsManager { /** * Key hint formatting utilities for UI labels. + * + * Modifier labels are platform-aware: macOS names the physical keys `Option` + * (`alt`) and `Cmd` (`super`), so rendering `Alt`/`Super` there would name keys + * absent from a Mac keyboard. Every other platform keeps `Alt`/`Super`. */ -const MODIFIER_LABELS: Record = { - ctrl: "Ctrl", - shift: "Shift", - alt: "Alt", -}; + +/** + * Platform override for key-hint rendering; `undefined` resolves to the host + * `process.platform`. Mirrors `setKittyProtocolActive` in the TUI keys module: + * a single seam that keeps hint output deterministic in tests without mutating + * the global `process.platform`. + */ +let keyHintPlatformOverride: NodeJS.Platform | undefined; + +/** Pin the platform used to render modifier labels (test seam). */ +export function setKeyHintPlatform(platform: NodeJS.Platform | undefined): void { + keyHintPlatformOverride = platform; +} + +/** Platform currently used for key-hint rendering. */ +export function keyHintPlatform(): NodeJS.Platform { + return keyHintPlatformOverride ?? process.platform; +} + +type Modifier = "ctrl" | "shift" | "alt" | "super"; + +function isModifier(part: string): part is Modifier { + return part === "ctrl" || part === "shift" || part === "alt" || part === "super"; +} + +/** + * Human label for a modifier, using each platform's own key names. `ctrl` and + * `shift` are the same everywhere; `alt`/`super` become `Option`/`Cmd` on macOS. + */ +export function modifierLabel(mod: Modifier, platform: NodeJS.Platform = keyHintPlatform()): string { + switch (mod) { + case "ctrl": + return "Ctrl"; + case "shift": + return "Shift"; + case "alt": + return platform === "darwin" ? "Option" : "Alt"; + case "super": + return platform === "darwin" ? "Cmd" : "Super"; + } +} const KEY_LABELS: Record = { esc: "Esc", @@ -680,10 +720,9 @@ const KEY_LABELS: Record = { right: "Right", }; -function formatKeyPart(part: string): string { +function formatKeyPart(part: string, platform: NodeJS.Platform): string { const lower = part.toLowerCase(); - const modifier = MODIFIER_LABELS[lower]; - if (modifier) return modifier; + if (isModifier(lower)) return modifierLabel(lower, platform); const label = KEY_LABELS[lower]; if (label) return label; if (part.length === 1) return part.toUpperCase(); @@ -691,7 +730,11 @@ function formatKeyPart(part: string): string { } export function formatKeyHint(key: KeyId): string { - return key.split("+").map(formatKeyPart).join("+"); + const platform = keyHintPlatform(); + return key + .split("+") + .map(part => formatKeyPart(part, platform)) + .join("+"); } export function formatKeyHints(keys: KeyId | KeyId[]): string { diff --git a/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts b/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts index 528241051..ed8bb035e 100644 --- a/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts +++ b/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts @@ -1,4 +1,4 @@ -import type { AppKeybinding, KeybindingsManager } from "../../config/keybindings"; +import { type AppKeybinding, type KeybindingsManager, keyHintPlatform, modifierLabel } from "../../config/keybindings"; export interface HotkeysMarkdownBindings { keybindings: Pick; @@ -9,21 +9,25 @@ function appKey(bindings: HotkeysMarkdownBindings, action: AppKeybinding): strin } export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string { + const platform = keyHintPlatform(); + const isMac = platform === "darwin"; + const alt = modifierLabel("alt", platform); + const cmd = modifierLabel("super", platform); return [ "**Navigation**", "| Key | Action |", "|-----|--------|", "| `Arrow keys` | Move cursor / browse history (Up when empty) |", - "| `Option+Left/Right` | Move by word |", - "| `Ctrl+A` / `Home` / `Cmd+Left` | Start of line |", - "| `Ctrl+E` / `End` / `Cmd+Right` | End of line |", + `| \`${alt}+Left/Right\` | Move by word |`, + isMac ? `| \`Ctrl+A\` / \`Home\` / \`${cmd}+Left\` | Start of line |` : "| `Ctrl+A` / `Home` | Start of line |", + isMac ? `| \`Ctrl+E\` / \`End\` / \`${cmd}+Right\` | End of line |` : "| `Ctrl+E` / `End` | End of line |", "", "**Editing**", "| Key | Action |", "|-----|--------|", "| `Enter` | Send message |", - "| `Shift+Enter` / `Alt+Enter` | New line |", - "| `Ctrl+W` / `Option+Backspace` | Delete word backwards |", + `| \`Shift+Enter\` / \`${alt}+Enter\` | New line |`, + `| \`Ctrl+W\` / \`${alt}+Backspace\` | Delete word backwards |`, "| `Ctrl+U` | Delete to start of line |", "| `Ctrl+K` | Delete to end of line |", `| \`${appKey(bindings, "app.clipboard.copyLine")}\` | Copy current line |`, diff --git a/packages/coding-agent/test/keybindings-display.test.ts b/packages/coding-agent/test/keybindings-display.test.ts index e2f191831..3bd75de8a 100644 --- a/packages/coding-agent/test/keybindings-display.test.ts +++ b/packages/coding-agent/test/keybindings-display.test.ts @@ -1,9 +1,16 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { getDefaultPasteImageKeys, KeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings"; +import { + getDefaultPasteImageKeys, + KeybindingsManager, + setKeyHintPlatform, +} from "@oh-my-pi/pi-coding-agent/config/keybindings"; import { keyText } from "@oh-my-pi/pi-coding-agent/extensibility/legacy-pi-coding-agent-shim"; import { getKeybindings, setKeybindings, type KeybindingsManager as TuiKeybindingsManager } from "@oh-my-pi/pi-tui"; describe("KeybindingsManager.getDisplayString", () => { + beforeEach(() => setKeyHintPlatform("linux")); + afterEach(() => setKeyHintPlatform(undefined)); + it("formats a single binding as a human-readable key hint", () => { const keybindings = KeybindingsManager.inMemory({ "app.message.dequeue": "alt+up", @@ -33,6 +40,28 @@ describe("KeybindingsManager.getDisplayString", () => { expect(keybindings.getDisplayString("app.clipboard.copyPrompt")).toBe(""); }); + + it("renders macOS modifier labels for alt and super", () => { + setKeyHintPlatform("darwin"); + const keybindings = KeybindingsManager.inMemory({ + "app.display.reset": "alt+l", + "app.clipboard.pasteImage": ["ctrl+v", "super+v"], + }); + + expect(keybindings.getDisplayString("app.display.reset")).toBe("Option+L"); + expect(keybindings.getDisplayString("app.clipboard.pasteImage")).toBe("Ctrl+V/Cmd+V"); + }); + + it("keeps Alt and Super labels off macOS", () => { + setKeyHintPlatform("linux"); + const keybindings = KeybindingsManager.inMemory({ + "app.display.reset": "alt+l", + "app.clipboard.pasteImage": ["ctrl+v", "super+v"], + }); + + expect(keybindings.getDisplayString("app.display.reset")).toBe("Alt+L"); + expect(keybindings.getDisplayString("app.clipboard.pasteImage")).toBe("Ctrl+V/Super+V"); + }); }); describe("legacy keyText", () => { @@ -40,10 +69,12 @@ describe("legacy keyText", () => { beforeEach(() => { previous = getKeybindings(); + setKeyHintPlatform("linux"); }); afterEach(() => { setKeybindings(previous); + setKeyHintPlatform(undefined); }); it("formats the active binding for legacy extensions", () => { diff --git a/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts b/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts index 74487a272..724ef9734 100644 --- a/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts +++ b/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it } from "bun:test"; +import { setKeyHintPlatform } from "@oh-my-pi/pi-coding-agent/config/keybindings"; import { buildHotkeysMarkdown } from "@oh-my-pi/pi-coding-agent/modes/utils/hotkeys-markdown"; describe("buildHotkeysMarkdown", () => { + afterEach(() => setKeyHintPlatform(undefined)); + it("emits flush-left markdown and uses the configured temporary selector hint", () => { const displayStrings: Record = { "app.clipboard.copyLine": "Alt+Shift+L", @@ -75,4 +78,25 @@ describe("buildHotkeysMarkdown", () => { expect(markdown).toContain("| `Disabled` | Select model (temporary) |"); expect(markdown).toContain("| `Alt+M` | Select model (set roles) |"); }); + + it("renders macOS static navigation rows on darwin", () => { + setKeyHintPlatform("darwin"); + const markdown = buildHotkeysMarkdown({ keybindings: { getDisplayString: () => "Disabled" } }); + + expect(markdown).toContain("| `Option+Left/Right` | Move by word |"); + expect(markdown).toContain("| `Ctrl+A` / `Home` / `Cmd+Left` | Start of line |"); + expect(markdown).toContain("| `Ctrl+W` / `Option+Backspace` | Delete word backwards |"); + expect(markdown).toContain("| `Shift+Enter` / `Option+Enter` | New line |"); + }); + + it("drops Option/Cmd static navigation labels off darwin", () => { + setKeyHintPlatform("linux"); + const markdown = buildHotkeysMarkdown({ keybindings: { getDisplayString: () => "Disabled" } }); + + expect(markdown).toContain("| `Alt+Left/Right` | Move by word |"); + expect(markdown).toContain("| `Ctrl+A` / `Home` | Start of line |"); + expect(markdown).toContain("| `Ctrl+W` / `Alt+Backspace` | Delete word backwards |"); + expect(markdown).not.toContain("Option+"); + expect(markdown).not.toContain("Cmd+"); + }); }); diff --git a/packages/coding-agent/test/prompt-action-autocomplete.test.ts b/packages/coding-agent/test/prompt-action-autocomplete.test.ts index 5d1ee3abc..b681434fc 100644 --- a/packages/coding-agent/test/prompt-action-autocomplete.test.ts +++ b/packages/coding-agent/test/prompt-action-autocomplete.test.ts @@ -1,5 +1,8 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { KeybindingsManager as AppKeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings"; +import { + KeybindingsManager as AppKeybindingsManager, + setKeyHintPlatform, +} from "@oh-my-pi/pi-coding-agent/config/keybindings"; import { createPromptActionAutocompleteProvider } from "@oh-my-pi/pi-coding-agent/modes/prompt-action-autocomplete"; import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "@oh-my-pi/pi-tui"; @@ -12,10 +15,12 @@ describe("prompt action autocomplete", () => { "tui.editor.undo": { defaultKeys: "f8", description: "Undo" }, }), ); + setKeyHintPlatform("linux"); }); afterEach(() => { setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS)); + setKeyHintPlatform(undefined); }); it("shows prompt actions with configured shortcut hints", async () => { diff --git a/packages/coding-agent/test/tools/render-utils.test.ts b/packages/coding-agent/test/tools/render-utils.test.ts index c86111bb4..62545d5f1 100644 --- a/packages/coding-agent/test/tools/render-utils.test.ts +++ b/packages/coding-agent/test/tools/render-utils.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import * as os from "node:os"; import * as path from "node:path"; -import { KeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings"; +import { KeybindingsManager, setKeyHintPlatform } from "@oh-my-pi/pi-coding-agent/config/keybindings"; import { getThemeByName, initTheme, type Theme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { dedupeParseErrors, @@ -328,9 +328,11 @@ describe("formatExpandHint / expandKeyHint", () => { let previous: TuiKeybindingsManager; beforeEach(() => { previous = getKeybindings(); + setKeyHintPlatform("linux"); }); afterEach(() => { setKeybindings(previous); + setKeyHintPlatform(undefined); }); it("reports the default tool-output expand key", () => {