feat(terminal): fix bottom padding and add font family setting (#489)

This commit is contained in:
Carlos Florêncio
2026-03-29 22:27:37 +01:00
committed by GitHub
parent 3a50cf42e5
commit 81341263fd
25 changed files with 912 additions and 92 deletions
+6
View File
@@ -58,6 +58,12 @@ test.describe("my feature", () => {
});
```
## Test quality guidelines
- **Test through the UI, not the API.** E2E tests verify user-facing behavior. Don't write tests that only call the API and assert the response -- those are integration tests. Instead, navigate to the page, interact with UI elements, and assert what the user sees.
- **Verify persistence with page reload.** After changing a setting or creating data, reload the page (`testPage.reload()`) and assert the state is still correct. This catches hydration bugs and SSR/client mismatches.
- **Seed via API, assert via UI.** Use `apiClient` to set up preconditions quickly, but always verify the result by opening the page and checking the DOM.
## Debugging failures
```bash
@@ -64,6 +64,8 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS
DefaultUtilityModel: req.DefaultUtilityModel,
KeyboardShortcuts: req.KeyboardShortcuts,
TerminalLinkBehavior: req.TerminalLinkBehavior,
TerminalFontFamily: req.TerminalFontFamily,
TerminalFontSize: req.TerminalFontSize,
})
if err != nil {
return dto.UserSettingsResponse{}, err
+6
View File
@@ -35,6 +35,8 @@ type UserSettingsDTO struct {
DefaultUtilityModel string `json:"default_utility_model"`
KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts,omitempty"`
TerminalLinkBehavior string `json:"terminal_link_behavior"`
TerminalFontFamily string `json:"terminal_font_family"`
TerminalFontSize int `json:"terminal_font_size"`
UpdatedAt string `json:"updated_at"`
}
@@ -74,6 +76,8 @@ type UpdateUserSettingsRequest struct {
DefaultUtilityModel *string `json:"default_utility_model,omitempty"`
KeyboardShortcuts *map[string]interface{} `json:"keyboard_shortcuts,omitempty"`
TerminalLinkBehavior *string `json:"terminal_link_behavior,omitempty"`
TerminalFontFamily *string `json:"terminal_font_family,omitempty"`
TerminalFontSize *int `json:"terminal_font_size,omitempty"`
}
func FromUser(user *models.User) UserDTO {
@@ -108,6 +112,8 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO {
DefaultUtilityModel: settings.DefaultUtilityModel,
KeyboardShortcuts: settings.KeyboardShortcuts,
TerminalLinkBehavior: settings.TerminalLinkBehavior,
TerminalFontFamily: settings.TerminalFontFamily,
TerminalFontSize: settings.TerminalFontSize,
UpdatedAt: settings.UpdatedAt.Format(time.RFC3339),
}
}
@@ -34,6 +34,8 @@ type UserSettings struct {
DefaultUtilityModel string `json:"default_utility_model"` // Default model for utility agents
KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"` // User-configured keyboard shortcut overrides
TerminalLinkBehavior string `json:"terminal_link_behavior"` // "new_tab" | "browser_panel"
TerminalFontFamily string `json:"terminal_font_family"`
TerminalFontSize int `json:"terminal_font_size"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -49,6 +49,8 @@ type UpdateUserSettingsRequest struct {
DefaultUtilityModel *string
KeyboardShortcuts *map[string]interface{}
TerminalLinkBehavior *string
TerminalFontFamily *string
TerminalFontSize *int
}
func NewService(repo store.Repository, eventBus bus.EventBus, log *logger.Logger) *Service {
@@ -168,6 +170,16 @@ func applyBasicSettings(settings *models.UserSettings, req *UpdateUserSettingsRe
if err := applyTerminalLinkBehavior(settings, req.TerminalLinkBehavior); err != nil {
return err
}
if req.TerminalFontFamily != nil {
settings.TerminalFontFamily = strings.TrimSpace(*req.TerminalFontFamily)
}
if req.TerminalFontSize != nil {
v := *req.TerminalFontSize
if v != 0 && (v < 8 || v > 24) {
return errors.New("terminal_font_size must be 0 (default) or between 8 and 24")
}
settings.TerminalFontSize = v
}
return nil
}
@@ -263,6 +275,8 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models
"default_utility_model": settings.DefaultUtilityModel,
"keyboard_shortcuts": settings.KeyboardShortcuts,
"terminal_link_behavior": settings.TerminalLinkBehavior,
"terminal_font_family": settings.TerminalFontFamily,
"terminal_font_size": settings.TerminalFontSize,
"updated_at": settings.UpdatedAt.Format(time.RFC3339),
}
if err := s.eventBus.Publish(ctx, events.UserSettingsUpdated, bus.NewEvent(events.UserSettingsUpdated, "user-service", data)); err != nil {
@@ -88,6 +88,103 @@ func TestApplyBasicSettings_ReleaseNotes(t *testing.T) {
})
}
func TestApplyBasicSettings_TerminalFontFamily(t *testing.T) {
t.Run("nil leaves settings unchanged", func(t *testing.T) {
settings := &models.UserSettings{TerminalFontFamily: "Fira Code"}
req := &UpdateUserSettingsRequest{}
if err := applyBasicSettings(settings, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if settings.TerminalFontFamily != "Fira Code" {
t.Fatalf("expected TerminalFontFamily=Fira Code, got %s", settings.TerminalFontFamily)
}
})
t.Run("sets value when provided", func(t *testing.T) {
settings := &models.UserSettings{}
req := &UpdateUserSettingsRequest{TerminalFontFamily: ptr("JetBrains Mono")}
if err := applyBasicSettings(settings, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if settings.TerminalFontFamily != "JetBrains Mono" {
t.Fatalf("expected TerminalFontFamily=JetBrains Mono, got %s", settings.TerminalFontFamily)
}
})
t.Run("trims whitespace", func(t *testing.T) {
settings := &models.UserSettings{}
req := &UpdateUserSettingsRequest{TerminalFontFamily: ptr(" Fira Code ")}
if err := applyBasicSettings(settings, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if settings.TerminalFontFamily != "Fira Code" {
t.Fatalf("expected TerminalFontFamily=Fira Code, got %q", settings.TerminalFontFamily)
}
})
t.Run("clears with empty string", func(t *testing.T) {
settings := &models.UserSettings{TerminalFontFamily: "Fira Code"}
req := &UpdateUserSettingsRequest{TerminalFontFamily: ptr("")}
if err := applyBasicSettings(settings, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if settings.TerminalFontFamily != "" {
t.Fatalf("expected empty TerminalFontFamily, got %s", settings.TerminalFontFamily)
}
})
}
func TestApplyBasicSettings_TerminalFontSize(t *testing.T) {
t.Run("nil leaves settings unchanged", func(t *testing.T) {
settings := &models.UserSettings{TerminalFontSize: 14}
req := &UpdateUserSettingsRequest{}
if err := applyBasicSettings(settings, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if settings.TerminalFontSize != 14 {
t.Fatalf("expected TerminalFontSize=14, got %d", settings.TerminalFontSize)
}
})
t.Run("sets value when provided", func(t *testing.T) {
settings := &models.UserSettings{}
req := &UpdateUserSettingsRequest{TerminalFontSize: ptr(16)}
if err := applyBasicSettings(settings, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if settings.TerminalFontSize != 16 {
t.Fatalf("expected TerminalFontSize=16, got %d", settings.TerminalFontSize)
}
})
t.Run("value below 8 returns error", func(t *testing.T) {
settings := &models.UserSettings{}
req := &UpdateUserSettingsRequest{TerminalFontSize: ptr(7)}
if err := applyBasicSettings(settings, req); err == nil {
t.Fatal("expected error for font size 7, got nil")
}
})
t.Run("value above 24 returns error", func(t *testing.T) {
settings := &models.UserSettings{}
req := &UpdateUserSettingsRequest{TerminalFontSize: ptr(25)}
if err := applyBasicSettings(settings, req); err == nil {
t.Fatal("expected error for font size 25, got nil")
}
})
t.Run("resets to 0 when 0 is provided", func(t *testing.T) {
settings := &models.UserSettings{TerminalFontSize: 14}
req := &UpdateUserSettingsRequest{TerminalFontSize: ptr(0)}
if err := applyBasicSettings(settings, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if settings.TerminalFontSize != 0 {
t.Fatalf("expected TerminalFontSize=0, got %d", settings.TerminalFontSize)
}
})
}
func TestApplySavedLayouts(t *testing.T) {
tests := []struct {
name string
@@ -154,6 +154,8 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod
"default_utility_model": settings.DefaultUtilityModel,
"keyboard_shortcuts": keyboardShortcuts,
"terminal_link_behavior": settings.TerminalLinkBehavior,
"terminal_font_family": settings.TerminalFontFamily,
"terminal_font_size": settings.TerminalFontSize,
})
if err != nil {
return err
@@ -221,6 +223,8 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
DefaultUtilityModel string `json:"default_utility_model"`
KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"`
TerminalLinkBehavior string `json:"terminal_link_behavior"`
TerminalFontFamily string `json:"terminal_font_family"`
TerminalFontSize int `json:"terminal_font_size"`
}
if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil {
return nil, err
@@ -274,5 +278,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
} else {
settings.TerminalLinkBehavior = "new_tab"
}
settings.TerminalFontFamily = payload.TerminalFontFamily
settings.TerminalFontSize = payload.TerminalFontSize
return settings, nil
}
@@ -240,6 +240,8 @@ function buildUserSettingsFromResponse(
defaultUtilityAgentId: s.default_utility_agent_id || null,
keyboardShortcuts: s.keyboard_shortcuts ?? {},
terminalLinkBehavior: parseTerminalLinkBehavior(s.terminal_link_behavior),
terminalFontFamily: s.terminal_font_family || null,
terminalFontSize: s.terminal_font_size || null,
...mapEditorSettingsFields(s),
};
}
@@ -2,11 +2,27 @@
import { useState } from "react";
import { useTheme } from "next-themes";
import { IconCommand, IconLink, IconPalette, IconServer, IconKeyboard } from "@tabler/icons-react";
import {
IconCommand,
IconPalette,
IconServer,
IconKeyboard,
IconTerminal2,
} from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Label } from "@kandev/ui/label";
import { Input } from "@kandev/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@kandev/ui/select";
import { Separator } from "@kandev/ui/separator";
import { SettingsSection } from "@/components/settings/settings-section";
import { ShellSettingsCard } from "@/components/settings/shell-settings-card";
@@ -14,6 +30,8 @@ import { KeyboardShortcutsCard } from "@/components/settings/keyboard-shortcuts-
import { getBackendConfig } from "@/lib/config";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { updateUserSettings } from "@/lib/api";
import { TERMINAL_FONT_PRESETS } from "@/lib/terminal/terminal-font";
import type { FontCategory } from "@/lib/terminal/terminal-font";
import type { Theme } from "@/lib/settings/types";
function ThemeSettingsCard() {
@@ -154,6 +172,206 @@ function TerminalLinksCard() {
);
}
const CUSTOM_VALUE = "__custom__";
const CATEGORY_LABELS: Record<FontCategory, string> = {
icons: "Nerd Fonts",
ligatures: "Programming",
system: "System",
};
const CATEGORY_BADGES: Partial<Record<FontCategory, string>> = {
icons: "Icons",
ligatures: "Ligatures",
};
const FONT_GROUPS: Record<string, typeof TERMINAL_FONT_PRESETS> = TERMINAL_FONT_PRESETS.reduce(
(acc, p) => {
(acc[p.category] ??= []).push(p);
return acc;
},
{} as Record<string, typeof TERMINAL_FONT_PRESETS>,
);
const FONT_CATEGORIES: FontCategory[] = ["icons", "ligatures", "system"];
function FontGroupOptions() {
return FONT_CATEGORIES.map((category) => (
<SelectGroup key={category}>
<SelectLabel className="flex items-center gap-2">
{CATEGORY_LABELS[category]}
{CATEGORY_BADGES[category] && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{CATEGORY_BADGES[category]}
</Badge>
)}
</SelectLabel>
{(FONT_GROUPS[category] ?? []).map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
</SelectGroup>
));
}
function TerminalFontSizeCard() {
const storeApi = useAppStoreApi();
const userSettings = useAppStore((state) => state.userSettings);
const setUserSettings = useAppStore((state) => state.setUserSettings);
const [isSaving, setIsSaving] = useState(false);
const [fontSize, setFontSize] = useState(() => userSettings.terminalFontSize ?? 13);
const saveFontSize = async (value: number) => {
if (isSaving) return;
if (value < 8 || value > 24) return;
setIsSaving(true);
const current = storeApi.getState().userSettings;
const previous = current.terminalFontSize;
try {
setUserSettings({ ...current, terminalFontSize: value });
await updateUserSettings({
workspace_id: current.workspaceId || "",
repository_ids: current.repositoryIds || [],
terminal_font_size: value,
});
} catch {
setUserSettings({ ...storeApi.getState().userSettings, terminalFontSize: previous });
} finally {
setIsSaving(false);
}
};
const handleFontSizeBlur = () => {
const v = Math.min(24, Math.max(8, fontSize));
setFontSize(v);
saveFontSize(v);
};
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Terminal Font Size</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="terminal-font-size">Font Size</Label>
<div className="flex items-center gap-3">
<Input
id="terminal-font-size"
type="number"
min={8}
max={24}
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
onBlur={handleFontSizeBlur}
onKeyDown={(e) => {
if (e.key === "Enter") handleFontSizeBlur();
}}
className="w-20"
disabled={isSaving}
data-testid="terminal-font-size-input"
/>
<span className="text-xs text-muted-foreground">px (8-24)</span>
</div>
<p className="text-xs text-muted-foreground">
Set the font size for the terminal. Default is 13px.
</p>
</div>
</CardContent>
</Card>
);
}
function TerminalFontCard() {
const storeApi = useAppStoreApi();
const userSettings = useAppStore((state) => state.userSettings);
const setUserSettings = useAppStore((state) => state.setUserSettings);
const [isSaving, setIsSaving] = useState(false);
const [isCustom, setIsCustom] = useState(() => {
const current = userSettings.terminalFontFamily;
if (!current) return false;
return !TERMINAL_FONT_PRESETS.some((p) => p.value === current);
});
const [customValue, setCustomValue] = useState(
() => (isCustom ? userSettings.terminalFontFamily : "") ?? "",
);
const saveFontFamily = async (value: string) => {
if (isSaving) return;
setIsSaving(true);
const current = storeApi.getState().userSettings;
const previous = current.terminalFontFamily;
try {
setUserSettings({ ...current, terminalFontFamily: value || null });
await updateUserSettings({
workspace_id: current.workspaceId || "",
repository_ids: current.repositoryIds || [],
terminal_font_family: value,
});
} catch {
setUserSettings({
...storeApi.getState().userSettings,
terminalFontFamily: previous,
});
} finally {
setIsSaving(false);
}
};
const handleSelectChange = (value: string) => {
if (value === CUSTOM_VALUE) {
setIsCustom(true);
return;
}
setIsCustom(false);
setCustomValue("");
saveFontFamily(value === "default" ? "" : value);
};
const handleCustomBlur = () => {
const trimmed = customValue.trim();
if (trimmed) saveFontFamily(trimmed);
};
const selectValue = isCustom ? CUSTOM_VALUE : userSettings.terminalFontFamily || "default";
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Terminal Font</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<Label htmlFor="terminal-font">Font Family</Label>
<Select value={selectValue} onValueChange={handleSelectChange} disabled={isSaving}>
<SelectTrigger id="terminal-font" data-testid="terminal-font-select">
<SelectValue placeholder="Default" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default (Menlo / Monaco)</SelectItem>
<FontGroupOptions />
<SelectSeparator />
<SelectItem value={CUSTOM_VALUE}>Custom...</SelectItem>
</SelectContent>
</Select>
{isCustom && (
<Input
placeholder='e.g. "My Custom Font"'
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
onBlur={handleCustomBlur}
onKeyDown={(e) => {
if (e.key === "Enter") handleCustomBlur();
}}
data-testid="terminal-font-custom-input"
/>
)}
<p className="text-xs text-muted-foreground">
Choose a monospace font for the terminal. Nerd Fonts include icons for CLI tools.
</p>
</div>
</CardContent>
</Card>
);
}
function BackendConnectionCard() {
const [backendUrl] = useState<string>(() => getBackendConfig().apiBaseUrl);
const displayBackendUrl = backendUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
@@ -211,10 +429,12 @@ export function GeneralSettings() {
<Separator />
<SettingsSection
icon={<IconLink className="h-5 w-5" />}
title="Terminal Links"
description="Configure how clickable URLs in the terminal are opened"
icon={<IconTerminal2 className="h-5 w-5" />}
title="Terminal"
description="Configure terminal appearance and behavior"
>
<TerminalFontCard />
<TerminalFontSizeCard />
<TerminalLinksCard />
</SettingsSection>
@@ -94,8 +94,8 @@ export function BottomTerminalPanel({ sessionId }: Props) {
<IconMinus className="h-3 w-3" />
</Button>
</div>
{/* Terminal content - pb-2 prevents last line from being cut off */}
<div className="flex-1 min-h-0 overflow-hidden pb-2">
{/* Terminal content */}
<div className="flex-1 min-h-0 overflow-hidden">
<PassthroughTerminal
sessionId={sessionId}
mode="shell"
@@ -12,6 +12,7 @@ import { useSession } from "@/hooks/domains/session/use-session";
import { useSessionAgentctl } from "@/hooks/domains/session/use-session-agentctl";
import { getBackendConfig } from "@/lib/config";
import { useTerminalLinkHandler } from "@/hooks/use-terminal-link-handler";
import { buildTerminalFontFamily } from "@/lib/terminal/terminal-font";
import {
MIN_WIDTH,
MIN_HEIGHT,
@@ -42,18 +43,37 @@ type PassthroughTerminalProps = AgentTerminalProps | ShellTerminalProps;
* - Unicode11Addon enables proper unicode character support
* - Resize commands sent via binary protocol: [0x01][JSON {cols, rows}]
*/
function useTerminalRefs() {
return {
terminalRef: useRef<HTMLDivElement>(null),
xtermRef: useRef<Terminal | null>(null),
fitAddonRef: useRef<FitAddon | null>(null),
wsRef: useRef<WebSocket | null>(null),
attachAddonRef: useRef<AttachAddon | null>(null),
isInitializedRef: useRef(false),
lastDimensionsRef: useRef({ cols: 0, rows: 0 }),
resizeTimeoutRef: useRef<ReturnType<typeof setTimeout> | null>(null),
webglAddonRef: useRef<WebglAddon | null>(null),
};
}
const WS_BASE_URL_FALLBACK = "ws://localhost:8080";
function useWsBaseUrl() {
return useMemo(() => {
try {
const url = new URL(getBackendConfig().apiBaseUrl);
return `${url.protocol === "https:" ? "wss:" : "ws:"}//${url.host}`;
} catch {
return WS_BASE_URL_FALLBACK;
}
}, []);
}
export function PassthroughTerminal(props: PassthroughTerminalProps) {
const { sessionId: propSessionId, mode, label, autoFocus, pendingCommand, onCommandSent } = props;
const terminalId = mode === "shell" ? props.terminalId : undefined;
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const attachAddonRef = useRef<AttachAddon | null>(null);
const isInitializedRef = useRef(false);
const lastDimensionsRef = useRef({ cols: 0, rows: 0 });
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const webglAddonRef = useRef<WebglAddon | null>(null);
const refs = useTerminalRefs();
const { terminalRef, xtermRef, fitAddonRef, wsRef, attachAddonRef } = refs;
const storeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const sessionId = propSessionId ?? storeSessionId;
@@ -62,15 +82,7 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
useSessionAgentctl(sessionId);
const taskId = session?.task_id ?? null;
const canConnect = Boolean(sessionId && isActive);
const wsBaseUrl = useMemo(() => {
try {
const url = new URL(getBackendConfig().apiBaseUrl);
return `${url.protocol === "https:" ? "wss:" : "ws:"}//${url.host}`;
} catch {
return "ws://localhost:8080";
}
}, []);
const wsBaseUrl = useWsBaseUrl();
const [isTerminalReady, setIsTerminalReady] = useState(false);
const onTerminalReady = useCallback(() => setIsTerminalReady(true), []);
@@ -81,32 +93,36 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
const isConnected = sessionId != null && connectedSessionId === sessionId;
const onConnected = useCallback(() => {
setConnectedSessionId(sessionId ?? null);
if (autoFocus) xtermRef.current?.textarea?.focus({ preventScroll: true });
}, [sessionId, autoFocus]);
if (autoFocus) refs.xtermRef.current?.textarea?.focus({ preventScroll: true });
}, [sessionId, autoFocus, refs.xtermRef]);
const linkHandler = useTerminalLinkHandler();
const terminalFontFamily = useAppStore((s) => s.userSettings.terminalFontFamily);
const terminalFontSize = useAppStore((s) => s.userSettings.terminalFontSize);
const sendResize = useSendResize(wsRef);
const fitAndResize = useFitAndResize({
xtermRef,
fitAddonRef,
terminalRef,
lastDimensionsRef,
xtermRef: refs.xtermRef,
fitAddonRef: refs.fitAddonRef,
terminalRef: refs.terminalRef,
lastDimensionsRef: refs.lastDimensionsRef,
sendResize,
});
const sendInput = useSendInput(wsRef);
const toggleBottomTerminal = useAppStore((s) => s.toggleBottomTerminal);
useTerminalInit({
terminalRef,
xtermRef,
fitAddonRef,
isInitializedRef,
lastDimensionsRef,
resizeTimeoutRef,
webglAddonRef,
terminalRef: refs.terminalRef,
xtermRef: refs.xtermRef,
fitAddonRef: refs.fitAddonRef,
isInitializedRef: refs.isInitializedRef,
lastDimensionsRef: refs.lastDimensionsRef,
resizeTimeoutRef: refs.resizeTimeoutRef,
webglAddonRef: refs.webglAddonRef,
fitAndResize,
onReady: onTerminalReady,
linkHandler,
fontFamily: buildTerminalFontFamily(terminalFontFamily),
fontSize: terminalFontSize ?? undefined,
onToggleBottomTerminal: toggleBottomTerminal,
sendInput,
});
@@ -136,7 +152,9 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
className="relative h-full w-full overflow-hidden bg-background"
style={{ minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT }}
>
<div ref={terminalRef} className="h-full w-full p-2" />
<div className="h-full w-full p-2 pb-3">
<div ref={terminalRef} className="h-full w-full" />
</div>
{!isConnected && (
<div
data-testid="passthrough-loading"
+84 -43
View File
@@ -11,6 +11,7 @@ import { useSession } from "@/hooks/domains/session/use-session";
import { useSessionAgentctl } from "@/hooks/domains/session/use-session-agentctl";
import { getTerminalTheme } from "@/lib/theme/terminal-theme";
import { useTerminalLinkHandler } from "@/hooks/use-terminal-link-handler";
import { buildTerminalFontFamily } from "@/lib/terminal/terminal-font";
import { exposeBufferReader } from "./terminal-buffer-reader";
type ShellTerminalProps = {
@@ -28,13 +29,25 @@ type TerminalRefs = {
outputRef: React.RefObject<string>;
};
function useTerminalInit(
refs: TerminalRefs,
isReadOnlyMode: boolean,
taskId: string | null,
sessionId: string | null | undefined,
linkHandler?: (event: MouseEvent, uri: string) => void,
) {
type ShellTerminalInitOptions = {
refs: TerminalRefs;
isReadOnlyMode: boolean;
taskId: string | null;
sessionId: string | null | undefined;
linkHandler?: (event: MouseEvent, uri: string) => void;
fontFamily?: string;
fontSize?: number;
};
function useTerminalInit({
refs,
isReadOnlyMode,
taskId,
sessionId,
linkHandler,
fontFamily,
fontSize,
}: ShellTerminalInitOptions) {
const { terminalRef, xtermRef, fitAddonRef, lastOutputLengthRef, outputRef } = refs;
useEffect(() => {
@@ -43,8 +56,8 @@ function useTerminalInit(
cursorBlink: !isReadOnlyMode,
disableStdin: isReadOnlyMode,
convertEol: isReadOnlyMode,
fontSize: isReadOnlyMode ? 12 : 13,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
fontSize: fontSize ?? (isReadOnlyMode ? 12 : 13),
fontFamily: fontFamily || 'Menlo, Monaco, "Courier New", monospace',
macOptionIsMeta: true,
theme: getTerminalTheme(terminalRef.current),
});
@@ -98,6 +111,8 @@ function useTerminalInit(
sessionId,
isReadOnlyMode,
linkHandler,
fontFamily,
fontSize,
terminalRef,
xtermRef,
fitAddonRef,
@@ -279,6 +294,50 @@ function useCmdArrowHandler(
}, [xtermRef, sessionId, send, isReadOnlyMode, stateRef]);
}
/** Handles user input in interactive mode, filtering out cursor position responses. */
function useShellInputHandler(opts: {
xtermRef: React.RefObject<Terminal | null>;
onDataDisposableRef: React.MutableRefObject<{ dispose: () => void } | null>;
isReadOnlyMode: boolean;
taskId: string | null;
sessionId: string | null | undefined;
send: (action: string, payload: Record<string, unknown>) => void;
}) {
const { xtermRef, onDataDisposableRef, isReadOnlyMode, taskId, sessionId, send } = opts;
useEffect(() => {
if (!xtermRef.current || isReadOnlyMode) return;
onDataDisposableRef.current?.dispose();
onDataDisposableRef.current = null;
if (!taskId || !sessionId) return;
onDataDisposableRef.current = xtermRef.current.onData((data) => {
if (/^\x1b\[\d+;\d+R$/.test(data) || /^\x1b\[\d+R$/.test(data)) return;
send("shell.input", { session_id: sessionId, data });
});
return () => {
onDataDisposableRef.current?.dispose();
onDataDisposableRef.current = null;
};
}, [taskId, sessionId, send, isReadOnlyMode, xtermRef, onDataDisposableRef]);
}
function useShellSessionState(propSessionId: string | undefined, isReadOnlyMode: boolean) {
const storeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const sessionId = propSessionId ?? storeSessionId;
const { session, isActive, isFailed, errorMessage } = useSession(
isReadOnlyMode ? null : sessionId,
);
useSessionAgentctl(isReadOnlyMode ? null : sessionId);
const taskId = session?.task_id ?? null;
const isSessionFailed = !isReadOnlyMode && isFailed;
const shellOutput = useAppStore((state) => {
if (!sessionId || isReadOnlyMode) return "";
const envKey = state.environmentIdBySessionId[sessionId] ?? sessionId;
return state.shell.outputs[envKey] || "";
});
const canSubscribe = Boolean(sessionId && isActive && !isReadOnlyMode);
return { sessionId, taskId, isSessionFailed, errorMessage, shellOutput, canSubscribe };
}
export function ShellTerminal({
sessionId: propSessionId,
processOutput,
@@ -295,21 +354,8 @@ export function ShellTerminal({
const storeApi = useAppStoreApi();
const isReadOnlyMode = processOutput !== undefined;
const storeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const sessionId = propSessionId ?? storeSessionId;
const { session, isActive, isFailed, errorMessage } = useSession(
isReadOnlyMode ? null : sessionId,
);
useSessionAgentctl(isReadOnlyMode ? null : sessionId);
const taskId = session?.task_id ?? null;
const isSessionFailed = !isReadOnlyMode && isFailed;
const shellOutput = useAppStore((state) => {
if (!sessionId || isReadOnlyMode) return "";
const envKey = state.environmentIdBySessionId[sessionId] ?? sessionId;
return state.shell.outputs[envKey] || "";
});
const canSubscribe = Boolean(sessionId && isActive && !isReadOnlyMode);
const { sessionId, taskId, isSessionFailed, errorMessage, shellOutput, canSubscribe } =
useShellSessionState(propSessionId, isReadOnlyMode);
useReadOnlyOutputSync({
xtermRef,
isReadOnlyMode,
@@ -326,25 +372,20 @@ export function ShellTerminal({
}, []);
const linkHandler = useTerminalLinkHandler();
const terminalFontFamily = useAppStore((s) => s.userSettings.terminalFontFamily);
const terminalFontSize = useAppStore((s) => s.userSettings.terminalFontSize);
const refs: TerminalRefs = { terminalRef, xtermRef, fitAddonRef, lastOutputLengthRef, outputRef };
useTerminalInit(refs, isReadOnlyMode, taskId, sessionId, linkHandler);
// Handle user input (interactive mode only)
useEffect(() => {
if (!xtermRef.current || isReadOnlyMode) return;
onDataDisposableRef.current?.dispose();
onDataDisposableRef.current = null;
if (!taskId || !sessionId) return;
onDataDisposableRef.current = xtermRef.current.onData((data) => {
if (/^\x1b\[\d+;\d+R$/.test(data) || /^\x1b\[\d+R$/.test(data)) return;
send("shell.input", { session_id: sessionId, data });
});
return () => {
onDataDisposableRef.current?.dispose();
onDataDisposableRef.current = null;
};
}, [taskId, sessionId, send, isReadOnlyMode]);
useTerminalInit({
refs,
isReadOnlyMode,
taskId,
sessionId,
linkHandler,
fontFamily: buildTerminalFontFamily(terminalFontFamily),
fontSize: terminalFontSize ?? undefined,
});
useShellInputHandler({ xtermRef, onDataDisposableRef, isReadOnlyMode, taskId, sessionId, send });
useCmdArrowHandler(xtermRef, isReadOnlyMode, sessionId, send);
useTerminalOutputWrite(xtermRef, isReadOnlyMode, processOutput, shellOutput, lastOutputLengthRef);
@@ -360,7 +401,7 @@ export function ShellTerminal({
if (isReadOnlyMode) {
return (
<div className="h-full w-full bg-transparent relative">
<div className="p-1 absolute inset-0">
<div className="p-1 pb-2 absolute inset-0">
<div ref={terminalRef} className="h-full w-full" />
</div>
{isStopping && (
@@ -378,7 +419,7 @@ export function ShellTerminal({
);
}
return (
<div className="h-full p-1 w-full overflow-hidden bg-transparent">
<div className="h-full p-1 pb-2 w-full overflow-hidden bg-transparent">
<div ref={terminalRef} className="h-full w-full" />
</div>
);
@@ -1,10 +1,15 @@
import type { Terminal } from "@xterm/xterm";
type TerminalContainerWithBuffer = HTMLDivElement & { __xtermReadBuffer?: () => string };
type TerminalContainerWithBuffer = HTMLDivElement & {
__xtermReadBuffer?: () => string;
__xtermGetFontFamily?: () => string;
__xtermGetFontSize?: () => number;
};
/** Expose buffer reader on the container for e2e tests (xterm renders to canvas). */
export function exposeBufferReader(container: HTMLDivElement, terminal: Terminal) {
(container as TerminalContainerWithBuffer).__xtermReadBuffer = () => {
const c = container as TerminalContainerWithBuffer;
c.__xtermReadBuffer = () => {
const buf = terminal.buffer.active;
const lines: string[] = [];
for (let i = 0; i <= buf.baseY + buf.cursorY; i++) {
@@ -12,6 +17,8 @@ export function exposeBufferReader(container: HTMLDivElement, terminal: Terminal
}
return lines.join("\n");
};
c.__xtermGetFontFamily = () => terminal.options.fontFamily ?? "";
c.__xtermGetFontSize = () => terminal.options.fontSize ?? 13;
}
export function clearBufferReader(container: HTMLDivElement) {
@@ -79,7 +79,11 @@ function initTerminalInstance(
| "resizeTimeoutRef"
>,
fitAndResize: (force?: boolean) => void,
options: { linkHandler?: (event: MouseEvent, uri: string) => void } & TerminalKeyHandlerOptions,
options: {
linkHandler?: (event: MouseEvent, uri: string) => void;
fontFamily?: string;
fontSize?: number;
} & TerminalKeyHandlerOptions,
) {
if (refs.isInitializedRef.current || refs.xtermRef.current) return undefined;
refs.isInitializedRef.current = true;
@@ -91,8 +95,8 @@ function initTerminalInstance(
convertEol: false,
scrollOnUserInput: true,
scrollback: 5000,
fontSize: 13,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
fontSize: options.fontSize || 13,
fontFamily: options.fontFamily || 'Menlo, Monaco, "Courier New", monospace',
macOptionIsMeta: true,
theme: getTerminalTheme(termContainer),
});
@@ -167,6 +171,8 @@ function initTerminalInstance(
type TerminalInitHookOptions = TerminalInitOptions &
TerminalKeyHandlerOptions & {
linkHandler?: (event: MouseEvent, uri: string) => void;
fontFamily?: string;
fontSize?: number;
};
export function useTerminalInit({
@@ -180,6 +186,8 @@ export function useTerminalInit({
fitAndResize,
onReady,
linkHandler,
fontFamily,
fontSize,
onToggleBottomTerminal,
sendInput,
}: TerminalInitHookOptions) {
@@ -210,6 +218,8 @@ export function useTerminalInit({
if (rect.width >= MIN_WIDTH && rect.height >= MIN_HEIGHT) {
initTerminalInstance(container, refs, fitAndResize, {
linkHandler,
fontFamily,
fontSize,
onToggleBottomTerminal,
sendInput,
});
+8 -1
View File
@@ -368,7 +368,12 @@ export class ApiClient {
}
async getUserSettings(): Promise<{
settings: { terminal_link_behavior?: string; [key: string]: unknown };
settings: {
terminal_link_behavior?: string;
terminal_font_family?: string;
terminal_font_size?: number;
[key: string]: unknown;
};
}> {
return this.request("GET", "/api/v1/user/settings");
}
@@ -378,6 +383,8 @@ export class ApiClient {
workspace_id?: string;
workflow_filter_id?: string;
terminal_link_behavior?: "new_tab" | "browser_panel";
terminal_font_family?: string;
terminal_font_size?: number;
keyboard_shortcuts?: Record<string, unknown>;
default_utility_agent_id?: string;
default_utility_model?: string;
@@ -0,0 +1,134 @@
import { type Page } from "@playwright/test";
import { test, expect } from "../fixtures/test-base";
import type { SeedData } from "../fixtures/test-base";
import type { ApiClient } from "../helpers/api-client";
import { SessionPage } from "../pages/session-page";
// The full font stack value for "JetBrains Mono" preset
const JB_MONO_STACK = '"JetBrains Mono", "Fira Code", Menlo, Consolas, monospace';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Create a non-TUI task and navigate to its session. Waits for agent idle. */
async function seedTaskWithSession(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
): Promise<SessionPage> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
return session;
}
/** Read the xterm font family from the terminal panel via the exposed helper. */
async function readTerminalFontFamily(page: Page): Promise<string> {
return page.evaluate(() => {
const panel = document.querySelector('[data-testid="terminal-panel"]');
if (!panel) return "";
const xtermEl = panel.querySelector(".xterm");
type XC = HTMLElement & { __xtermGetFontFamily?: () => string };
const container = xtermEl?.parentElement as XC | null | undefined;
return container?.__xtermGetFontFamily?.() ?? "";
});
}
/** Read the xterm font size from the terminal panel via the exposed helper. */
async function readTerminalFontSize(page: Page): Promise<number> {
return page.evaluate(() => {
const panel = document.querySelector('[data-testid="terminal-panel"]');
if (!panel) return 0;
const xtermEl = panel.querySelector(".xterm");
type XC = HTMLElement & { __xtermGetFontSize?: () => number };
const container = xtermEl?.parentElement as XC | null | undefined;
return container?.__xtermGetFontSize?.() ?? 0;
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test.describe("Terminal font settings", () => {
test.describe.configure({ retries: 1 });
test("font family persists after page reload", async ({ testPage, apiClient, seedData }) => {
// Seed font via API, then verify through UI
await apiClient.saveUserSettings({ terminal_font_family: JB_MONO_STACK });
const session = await seedTaskWithSession(testPage, apiClient, seedData, "Font Persist");
await expect(session.terminal).toBeVisible({ timeout: 15_000 });
// Verify font is applied
const fontBefore = await readTerminalFontFamily(testPage);
expect(fontBefore).toContain("JetBrains Mono");
// Reload and verify persistence
await testPage.reload();
await session.waitForLoad();
await expect(session.terminal).toBeVisible({ timeout: 15_000 });
const fontAfter = await readTerminalFontFamily(testPage);
expect(fontAfter).toContain("JetBrains Mono");
// Clean up
await apiClient.saveUserSettings({ terminal_font_family: "" });
});
test("font size persists after page reload", async ({ testPage, apiClient, seedData }) => {
await apiClient.saveUserSettings({ terminal_font_size: 18 });
const session = await seedTaskWithSession(testPage, apiClient, seedData, "Size Persist");
await expect(session.terminal).toBeVisible({ timeout: 15_000 });
const sizeBefore = await readTerminalFontSize(testPage);
expect(sizeBefore).toBe(18);
// Reload and verify persistence
await testPage.reload();
await session.waitForLoad();
await expect(session.terminal).toBeVisible({ timeout: 15_000 });
const sizeAfter = await readTerminalFontSize(testPage);
expect(sizeAfter).toBe(18);
// Clean up
await apiClient.saveUserSettings({ terminal_font_size: 0 });
});
test("settings page shows font and size controls", async ({ testPage }) => {
await testPage.goto("/settings/general");
// Font family selector
const fontSelect = testPage.getByTestId("terminal-font-select");
await expect(fontSelect).toBeVisible({ timeout: 10_000 });
await fontSelect.click();
// Verify a preset is listed (exact match to avoid matching Nerd Font variant)
const option = testPage.getByRole("option", { name: "JetBrains Mono", exact: true });
await expect(option).toBeVisible({ timeout: 5_000 });
// Close dropdown by pressing Escape
await testPage.keyboard.press("Escape");
// Font size input
const fontSizeInput = testPage.getByTestId("terminal-font-size-input");
await expect(fontSizeInput).toBeVisible();
});
});
+11 -3
View File
@@ -26,6 +26,16 @@ type CommitPayload = {
kanbanViewMode?: string | null;
};
function carryForwardTerminalSettings(current: DisplaySettings) {
return {
terminalLinkBehavior: (current.terminalLinkBehavior ?? "new_tab") as
| "new_tab"
| "browser_panel",
terminalFontFamily: current.terminalFontFamily ?? null,
terminalFontSize: current.terminalFontSize ?? null,
};
}
function carryForwardSettings(current: DisplaySettings) {
return {
shellOptions: current.shellOptions ?? [],
@@ -40,9 +50,7 @@ function carryForwardSettings(current: DisplaySettings) {
savedLayouts: current.savedLayouts ?? [],
defaultUtilityAgentId: current.defaultUtilityAgentId ?? null,
keyboardShortcuts: current.keyboardShortcuts ?? {},
terminalLinkBehavior: (current.terminalLinkBehavior ?? "new_tab") as
| "new_tab"
| "browser_panel",
...carryForwardTerminalSettings(current),
};
}
+2
View File
@@ -47,6 +47,8 @@ export async function updateUserSettings(
default_utility_model?: string;
keyboard_shortcuts?: Record<string, { key: string; modifiers?: Record<string, boolean> }>;
terminal_link_behavior?: "new_tab" | "browser_panel";
terminal_font_family?: string;
terminal_font_size?: number;
},
options?: ApiRequestOptions,
) {
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import { buildCoreFields, mapUserSettingsResponse } from "./user-settings";
describe("buildCoreFields", () => {
it("maps terminal_font_family to terminalFontFamily", () => {
const settings = {
workspace_id: "",
workflow_filter_id: "",
kanban_view_mode: "",
repository_ids: [],
preferred_shell: "",
default_editor_id: "",
enable_preview_on_click: false,
chat_submit_key: "cmd_enter",
review_auto_mark_on_scroll: true,
show_release_notification: true,
release_notes_last_seen_version: "",
saved_layouts: [],
default_utility_agent_id: "",
default_utility_model: "",
keyboard_shortcuts: {},
terminal_link_behavior: "new_tab",
terminal_font_family: "JetBrains Mono",
updated_at: "2026-01-01T00:00:00Z",
} as unknown as Parameters<typeof buildCoreFields>[0];
const result = buildCoreFields(settings);
expect(result.terminalFontFamily).toBe("JetBrains Mono");
});
it("returns null when terminal_font_family is empty", () => {
const settings = {
workspace_id: "",
workflow_filter_id: "",
kanban_view_mode: "",
repository_ids: [],
preferred_shell: "",
default_editor_id: "",
enable_preview_on_click: false,
chat_submit_key: "cmd_enter",
review_auto_mark_on_scroll: true,
show_release_notification: true,
release_notes_last_seen_version: "",
saved_layouts: [],
default_utility_agent_id: "",
default_utility_model: "",
keyboard_shortcuts: {},
terminal_link_behavior: "new_tab",
terminal_font_family: "",
updated_at: "2026-01-01T00:00:00Z",
} as unknown as Parameters<typeof buildCoreFields>[0];
const result = buildCoreFields(settings);
expect(result.terminalFontFamily).toBeNull();
});
});
describe("buildTerminalFields via buildCoreFields", () => {
it("maps terminal_font_size to terminalFontSize", () => {
const settings = {
terminal_font_size: 16,
terminal_font_family: "",
terminal_link_behavior: "new_tab",
} as unknown as Parameters<typeof buildCoreFields>[0];
const result = buildCoreFields(settings);
expect(result.terminalFontSize).toBe(16);
});
it("returns null when terminal_font_size is 0", () => {
const settings = {
terminal_font_size: 0,
terminal_font_family: "",
terminal_link_behavior: "new_tab",
} as unknown as Parameters<typeof buildCoreFields>[0];
const result = buildCoreFields(settings);
expect(result.terminalFontSize).toBeNull();
});
});
describe("mapUserSettingsResponse", () => {
it("returns null terminalFontFamily when response is null", () => {
const result = mapUserSettingsResponse(null);
expect(result.terminalFontFamily).toBeNull();
});
});
+11 -1
View File
@@ -6,6 +6,14 @@ export function parseTerminalLinkBehavior(value: string | undefined): "new_tab"
return value === "browser_panel" ? "browser_panel" : "new_tab";
}
function buildTerminalFields(s: UserSettingsData) {
return {
terminalLinkBehavior: parseTerminalLinkBehavior(s.terminal_link_behavior),
terminalFontFamily: s.terminal_font_family || null,
terminalFontSize: s.terminal_font_size || null,
};
}
export function buildCoreFields(s: UserSettingsData) {
return {
workspaceId: s.workspace_id || null,
@@ -22,7 +30,7 @@ export function buildCoreFields(s: UserSettingsData) {
savedLayouts: s.saved_layouts ?? [],
defaultUtilityAgentId: s.default_utility_agent_id || null,
keyboardShortcuts: s.keyboard_shortcuts ?? {},
terminalLinkBehavior: parseTerminalLinkBehavior(s.terminal_link_behavior),
...buildTerminalFields(s),
};
}
@@ -59,6 +67,8 @@ export function mapUserSettingsResponse(response: UserSettingsResponse | null) {
defaultUtilityAgentId: null,
keyboardShortcuts: {} as Record<string, { key: string; modifiers?: Record<string, boolean> }>,
terminalLinkBehavior: "new_tab" as const,
terminalFontFamily: null,
terminalFontSize: null,
...buildLspFields(undefined),
loaded: false,
};
@@ -39,6 +39,8 @@ export const defaultSettingsState: SettingsSliceState = {
savedLayouts: [],
defaultUtilityAgentId: null,
keyboardShortcuts: {},
terminalFontFamily: null,
terminalFontSize: null,
loaded: false,
},
};
@@ -119,6 +119,8 @@ export type UserSettingsState = {
defaultUtilityAgentId: string | null;
keyboardShortcuts: Record<string, { key: string; modifiers?: Record<string, boolean> }>;
terminalLinkBehavior: "new_tab" | "browser_panel";
terminalFontFamily: string | null;
terminalFontSize: number | null;
loaded: boolean;
};
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { buildTerminalFontFamily, TERMINAL_FONT_PRESETS, DEFAULT_FONT_SIZE } from "./terminal-font";
const DEFAULT_FONT = 'Menlo, Monaco, "Courier New", monospace';
describe("buildTerminalFontFamily", () => {
it("returns default font stack when selection is null", () => {
expect(buildTerminalFontFamily(null)).toBe(DEFAULT_FONT);
});
it("returns default font stack when selection is empty string", () => {
expect(buildTerminalFontFamily("")).toBe(DEFAULT_FONT);
});
it("returns preset value as-is without wrapping", () => {
const preset = '"JetBrains Mono", "Fira Code", Menlo, Consolas, monospace';
expect(buildTerminalFontFamily(preset)).toBe(preset);
});
it("returns custom string as-is without wrapping", () => {
const custom = "My Custom Font, monospace";
expect(buildTerminalFontFamily(custom)).toBe(custom);
});
});
describe("TERMINAL_FONT_PRESETS", () => {
it("has presets in all three categories", () => {
const categories = new Set(TERMINAL_FONT_PRESETS.map((p) => p.category));
expect(categories).toContain("icons");
expect(categories).toContain("ligatures");
expect(categories).toContain("system");
});
it("each preset value contains monospace as a fallback", () => {
for (const preset of TERMINAL_FONT_PRESETS) {
expect(preset.value).toContain("monospace");
}
});
it("each preset has value, label, and category", () => {
for (const preset of TERMINAL_FONT_PRESETS) {
expect(preset.value).toBeTruthy();
expect(preset.label).toBeTruthy();
expect(preset.category).toBeTruthy();
}
});
});
describe("DEFAULT_FONT_SIZE", () => {
it("equals 13", () => {
expect(DEFAULT_FONT_SIZE).toBe(13);
});
});
+82
View File
@@ -0,0 +1,82 @@
export type FontCategory = "icons" | "ligatures" | "system";
export type TerminalFontPreset = {
value: string;
label: string;
category: FontCategory;
};
export const TERMINAL_FONT_PRESETS: TerminalFontPreset[] = [
// Nerd Fonts (icon/glyph support)
{
value: '"JetBrainsMono Nerd Font", "JetBrains Mono", Menlo, Consolas, monospace',
label: "JetBrains Mono Nerd Font",
category: "icons",
},
{
value: '"FiraCode Nerd Font", "Fira Code", Menlo, Consolas, monospace',
label: "Fira Code Nerd Font",
category: "icons",
},
{
value: '"MesloLGS NF", "MesloLGS Nerd Font", Menlo, Consolas, monospace',
label: "MesloLGS Nerd Font",
category: "icons",
},
{
value: '"Hack Nerd Font", Hack, Menlo, Consolas, monospace',
label: "Hack Nerd Font",
category: "icons",
},
{
value: '"CaskaydiaCove Nerd Font", "Cascadia Code", Menlo, Consolas, monospace',
label: "Cascadia Code Nerd Font",
category: "icons",
},
// Programming fonts (ligatures)
{
value: '"JetBrains Mono", "Fira Code", Menlo, Consolas, monospace',
label: "JetBrains Mono",
category: "ligatures",
},
{
value: '"Fira Code", "JetBrains Mono", Menlo, Consolas, monospace',
label: "Fira Code",
category: "ligatures",
},
{
value: '"Cascadia Code", "Cascadia Mono", Menlo, Consolas, monospace',
label: "Cascadia Code",
category: "ligatures",
},
{
value: '"Source Code Pro", "DejaVu Sans Mono", Menlo, Consolas, monospace',
label: "Source Code Pro",
category: "ligatures",
},
// System monospace (cross-platform fallback chains)
{
value: 'Menlo, "DejaVu Sans Mono", Consolas, monospace',
label: "System Default",
category: "system",
},
{
value: '"SF Mono", Menlo, "DejaVu Sans Mono", Consolas, monospace',
label: "SF Mono",
category: "system",
},
{
value: '"Ubuntu Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace',
label: "Ubuntu Mono",
category: "system",
},
];
const DEFAULT_FONT_FAMILY = 'Menlo, Monaco, "Courier New", monospace';
export const DEFAULT_FONT_SIZE = 13;
export function buildTerminalFontFamily(selected: string | null): string {
if (!selected) return DEFAULT_FONT_FAMILY;
return selected;
}
+2
View File
@@ -377,6 +377,8 @@ export type UserSettings = {
default_utility_model?: string;
keyboard_shortcuts?: Record<string, { key: string; modifiers?: Record<string, boolean> }>;
terminal_link_behavior?: string;
terminal_font_family?: string;
terminal_font_size?: number;
updated_at: string;
};