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\""
]
},