feat(terminal): fix bottom padding and add font family setting (#489)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user