diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts deleted file mode 100644 index bbf243a294c..00000000000 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.stream.test.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test"; -import { EventEmitter } from "node:events"; - -interface MockManagement { - listSessions: () => Promise<{ - sessions: Array<{ - sessionId: string; - paneId: string; - workspaceId: string; - isAlive: boolean; - }>; - }>; - killAllSessions: () => Promise; - resetHistoryPersistence: () => Promise; -} - -/** - * Mock terminal runtime for testing. - * Extends EventEmitter and provides the minimal TerminalRuntime interface. - */ -class MockTerminalRuntime extends EventEmitter { - management: MockManagement; - capabilities = { persistent: true, coldRestore: true }; - killCalls: Array<{ paneId: string }> = []; - - constructor() { - super(); - this.management = { - listSessions: async () => ({ sessions: [] }), - killAllSessions: async () => {}, - resetHistoryPersistence: async () => {}, - }; - } - - async kill(params: { paneId: string }) { - this.killCalls.push(params); - } - - detachAllListeners() { - for (const event of this.eventNames()) { - const name = String(event); - if ( - name.startsWith("data:") || - name.startsWith("exit:") || - name.startsWith("disconnect:") || - name.startsWith("error:") || - name === "terminalExit" - ) { - this.removeAllListeners(event); - } - } - } -} - -let mockTerminal: MockTerminalRuntime = new MockTerminalRuntime(); -let mockListSessionsCallCount = 0; -let mockDaemonSessions: Array<{ - sessionId: string; - paneId: string; - workspaceId: string; - isAlive: boolean; -}> = []; -let mockListSessions: () => Promise<{ sessions: typeof mockDaemonSessions }> = - async () => ({ sessions: mockDaemonSessions }); - -beforeEach(() => { - mockListSessionsCallCount = 0; - mockDaemonSessions = []; - mockListSessions = async () => ({ sessions: mockDaemonSessions }); -}); - -// Mock the workspace-runtime module -mock.module("main/lib/workspace-runtime", () => ({ - getWorkspaceRuntimeRegistry: () => ({ - getDefault: () => ({ - id: "local", - terminal: mockTerminal, - capabilities: { terminal: mockTerminal.capabilities }, - }), - getForWorkspaceId: () => ({ - id: "local", - terminal: mockTerminal, - capabilities: { terminal: mockTerminal.capabilities }, - }), - }), -})); - -// Mock @superset/local-db to avoid drizzle-orm resolution failures in CI. -mock.module("@superset/local-db", () => ({ - projects: { id: "id" }, - workspaces: { id: "id" }, - worktrees: { id: "id" }, - settings: { id: "id" }, - EXTERNAL_APPS: [], - EXECUTION_MODES: ["sequential", "parallel"], -})); - -// Avoid importing Electron/local-db during test bootstrap. -mock.module("main/lib/local-db", () => ({ - localDb: { - select: () => ({ - from: () => ({ - where: () => ({ - get: () => undefined, - }), - }), - }), - }, -})); - -// Mock terminal module to avoid Electron imports from terminal-host/client -mock.module("main/lib/terminal", () => ({ - getDaemonTerminalManager: () => ({ - reset: () => {}, - }), -})); - -// Mock terminal-host/client to avoid Electron app import -mock.module("main/lib/terminal-host/client", () => ({ - getTerminalHostClient: () => ({ - tryConnectAndAuthenticate: async () => false, - listSessions: async () => mockListSessions(), - killAll: async () => ({}), - kill: async () => ({}), - }), - disposeTerminalHostClient: () => {}, -})); - -const { createTerminalRouter } = await import("./terminal"); - -describe("terminal.stream", () => { - it("does not complete on exit (paneId is stable across restarts)", async () => { - // Reset the mock terminal for this test - mockTerminal = new MockTerminalRuntime(); - - const router = createTerminalRouter(); - const caller = router.createCaller({} as never); - const stream$ = await caller.stream("pane-1"); - - const events: Array<{ type: string }> = []; - let didComplete = false; - - const subscription = stream$.subscribe({ - next: (event) => { - events.push(event); - }, - complete: () => { - didComplete = true; - }, - }); - - // Emit exit event - stream should NOT complete - mockTerminal.emit("exit:pane-1", 0, 15); - - expect(didComplete).toBe(false); - expect(mockTerminal.listenerCount("data:pane-1")).toBeGreaterThan(0); - - // Data should still be receivable after exit - mockTerminal.emit("data:pane-1", "echo ok\r\n"); - - expect(events.map((e) => e.type)).toEqual(["exit", "data"]); - - subscription.unsubscribe(); - - // All listeners should be cleaned up after unsubscribe - expect(mockTerminal.listenerCount("data:pane-1")).toBe(0); - expect(mockTerminal.listenerCount("exit:pane-1")).toBe(0); - expect(mockTerminal.listenerCount("disconnect:pane-1")).toBe(0); - expect(mockTerminal.listenerCount("error:pane-1")).toBe(0); - }); - - it("does not complete on disconnect event", async () => { - mockTerminal = new MockTerminalRuntime(); - - const router = createTerminalRouter(); - const caller = router.createCaller({} as never); - const stream$ = await caller.stream("pane-2"); - - const events: Array<{ type: string }> = []; - let didComplete = false; - - const subscription = stream$.subscribe({ - next: (event) => { - events.push(event); - }, - complete: () => { - didComplete = true; - }, - }); - - // Emit disconnect event - stream should NOT complete - mockTerminal.emit("disconnect:pane-2", "Connection lost"); - - expect(didComplete).toBe(false); - expect(events.map((e) => e.type)).toEqual(["disconnect"]); - - subscription.unsubscribe(); - }); - - it("does not complete on error event", async () => { - mockTerminal = new MockTerminalRuntime(); - - const router = createTerminalRouter(); - const caller = router.createCaller({} as never); - const stream$ = await caller.stream("pane-3"); - - const events: Array<{ type: string }> = []; - let didComplete = false; - - const subscription = stream$.subscribe({ - next: (event) => { - events.push(event); - }, - complete: () => { - didComplete = true; - }, - }); - - // Emit error event - stream should NOT complete - mockTerminal.emit("error:pane-3", { error: "Test error", code: "TEST" }); - - expect(didComplete).toBe(false); - expect(events.map((e) => e.type)).toEqual(["error"]); - - subscription.unsubscribe(); - }); -}); - -describe("terminal.listDaemonSessions", () => { - it("returns sessions from management list", async () => { - mockTerminal = new MockTerminalRuntime(); - mockDaemonSessions = [ - { - sessionId: "pane-1", - paneId: "pane-1", - workspaceId: "ws-1", - isAlive: true, - }, - ]; - mockTerminal.management.listSessions = async () => ({ - sessions: mockDaemonSessions, - }); - - const router = createTerminalRouter(); - const caller = router.createCaller({} as never); - const result = await caller.listDaemonSessions(); - - expect(result.sessions.length).toBe(1); - expect(result.sessions[0].sessionId).toBe("pane-1"); - }); -}); - -describe("terminal daemon kill helpers", () => { - it("killAllDaemonSessions forwards kills for each daemon session", async () => { - mockTerminal = new MockTerminalRuntime(); - mockDaemonSessions = [ - { - sessionId: "pane-1", - paneId: "pane-1", - workspaceId: "ws-1", - isAlive: true, - }, - { - sessionId: "pane-2", - paneId: "pane-2", - workspaceId: "ws-2", - isAlive: true, - }, - ]; - mockListSessionsCallCount = 0; - mockTerminal.management.listSessions = async () => ({ - sessions: mockDaemonSessions, - }); - mockListSessions = async () => { - mockListSessionsCallCount++; - if (mockListSessionsCallCount === 1) { - return { sessions: mockDaemonSessions }; - } - return { sessions: [] }; - }; - - const router = createTerminalRouter(); - const caller = router.createCaller({} as never); - const result = await caller.killAllDaemonSessions(); - - expect(result.killedCount).toBe(2); - expect(mockTerminal.killCalls).toEqual([ - { paneId: "pane-1" }, - { paneId: "pane-2" }, - ]); - }); - - it("killDaemonSessionsForWorkspace only kills matching workspace sessions", async () => { - mockTerminal = new MockTerminalRuntime(); - mockDaemonSessions = [ - { - sessionId: "pane-1", - paneId: "pane-1", - workspaceId: "ws-1", - isAlive: true, - }, - { - sessionId: "pane-2", - paneId: "pane-2", - workspaceId: "ws-2", - isAlive: true, - }, - ]; - mockTerminal.management.listSessions = async () => ({ - sessions: mockDaemonSessions, - }); - mockListSessions = async () => ({ sessions: mockDaemonSessions }); - - const router = createTerminalRouter(); - const caller = router.createCaller({} as never); - const result = await caller.killDaemonSessionsForWorkspace({ - workspaceId: "ws-1", - }); - - expect(result.killedCount).toBe(1); - expect(mockTerminal.killCalls).toEqual([{ paneId: "pane-1" }]); - }); -}); diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index cadb8cdbd50..dd09558d6c9 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -2,6 +2,7 @@ import { projects, workspaces, worktrees } from "@superset/local-db"; import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; import { eq } from "drizzle-orm"; +import { appState } from "main/lib/app-state"; import { localDb } from "main/lib/local-db"; import { getDaemonTerminalManager } from "main/lib/terminal"; import { @@ -14,6 +15,7 @@ import { z } from "zod"; import { publicProcedure, router } from "../.."; import { assertWorkspaceUsable } from "../workspaces/utils/usability"; import { getWorkspacePath } from "../workspaces/utils/worktree"; +import { resolveTerminalThemeType } from "./theme-type"; import { resolveCwd } from "./utils"; const DEBUG_TERMINAL = process.env.SUPERSET_TERMINAL_DEBUG === "1"; @@ -66,6 +68,7 @@ export const createTerminalRouter = () => { initialCommands: z.array(z.string()).optional(), skipColdRestore: z.boolean().optional(), allowKilled: z.boolean().optional(), + themeType: z.enum(["dark", "light"]).optional(), }), ) .mutation(async ({ input }) => { @@ -81,6 +84,7 @@ export const createTerminalRouter = () => { initialCommands, skipColdRestore, allowKilled, + themeType, } = input; const workspace = localDb @@ -115,6 +119,10 @@ export const createTerminalRouter = () => { .where(eq(projects.id, workspace.projectId)) .get() : undefined; + const resolvedThemeType = resolveTerminalThemeType({ + requestedThemeType: themeType, + persistedThemeState: appState.data.themeState, + }); try { const result = await terminal.createOrAttach({ @@ -130,6 +138,7 @@ export const createTerminalRouter = () => { initialCommands, skipColdRestore, allowKilled, + themeType: resolvedThemeType, portBase: workspace?.portBase, }); diff --git a/apps/desktop/src/lib/trpc/routers/terminal/theme-type.test.ts b/apps/desktop/src/lib/trpc/routers/terminal/theme-type.test.ts new file mode 100644 index 00000000000..c0582b15010 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/terminal/theme-type.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "bun:test"; +import type { ThemeState } from "main/lib/app-state/schemas"; +import { builtInThemes } from "shared/themes"; +import { resolveTerminalThemeType } from "./theme-type"; + +function createThemeState(params: Partial): ThemeState { + return { + activeThemeId: "dark", + customThemes: [], + ...params, + }; +} + +describe("resolveTerminalThemeType", () => { + it("returns requested theme type when provided", () => { + const result = resolveTerminalThemeType({ + requestedThemeType: "light", + }); + expect(result).toBe("light"); + }); + + it("resolves built-in theme type from persisted state", () => { + const result = resolveTerminalThemeType({ + persistedThemeState: createThemeState({ activeThemeId: "light" }), + }); + expect(result).toBe("light"); + }); + + it("resolves custom theme type from persisted state", () => { + const baseLightTheme = builtInThemes.find( + (theme) => theme.type === "light", + ); + if (!baseLightTheme) { + throw new Error("Missing built-in light theme for test"); + } + + const customLightTheme = { + ...baseLightTheme, + id: "custom-light", + isBuiltIn: false, + isCustom: true, + }; + + const result = resolveTerminalThemeType({ + persistedThemeState: createThemeState({ + activeThemeId: customLightTheme.id, + customThemes: [customLightTheme], + }), + }); + expect(result).toBe("light"); + }); + + it("resolves system theme using system preference", () => { + const darkResult = resolveTerminalThemeType({ + persistedThemeState: createThemeState({ activeThemeId: "system" }), + systemPrefersDark: true, + }); + expect(darkResult).toBe("dark"); + + const lightResult = resolveTerminalThemeType({ + persistedThemeState: createThemeState({ activeThemeId: "system" }), + systemPrefersDark: false, + }); + expect(lightResult).toBe("light"); + }); + + it("falls back to dark for unknown themes", () => { + const result = resolveTerminalThemeType({ + persistedThemeState: createThemeState({ activeThemeId: "unknown-theme" }), + }); + expect(result).toBe("dark"); + }); +}); diff --git a/apps/desktop/src/lib/trpc/routers/terminal/theme-type.ts b/apps/desktop/src/lib/trpc/routers/terminal/theme-type.ts new file mode 100644 index 00000000000..45621232a43 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/terminal/theme-type.ts @@ -0,0 +1,37 @@ +import type { ThemeState } from "main/lib/app-state/schemas"; +import { builtInThemes, DEFAULT_THEME_ID } from "shared/themes"; + +type ThemeType = "dark" | "light"; + +export function resolveTerminalThemeType(params: { + requestedThemeType?: ThemeType; + persistedThemeState?: ThemeState; + systemPrefersDark?: boolean; +}): ThemeType { + const { + requestedThemeType, + persistedThemeState, + systemPrefersDark = true, + } = params; + + if (requestedThemeType) { + return requestedThemeType; + } + + if (!persistedThemeState) { + return "dark"; + } + + const { activeThemeId, customThemes } = persistedThemeState; + + if (activeThemeId === "system") { + return systemPrefersDark ? "dark" : "light"; + } + + const matchingTheme = + customThemes.find((theme) => theme.id === activeThemeId) || + builtInThemes.find((theme) => theme.id === activeThemeId) || + builtInThemes.find((theme) => theme.id === DEFAULT_THEME_ID); + + return matchingTheme?.type ?? "dark"; +} diff --git a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts index cfa1fe491cb..ed16535b997 100644 --- a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts @@ -322,6 +322,7 @@ export class DaemonTerminalManager extends EventEmitter { rows = 24, initialCommands, skipColdRestore, + themeType, portBase, } = params; @@ -380,6 +381,7 @@ export class DaemonTerminalManager extends EventEmitter { workspaceName, workspacePath, rootPath, + themeType, portBase, }); diff --git a/apps/desktop/src/main/lib/terminal/env.test.ts b/apps/desktop/src/main/lib/terminal/env.test.ts index 89bda1dfb73..ab77a29c478 100644 --- a/apps/desktop/src/main/lib/terminal/env.test.ts +++ b/apps/desktop/src/main/lib/terminal/env.test.ts @@ -677,5 +677,28 @@ describe("env", () => { expect(result.SUPERSET_HOOK_VERSION).toBeDefined(); expect(result.SUPERSET_HOOK_VERSION).toBe("2"); }); + + describe("COLORFGBG for light mode detection", () => { + it("should set COLORFGBG to dark mode by default", () => { + const result = buildTerminalEnv(baseParams); + expect(result.COLORFGBG).toBe("15;0"); + }); + + it("should set COLORFGBG to dark mode when themeType is dark", () => { + const result = buildTerminalEnv({ + ...baseParams, + themeType: "dark", + }); + expect(result.COLORFGBG).toBe("15;0"); + }); + + it("should set COLORFGBG to light mode when themeType is light", () => { + const result = buildTerminalEnv({ + ...baseParams, + themeType: "light", + }); + expect(result.COLORFGBG).toBe("0;15"); + }); + }); }); }); diff --git a/apps/desktop/src/main/lib/terminal/env.ts b/apps/desktop/src/main/lib/terminal/env.ts index c83443834a2..37af571c4cd 100644 --- a/apps/desktop/src/main/lib/terminal/env.ts +++ b/apps/desktop/src/main/lib/terminal/env.ts @@ -330,6 +330,7 @@ export function buildTerminalEnv(params: { workspaceName?: string; workspacePath?: string; rootPath?: string; + themeType?: "dark" | "light"; portBase?: number | null; }): Record { const { @@ -340,6 +341,7 @@ export function buildTerminalEnv(params: { workspaceName, workspacePath, rootPath, + themeType, portBase, } = params; @@ -353,12 +355,16 @@ export function buildTerminalEnv(params: { const shellEnv = getShellEnv(shell); const locale = getLocale(rawBaseEnv); + // COLORFGBG: "foreground;background" ANSI color indices — TUI apps use this to detect light/dark + const colorFgBg = themeType === "light" ? "0;15" : "15;0"; + const terminalEnv: Record = { ...baseEnv, ...shellEnv, TERM_PROGRAM: "Superset", TERM_PROGRAM_VERSION: process.env.npm_package_version || "1.0.0", COLORTERM: "truecolor", + COLORFGBG: colorFgBg, LANG: locale, SUPERSET_PANE_ID: paneId, SUPERSET_TAB_ID: tabId, diff --git a/apps/desktop/src/main/lib/terminal/session.ts b/apps/desktop/src/main/lib/terminal/session.ts index 1f5bb9b2298..d1f5f23da61 100644 --- a/apps/desktop/src/main/lib/terminal/session.ts +++ b/apps/desktop/src/main/lib/terminal/session.ts @@ -93,6 +93,7 @@ export async function createSession( rows, existingScrollback, useFallbackShell = false, + themeType, portBase, } = params; @@ -120,6 +121,7 @@ export async function createSession( workspaceName, workspacePath, rootPath, + themeType, portBase, }); diff --git a/apps/desktop/src/main/lib/terminal/types.ts b/apps/desktop/src/main/lib/terminal/types.ts index d114918dfe5..d83468a9190 100644 --- a/apps/desktop/src/main/lib/terminal/types.ts +++ b/apps/desktop/src/main/lib/terminal/types.ts @@ -104,6 +104,7 @@ export interface CreateSessionParams { skipColdRestore?: boolean; /** Allow restarting a session that was explicitly killed */ allowKilled?: boolean; + themeType?: "dark" | "light"; /** Port base for multi-worktree dev instances */ portBase?: number | null; } diff --git a/apps/desktop/src/renderer/hooks/useCreateOrAttachWithTheme.ts b/apps/desktop/src/renderer/hooks/useCreateOrAttachWithTheme.ts new file mode 100644 index 00000000000..044e2d47133 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useCreateOrAttachWithTheme.ts @@ -0,0 +1,42 @@ +import { useCallback } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useTheme } from "renderer/stores/theme"; +import { resolveTerminalThemeType } from "renderer/stores/theme/utils"; + +export function useCreateOrAttachWithTheme() { + const mutation = electronTrpc.terminal.createOrAttach.useMutation(); + const activeTheme = useTheme(); + const themeType = resolveTerminalThemeType({ + activeThemeType: activeTheme?.type, + }); + const { + mutate: baseMutate, + mutateAsync: baseMutateAsync, + ...mutationState + } = mutation; + type CreateOrAttachInput = Parameters[0]; + + const withTheme = useCallback( + (input: CreateOrAttachInput): CreateOrAttachInput => ({ + ...input, + themeType: input.themeType ?? themeType, + }), + [themeType], + ); + + const mutate = useCallback( + (input, options) => baseMutate(withTheme(input), options), + [baseMutate, withTheme], + ); + + const mutateAsync = useCallback( + (input, options) => baseMutateAsync(withTheme(input), options), + [baseMutateAsync, withTheme], + ); + + return { + ...mutationState, + mutate, + mutateAsync, + }; +} diff --git a/apps/desktop/src/renderer/react-query/workspaces/useOpenExternalWorktree.ts b/apps/desktop/src/renderer/react-query/workspaces/useOpenExternalWorktree.ts index b9d60cbae62..43b4ecba988 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useOpenExternalWorktree.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useOpenExternalWorktree.ts @@ -1,4 +1,5 @@ import { useNavigate } from "@tanstack/react-router"; +import { useCreateOrAttachWithTheme } from "renderer/hooks/useCreateOrAttachWithTheme"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -12,7 +13,7 @@ export function useOpenExternalWorktree( const utils = electronTrpc.useUtils(); const addTab = useTabsStore((state) => state.addTab); const setTabAutoTitle = useTabsStore((state) => state.setTabAutoTitle); - const createOrAttach = electronTrpc.terminal.createOrAttach.useMutation(); + const createOrAttach = useCreateOrAttachWithTheme(); return electronTrpc.workspaces.openExternalWorktree.useMutation({ ...options, diff --git a/apps/desktop/src/renderer/react-query/workspaces/useOpenWorktree.ts b/apps/desktop/src/renderer/react-query/workspaces/useOpenWorktree.ts index 027686fad09..2cb3f3b8244 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useOpenWorktree.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useOpenWorktree.ts @@ -1,4 +1,5 @@ import { useNavigate } from "@tanstack/react-router"; +import { useCreateOrAttachWithTheme } from "renderer/hooks/useCreateOrAttachWithTheme"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -17,7 +18,7 @@ export function useOpenWorktree( const utils = electronTrpc.useUtils(); const addTab = useTabsStore((state) => state.addTab); const setTabAutoTitle = useTabsStore((state) => state.setTabAutoTitle); - const createOrAttach = electronTrpc.terminal.createOrAttach.useMutation(); + const createOrAttach = useCreateOrAttachWithTheme(); return electronTrpc.workspaces.openWorktree.useMutation({ ...options, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceInitEffects.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceInitEffects.tsx index 2cf37b499d2..9f5813a08b9 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceInitEffects.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceInitEffects.tsx @@ -1,5 +1,6 @@ import { toast } from "@superset/ui/sonner"; import { useCallback, useEffect, useRef } from "react"; +import { useCreateOrAttachWithTheme } from "renderer/hooks/useCreateOrAttachWithTheme"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; import { useTabsWithPresets } from "renderer/stores/tabs/useTabsWithPresets"; @@ -30,7 +31,7 @@ export function WorkspaceInitEffects() { const addTab = useTabsStore((state) => state.addTab); const setTabAutoTitle = useTabsStore((state) => state.setTabAutoTitle); const { openPreset } = useTabsWithPresets(); - const createOrAttach = electronTrpc.terminal.createOrAttach.useMutation(); + const createOrAttach = useCreateOrAttachWithTheme(); const utils = electronTrpc.useUtils(); const handleTerminalSetup = useCallback( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx index 7e0fb8ad393..bed05a494c4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx @@ -18,6 +18,7 @@ import { cleanLayout, extractPaneIdsFromLayout, } from "renderer/stores/tabs/utils"; +import { useTheme } from "renderer/stores/theme"; import { ChatPane } from "./ChatPane"; import { FileViewerPane } from "./FileViewerPane"; import { TabPane } from "./TabPane"; @@ -27,6 +28,7 @@ interface TabViewProps { } export function TabView({ tab }: TabViewProps) { + const activeTheme = useTheme(); const updateTabLayout = useTabsStore((s) => s.updateTabLayout); const removePane = useTabsStore((s) => s.removePane); const removeTab = useTabsStore((s) => s.removeTab); @@ -225,7 +227,11 @@ export function TabView({ tab }: TabViewProps) { renderTile={renderPane} value={cleanedLayout} onChange={handleLayoutChange} - className="mosaic-theme-dark" + className={ + activeTheme?.type === "light" + ? "mosaic-theme-light" + : "mosaic-theme-dark" + } dragAndDropManager={dragDropManager} /> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/mosaic-theme.css b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/mosaic-theme.css index 7d0b113540e..a777607fd18 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/mosaic-theme.css +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/mosaic-theme.css @@ -2,28 +2,30 @@ background: transparent; } -.mosaic-theme-dark .mosaic-root { +:is(.mosaic-theme-dark, .mosaic-theme-light) .mosaic-root { top: 0; left: 0; right: 0; bottom: 0; } -.mosaic-theme-dark .mosaic-tile { +:is(.mosaic-theme-dark, .mosaic-theme-light) .mosaic-tile { margin: 0; } -.mosaic-theme-dark .mosaic-window { +:is(.mosaic-theme-dark, .mosaic-theme-light) .mosaic-window { border: 0.5px solid var(--color-border); border-radius: 0; overflow: hidden; box-shadow: none; } -.mosaic-theme-dark .mosaic-window-focused { +:is(.mosaic-theme-dark, .mosaic-theme-light) .mosaic-window-focused { border-color: var(--color-border); } -.mosaic-theme-dark .mosaic-window .mosaic-window-toolbar { +:is(.mosaic-theme-dark, .mosaic-theme-light) + .mosaic-window + .mosaic-window-toolbar { background: var(--color-tertiary); height: 28px; padding: 0 8px; @@ -32,7 +34,7 @@ transition: background-color 0.15s ease; border-radius: 0; } -.mosaic-theme-dark +:is(.mosaic-theme-dark, .mosaic-theme-light) .mosaic-window .mosaic-window-toolbar .mosaic-window-controls { @@ -42,34 +44,42 @@ align-items: center; height: 100%; } -.mosaic-theme-dark +:is(.mosaic-theme-dark, .mosaic-theme-light) .mosaic-window .mosaic-window-toolbar:hover .mosaic-window-controls, -.mosaic-theme-dark +:is(.mosaic-theme-dark, .mosaic-theme-light) .mosaic-window-focused .mosaic-window-toolbar .mosaic-window-controls, -.mosaic-theme-dark +:is(.mosaic-theme-dark, .mosaic-theme-light) .mosaic-window .mosaic-window-toolbar:focus-within .mosaic-window-controls { opacity: 1; } -.mosaic-theme-dark .mosaic-window-focused .mosaic-window-toolbar { +:is(.mosaic-theme-dark, .mosaic-theme-light) + .mosaic-window-focused + .mosaic-window-toolbar { background: var(--color-secondary); } -.mosaic-theme-dark .mosaic-window .mosaic-window-title { +:is(.mosaic-theme-dark, .mosaic-theme-light) + .mosaic-window + .mosaic-window-title { color: var(--color-muted-foreground); font-size: 11px; font-weight: 500; letter-spacing: 0.01em; transition: color 0.15s ease; } -.mosaic-theme-dark .mosaic-window-focused .mosaic-window-title { +:is(.mosaic-theme-dark, .mosaic-theme-light) + .mosaic-window-focused + .mosaic-window-title { color: var(--color-foreground); } -.mosaic-theme-dark .mosaic-window .mosaic-window-body { +:is(.mosaic-theme-dark, .mosaic-theme-light) + .mosaic-window + .mosaic-window-body { background: transparent; border: none; box-shadow: none; @@ -84,17 +94,17 @@ outline: 2px solid var(--color-ring); outline-offset: -2px; } -.mosaic-theme-dark .mosaic-split { +:is(.mosaic-theme-dark, .mosaic-theme-light) .mosaic-split { background: transparent; opacity: 1; transition: background-color 0.15s ease; } -.mosaic-theme-dark .mosaic-split.-column { +:is(.mosaic-theme-dark, .mosaic-theme-light) .mosaic-split.-column { cursor: row-resize; } -.mosaic-theme-dark .mosaic-split.-row { +:is(.mosaic-theme-dark, .mosaic-theme-light) .mosaic-split.-row { cursor: col-resize; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts index ea788acbae1..c3207a5041d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts @@ -1,4 +1,5 @@ import { useRef, useState } from "react"; +import { useCreateOrAttachWithTheme } from "renderer/hooks/useCreateOrAttachWithTheme"; import { electronTrpc } from "renderer/lib/electron-trpc"; export interface UseTerminalConnectionOptions { @@ -23,8 +24,7 @@ export function useTerminalConnection({ const [connectionError, setConnectionError] = useState(null); // tRPC mutations - const createOrAttachMutation = - electronTrpc.terminal.createOrAttach.useMutation(); + const createOrAttachMutation = useCreateOrAttachWithTheme(); const writeMutation = electronTrpc.terminal.write.useMutation(); const resizeMutation = electronTrpc.terminal.resize.useMutation(); const detachMutation = electronTrpc.terminal.detach.useMutation(); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts index 6f8cb4c2322..d83cf462a92 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts @@ -67,6 +67,7 @@ export interface CreateOrAttachInput { initialCommands?: string[]; skipColdRestore?: boolean; allowKilled?: boolean; + themeType?: "dark" | "light"; } /** diff --git a/apps/desktop/src/renderer/stores/theme/utils/index.ts b/apps/desktop/src/renderer/stores/theme/utils/index.ts index 2fb7b4213fd..3bbe3434ac0 100644 --- a/apps/desktop/src/renderer/stores/theme/utils/index.ts +++ b/apps/desktop/src/renderer/stores/theme/utils/index.ts @@ -6,3 +6,4 @@ export { export type { MonacoTheme } from "./monaco-theme"; export { toMonacoTheme } from "./monaco-theme"; export { toXtermTheme } from "./terminal-theme"; +export { resolveTerminalThemeType } from "./terminal-theme-type"; diff --git a/apps/desktop/src/renderer/stores/theme/utils/terminal-theme-type.test.ts b/apps/desktop/src/renderer/stores/theme/utils/terminal-theme-type.test.ts new file mode 100644 index 00000000000..7ea67ae5a5f --- /dev/null +++ b/apps/desktop/src/renderer/stores/theme/utils/terminal-theme-type.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { resolveTerminalThemeType } from "./terminal-theme-type"; + +// Mock localStorage for Node.js test environment +const mockStorage = new Map(); +const mockLocalStorage = { + getItem: (key: string) => mockStorage.get(key) ?? null, + setItem: (key: string, value: string) => mockStorage.set(key, value), + removeItem: (key: string) => mockStorage.delete(key), + clear: () => mockStorage.clear(), +}; + +// @ts-expect-error - mocking global localStorage +globalThis.localStorage = mockLocalStorage; + +describe("resolveTerminalThemeType", () => { + beforeEach(() => { + mockStorage.clear(); + }); + + it("prefers active theme type when provided", () => { + localStorage.setItem("theme-type", "dark"); + const result = resolveTerminalThemeType({ activeThemeType: "light" }); + expect(result).toBe("light"); + }); + + it("falls back to persisted theme-type when active theme is unavailable", () => { + localStorage.setItem("theme-type", "light"); + const result = resolveTerminalThemeType(); + expect(result).toBe("light"); + }); + + it("falls back to dark when persisted theme-type is invalid", () => { + localStorage.setItem("theme-type", "invalid"); + const result = resolveTerminalThemeType(); + expect(result).toBe("dark"); + }); + + it("falls back to dark when localStorage is empty", () => { + const result = resolveTerminalThemeType(); + expect(result).toBe("dark"); + }); +}); diff --git a/apps/desktop/src/renderer/stores/theme/utils/terminal-theme-type.ts b/apps/desktop/src/renderer/stores/theme/utils/terminal-theme-type.ts new file mode 100644 index 00000000000..a27bb49b28d --- /dev/null +++ b/apps/desktop/src/renderer/stores/theme/utils/terminal-theme-type.ts @@ -0,0 +1,25 @@ +type ThemeType = "dark" | "light"; + +function isThemeType(value: string | null): value is ThemeType { + return value === "dark" || value === "light"; +} + +export function resolveTerminalThemeType(params?: { + activeThemeType?: ThemeType; +}): ThemeType { + const activeThemeType = params?.activeThemeType; + if (activeThemeType) { + return activeThemeType; + } + + try { + const persistedThemeType = localStorage.getItem("theme-type"); + if (isThemeType(persistedThemeType)) { + return persistedThemeType; + } + } catch { + // localStorage unavailable in some contexts + } + + return "dark"; +}