Skip to content
Closed
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
5 changes: 5 additions & 0 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,8 @@ export function TerminalViewport({
}),
);
const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize });
const terminalOptionAsAlt = useClientSettings((settings) => settings.terminalOptionAsAlt);
const readTerminalOptionAsAlt = useEffectEvent(() => terminalOptionAsAlt);
const terminalSession = useAttachedTerminalSession({
environmentId,
terminal: {
Expand Down Expand Up @@ -591,6 +593,9 @@ export function TerminalViewport({
onResize: (cols, rows) => void resizeTerminal(cols, rows),
onSelectionChange: () => handleSelectionChange(),
beforeKey: (event) => handleBeforeKey(event),
get optionAsAlt() {
return readTerminalOptionAsAlt();
},
onLinkActivate: (text, event) => handleLinkActivate(text, event),
canActivateLink: (text) => canActivateTerminalLink(text),
// The surface listens from construction, so a right-click can land
Expand Down
97 changes: 96 additions & 1 deletion apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import { useScopedSettingsWriteAllowed } from "./useScopedSettings";
import { Spinner } from "~/components/ui/spinner";
import { NotificationSettings } from "./NotificationSettings";
import { PRIVACY_POLICY_URL } from "../../legalLinks";
import { ArchiveIcon, ArchiveX, CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react";
import {
ArchiveIcon,
ArchiveX,
CheckIcon,
ChevronRightIcon,
InfoIcon,
SettingsIcon,
} from "lucide-react";
import { Link, useNavigate } from "@tanstack/react-router";
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
Expand All @@ -28,6 +35,7 @@ import {
DEFAULT_UNIFIED_SETTINGS,
type ChatWidth,
type DiffLayout,
TerminalOptionAsAlt,
type EnvironmentIdentificationMode,
MAX_APPEARANCE_CONTRAST,
MAX_CODE_FONT_SIZE,
Expand Down Expand Up @@ -193,6 +201,7 @@ const SIDEBAR_PROJECT_SORT_ORDER_LABELS: Record<SidebarProjectSortOrder, string>
manual: "Manual",
};
const isSidebarProjectSortOrder = Schema.is(SidebarProjectSortOrder);
const isTerminalOptionAsAlt = Schema.is(TerminalOptionAsAlt);

const TIMESTAMP_FORMAT_LABELS = {
locale: "System default",
Expand All @@ -211,6 +220,13 @@ const DIFF_LAYOUT_LABELS: Record<DiffLayout, string> = {
split: "Split",
};

const TERMINAL_OPTION_AS_ALT_LABELS: Record<TerminalOptionAsAlt, string> = {
off: "Off",
left: "Left Option",
right: "Right Option",
both: "Both",
};

const QUIT_CONFIRMATION_MODE_LABELS: Record<QuitConfirmationMode, string> = {
direct: "Direct",
hold: "Hold",
Expand Down Expand Up @@ -604,6 +620,9 @@ export function useSettingsRestore(onRestored?: () => void) {
? ["Diff whitespace changes"]
: []),
...(settings.diffLayout !== DEFAULT_UNIFIED_SETTINGS.diffLayout ? ["Diff layout"] : []),
...(settings.terminalOptionAsAlt !== DEFAULT_UNIFIED_SETTINGS.terminalOptionAsAlt
? ["Option as Alt in terminal"]
: []),
...(settings.proactivePanelsEnabled !== DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled
? ["Proactive panels"]
: []),
Expand Down Expand Up @@ -691,6 +710,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.diffFilesCollapsed,
settings.diffIgnoreWhitespace,
settings.diffLayout,
settings.terminalOptionAsAlt,
settings.proactivePanelsEnabled,
settings.environmentIdentificationMode,
settings.contextWindowMeterEnabled,
Expand Down Expand Up @@ -796,6 +816,7 @@ export function useSettingsRestore(onRestored?: () => void) {
diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed,
diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout,
terminalOptionAsAlt: DEFAULT_UNIFIED_SETTINGS.terminalOptionAsAlt,
proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,
showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu,
composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll,
Expand Down Expand Up @@ -1703,6 +1724,78 @@ function FontSmoothingRow() {
);
}

function TerminalOptionAsAltRow() {
const settings = useScopedSettings();
const updateSettings = useUpdateScopedSettings();
if (!isMacPlatform(navigator.platform)) return null;
const { id, title } = searchableSetting("terminal-option-as-alt");
return (
<SettingsRow
id={id}
title={
<span className="inline-flex items-center gap-1.5">
{title}
<Tooltip>
<TooltipTrigger
delay={200}
render={
<Button size="icon-micro" variant="ghost-muted" aria-label="Option as Alt details">
<InfoIcon className="size-3.5" />
</Button>
}
/>
<TooltipPopup side="top">
When off, Option types the character your keyboard layout puts on that key. For
example, Option+L types @ on a German keyboard, and Option+B types ∫ on a US keyboard.
</TooltipPopup>
</Tooltip>
</span>
}
description="Use Option for shell shortcuts like Option+B instead of typing characters."
resetAction={
settings.terminalOptionAsAlt !== DEFAULT_UNIFIED_SETTINGS.terminalOptionAsAlt ? (
<SettingResetButton
label="Option as Alt"
onClick={() =>
updateSettings({
terminalOptionAsAlt: DEFAULT_UNIFIED_SETTINGS.terminalOptionAsAlt,
})
}
/>
) : null
}
control={
<Select
value={settings.terminalOptionAsAlt}
onValueChange={(value) => {
if (isTerminalOptionAsAlt(value)) {
updateSettings({ terminalOptionAsAlt: value });
}
}}
>
<SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Option as Alt">
<SelectValue>{TERMINAL_OPTION_AS_ALT_LABELS[settings.terminalOptionAsAlt]}</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem hideIndicator value="off">
{TERMINAL_OPTION_AS_ALT_LABELS.off}
</SelectItem>
<SelectItem hideIndicator value="left">
{TERMINAL_OPTION_AS_ALT_LABELS.left}
</SelectItem>
<SelectItem hideIndicator value="right">
{TERMINAL_OPTION_AS_ALT_LABELS.right}
</SelectItem>
<SelectItem hideIndicator value="both">
{TERMINAL_OPTION_AS_ALT_LABELS.both}
</SelectItem>
</SelectPopup>
</Select>
}
/>
);
}

function WordWrapRow() {
const settings = useScopedSettings();
const updateSettings = useUpdateScopedSettings();
Expand Down Expand Up @@ -2850,6 +2943,8 @@ export function GeneralSettingsPanel() {
}
/>

<TerminalOptionAsAltRow />

<SettingsRow
serverScoped
settingKeys={["enableProviderUpdateChecks"]}
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,13 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/general",
searchTerms: ["queue steer running turn send default behavior composer"],
},
{
id: "terminal-option-as-alt",
title: "Option as Alt in terminal",
to: "/settings/general",
searchTerms: ["alt key keyboard layout shell readline emacs macos international"],
macOnly: true,
},
{
id: "provider-update-checks",
title: "Provider update checks",
Expand Down
60 changes: 60 additions & 0 deletions apps/web/src/terminal/ghostty/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
clearPrimedTerminalCopyInput,
ghosttyMouseButton,
isTerminalAltGraphText,
isTerminalOptionComposedText,
isTerminalCompositionCommitInput,
isTerminalCompositionKey,
isTerminalCopyShortcut,
Expand Down Expand Up @@ -957,6 +958,65 @@ describe("isTerminalAltGraphText", () => {
});
});

describe("isTerminalOptionComposedText", () => {
const option = (key: string, extra: { ctrlKey?: boolean; metaKey?: boolean } = {}) => ({
key,
altKey: true,
ctrlKey: extra.ctrlKey ?? false,
metaKey: extra.metaKey ?? false,
});
const noneHeld = { left: false, right: false };
const leftHeld = { left: true, right: false };
const rightHeld = { left: false, right: true };

it("types layout characters made with Option on macOS", () => {
// German Option+L is @, and Option+Shift+7 is a backslash.
expect(isTerminalOptionComposedText(option("@"), "off", leftHeld, "MacIntel")).toBe(true);
expect(isTerminalOptionComposedText(option("\\"), "off", rightHeld, "MacIntel")).toBe(true);
});

it("leaves other platforms, chords, and non-character keys to the encoder", () => {
expect(isTerminalOptionComposedText(option("@"), "off", leftHeld, "Win32")).toBe(false);
expect(
isTerminalOptionComposedText(option("@", { ctrlKey: true }), "off", leftHeld, "MacIntel"),
).toBe(false);
expect(
isTerminalOptionComposedText(option("@", { metaKey: true }), "off", leftHeld, "MacIntel"),
).toBe(false);
expect(isTerminalOptionComposedText(option("ArrowLeft"), "off", leftHeld, "MacIntel")).toBe(
false,
);
// Option+Space is a non-breaking space on Apple layouts, which breaks shell commands.
expect(isTerminalOptionComposedText(option("\u00a0"), "off", leftHeld, "MacIntel")).toBe(false);
expect(
isTerminalOptionComposedText({ ...option("b"), altKey: false }, "off", noneHeld, "MacIntel"),
).toBe(false);
});

it("keeps Alt for the Option keys the setting names", () => {
expect(isTerminalOptionComposedText(option("∫"), "both", leftHeld, "MacIntel")).toBe(false);
expect(isTerminalOptionComposedText(option("∫"), "left", leftHeld, "MacIntel")).toBe(false);
expect(isTerminalOptionComposedText(option("@"), "left", rightHeld, "MacIntel")).toBe(true);
expect(isTerminalOptionComposedText(option("∫"), "right", rightHeld, "MacIntel")).toBe(false);
expect(isTerminalOptionComposedText(option("@"), "right", leftHeld, "MacIntel")).toBe(true);
});

it("types a regular space from layouts whose Option+Space makes one", () => {
expect(isTerminalOptionComposedText(option(" "), "off", leftHeld, "MacIntel")).toBe(true);
expect(isTerminalOptionComposedText(option(" "), "both", leftHeld, "MacIntel")).toBe(false);
});

it("lets Option dead keys compose in the textarea", () => {
// German Option+N starts `~`.
expect(isTerminalOptionComposedText(option("Dead"), "off", leftHeld, "MacIntel")).toBe(true);
expect(isTerminalOptionComposedText(option("Dead"), "both", leftHeld, "MacIntel")).toBe(false);
});

it("types the character when the held side was never seen", () => {
expect(isTerminalOptionComposedText(option("@"), "left", noneHeld, "MacIntel")).toBe(true);
});
});

describe("terminalGridCellAt", () => {
const options = {
bounds: { left: 100, top: 200 },
Expand Down
70 changes: 70 additions & 0 deletions apps/web/src/terminal/ghostty/surface.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { TerminalOptionAsAlt } from "@t3tools/contracts";
import { isMacPlatform } from "../../lib/utils";
import { SELECTION_MULTI_CLICK_INTERVAL_MS } from "../../lib/selectionActions";
import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "../../terminal-links";
Expand Down Expand Up @@ -384,6 +385,45 @@ export function isTerminalAltGraphText(
return event.getModifierState("AltGraph") && [...event.key].length === 1;
}

/** Which Option keys are down, tracked from their own keydown and keyup. */
export interface TerminalHeldOptionKeys {
readonly left: boolean;
readonly right: boolean;
}

/**
* A macOS Option press that should type the character the layout composed
* (German Option+L is `@`) through the textarea, as Terminal.app does with
* "Use Option as Meta key" off. Browsers report Option as Alt, never AltGraph,
* so encoding it sends an Alt sequence and the character is lost. Option keys
* set to act as Alt keep that encoding. An Option press that started before
* the terminal had focus has no tracked side and types the character.
* Dead keys (German Option+N for `~`) go to the textarea to compose. An Option+Space
* that types a non-breaking space stays encoded, since one in a command line breaks
* it; a layout whose Option+Space types a regular space types that.
* Option+arrow is not a single character, so word motion is unaffected.
*/
export function isTerminalOptionComposedText(
event: Pick<KeyboardEvent, "altKey" | "ctrlKey" | "key" | "metaKey">,
optionAsAlt: TerminalOptionAsAlt,
heldOption: TerminalHeldOptionKeys,
platform: string,
): boolean {
if (!isMacPlatform(platform) || !event.altKey || event.ctrlKey || event.metaKey) return false;
if (event.key === "\u00a0") return false;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (event.key !== "Dead" && [...event.key].length !== 1) return false;
switch (optionAsAlt) {
case "off":
return true;
case "both":
return false;
case "left":
return !heldOption.left;
case "right":
return !heldOption.right;
}
}

export function shouldReportTerminalMouse(
tracking: boolean,
event: Pick<MouseEvent, "ctrlKey" | "metaKey" | "shiftKey">,
Expand Down Expand Up @@ -503,6 +543,8 @@ export interface GhosttyTerminalSurfaceOptions {
readonly onResize: (cols: number, rows: number) => void;
readonly onSelectionChange: () => void;
readonly beforeKey: (event: KeyboardEvent) => boolean;
/** Read on every key press. Hosts can supply a getter for the latest value. */
readonly optionAsAlt?: TerminalOptionAsAlt;
readonly onLinkActivate: (text: string, event: MouseEvent) => void;
readonly canActivateLink?: (text: string) => boolean;
/**
Expand Down Expand Up @@ -575,6 +617,7 @@ export class GhosttyTerminalSurface {
private selectionMoved = false;
private composing = false;
private focused = false;
private heldOption: TerminalHeldOptionKeys = { left: false, right: false };
private resizeNotified = false;
private canvasConfigured = false;
private theme: GhosttyTheme;
Expand Down Expand Up @@ -1037,6 +1080,7 @@ export class GhosttyTerminalSurface {
}

private readonly onKeyDown = (event: KeyboardEvent) => {
this.trackHeldOption(event, true);
// Presses handled outside the terminal must also swallow their release:
// beforeKey runs side effects (keybindings, navigation sends), so it cannot
// be consulted again on keyup, and Kitty report-event-types sessions would
Expand All @@ -1051,6 +1095,17 @@ export class GhosttyTerminalSurface {
this.suppressedKeyCodes.add(event.code);
return;
}
if (
isTerminalOptionComposedText(
event,
this.options.optionAsAlt ?? "off",
this.heldOption,
navigator.platform,
)
) {
this.suppressedKeyCodes.add(event.code);
return;
}
const mac = isMacPlatform(navigator.platform);
const primaryModifier = mac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
const selectAllShortcut =
Expand Down Expand Up @@ -1166,6 +1221,7 @@ export class GhosttyTerminalSurface {
};

private readonly onKeyUp = (event: KeyboardEvent) => {
this.trackHeldOption(event, false);
if (this.suppressedKeyCodes.delete(event.code)) return;
if (isTerminalCompositionKey(event, this.composing)) {
return;
Expand All @@ -1179,6 +1235,19 @@ export class GhosttyTerminalSurface {
this.options.onData(data);
};

// Any event without Alt means no Option is down, which also clears a side
// whose release happened while focus was elsewhere.
private trackHeldOption(event: KeyboardEvent, down: boolean): void {
if (!event.altKey && event.code !== "AltLeft" && event.code !== "AltRight") {
if (this.heldOption.left || this.heldOption.right) {
this.heldOption = { left: false, right: false };
}
return;
}
if (event.code === "AltLeft") this.heldOption = { ...this.heldOption, left: down };
if (event.code === "AltRight") this.heldOption = { ...this.heldOption, right: down };
}

private readonly onFocus = () => {
this.focused = true;
this.cursorOn = true;
Expand All @@ -1187,6 +1256,7 @@ export class GhosttyTerminalSurface {

private readonly onBlur = () => {
this.focused = false;
this.heldOption = { left: false, right: false };
this.refreshHoveredLink();
// Suppressions survive blur deliberately: a shortcut that moves focus (for
// example terminal-toggle) must still swallow its own keyup if focus comes
Expand Down
Loading
Loading