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
11 changes: 11 additions & 0 deletions apps/web/src/components/ThreadTerminalDrawer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ describe("terminalThemeFromApp", () => {
"--terminal-foreground": "#fff",
"--terminal-cursor": "#ddd",
"--terminal-selection-background": "rgba(255, 255, 255, 0.2)",
...Object.fromEntries(
Array.from({ length: 16 }, (_, index) => [
`--terminal-ansi-${index}`,
index % 2 === 0 ? "#111" : "#ddd",
]),
),
}
: {
"--terminal-background": "#fff",
Expand All @@ -90,6 +96,11 @@ describe("terminalThemeFromApp", () => {
expect(theme.background).toEqual({ r: 0, g: 0, b: 0 });
expect(theme.foreground).toEqual({ r: 255, g: 255, b: 255 });
expect(theme.cursor).toEqual({ r: 221, g: 221, b: 221 });
expect(theme.palette).toHaveLength(16);
expect(theme.palette?.slice(0, 2)).toEqual([
{ r: 17, g: 17, b: 17 },
{ r: 221, g: 221, b: 221 },
]);
});
});

Expand Down
33 changes: 29 additions & 4 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,18 @@ export function writeTerminalOutputUpdate(
}
}

let colorProbeContext: CanvasRenderingContext2D | null = null;

function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor {
if (typeof document === "undefined") return fallback;

const canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!colorProbeContext) {
const canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
colorProbeContext = canvas.getContext("2d", { willReadFrequently: true });
}
const context = colorProbeContext;
if (!context) return fallback;

context.clearRect(0, 0, 1, 1);
Expand Down Expand Up @@ -219,6 +224,13 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty
"--terminal-selection-background",
isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)",
);
// Only themes carry an ANSI palette; without one Ghostty keeps its own.
const paletteValues = Array.from({ length: 16 }, (_, index) =>
readThemeColor(themeStyles, `--terminal-ansi-${index}`, ""),
);
const palette = paletteValues.every((value) => value.length > 0)
? paletteValues.map((value) => parseTerminalColor(value, { r: 0, g: 0, b: 0 }))
: null;
return {
background: parseTerminalColor(
terminalBackground,
Expand All @@ -233,6 +245,7 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty
isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 },
),
selectionBackground: terminalSelection,
...(palette ? { palette } : {}),
};
}

