diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index b676a864a..d5dc101fa 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -14,6 +14,7 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { PortalProvider } from "@gorhom/portal"; import { VoiceProvider } from "@/contexts/voice-context"; import { useAppSettings } from "@/hooks/use-settings"; +import { THEME_TO_UNISTYLES } from "@/styles/theme"; import { useFaviconStatus } from "@/hooks/use-favicon-status"; import { View, Text } from "react-native"; import { UnistylesRuntime, useUnistyles } from "react-native-unistyles"; @@ -564,7 +565,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { UnistylesRuntime.setAdaptiveThemes(true); } else { UnistylesRuntime.setAdaptiveThemes(false); - UnistylesRuntime.setTheme(settings.theme); + UnistylesRuntime.setTheme(THEME_TO_UNISTYLES[settings.theme]); } }, [settingsLoading, settings.theme]); diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index fcfe9a140..f324ae158 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -472,7 +472,7 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.md, }, tabActive: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, tabText: { fontSize: theme.fontSize.sm, diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 983fd1147..b1d0230f9 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -849,7 +849,7 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.md, }, entryRowActive: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, indentGuide: { position: "absolute", diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index b513c1c3b..159ea3d0d 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -58,7 +58,12 @@ import { import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { Fonts } from "@/constants/theme"; import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll"; -import { buildSplitDiffRows, type SplitDiffDisplayLine, type SplitDiffRow } from "@/utils/diff-layout"; +import { + buildSplitDiffRows, + buildUnifiedDiffLines, + type SplitDiffDisplayLine, + type SplitDiffRow, +} from "@/utils/diff-layout"; import { DropdownMenu, DropdownMenuContent, @@ -76,6 +81,11 @@ import { openExternalUrl } from "@/utils/open-external-url"; import { GitActionsSplitButton } from "@/components/git-actions-split-button"; import { usePanelStore } from "@/stores/panel-store"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; +import { + formatDiffContentText, + formatDiffGutterText, + hasVisibleDiffTokens, +} from "@/utils/diff-rendering"; export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; @@ -166,7 +176,7 @@ function DiffGutterCell({ type === "remove" && styles.removeLineNumberText, ]} > - {lineNumber != null ? String(lineNumber) : ""} + {formatDiffGutterText(lineNumber)} ); @@ -179,10 +189,12 @@ function DiffTextLine({ line: DiffLine; wrapLines: boolean; }) { + const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null; + return ( - {line.tokens && line.type !== "header" ? ( - + {line.type !== "header" && visibleTokens ? ( + ) : ( - {line.content || " "} + {formatDiffContentText(line.content)} )} @@ -208,10 +220,12 @@ function SplitTextLine({ line: SplitDiffDisplayLine | null; wrapLines: boolean; }) { + const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null; + return ( - {line?.tokens ? ( - + {visibleTokens ? ( + ) : ( - {line?.content ?? ""} + {formatDiffContentText(line?.content)} )} @@ -241,6 +255,8 @@ function DiffLineView({ gutterWidth: number; wrapLines: boolean; }) { + const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null; + return ( - {lineNumber != null ? String(lineNumber) : ""} + {formatDiffGutterText(lineNumber)} - {line.tokens && line.type !== "header" ? ( - + {line.type !== "header" && visibleTokens ? ( + ) : ( - {line.content || " "} + {formatDiffContentText(line.content)} )} @@ -288,6 +304,8 @@ function SplitDiffLine({ gutterWidth: number; wrapLines: boolean; }) { + const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null; + return ( - {line?.lineNumber != null ? String(line.lineNumber) : ""} + {formatDiffGutterText(line?.lineNumber ?? null)} - {line?.tokens ? ( - + {visibleTokens ? ( + ) : ( - {line?.content ?? ""} + {formatDiffContentText(line?.content)} )} @@ -535,26 +553,7 @@ function DiffFileBody({ ); } - const computedLines: { line: DiffLine; lineNumber: number | null; key: string }[] = []; - for (const [hunkIndex, hunk] of file.hunks.entries()) { - let oldLineNo = hunk.oldStart; - let newLineNo = hunk.newStart; - for (const [lineIndex, line] of hunk.lines.entries()) { - let lineNumber: number | null = null; - if (line.type === "remove") { - lineNumber = oldLineNo; - oldLineNo++; - } else if (line.type === "add") { - lineNumber = newLineNo; - newLineNo++; - } else if (line.type === "context") { - lineNumber = newLineNo; - oldLineNo++; - newLineNo++; - } - computedLines.push({ line, lineNumber, key: `${hunkIndex}-${lineIndex}` }); - } - } + const computedLines = buildUnifiedDiffLines(file); if (wrapLines) { return ( @@ -1725,7 +1724,7 @@ const styles = StyleSheet.create((theme) => ({ newBadgeText: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, - color: theme.colors.palette.green[400], + color: theme.colors.diffAddition, }, deletedBadge: { backgroundColor: "rgba(248, 81, 73, 0.2)", @@ -1737,7 +1736,17 @@ const styles = StyleSheet.create((theme) => ({ deletedBadgeText: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, - color: theme.colors.palette.red[500], + color: theme.colors.diffDeletion, + }, + additions: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.diffAddition, + }, + deletions: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.diffDeletion, }, diffContent: { borderTopWidth: theme.borderWidth[1], @@ -1815,10 +1824,10 @@ const styles = StyleSheet.create((theme) => ({ userSelect: "none", }, addLineNumberText: { - color: theme.colors.palette.green[400], + color: theme.colors.diffAddition, }, removeLineNumberText: { - color: theme.colors.palette.red[500], + color: theme.colors.diffDeletion, }, diffLineText: { flex: 1, diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 5d9b27e63..c3eb01e31 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -885,10 +885,10 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, }, newAgentButtonHovered: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, newAgentButtonActive: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, hostTrigger: { flexDirection: "row", @@ -901,7 +901,7 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.lg, }, hostTriggerHovered: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, hostStatusDot: { width: 8, diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index eb0474b4e..24687700b 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -2226,7 +2226,7 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], }, projectRowHovered: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, projectRowPressed: { backgroundColor: theme.colors.surface2, @@ -2296,7 +2296,7 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 0, }, projectActionButtonHovered: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, projectActionButtonText: { color: theme.colors.foregroundMuted, @@ -2311,7 +2311,7 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 0, }, projectIconActionButtonHovered: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, projectIconActionButtonHidden: { opacity: 0, @@ -2389,7 +2389,7 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 0, }, workspaceRowHovered: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, workspaceRowPressed: { backgroundColor: theme.colors.surface2, @@ -2403,7 +2403,7 @@ const styles = StyleSheet.create((theme) => ({ ...theme.shadow.md, }, sidebarRowSelected: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, workspaceRowContainer: { position: "relative", @@ -2467,6 +2467,22 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, flexShrink: 0, }, + diffStatRow: { + flexDirection: "row", + alignItems: "center", + gap: 6, + flexShrink: 0, + }, + diffStatAdditions: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.diffAddition, + }, + diffStatDeletions: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.diffDeletion, + }, kebabButton: { padding: 2, borderRadius: 4, diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index b760ee60f..a3a0618d0 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -550,9 +550,9 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.xl, backgroundColor: theme.colors.popover, - borderWidth: theme.borderWidth[2], - borderColor: theme.colors.border, - ...theme.shadow.sm, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.borderAccent, + ...theme.shadow.md, zIndex: 1000, }, })); diff --git a/packages/app/src/hooks/use-settings.ts b/packages/app/src/hooks/use-settings.ts index 762f610e5..1c3793e49 100644 --- a/packages/app/src/hooks/use-settings.ts +++ b/packages/app/src/hooks/use-settings.ts @@ -6,10 +6,14 @@ export const APP_SETTINGS_KEY = "@paseo:app-settings"; const LEGACY_SETTINGS_KEY = "@paseo:settings"; const APP_SETTINGS_QUERY_KEY = ["app-settings"]; +import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; + export type SendBehavior = "interrupt" | "queue"; +const VALID_THEMES = new Set([...Object.keys(THEME_TO_UNISTYLES), "auto"]); + export interface AppSettings { - theme: "dark" | "light" | "auto"; + theme: ThemeName | "auto"; manageBuiltInDaemon: boolean; sendBehavior: SendBehavior; } @@ -78,6 +82,9 @@ export async function loadSettingsFromStorage(): Promise { const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY); if (stored) { const parsed = JSON.parse(stored) as Partial; + if (parsed.theme && !VALID_THEMES.has(parsed.theme)) { + parsed.theme = DEFAULT_APP_SETTINGS.theme; + } return { ...DEFAULT_APP_SETTINGS, ...parsed }; } diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index b7c190b10..6a11bf39c 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -10,6 +10,7 @@ import { Sun, Moon, Monitor, + ChevronDown, Globe, Settings, RotateCw, @@ -24,6 +25,7 @@ import { Smartphone, } from "lucide-react-native"; import { useAppSettings, type AppSettings, type SendBehavior } from "@/hooks/use-settings"; +import { THEME_SWATCHES, type ThemeName } from "@/styles/theme"; import type { HostProfile, HostConnection } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; @@ -47,6 +49,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; @@ -362,11 +365,53 @@ interface GeneralSectionProps { handleSendBehaviorChange: (behavior: SendBehavior) => void; } +function ThemeIcon({ theme, size, color }: { theme: AppSettings["theme"]; size: number; color: string }) { + switch (theme) { + case "light": + return ; + case "dark": + return ; + case "auto": + return ; + default: + return ; + } +} + +function ThemeSwatch({ color, size }: { color: string; size: number }) { + return ( + + ); +} + +const THEME_LABELS: Record = { + light: "Light", + dark: "Dark", + zinc: "Zinc", + midnight: "Midnight", + claude: "Claude", + ghostty: "Ghostty", + auto: "System", +}; + function GeneralSection({ settings, handleThemeChange, handleSendBehaviorChange, }: GeneralSectionProps) { + const { theme } = useUnistyles(); + const iconSize = theme.iconSize.md; + const iconColor = theme.colors.foregroundMuted; + return ( General @@ -375,29 +420,43 @@ function GeneralSection({ Theme - , - }, - { - value: "dark", - label: "Dark", - icon: ({ color, size }) => , - }, - { - value: "auto", - label: "System", - icon: ({ color, size }) => , - }, - ]} - /> + + [ + styles.themeTrigger, + pressed && { opacity: 0.85 }, + ]} + > + + + {THEME_LABELS[settings.theme]} + + + + + {(["light", "dark", "auto"] as const).map((t) => ( + handleThemeChange(t)} + leading={} + > + {THEME_LABELS[t]} + + ))} + + {(["zinc", "midnight", "claude", "ghostty"] as const).map((t) => ( + handleThemeChange(t)} + leading={} + > + {THEME_LABELS[t]} + + ))} + + @@ -1805,6 +1864,20 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, + themeTrigger: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + }, + themeTriggerText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, disabled: { opacity: theme.opacity[50], }, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index f2d5c9b04..dc4eaa7fa 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -2445,6 +2445,22 @@ const styles = StyleSheet.create((theme) => ({ sourceControlButtonHovered: { backgroundColor: theme.colors.surface2, }, + diffStatRow: { + flexDirection: "row", + alignItems: "center", + gap: 6, + flexShrink: 0, + }, + diffStatAdditions: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.diffAddition, + }, + diffStatDeletions: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.diffDeletion, + }, newTabActions: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 4c8f8ce0a..8f0784891 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -103,6 +103,19 @@ export const baseColors = { }, } as const; +export type ThemeName = "light" | "dark" | "zinc" | "midnight" | "claude" | "ghostty"; + +// Diff stat colors — light uses muted tones, dark uses the brighter palette values +const lightDiffColors = { + diffAddition: "#15803d", // green-700 — readable on white without screaming + diffDeletion: "#b91c1c", // red-700 +}; + +const darkDiffColors = { + diffAddition: "#4ade80", // green-400 + diffDeletion: "#ef4444", // red-500 +}; + // Semantic color tokens - Layer-based system const lightSemanticColors = { // Surfaces (layers) - shifted one step lighter @@ -113,10 +126,11 @@ const lightSemanticColors = { surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300) surfaceDiffEmpty: "#f6f6f6", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2 surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main) + surfaceSidebarHover: "#e9e9ec", // Sidebar hover (darker in light mode) surfaceWorkspace: "#ffffff", // Workspace main background // Text - foreground: "#09090b", + foreground: "#1a1a1e", foregroundMuted: "#71717a", // Controls @@ -140,26 +154,28 @@ const lightSemanticColors = { // Legacy aliases (for gradual migration) background: "#ffffff", popover: "#ffffff", - popoverForeground: "#09090b", + popoverForeground: "#1a1a1e", primary: "#18181b", primaryForeground: "#fafafa", secondary: "#f4f4f5", - secondaryForeground: "#09090b", + secondaryForeground: "#1a1a1e", muted: "#f4f4f5", mutedForeground: "#71717a", accentBorder: "#ececf1", input: "#f4f4f5", ring: "#18181b", + ...lightDiffColors, + terminal: { background: "#ffffff", - foreground: "#09090b", - cursor: "#09090b", + foreground: "#1a1a1e", + cursor: "#1a1a1e", cursorAccent: "#ffffff", selectionBackground: "rgba(0, 0, 0, 0.15)", - selectionForeground: "#09090b", + selectionForeground: "#1a1a1e", - black: "#09090b", + black: "#1a1a1e", red: "#dc2626", green: "#16a34a", yellow: "#ca8a04", @@ -179,80 +195,196 @@ const lightSemanticColors = { }, } as const; -const darkSemanticColors = { - // Surfaces (layers) — subtle teal tint - surface0: "#181B1A", // App background - surface1: "#1E2120", // Subtle hover - surface2: "#272A29", // Elevated: badges, inputs, sheets - surface3: "#434645", // Highest elevation - surface4: "#595B5B", // Extra emphasis - surfaceDiffEmpty: "#252827", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2 - surfaceSidebar: "#141716", // Sidebar background (darker than main) - surfaceWorkspace: "#1E2120", // Workspace main background (surface1) +// --------------------------------------------------------------------------- +// Dark theme variant builder +// --------------------------------------------------------------------------- - // Text - foreground: "#fafafa", +interface DarkThemeConfig { + surface0: string; + surface1: string; + surface2: string; + surface3: string; + surface4: string; + surfaceDiffEmpty: string; + surfaceSidebar: string; + surfaceSidebarHover: string; + foregroundMuted: string; + scrollbarHandle: string; + border: string; + borderAccent: string; + accent: string; + accentBright: string; +} + +const darkTerminalAnsi = { + red: "#e07070", + green: "#5dba80", + yellow: "#d4a44a", + blue: "#6a9de0", + magenta: "#b07ad0", + cyan: "#4aabb8", + white: "#d4d4d8", + brightRed: "#e89090", + brightGreen: "#7ecf9a", + brightYellow: "#e0be6e", + brightBlue: "#8ab4e8", + brightMagenta: "#c49ae0", + brightCyan: "#6ec2cc", + brightWhite: "#f0f0f2", +} as const; + +function buildDarkSemanticColors(tint: DarkThemeConfig) { + return { + surface0: tint.surface0, + surface1: tint.surface1, + surface2: tint.surface2, + surface3: tint.surface3, + surface4: tint.surface4, + surfaceDiffEmpty: tint.surfaceDiffEmpty, + surfaceSidebar: tint.surfaceSidebar, + surfaceSidebarHover: tint.surfaceSidebarHover, + surfaceWorkspace: tint.surface1, + + foreground: "#fafafa", + foregroundMuted: tint.foregroundMuted, + + scrollbarHandle: tint.scrollbarHandle, + + border: tint.border, + borderAccent: tint.borderAccent, + + accent: tint.accent, + accentBright: tint.accentBright, + accentForeground: "#ffffff", + + destructive: "#ef4444", + destructiveForeground: "#ffffff", + success: tint.accent, + successForeground: "#ffffff", + + // Legacy aliases (for gradual migration) + background: tint.surface0, + popover: tint.surface2, + popoverForeground: "#fafafa", + primary: "#fafafa", + primaryForeground: tint.surface0, + secondary: tint.surface2, + secondaryForeground: "#fafafa", + muted: tint.surface2, + mutedForeground: tint.foregroundMuted, + accentBorder: tint.borderAccent, + input: tint.surface2, + ring: "#d4d4d8", + + ...darkDiffColors, + + terminal: { + background: tint.surface0, + foreground: "#fafafa", + cursor: "#fafafa", + cursorAccent: tint.surface0, + selectionBackground: "rgba(255, 255, 255, 0.2)", + selectionForeground: "#fafafa", + black: tint.surfaceSidebar, + ...darkTerminalAnsi, + brightBlack: tint.surface3, + }, + }; +} + +// --------------------------------------------------------------------------- +// Dark tint definitions +// --------------------------------------------------------------------------- + +// Paseo — subtle teal-green tint (default) +const paseoDarkColors = buildDarkSemanticColors({ + surface0: "#181B1A", + surface1: "#1E2120", + surface2: "#272A29", + surface3: "#434645", + surface4: "#595B5B", + surfaceDiffEmpty: "#252827", + surfaceSidebar: "#141716", + surfaceSidebarHover: "#1c1f1e", foregroundMuted: "#A1A5A4", - - // Controls - scrollbarHandle: "#717574", // zinc-500 w/ teal tint - - // Borders + scrollbarHandle: "#717574", border: "#252B2A", borderAccent: "#2F3534", - - // Brand accent: "#20744A", accentBright: "#7ccba0", - accentForeground: "#ffffff", +}); - // Semantic - destructive: "#ef4444", - destructiveForeground: "#ffffff", - success: "#20744A", - successForeground: "#ffffff", +// Zinc — neutral gray, no tint +const zincDarkColors = buildDarkSemanticColors({ + surface0: "#18181b", + surface1: "#1f1f22", + surface2: "#27272a", + surface3: "#3f3f46", + surface4: "#52525b", + surfaceDiffEmpty: "#242427", + surfaceSidebar: "#131316", + surfaceSidebarHover: "#1b1b1e", + foregroundMuted: "#a1a1aa", + scrollbarHandle: "#71717a", + border: "#27272a", + borderAccent: "#303036", + accent: "#20744A", + accentBright: "#7ccba0", +}); - // Legacy aliases (for gradual migration) - background: "#181B1A", - popover: "#272A29", - popoverForeground: "#fafafa", - primary: "#fafafa", - primaryForeground: "#181B1A", - secondary: "#272A29", - secondaryForeground: "#fafafa", - muted: "#272A29", - mutedForeground: "#A1A5A4", - accentBorder: "#2F3534", - input: "#272A29", - ring: "#d4d4d8", +// Midnight — subtle blue tint +const midnightDarkColors = buildDarkSemanticColors({ + surface0: "#161820", + surface1: "#1c1e27", + surface2: "#252731", + surface3: "#3c3e4c", + surface4: "#535564", + surfaceDiffEmpty: "#222430", + surfaceSidebar: "#121420", + surfaceSidebarHover: "#1a1c28", + foregroundMuted: "#9a9db0", + scrollbarHandle: "#6b6e82", + border: "#242636", + borderAccent: "#2e3040", + accent: "#3b6fcf", + accentBright: "#7eaaeb", +}); - terminal: { - background: "#181B1A", - foreground: "#fafafa", - cursor: "#fafafa", - cursorAccent: "#181B1A", - selectionBackground: "rgba(255, 255, 255, 0.2)", - selectionForeground: "#fafafa", +// Claude — warm neutral with subtle orange undertone +const claudeDarkColors = buildDarkSemanticColors({ + surface0: "#1f1f1e", + surface1: "#262523", + surface2: "#2f2d2b", + surface3: "#4a4745", + surface4: "#605d5b", + surfaceDiffEmpty: "#2a2826", + surfaceSidebar: "#1a1918", + surfaceSidebarHover: "#222120", + foregroundMuted: "#ada9a5", + scrollbarHandle: "#78746f", + border: "#2c2a27", + borderAccent: "#36332f", + accent: "#d97757", + accentBright: "#e89a7f", +}); - black: "#141716", - red: "#ef4444", - green: "#22c55e", - yellow: "#f59e0b", - blue: "#3b82f6", - magenta: "#a855f7", - cyan: "#06b6d4", - white: "#e4e4e7", - - brightBlack: "#434645", - brightRed: "#f87171", - brightGreen: "#4ade80", - brightYellow: "#fbbf24", - brightBlue: "#60a5fa", - brightMagenta: "#c084fc", - brightCyan: "#22d3ee", - brightWhite: "#ffffff", - }, -} as const; +// Ghostty — blue-tinted dark based on Ghostty default background +const ghosttyDarkColors = buildDarkSemanticColors({ + surface0: "#282c34", + surface1: "#2f333d", + surface2: "#383c48", + surface3: "#4a4f5e", + surface4: "#5b6175", + surfaceDiffEmpty: "#323643", + surfaceSidebar: "#21252d", + surfaceSidebarHover: "#292d36", + foregroundMuted: "#c8ccd8", + scrollbarHandle: "#a0a4b2", + border: "#353a47", + borderAccent: "#3f4454", + accent: "#89b4fa", + accentBright: "#b4d0fc", +}); const commonTheme = { spacing: { @@ -324,35 +456,45 @@ const commonTheme = { }, } as const; -export const darkTheme = { - colorScheme: "dark" as const, - colors: { - ...darkSemanticColors, - palette: baseColors, +const darkShadow = { + sm: { + shadowColor: "rgba(0, 0, 0, 0.25)", + shadowOffset: { width: 0, height: 2 }, + shadowRadius: 4, + elevation: 2, }, - shadow: { - sm: { - shadowColor: "rgba(0, 0, 0, 0.25)", - shadowOffset: { width: 0, height: 2 }, - shadowRadius: 4, - elevation: 2, - }, - md: { - shadowColor: "rgba(0, 0, 0, 0.20)", - shadowOffset: { width: 0, height: 4 }, - shadowRadius: 8, - elevation: 8, - }, - lg: { - shadowColor: "rgba(0, 0, 0, 0.40)", - shadowOffset: { width: 0, height: 12 }, - shadowRadius: 24, - elevation: 8, - }, + md: { + shadowColor: "rgba(0, 0, 0, 0.20)", + shadowOffset: { width: 0, height: 4 }, + shadowRadius: 8, + elevation: 8, + }, + lg: { + shadowColor: "rgba(0, 0, 0, 0.40)", + shadowOffset: { width: 0, height: 12 }, + shadowRadius: 24, + elevation: 8, }, - ...commonTheme, } as const; +function buildDarkTheme(semanticColors: ReturnType) { + return { + colorScheme: "dark" as const, + colors: { + ...semanticColors, + palette: baseColors, + }, + shadow: darkShadow, + ...commonTheme, + } as const; +} + +export const darkTheme = buildDarkTheme(paseoDarkColors); +export const darkZincTheme = buildDarkTheme(zincDarkColors); +export const darkMidnightTheme = buildDarkTheme(midnightDarkColors); +export const darkClaudeTheme = buildDarkTheme(claudeDarkColors); +export const darkGhosttyTheme = buildDarkTheme(ghosttyDarkColors); + export const lightTheme = { colorScheme: "light" as const, colors: { @@ -387,3 +529,23 @@ export const theme = darkTheme; // Export a union type that works for both themes export type Theme = typeof darkTheme | typeof lightTheme; + +type UnistylesThemeKey = "light" | "dark" | "darkZinc" | "darkMidnight" | "darkClaude" | "darkGhostty"; + +export const THEME_TO_UNISTYLES: Record = { + light: "light", + dark: "dark", + zinc: "darkZinc", + midnight: "darkMidnight", + claude: "darkClaude", + ghostty: "darkGhostty", +}; + +export const THEME_SWATCHES: Record = { + light: "#ffffff", + dark: "#2D8B62", + zinc: "#808080", + midnight: "#4A6BA8", + claude: "#D97757", + ghostty: "#8caaee", +}; diff --git a/packages/app/src/styles/unistyles.ts b/packages/app/src/styles/unistyles.ts index 4bcb6e072..3bb09dc8a 100644 --- a/packages/app/src/styles/unistyles.ts +++ b/packages/app/src/styles/unistyles.ts @@ -1,11 +1,21 @@ import { StyleSheet } from "react-native-unistyles"; -// import { UnistylesRuntime } from "react-native-unistyles"; -import { lightTheme, darkTheme } from "./theme"; +import { + lightTheme, + darkTheme, + darkZincTheme, + darkMidnightTheme, + darkClaudeTheme, + darkGhosttyTheme, +} from "./theme"; StyleSheet.configure({ themes: { light: lightTheme, dark: darkTheme, + darkZinc: darkZincTheme, + darkMidnight: darkMidnightTheme, + darkClaude: darkClaudeTheme, + darkGhostty: darkGhosttyTheme, }, breakpoints: { xs: 0, @@ -23,6 +33,10 @@ StyleSheet.configure({ type AppThemes = { light: typeof lightTheme; dark: typeof darkTheme; + darkZinc: typeof darkZincTheme; + darkMidnight: typeof darkMidnightTheme; + darkClaude: typeof darkClaudeTheme; + darkGhostty: typeof darkGhosttyTheme; }; type AppBreakpoints = { @@ -37,5 +51,3 @@ declare module "react-native-unistyles" { export interface UnistylesThemes extends AppThemes {} export interface UnistylesBreakpoints extends AppBreakpoints {} } - -// UnistylesRuntime.setRootViewBackgroundColor(lightTheme.colors.background); diff --git a/packages/app/src/utils/diff-layout.test.ts b/packages/app/src/utils/diff-layout.test.ts index c5e756d81..a6f00f0cc 100644 --- a/packages/app/src/utils/diff-layout.test.ts +++ b/packages/app/src/utils/diff-layout.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildSplitDiffRows } from "./diff-layout"; +import { buildSplitDiffRows, buildUnifiedDiffLines } from "./diff-layout"; import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query"; function makeFile(lines: ParsedDiffFile["hunks"][number]["lines"]): ParsedDiffFile { @@ -79,3 +79,76 @@ describe("buildSplitDiffRows", () => { }); }); }); + +describe("buildUnifiedDiffLines", () => { + it("computes line numbers per line type within a hunk", () => { + const lines = buildUnifiedDiffLines( + makeFile([ + { type: "header", content: "@@ -10,3 +10,4 @@" }, + { type: "context", content: "before" }, + { type: "add", content: "inserted" }, + { type: "remove", content: "removed" }, + { type: "context", content: "after" }, + ]), + ); + + expect( + lines.map(({ line, lineNumber }) => ({ + type: line.type, + lineNumber, + content: line.content, + })), + ).toEqual([ + { type: "header", lineNumber: null, content: "@@ -10,3 +10,4 @@" }, + { type: "context", lineNumber: 10, content: "before" }, + { type: "add", lineNumber: 11, content: "inserted" }, + { type: "remove", lineNumber: 11, content: "removed" }, + { type: "context", lineNumber: 12, content: "after" }, + ]); + }); + + it("restarts numbering at each hunk boundary", () => { + const file: ParsedDiffFile = { + path: "example.ts", + isNew: false, + isDeleted: false, + additions: 1, + deletions: 0, + status: "ok", + hunks: [ + { + oldStart: 75, + oldCount: 2, + newStart: 75, + newCount: 3, + lines: [ + { type: "header", content: "@@ -75,2 +75,3 @@" }, + { type: "context", content: "first" }, + { type: "add", content: "inserted" }, + { type: "context", content: "second" }, + ], + }, + { + oldStart: 165, + oldCount: 2, + newStart: 166, + newCount: 2, + lines: [ + { type: "header", content: "@@ -165,2 +166,2 @@" }, + { type: "context", content: "third" }, + { type: "context", content: "fourth" }, + ], + }, + ], + }; + + const lines = buildUnifiedDiffLines(file); + + expect(lines[0]?.lineNumber).toBeNull(); + expect(lines[1]?.lineNumber).toBe(75); + expect(lines[3]?.lineNumber).toBe(77); + expect(lines[4]?.lineNumber).toBeNull(); + expect(lines[5]?.lineNumber).toBe(166); + expect(lines[6]?.lineNumber).toBe(167); + }); +}); diff --git a/packages/app/src/utils/diff-layout.ts b/packages/app/src/utils/diff-layout.ts index b5746eab3..e6d9a2473 100644 --- a/packages/app/src/utils/diff-layout.ts +++ b/packages/app/src/utils/diff-layout.ts @@ -7,6 +7,12 @@ export interface SplitDiffDisplayLine { lineNumber: number | null; } +export interface UnifiedDiffDisplayLine { + key: string; + line: DiffLine; + lineNumber: number | null; +} + export type SplitDiffRow = | { kind: "header"; @@ -61,6 +67,39 @@ function toDisplayLine(input: { }; } +export function buildUnifiedDiffLines(file: ParsedDiffFile): UnifiedDiffDisplayLine[] { + const lines: UnifiedDiffDisplayLine[] = []; + + for (const [hunkIndex, hunk] of file.hunks.entries()) { + let oldLineNo = hunk.oldStart; + let newLineNo = hunk.newStart; + + for (const [lineIndex, line] of hunk.lines.entries()) { + let lineNumber: number | null = null; + + if (line.type === "remove") { + lineNumber = oldLineNo; + oldLineNo += 1; + } else if (line.type === "add") { + lineNumber = newLineNo; + newLineNo += 1; + } else if (line.type === "context") { + lineNumber = newLineNo; + oldLineNo += 1; + newLineNo += 1; + } + + lines.push({ + key: `${hunkIndex}-${lineIndex}`, + line, + lineNumber, + }); + } + } + + return lines; +} + export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] { const rows: SplitDiffRow[] = []; diff --git a/packages/app/src/utils/diff-rendering.test.ts b/packages/app/src/utils/diff-rendering.test.ts new file mode 100644 index 000000000..c63f18e5f --- /dev/null +++ b/packages/app/src/utils/diff-rendering.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { formatDiffContentText, formatDiffGutterText, hasVisibleDiffTokens } from "./diff-rendering"; + +describe("diff-rendering", () => { + it("keeps header gutters tall even when they do not show a line number", () => { + expect(formatDiffGutterText(null)).toBe(" "); + expect(formatDiffGutterText(82)).toBe("82"); + }); + + it("keeps empty split cells tall even when they have no visible content", () => { + expect(formatDiffContentText(undefined)).toBe(" "); + expect(formatDiffContentText("")).toBe(" "); + expect(formatDiffContentText("const value = 1;")).toBe("const value = 1;"); + }); + + it("treats empty highlighted token rows as blank lines instead of visible content", () => { + expect(hasVisibleDiffTokens(undefined)).toBe(false); + expect(hasVisibleDiffTokens([])).toBe(false); + expect(hasVisibleDiffTokens([{ text: "" }])).toBe(false); + expect(hasVisibleDiffTokens([{ text: "const value = 1;" }])).toBe(true); + }); +}); diff --git a/packages/app/src/utils/diff-rendering.ts b/packages/app/src/utils/diff-rendering.ts new file mode 100644 index 000000000..ec18716c7 --- /dev/null +++ b/packages/app/src/utils/diff-rendering.ts @@ -0,0 +1,16 @@ +interface HighlightLikeToken { + text: string; +} + +// Preserve row height when a gutter or diff cell is intentionally blank. +export function formatDiffGutterText(lineNumber: number | null): string { + return lineNumber == null ? " " : String(lineNumber); +} + +export function formatDiffContentText(content: string | null | undefined): string { + return content && content.length > 0 ? content : " "; +} + +export function hasVisibleDiffTokens(tokens: HighlightLikeToken[] | null | undefined): boolean { + return Boolean(tokens?.some((token) => token.text.length > 0)); +} diff --git a/packages/app/src/utils/terminal-keys.test.ts b/packages/app/src/utils/terminal-keys.test.ts index 4ea84d0af..2a627d587 100644 --- a/packages/app/src/utils/terminal-keys.test.ts +++ b/packages/app/src/utils/terminal-keys.test.ts @@ -52,7 +52,7 @@ describe("terminal key helpers", () => { }); }); - it("intercepts special keys and modifier combos", () => { + it("only intercepts when pending modifiers are active", () => { expect( shouldInterceptDomTerminalKey({ key: "Escape", @@ -60,7 +60,7 @@ describe("terminal key helpers", () => { altKey: false, pendingModifiers: { ctrl: false, shift: false, alt: false }, }), - ).toBe(true); + ).toBe(false); expect( shouldInterceptDomTerminalKey({ key: "c", @@ -68,15 +68,23 @@ describe("terminal key helpers", () => { altKey: false, pendingModifiers: { ctrl: false, shift: false, alt: false }, }), - ).toBe(true); + ).toBe(false); expect( shouldInterceptDomTerminalKey({ key: "c", ctrlKey: false, altKey: false, - pendingModifiers: { ctrl: false, shift: false, alt: false }, + pendingModifiers: { ctrl: true, shift: false, alt: false }, }), - ).toBe(false); + ).toBe(true); + expect( + shouldInterceptDomTerminalKey({ + key: "Escape", + ctrlKey: false, + altKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: true }, + }), + ).toBe(true); }); it("detects pending modifier state", () => { diff --git a/packages/app/src/utils/terminal-keys.ts b/packages/app/src/utils/terminal-keys.ts index aad38de42..55c7c523a 100644 --- a/packages/app/src/utils/terminal-keys.ts +++ b/packages/app/src/utils/terminal-keys.ts @@ -88,12 +88,7 @@ export function shouldInterceptDomTerminalKey(args: { altKey: boolean; pendingModifiers: PendingTerminalModifiers; }): boolean { - return ( - args.key.length > 1 || - args.ctrlKey || - args.altKey || - hasPendingTerminalModifiers(args.pendingModifiers) - ); + return hasPendingTerminalModifiers(args.pendingModifiers); } export function mergeTerminalModifiers(args: { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index cdc4c8ee0..64135eadb 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -220,8 +220,6 @@ function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean { const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__"; -const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024; -const TERMINAL_STREAM_LOW_WATER_BYTES = 16 * 1024; const MAX_TERMINAL_STREAM_SLOTS = 256; const pendingAgentInitializations = new Map>(); @@ -299,7 +297,6 @@ type ActiveTerminalStream = { slot: number; unsubscribe: () => void; needsSnapshot: boolean; - snapshotRetryTimer: ReturnType | null; }; export type SessionRuntimeMetrics = { @@ -8618,7 +8615,6 @@ export class Session { slot, unsubscribe: () => {}, needsSnapshot: true, - snapshotRetryTimer: null, }; this.activeTerminalStreams.set(slot, activeStream); @@ -8636,10 +8632,6 @@ export class Session { if (activeStream.needsSnapshot || message.data.length === 0) { return; } - if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_HIGH_WATER_BYTES) { - this.markAllActiveTerminalStreamsForSnapshot(); - return; - } this.emitBinary( encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Output, @@ -8647,9 +8639,6 @@ export class Session { payload: new Uint8Array(Buffer.from(message.data, "utf8")), }), ); - if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_HIGH_WATER_BYTES) { - this.markAllActiveTerminalStreamsForSnapshot(); - } }); return slot; } @@ -8662,21 +8651,6 @@ export class Session { return; } - if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_LOW_WATER_BYTES) { - if (!activeStream.snapshotRetryTimer) { - activeStream.snapshotRetryTimer = setTimeout(() => { - activeStream.snapshotRetryTimer = null; - this.trySendTerminalSnapshot(activeStream); - }, 33); - } - return; - } - - if (activeStream.snapshotRetryTimer) { - clearTimeout(activeStream.snapshotRetryTimer); - activeStream.snapshotRetryTimer = null; - } - const terminal = this.terminalManager?.getTerminal(activeStream.terminalId); if (!terminal) { this.detachTerminalStream(activeStream.terminalId, { emitExit: true }); @@ -8693,13 +8667,6 @@ export class Session { ); } - private markAllActiveTerminalStreamsForSnapshot(): void { - for (const activeStream of this.activeTerminalStreams.values()) { - activeStream.needsSnapshot = true; - this.trySendTerminalSnapshot(activeStream); - } - } - private allocateTerminalSlot(): number | null { for (let attempt = 0; attempt < MAX_TERMINAL_STREAM_SLOTS; attempt += 1) { const slot = (this.nextTerminalSlot + attempt) % MAX_TERMINAL_STREAM_SLOTS; @@ -8724,10 +8691,6 @@ export class Session { } this.activeTerminalStreams.delete(slot); this.terminalIdToSlot.delete(terminalId); - if (activeStream.snapshotRetryTimer) { - clearTimeout(activeStream.snapshotRetryTimer); - activeStream.snapshotRetryTimer = null; - } try { activeStream.unsubscribe(); } catch (error) { diff --git a/paseo.json b/paseo.json index 239a49f52..a1d21b31f 100644 --- a/paseo.json +++ b/paseo.json @@ -2,7 +2,7 @@ "worktree": { "setup": [ "npm ci", - "npm run build --workspace=@getpaseo/relay", + "npm run build:daemon", "cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PASEO_WORKTREE_PATH/packages/server/.env\"" ] },