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:
roboomp
2026-08-11 09:28:10 +00:00
parent 45e12e5bb7
commit 097845bff0
7 changed files with 132 additions and 19 deletions
+4
View File
@@ -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", () => {