From 81341263fdd4b79bceb21334128da0b6e17157f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 29 Mar 2026 22:27:37 +0100 Subject: [PATCH] feat(terminal): fix bottom padding and add font family setting (#489) --- .agents/skills/e2e/SKILL.md | 6 + .../internal/user/controller/controller.go | 2 + apps/backend/internal/user/dto/dto.go | 6 + apps/backend/internal/user/models/models.go | 2 + apps/backend/internal/user/service/service.go | 14 ++ .../internal/user/service/service_test.go | 97 ++++++++ apps/backend/internal/user/store/sqlite.go | 6 + .../settings/editors-settings-state.tsx | 2 + .../components/settings/general-settings.tsx | 230 +++++++++++++++++- .../components/task/bottom-terminal-panel.tsx | 4 +- .../components/task/passthrough-terminal.tsx | 82 ++++--- apps/web/components/task/shell-terminal.tsx | 127 ++++++---- .../components/task/terminal-buffer-reader.ts | 11 +- .../task/use-passthrough-terminal.ts | 16 +- apps/web/e2e/helpers/api-client.ts | 9 +- .../e2e/tests/terminal-font-settings.spec.ts | 134 ++++++++++ apps/web/hooks/use-user-display-settings.ts | 14 +- apps/web/lib/api/domains/settings-api.ts | 2 + apps/web/lib/ssr/user-settings.test.ts | 87 +++++++ apps/web/lib/ssr/user-settings.ts | 12 +- .../state/slices/settings/settings-slice.ts | 2 + apps/web/lib/state/slices/settings/types.ts | 2 + apps/web/lib/terminal/terminal-font.test.ts | 53 ++++ apps/web/lib/terminal/terminal-font.ts | 82 +++++++ apps/web/lib/types/http.ts | 2 + 25 files changed, 912 insertions(+), 92 deletions(-) create mode 100644 apps/web/e2e/tests/terminal-font-settings.spec.ts create mode 100644 apps/web/lib/ssr/user-settings.test.ts create mode 100644 apps/web/lib/terminal/terminal-font.test.ts create mode 100644 apps/web/lib/terminal/terminal-font.ts diff --git a/.agents/skills/e2e/SKILL.md b/.agents/skills/e2e/SKILL.md index 56da50e14f..431d98cb51 100644 --- a/.agents/skills/e2e/SKILL.md +++ b/.agents/skills/e2e/SKILL.md @@ -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 diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 0564d5901a..1980d502f6 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -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 diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index 64bf4e13ae..307433d020 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -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), } } diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index ecace6ae23..30e92f9a77 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -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"` } diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 2e99cad515..717f6bc28e 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -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 { diff --git a/apps/backend/internal/user/service/service_test.go b/apps/backend/internal/user/service/service_test.go index 897996828f..f2ed9c7d50 100644 --- a/apps/backend/internal/user/service/service_test.go +++ b/apps/backend/internal/user/service/service_test.go @@ -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 diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 487e55028b..fb5837e404 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -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 } diff --git a/apps/web/components/settings/editors-settings-state.tsx b/apps/web/components/settings/editors-settings-state.tsx index 58f128546b..65b89214ca 100644 --- a/apps/web/components/settings/editors-settings-state.tsx +++ b/apps/web/components/settings/editors-settings-state.tsx @@ -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), }; } diff --git a/apps/web/components/settings/general-settings.tsx b/apps/web/components/settings/general-settings.tsx index 2dd6b036eb..fd134b6291 100644 --- a/apps/web/components/settings/general-settings.tsx +++ b/apps/web/components/settings/general-settings.tsx @@ -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 = { + icons: "Nerd Fonts", + ligatures: "Programming", + system: "System", +}; +const CATEGORY_BADGES: Partial> = { + icons: "Icons", + ligatures: "Ligatures", +}; +const FONT_GROUPS: Record = TERMINAL_FONT_PRESETS.reduce( + (acc, p) => { + (acc[p.category] ??= []).push(p); + return acc; + }, + {} as Record, +); +const FONT_CATEGORIES: FontCategory[] = ["icons", "ligatures", "system"]; + +function FontGroupOptions() { + return FONT_CATEGORIES.map((category) => ( + + + {CATEGORY_LABELS[category]} + {CATEGORY_BADGES[category] && ( + + {CATEGORY_BADGES[category]} + + )} + + {(FONT_GROUPS[category] ?? []).map((preset) => ( + + {preset.label} + + ))} + + )); +} + +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 ( + + + Terminal Font Size + + +
+ +
+ 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" + /> + px (8-24) +
+

+ Set the font size for the terminal. Default is 13px. +

+
+
+
+ ); +} + +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 ( + + + Terminal Font + + +
+ + + {isCustom && ( + setCustomValue(e.target.value)} + onBlur={handleCustomBlur} + onKeyDown={(e) => { + if (e.key === "Enter") handleCustomBlur(); + }} + data-testid="terminal-font-custom-input" + /> + )} +