Expand Down Expand Up @@ -401,6 +414,8 @@ export function TerminalViewport({
}),
);
const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize });
const terminalCursorStyle = useClientSettings((settings) => settings.terminalCursorStyle);
const terminalCursorStyleRef = useRef(terminalCursorStyle);
const terminalSession = useAttachedTerminalSession({
environmentId,
terminal: {
Expand Down Expand Up @@ -477,6 +492,12 @@ export function TerminalViewport({
void terminalRef.current?.setFont(terminalFontOptions(terminalFontFamily, terminalFontSize));
}, [terminalFontFamily, terminalFontSize]);

useEffect(() => {
if (terminalCursorStyleRef.current === terminalCursorStyle) return;
terminalCursorStyleRef.current = terminalCursorStyle;
terminalRef.current?.setCursorStyle(terminalCursorStyle);
}, [terminalCursorStyle]);

useEffect(() => {
const mount = containerRef.current;
if (!mount) return;
Expand All @@ -493,6 +514,10 @@ export function TerminalViewport({
const terminalOptions: GhosttyTerminalSurfaceOptions = {
theme: terminalThemeFromApp(mount),
font: terminalFontOptions(setupFont.family, setupFont.size),
// Settings hydrate asynchronously, so read the style once WASM loads.
get cursorStyle() {
return terminalCursorStyleRef.current;
},
get visible() {
return visibleRef.current;
},
Expand Down
55 changes: 55 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
type ChatWidth,
type DiffLayout,
type EnvironmentIdentificationMode,
type TerminalCursorStyle,
type PullRequestOpenTarget,
MAX_APPEARANCE_CONTRAST,
MAX_CODE_FONT_SIZE,
Expand Down Expand Up @@ -183,6 +184,12 @@ const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, s
none: "None",
};

const TERMINAL_CURSOR_STYLE_LABELS: Record<TerminalCursorStyle, string> = {
block: "Block",
bar: "Bar",
underline: "Underline",
};

const RESPONSE_STREAMING_MODE_LABELS: Record<ResponseStreamingMode, string> = {
turn: "Wait for the full response",
paragraph: "Show finished paragraphs",
Expand Down Expand Up @@ -609,6 +616,9 @@ export function useSettingsRestore(onRestored?: () => void) {
? ["Snooze limited threads"]
: []),
...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),
...(settings.terminalCursorStyle !== DEFAULT_UNIFIED_SETTINGS.terminalCursorStyle
? ["Terminal cursor"]
: []),
...(settings.persistComposerContextStrip !==
DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip
? ["Composer context"]
Expand Down Expand Up @@ -731,6 +741,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.fontSizeInterface,
settings.fontSizePrompt,
settings.fontSizeTerminal,
settings.terminalCursorStyle,
settings.glassOpacity,
settings.panelAnimationDurationMs,
settings.responseStreamingMode,
Expand Down Expand Up @@ -882,6 +893,7 @@ export function useSettingsRestore(onRestored?: () => void) {
fontSizePrompt: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt,
fontSizeCode: DEFAULT_UNIFIED_SETTINGS.fontSizeCode,
fontSizeTerminal: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal,
terminalCursorStyle: DEFAULT_UNIFIED_SETTINGS.terminalCursorStyle,
browserDefaultViewport: DEFAULT_UNIFIED_SETTINGS.browserDefaultViewport,
browserDefaultZoomFactor: DEFAULT_UNIFIED_SETTINGS.browserDefaultZoomFactor,
browserDefaultAppearance: DEFAULT_UNIFIED_SETTINGS.browserDefaultAppearance,
Expand Down Expand Up @@ -1747,6 +1759,48 @@ function FontSmoothingRow() {
);
}

function TerminalCursorRow() {
const settings = useScopedSettings();
const updateSettings = useUpdateScopedSettings();
return (
<SettingsRow
{...searchableSetting("terminal-cursor")}
description="Cursor shape in the terminal. Programs that set their own cursor still win."
resetAction={
settings.terminalCursorStyle !== DEFAULT_UNIFIED_SETTINGS.terminalCursorStyle ? (
<SettingResetButton
label="terminal cursor"
onClick={() =>
updateSettings({ terminalCursorStyle: DEFAULT_UNIFIED_SETTINGS.terminalCursorStyle })
}
/>
) : null
}
control={
<Select
value={settings.terminalCursorStyle}
onValueChange={(value) => {
if (value === "block" || value === "bar" || value === "underline") {
updateSettings({ terminalCursorStyle: value });
}
}}
>
<SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Terminal cursor">
<SelectValue>{TERMINAL_CURSOR_STYLE_LABELS[settings.terminalCursorStyle]}</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
{Object.entries(TERMINAL_CURSOR_STYLE_LABELS).map(([value, label]) => (
<SelectItem hideIndicator key={value} value={value}>
{label}
</SelectItem>
))}
</SelectPopup>
</Select>
}
/>
);
}

function WordWrapRow() {
const settings = useScopedSettings();
const updateSettings = useUpdateScopedSettings();
Expand Down Expand Up @@ -1870,6 +1924,7 @@ function TypographySection() {
}
>
{advanced ? <FontSettingsGroup /> : <SimpleFontRows />}
<TerminalCursorRow />
<WordWrapRow />
</SettingsSection>
);
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/components/settings/ThemeEditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "react";
import {
applyThemeColorPreview,
TERMINAL_ANSI_ROLES,
THEME_COLOR_ROLES,
THEME_FILE_VERSION,
createVividThemeColors,
Expand Down Expand Up @@ -216,6 +217,23 @@ const THEME_EDITOR_ROLE_GROUPS: ReadonlyArray<{
},
],
},
{
id: "terminal-ansi",
title: "Terminal colors",
// terminalAnsiBrightRed -> "Bright red"
families: TERMINAL_ANSI_ROLES.map((role) => {
const label = role
.slice("terminalAnsi".length)
.replace(/([a-z])([A-Z])/g, "$1 $2")
.toLowerCase();
return {
id: role,
label: label.charAt(0).toUpperCase() + label.slice(1),
role,
roles: [role],
};
}),
},
];

const THEME_EDITOR_COLOR_FAMILIES = THEME_EDITOR_ROLE_GROUPS.flatMap((group) => group.families);
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,12 @@ export const SETTINGS_SEARCH_ITEMS = [
searchTerms: ["typography text grayscale anti aliasing macos thin"],
macOnly: true,
},
{
id: "terminal-cursor",
title: "Terminal cursor",
to: "/settings/appearance",
searchTerms: ["caret block bar beam underline shape"],
},
{
id: "word-wrap",
title: "Word wrap",
Expand Down
13 changes: 11 additions & 2 deletions apps/web/src/components/settings/themeInspector.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { THEME_COLOR_ROLES, getThemeColorVariable, type ThemeColorRole } from "../../themePalette";
import {
TERMINAL_ANSI_ROLES,
THEME_COLOR_ROLES,
getThemeColorVariable,
type ThemeColorRole,
} from "../../themePalette";

// ANSI colors only reach the terminal canvas, so probing them can never match.
const TERMINAL_ANSI_ROLE_SET: ReadonlySet<ThemeColorRole> = new Set(TERMINAL_ANSI_ROLES);
const DOM_THEME_COLOR_ROLES = THEME_COLOR_ROLES.filter((role) => !TERMINAL_ANSI_ROLE_SET.has(role));

export type ThemePaintKind = "background" | "border" | "foreground";

Expand Down Expand Up @@ -470,7 +479,7 @@ export function inspectThemeRoleAtElement(initialElement: Element): ThemeElement
if (snapshot) baseline.set(candidate, snapshot);
}

for (const role of THEME_COLOR_ROLES) {
for (const role of DOM_THEME_COLOR_ROLES) {
const restore = applyThemeTokenProbe(role);
try {
for (const [candidate, before] of baseline) {
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1289,6 +1289,22 @@ html[data-theme-id]:not([data-theme-id=""]) {
--terminal-foreground: var(--app-theme-terminal-foreground);
--terminal-cursor: var(--app-theme-terminal-cursor);
--terminal-selection-background: var(--app-theme-terminal-selection-background);
--terminal-ansi-0: var(--app-theme-terminal-ansi-0);
--terminal-ansi-1: var(--app-theme-terminal-ansi-1);
--terminal-ansi-2: var(--app-theme-terminal-ansi-2);
--terminal-ansi-3: var(--app-theme-terminal-ansi-3);
--terminal-ansi-4: var(--app-theme-terminal-ansi-4);
--terminal-ansi-5: var(--app-theme-terminal-ansi-5);
--terminal-ansi-6: var(--app-theme-terminal-ansi-6);
--terminal-ansi-7: var(--app-theme-terminal-ansi-7);
--terminal-ansi-8: var(--app-theme-terminal-ansi-8);
--terminal-ansi-9: var(--app-theme-terminal-ansi-9);
--terminal-ansi-10: var(--app-theme-terminal-ansi-10);
--terminal-ansi-11: var(--app-theme-terminal-ansi-11);
--terminal-ansi-12: var(--app-theme-terminal-ansi-12);
--terminal-ansi-13: var(--app-theme-terminal-ansi-13);
--terminal-ansi-14: var(--app-theme-terminal-ansi-14);
--terminal-ansi-15: var(--app-theme-terminal-ansi-15);
}

/* Theme-token dependency probes are restored synchronously, before paint. Keep
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/openVsxThemes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ const USED_WORKBENCH_COLORS = new Set([
"sideBar.background",
"sideBar.border",
"sideBar.foreground",
"terminal.ansiBlack",
"terminal.ansiBlue",
"terminal.ansiBrightBlack",
"terminal.ansiBrightBlue",
"terminal.ansiBrightCyan",
"terminal.ansiBrightGreen",
"terminal.ansiBrightMagenta",
"terminal.ansiBrightRed",
"terminal.ansiBrightWhite",
"terminal.ansiBrightYellow",
"terminal.ansiCyan",
"terminal.ansiGreen",
"terminal.ansiMagenta",
"terminal.ansiRed",
"terminal.ansiWhite",
"terminal.ansiYellow",
"terminal.background",
"terminal.foreground",
"terminal.selectionBackground",
Expand Down
Loading