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
16 changes: 14 additions & 2 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,8 @@ html, body {
overflow: hidden;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-family: var(--font-ui);
font-weight: var(--font-ui-weight);
font-size: 14px;
overscroll-behavior: none;
}
Expand Down Expand Up @@ -712,11 +713,22 @@ button.extension-widget-trigger:focus-visible {
}

:root {
--font-mono: var(--font-noto-mono), 'JetBrains Mono', 'Fira Code', 'Consolas', ui-monospace, 'PingFang SC', 'Microsoft YaHei', monospace;
--font-ui-default: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono-default: var(--font-noto-mono), 'JetBrains Mono', 'Fira Code', 'Consolas', ui-monospace, 'PingFang SC', 'Microsoft YaHei', monospace;
--font-ui: var(--font-ui-default);
--font-mono: var(--font-mono-default);
--font-ui-weight: 400;
--font-mono-weight: 400;
}

pre, code {
font-family: var(--font-mono);
font-weight: var(--font-mono-weight);
}

/* Inline code inside a heading or emphasis keeps that surrounding emphasis. */
.markdown-body :is(h1, h2, h3, h4, h5, h6, strong, b) code {
font-weight: inherit;
}

/* react-markdown output styles */
Expand Down
63 changes: 63 additions & 0 deletions app/settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -1604,6 +1604,69 @@
overflow-wrap: anywhere;
}

.settings-font-rows {
display: grid;
grid-template-columns: max-content minmax(0, 1fr) auto 28px;
gap: 12px 8px;
}

.settings-font-rows > .settings-font-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: max-content minmax(0, 1fr) auto 28px;
grid-template-columns: subgrid;
row-gap: 0;
}

.settings-font-rows > .settings-font-row > input[type="range"] {
grid-column: 1 / -1;
}

.settings-chat-range-header.settings-font-header {
grid-column: 1 / -1;
grid-template-columns: max-content minmax(0, 1fr) auto 28px;
grid-template-columns: subgrid;
}

.settings-chat-range-header.settings-font-header label {
padding-right: 4px;
}

/* Drawn in the font it names: the live preview. */
.settings-chat-range-option .settings-font-input {
height: 26px;
margin: 0;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
font-weight: var(--font-ui-weight);
font-size: 12px;
}

.settings-chat-range-option .settings-font-input.is-mono {
font-family: var(--font-mono);
font-weight: var(--font-mono-weight);
}

.settings-font-input::placeholder {
color: var(--text-dim);
}

.settings-font-input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}

@media (max-width: 640px) {
/* Phones get 16px form text (no focus zoom), so the name field needs the height. */
.settings-chat-range-option .settings-font-input {
height: 32px;
}
}

.settings-chat-options {
display: flex;
flex-direction: column;
Expand Down
3 changes: 3 additions & 0 deletions components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { AgentSessionPanel } from "./AgentSessionPanel";
import { TerminalPanel } from "./TerminalPanel";
import { newTerminalTab, restoreTerminalTabs, TERMINAL_TABS_KEY, type TerminalTab } from "./terminal-tab-state";
import { useTheme } from "@/hooks/useTheme";
import { useFontPreferences } from "@/hooks/useFontPreferences";
import { useI18n } from "@/hooks/useI18n";
import { useIsMobile, useIsNarrowMobile } from "@/hooks/useIsMobile";
import { useViewportHeight } from "@/hooks/useViewportHeight";
Expand Down Expand Up @@ -83,6 +84,8 @@ export function AppShell() {
const [initialNavigation, setInitialNavigation] = useState(() => getInitialNavigation(searchParams));
// Keep the system-theme subscription mounted for the lifetime of the app.
useTheme();
// Restore fonts even when Settings and the chat composer have not been opened.
useFontPreferences();
const { locale, t: translate } = useI18n();
const isMobile = useIsMobile();
const isNarrowMobile = useIsNarrowMobile();
Expand Down
4 changes: 3 additions & 1 deletion components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { useIsMobile } from "@/hooks/useIsMobile";
import { useEnterSendMode } from "@/hooks/useEnterSendMode";
import { useI18n } from "@/hooks/useI18n";
import { useChatAppearance } from "@/hooks/useChatAppearance";
import { useFontPreferences } from "@/hooks/useFontPreferences";
import type { ToolPreset } from "@/lib/tool-presets";
import { SelectorRow } from "./SelectorRow";
import { ModelSelector, type ModelSelectorOption } from "./ModelSelector";
Expand Down Expand Up @@ -601,6 +602,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
}: Props, ref) {
const { t } = useI18n();
const { fontSize } = useChatAppearance();
const { ui: uiFontFamily, uiWeight } = useFontPreferences();
const isMobile = useIsMobile();
const enterSendMode = useEnterSendMode();
const [value, setValue] = useState(() => (draftKey ? getDraft(draftKey)?.value ?? "" : ""));
Expand Down Expand Up @@ -942,7 +944,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
if (ta.value) ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
}, []);