+ Choose a monospace font for the terminal. Nerd Fonts include icons for CLI tools. +

+
+
+
+ ); +} + function BackendConnectionCard() { const [backendUrl] = useState(() => getBackendConfig().apiBaseUrl); const displayBackendUrl = backendUrl.replace(/^https?:\/\//, "").replace(/\/$/, ""); @@ -211,10 +429,12 @@ export function GeneralSettings() { } - title="Terminal Links" - description="Configure how clickable URLs in the terminal are opened" + icon={} + title="Terminal" + description="Configure terminal appearance and behavior" > + + diff --git a/apps/web/components/task/bottom-terminal-panel.tsx b/apps/web/components/task/bottom-terminal-panel.tsx index 1537fb958e..8572a27324 100644 --- a/apps/web/components/task/bottom-terminal-panel.tsx +++ b/apps/web/components/task/bottom-terminal-panel.tsx @@ -94,8 +94,8 @@ export function BottomTerminalPanel({ sessionId }: Props) { - {/* Terminal content - pb-2 prevents last line from being cut off */} -
+ {/* Terminal content */} +
(null), + xtermRef: useRef(null), + fitAddonRef: useRef(null), + wsRef: useRef(null), + attachAddonRef: useRef(null), + isInitializedRef: useRef(false), + lastDimensionsRef: useRef({ cols: 0, rows: 0 }), + resizeTimeoutRef: useRef | null>(null), + webglAddonRef: useRef(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(null); - const xtermRef = useRef(null); - const fitAddonRef = useRef(null); - const wsRef = useRef(null); - const attachAddonRef = useRef(null); - const isInitializedRef = useRef(false); - const lastDimensionsRef = useRef({ cols: 0, rows: 0 }); - const resizeTimeoutRef = useRef | null>(null); - const webglAddonRef = useRef(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 }} > -
+
+
+
{!isConnected && (
; }; -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; + onDataDisposableRef: React.MutableRefObject<{ dispose: () => void } | null>; + isReadOnlyMode: boolean; + taskId: string | null; + sessionId: string | null | undefined; + send: (action: string, payload: Record) => 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 (
-
+
{isStopping && ( @@ -378,7 +419,7 @@ export function ShellTerminal({ ); } return ( -
+
); diff --git a/apps/web/components/task/terminal-buffer-reader.ts b/apps/web/components/task/terminal-buffer-reader.ts index 75b783f479..743453de46 100644 --- a/apps/web/components/task/terminal-buffer-reader.ts +++ b/apps/web/components/task/terminal-buffer-reader.ts @@ -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) { diff --git a/apps/web/components/task/use-passthrough-terminal.ts b/apps/web/components/task/use-passthrough-terminal.ts index 15ece50388..1f953038e7 100644 --- a/apps/web/components/task/use-passthrough-terminal.ts +++ b/apps/web/components/task/use-passthrough-terminal.ts @@ -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, }); diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 1c838b2f82..bb46b59fcd 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -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; default_utility_agent_id?: string; default_utility_model?: string; diff --git a/apps/web/e2e/tests/terminal-font-settings.spec.ts b/apps/web/e2e/tests/terminal-font-settings.spec.ts new file mode 100644 index 0000000000..cdb67e70b5 --- /dev/null +++ b/apps/web/e2e/tests/terminal-font-settings.spec.ts @@ -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 { + 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 { + 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 { + 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(); + }); +}); diff --git a/apps/web/hooks/use-user-display-settings.ts b/apps/web/hooks/use-user-display-settings.ts index 9f09fd4254..e5defe1650 100644 --- a/apps/web/hooks/use-user-display-settings.ts +++ b/apps/web/hooks/use-user-display-settings.ts @@ -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), }; } diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts index 2d3ed964be..45909d6d46 100644 --- a/apps/web/lib/api/domains/settings-api.ts +++ b/apps/web/lib/api/domains/settings-api.ts @@ -47,6 +47,8 @@ export async function updateUserSettings( default_utility_model?: string; keyboard_shortcuts?: Record }>; terminal_link_behavior?: "new_tab" | "browser_panel"; + terminal_font_family?: string; + terminal_font_size?: number; }, options?: ApiRequestOptions, ) { diff --git a/apps/web/lib/ssr/user-settings.test.ts b/apps/web/lib/ssr/user-settings.test.ts new file mode 100644 index 0000000000..68e3004a12 --- /dev/null +++ b/apps/web/lib/ssr/user-settings.test.ts @@ -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[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[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[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[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(); + }); +}); diff --git a/apps/web/lib/ssr/user-settings.ts b/apps/web/lib/ssr/user-settings.ts index 508b0dc265..8ebb88a35c 100644 --- a/apps/web/lib/ssr/user-settings.ts +++ b/apps/web/lib/ssr/user-settings.ts @@ -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 }>, terminalLinkBehavior: "new_tab" as const, + terminalFontFamily: null, + terminalFontSize: null, ...buildLspFields(undefined), loaded: false, }; diff --git a/apps/web/lib/state/slices/settings/settings-slice.ts b/apps/web/lib/state/slices/settings/settings-slice.ts index 87cbd71d71..89025bb2d6 100644 --- a/apps/web/lib/state/slices/settings/settings-slice.ts +++ b/apps/web/lib/state/slices/settings/settings-slice.ts @@ -39,6 +39,8 @@ export const defaultSettingsState: SettingsSliceState = { savedLayouts: [], defaultUtilityAgentId: null, keyboardShortcuts: {}, + terminalFontFamily: null, + terminalFontSize: null, loaded: false, }, }; diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index 448bc93f1c..09a1d5fab7 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -119,6 +119,8 @@ export type UserSettingsState = { defaultUtilityAgentId: string | null; keyboardShortcuts: Record }>; terminalLinkBehavior: "new_tab" | "browser_panel"; + terminalFontFamily: string | null; + terminalFontSize: number | null; loaded: boolean; }; diff --git a/apps/web/lib/terminal/terminal-font.test.ts b/apps/web/lib/terminal/terminal-font.test.ts new file mode 100644 index 0000000000..bc69223019 --- /dev/null +++ b/apps/web/lib/terminal/terminal-font.test.ts @@ -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); + }); +}); diff --git a/apps/web/lib/terminal/terminal-font.ts b/apps/web/lib/terminal/terminal-font.ts new file mode 100644 index 0000000000..2b9a0062d9 --- /dev/null +++ b/apps/web/lib/terminal/terminal-font.ts @@ -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; +} diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index eb9b656b25..a3fb83a46b 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -377,6 +377,8 @@ export type UserSettings = { default_utility_model?: string; keyboard_shortcuts?: Record }>; terminal_link_behavior?: string; + terminal_font_family?: string; + terminal_font_size?: number; updated_at: string; };