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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
esc: "Esc",
|
||||
@@ -680,10 +720,9 @@ const KEY_LABELS: Record<string, string> = {
|
||||
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 {
|
||||
|
||||
@@ -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<KeybindingsManager, "getDisplayString">;
|
||||
@@ -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 |`,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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+");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user