useLayoutEffect(resizeTextarea, [value, fontSize, resizeTextarea]);
useLayoutEffect(resizeTextarea, [value, fontSize, uiFontFamily, uiWeight, resizeTextarea]);

useEffect(() => {
const ta = textareaRef.current;
Expand Down
3 changes: 3 additions & 0 deletions components/FileViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const DISPLAY_MODE_LABELS: Record<DisplayMode, string> = {

const FILE_CODE_STYLE: CSSProperties = {
fontFamily: "var(--font-mono)",
fontWeight: "var(--font-mono-weight)",
fontSize: 13,
lineHeight: 1.6,
};
Expand All @@ -82,6 +83,7 @@ const FILE_LINE_NUMBER_STYLE: CSSProperties = {
background: "var(--bg-panel)",
borderRight: "1px solid var(--border)",
fontFamily: "var(--font-mono)",
fontWeight: "var(--font-mono-weight)",
fontSize: 11,
fontStyle: "normal",
fontVariantNumeric: "tabular-nums",
Expand Down Expand Up @@ -1402,6 +1404,7 @@ function TextFileViewer({
codeTagProps={{
style: {
fontFamily: "var(--font-mono)",
fontWeight: "var(--font-mono-weight)",
overflowWrap: wrapLines ? "anywhere" : "normal",
},
}}
Expand Down
73 changes: 73 additions & 0 deletions components/FontSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"use client";

import { useI18n } from "@/hooks/useI18n";
import { useFontPreferences } from "@/hooks/useFontPreferences";
import { FONT_FAMILY_MAX_LENGTH, FONT_WEIGHT_DEFAULT, FONT_WEIGHT_OPTIONS } from "@/lib/font-preferences";
import { ConfigButton } from "./SettingsUi";

const FONT_FIELDS = [
{ kind: "ui", label: "settings.uiFontFamily", placeholder: "settings.uiFontPlaceholder", reset: "settings.resetUiFont", weightLabel: "settings.uiFontWeight" },
{ kind: "mono", label: "settings.monoFontFamily", placeholder: "settings.monoFontPlaceholder", reset: "settings.resetMonoFont", weightLabel: "settings.monoFontWeight" },
] as const;

/**
* Rows for the Fonts & layout section, shaped like the size and width rows below:
* label, name field, weight value and reset in the header line, the weight slider under it.
* The name field is drawn in the font it names, so it doubles as the preview.
*/
export function FontSettings() {
const { t } = useI18n();
const fonts = useFontPreferences();

// One grid for both rows, so the name fields start at the same x whatever the labels' widths.
return <div className="settings-font-rows">{FONT_FIELDS.map(({ kind, label, placeholder, reset, weightLabel }) => {
const weight = fonts[`${kind}Weight`];
const weightIndex = FONT_WEIGHT_OPTIONS.findIndex((option) => option.value === weight);
return (
<div key={kind} className="settings-chat-option settings-chat-range-option settings-font-row">
<div className="settings-chat-range-header settings-font-header">
<label htmlFor={`settings-font-${kind}`}>{t(label)}</label>
<input
id={`settings-font-${kind}`}
className={`settings-font-input${kind === "mono" ? " is-mono" : ""}`}
type="text"
value={fonts[kind]}
placeholder={t(placeholder)}
maxLength={FONT_FAMILY_MAX_LENGTH}
aria-describedby="settings-typography-description"
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
onChange={(event) => fonts.setFontPreference(kind, event.target.value)}
/>
<output htmlFor={`settings-font-${kind}-weight`}>{weight}</output>
<ConfigButton
variant="ghost"
size="small"
className="settings-chat-reset"
title={t(reset)}
aria-label={t(reset)}
disabled={!fonts[kind] && weight === FONT_WEIGHT_DEFAULT}
onClick={() => fonts.resetFontPreference(kind)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8M3 3v5h5" />
</svg>
</ConfigButton>
</div>
<input
id={`settings-font-${kind}-weight`}
type="range"
min={FONT_WEIGHT_OPTIONS[0].value}
max={FONT_WEIGHT_OPTIONS[FONT_WEIGHT_OPTIONS.length - 1].value}
step={100}
value={weight}
aria-label={t(weightLabel)}
aria-valuetext={`${weight} ${t(FONT_WEIGHT_OPTIONS[weightIndex].label)}`}
onChange={(event) => fonts.setFontWeight(kind, Number(event.target.value))}
/>
</div>
);
})}</div>;
}
3 changes: 2 additions & 1 deletion components/MermaidBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -316,13 +316,14 @@ export const CodeBlock = memo(function CodeBlock({ code, lang, headerAction, isS
padding: "11px 13px",
fontSize: "calc(12.5px + var(--chat-font-size-offset, 0px))",
lineHeight: 1.62,
fontWeight: "var(--font-mono-weight)",
// The light `vs` theme puts its own 1px #ddd border on <pre>; the
// block's wrapper already draws the frame.
border: "none",
borderRadius: 0,
background: "color-mix(in srgb, var(--bg) 92%, var(--bg-panel))",
}}
codeTagProps={{ style: { fontFamily: "var(--font-mono)" } }}
codeTagProps={{ style: { fontFamily: "var(--font-mono)", fontWeight: "var(--font-mono-weight)" } }}
>
{code}
</SyntaxHighlighter>
Expand Down
3 changes: 2 additions & 1 deletion components/MessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1319,6 +1319,7 @@ function SplitFilesView({ files }: { files: SplitDiffFile[] }) {
minWidth: 0,
borderTop: fileIndex === 0 ? "none" : "1px solid var(--border)",
fontFamily: "var(--font-mono)",
fontWeight: "var(--font-mono-weight)",
fontSize: "calc(12px + var(--chat-font-size-offset, 0px))",
lineHeight: 1.55,
}}
Expand Down Expand Up @@ -1447,7 +1448,7 @@ function PatchTextView({ text }: { text: string }) {
const lines = text.split(/\r?\n/);

return (
<div style={{ maxHeight: 520, overflowY: "auto", overflowX: "hidden", fontFamily: "var(--font-mono)", fontSize: "calc(12px + var(--chat-font-size-offset, 0px))", lineHeight: 1.55, minWidth: 0 }}>
<div style={{ maxHeight: 520, overflowY: "auto", overflowX: "hidden", fontFamily: "var(--font-mono)", fontWeight: "var(--font-mono-weight)", fontSize: "calc(12px + var(--chat-font-size-offset, 0px))", lineHeight: 1.55, minWidth: 0 }}>
{lines.map((line, i) => {
const kind =
line.startsWith("@@") ? "hunk" :
Expand Down
18 changes: 14 additions & 4 deletions components/SettingsPanel.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -150,21 +150,31 @@ test("keeps language selection in General settings", () => {
assert.match(panelSource, /setLocale\(plugin\.id/);
});

test("groups chat display controls together without row backgrounds", () => {
test("groups fonts, chat font size and width in one section, chat behavior in another, without row backgrounds", () => {
const appearanceSection = panelSource.slice(
panelSource.indexOf('{t("settings.appearance")}'),
panelSource.indexOf('{t("settings.typography")}'),
);
const typographySection = panelSource.slice(
panelSource.indexOf('{t("settings.typography")}'),
panelSource.indexOf('{t("settings.chat")}'),
);
const chatSection = panelSource.slice(
panelSource.indexOf('{t("settings.chat")}'),
panelSource.indexOf("{shellSettings?.isWindows"),
);

assert.doesNotMatch(appearanceSection, /settings-chat-content/);
assert.doesNotMatch(appearanceSection, /settings-chat-content|<FontSettings/);
assert.match(typographySection, /className="settings-chat-options"[\s\S]*<FontSettings \/>/);
assert.equal((typographySection.match(/className="settings-chat-option settings-chat-range-option"/g) ?? []).length, 2);
for (const key of ["chatContentWidth", "chatContentFontSize", "fontDescription"]) {
assert.match(typographySection, new RegExp(`t\\("settings\\.${key}"\\)`));
}
assert.match(chatSection, /className="settings-chat-options"/);
assert.equal((chatSection.match(/className="settings-chat-option(?: |")/g) ?? []).length, 5);
assert.doesNotMatch(chatSection, /settings-chat-content/);
assert.equal((chatSection.match(/className="settings-chat-option(?: |")/g) ?? []).length, 3);
assert.equal((chatSection.match(/<ConfigSwitch/g) ?? []).length, 2);
for (const key of ["thinkingExpandedDefault", "chatContentWidth", "chatContentFontSize", "quoteSelection", "enterSendMode", "enterSendModeEnter", "enterSendModeCtrlEnter"]) {
for (const key of ["thinkingExpandedDefault", "quoteSelection", "enterSendMode", "enterSendModeEnter", "enterSendModeCtrlEnter"]) {
assert.match(chatSection, new RegExp(`t\\("settings\\.${key}"\\)`));
}
assert.doesNotMatch(panelSource, /ThinkingIcon|settings-thinking-/);
Expand Down
Loading
Loading