diff --git a/apps/desktop/native-backend/src/devtools.rs b/apps/desktop/native-backend/src/devtools.rs index 2f0b8a43..e5f2c8ef 100644 --- a/apps/desktop/native-backend/src/devtools.rs +++ b/apps/desktop/native-backend/src/devtools.rs @@ -56,6 +56,8 @@ struct DevTerminalCreateInput { cwd: Option, cols: Option, rows: Option, + #[serde(default)] + extra_env: HashMap, } #[derive(Debug, Clone, Deserialize)] @@ -156,6 +158,32 @@ impl DevTerminalManager { let session_id = required_string(&args, &["sessionId", "session_id"])?; Ok(json!(self.snapshot(&session_id)?)) } + "devtools_check_binary" => { + let name = required_string(&args, &["name"])?; + // Reject anything that isn't a plain binary name to prevent + // shell injection when interpolating into the sh -lc command. + if !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') + { + return Err(format!("Invalid binary name: {name}")); + } + // Use a login shell so the full user PATH is available (important + // on macOS where Electron inherits a stripped environment PATH). + #[cfg(unix)] + let found = std::process::Command::new("sh") + .args(["-lc", &format!("command -v {name}")]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + #[cfg(windows)] + let found = std::process::Command::new("where.exe") + .arg(&name) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + Ok(json!({ "found": found })) + } _ => Err(format!("Unknown devtools command: {command}")), } } @@ -187,6 +215,7 @@ impl DevTerminalManager { cwd: Some(snapshot.cwd), cols: Some(snapshot.cols), rows: Some(snapshot.rows), + extra_env: HashMap::new(), }, ) } @@ -343,8 +372,12 @@ impl DevTerminalManager { command.args(&launch_config.args); command.cwd(&launch_config.cwd); command.env("TERM", "xterm-256color"); + command.env("COLORTERM", "truecolor"); command.env("COLUMNS", cols.to_string()); command.env("LINES", rows.to_string()); + for (key, value) in &input.extra_env { + command.env(key, value); + } let child = pair.slave.spawn_command(command).map_err(|error| { format!( diff --git a/apps/desktop/native-backend/src/main.rs b/apps/desktop/native-backend/src/main.rs index 34a34e35..208d759e 100644 --- a/apps/desktop/native-backend/src/main.rs +++ b/apps/desktop/native-backend/src/main.rs @@ -893,7 +893,8 @@ impl NativeBackend { | "devtools_resize_terminal_session" | "devtools_restart_terminal_session" | "devtools_close_terminal_session" - | "devtools_get_terminal_session_snapshot" => self.devtools.invoke(command, args), + | "devtools_get_terminal_session_snapshot" + | "devtools_check_binary" => self.devtools.invoke(command, args), "spellcheck_list_languages" | "spellcheck_list_catalog" | "spellcheck_check_text" diff --git a/apps/desktop/src-electron/main/nativeBackend.ts b/apps/desktop/src-electron/main/nativeBackend.ts index 35b21f0e..b7bbb89d 100644 --- a/apps/desktop/src-electron/main/nativeBackend.ts +++ b/apps/desktop/src-electron/main/nativeBackend.ts @@ -95,6 +95,7 @@ const SUPPORTED_COMMANDS = new Set([ "devtools_restart_terminal_session", "devtools_close_terminal_session", "devtools_get_terminal_session_snapshot", + "devtools_check_binary", "spellcheck_list_languages", "spellcheck_list_catalog", "spellcheck_check_text", diff --git a/apps/desktop/src/App.noteWindow.test.tsx b/apps/desktop/src/App.noteWindow.test.tsx index 5d03e3e6..f70a2fa7 100644 --- a/apps/desktop/src/App.noteWindow.test.tsx +++ b/apps/desktop/src/App.noteWindow.test.tsx @@ -177,14 +177,10 @@ describe("App note window", () => { expect(useEditorStore.getState().focusedPaneId).toBe("primary"); }); - it("opens workspace terminals from the developer terminal command", async () => { + it("opens workspace terminals from the terminal command", async () => { detachedWindowMock.label = "main"; detachedWindowMock.mode = "main"; window.history.replaceState({}, "", "/"); - useSettingsStore.setState({ - developerModeEnabled: true, - developerTerminalEnabled: true, - }); renderComponent(); await flushPromises(); @@ -197,7 +193,7 @@ describe("App note window", () => { ).toBe(true); await act(async () => { - useCommandStore.getState().execute("developer:new-terminal-tab"); + useCommandStore.getState().execute("workspace:new-terminal-tab"); await Promise.resolve(); }); await flushPromises(); @@ -214,10 +210,6 @@ describe("App note window", () => { detachedWindowMock.label = "main"; detachedWindowMock.mode = "main"; window.history.replaceState({}, "", "/"); - useSettingsStore.setState({ - developerModeEnabled: true, - developerTerminalEnabled: true, - }); renderComponent(); await flushPromises(); @@ -244,34 +236,6 @@ describe("App note window", () => { expect(activeTab && isTerminalTab(activeTab)).toBe(true); }); - it("does not open workspace terminals from the shortcut when disabled", async () => { - detachedWindowMock.label = "main"; - detachedWindowMock.mode = "main"; - window.history.replaceState({}, "", "/"); - useSettingsStore.setState({ - developerModeEnabled: true, - developerTerminalEnabled: false, - }); - - renderComponent(); - await flushPromises(); - - const platform = getDesktopPlatform(); - - await act(async () => { - window.dispatchEvent( - new KeyboardEvent("keydown", { - key: "r", - metaKey: platform === "macos", - ctrlKey: platform !== "macos", - }), - ); - await Promise.resolve(); - }); - await flushPromises(); - - expect(useEditorStore.getState().tabs.some(isTerminalTab)).toBe(false); - }); it("starts workspace terminal runtimes inside detached note windows", async () => { mockInvoke().mockResolvedValue({ @@ -308,6 +272,7 @@ describe("App note window", () => { cwd: "/vault", cols: 120, rows: 24, + extraEnv: {}, }, }, ); @@ -323,10 +288,6 @@ describe("App note window", () => { detachedWindowMock.label = "main"; detachedWindowMock.mode = "main"; window.history.replaceState({}, "", "/"); - useSettingsStore.setState({ - developerModeEnabled: true, - developerTerminalEnabled: true, - }); const restartSpy = vi .spyOn(useTerminalRuntimeStore.getState(), "restart") .mockResolvedValue(undefined); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 3b13b876..be2c88e1 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -15,6 +15,8 @@ import { OutlinePanel } from "./features/notes/OutlinePanel"; import { AIChatWorkspaceHost } from "./features/ai/AIChatWorkspaceHost"; import { AIChatDetachedWindowHost } from "./features/ai/AIChatDetachedWindowHost"; import { createNewChatInWorkspace } from "./features/ai/chatPaneMovement"; +import { CLAUDE_TERMINAL_RUNTIME_ID } from "./features/ai/utils/runtimeMetadata"; +import { openClaudeCodeTerminalWithContext } from "./features/terminal/claudeCodeTerminal"; import { WorkspaceTerminalHost } from "./features/terminal/WorkspaceTerminalHost"; import { migrateLegacyTerminalTabsToWorkspace } from "./features/terminal/legacyTerminalMigration"; import { UnifiedBar } from "./features/editor/UnifiedBar"; @@ -588,16 +590,12 @@ function useRegisterCommands( ? selectPaneNeighbor(state, focusedPaneId, direction) !== null : false; }; - const developerModeEnabled = () => - developerCommandsEnabled && - useSettingsStore.getState().developerModeEnabled && - useSettingsStore.getState().developerTerminalEnabled; const activeTerminalTab = () => { const tab = selectFocusedEditorTab(useEditorStore.getState()); return tab && isTerminalTab(tab) ? tab : null; }; const canRestartActiveTerminal = () => - developerModeEnabled() && activeTerminalTab() !== null; + developerCommandsEnabled && activeTerminalTab() !== null; // Navigation register({ @@ -685,7 +683,14 @@ function useRegisterCommands( category: newAgentShortcut.category, when: hasVault, execute: () => { - void createNewChatInWorkspace(); + if ( + useChatStore.getState().getDefaultNewChatRuntimeId() === + CLAUDE_TERMINAL_RUNTIME_ID + ) { + void openClaudeCodeTerminalWithContext(); + } else { + void createNewChatInWorkspace(); + } }, }); @@ -934,11 +939,10 @@ function useRegisterCommands( }); register({ - id: "developer:new-terminal-tab", + id: "workspace:new-terminal-tab", label: newTerminalShortcut.label, shortcut: formatShortcutAction(newTerminalShortcut.id, platform), category: newTerminalShortcut.category, - when: developerModeEnabled, execute: () => { useEditorStore.getState().openTerminal(); }, @@ -1046,7 +1050,7 @@ function useGlobalShortcuts(openSettings: () => void) { if (matchesShortcutAction(e, "new_terminal", platform)) { e.preventDefault(); - useCommandStore.getState().execute("developer:new-terminal-tab"); + useCommandStore.getState().execute("workspace:new-terminal-tab"); return; } diff --git a/apps/desktop/src/app/shortcuts/format.test.ts b/apps/desktop/src/app/shortcuts/format.test.ts index cd1e57c8..47ce90f4 100644 --- a/apps/desktop/src/app/shortcuts/format.test.ts +++ b/apps/desktop/src/app/shortcuts/format.test.ts @@ -69,7 +69,7 @@ describe("shortcut registry formatting", () => { entries.find((entry) => entry.id === "new_terminal"), ).toMatchObject({ label: "New Terminal", - category: "Developer", + category: "Workspace", shortcut: "Ctrl+R", }); expect(entries.find((entry) => entry.id === "zoom_in")).toMatchObject({ diff --git a/apps/desktop/src/app/shortcuts/registry.ts b/apps/desktop/src/app/shortcuts/registry.ts index 7e62f3a2..5b73b1de 100644 --- a/apps/desktop/src/app/shortcuts/registry.ts +++ b/apps/desktop/src/app/shortcuts/registry.ts @@ -127,7 +127,7 @@ const shortcutDefinitions = [ { id: "new_terminal", label: "New Terminal", - category: "Developer", + category: "Workspace", bindings: { macos: [{ key: "r", modifiers: ["meta"] }], windows: [{ key: "r", modifiers: ["ctrl"] }], diff --git a/apps/desktop/src/app/store/editorStore.test.ts b/apps/desktop/src/app/store/editorStore.test.ts index 149015aa..578b7ddc 100644 --- a/apps/desktop/src/app/store/editorStore.test.ts +++ b/apps/desktop/src/app/store/editorStore.test.ts @@ -2025,6 +2025,40 @@ describe("editorStore tab management", () => { ).toBeNull(); }); + it("numbers normal terminal tabs independently from Claude Code terminals", () => { + useVaultStore.setState({ vaultPath: "/vaults/project-alpha" }); + useEditorStore.getState().hydrateWorkspace( + [ + { + id: "primary", + tabs: [ + makeTerminalTab({ + id: "claude-code-tab-1", + terminalId: "claude-code-runtime-1", + title: "Claude Code 1", + cwd: "/vaults/project-alpha", + }), + ], + activeTabId: "claude-code-tab-1", + }, + ], + "primary", + ); + + const tabId = useEditorStore.getState().openTerminal(); + + const state = useEditorStore.getState(); + expect(tabId).toBeTruthy(); + expect(state.tabs).toContainEqual( + expect.objectContaining({ + id: tabId, + kind: "terminal", + title: "Terminal 1", + cwd: "/vaults/project-alpha", + }), + ); + }); + it("remembers terminal tabs in recently closed tabs", () => { useEditorStore.setState({ tabs: [ diff --git a/apps/desktop/src/app/store/editorWorkspace.ts b/apps/desktop/src/app/store/editorWorkspace.ts index 7ff74b8e..36765781 100644 --- a/apps/desktop/src/app/store/editorWorkspace.ts +++ b/apps/desktop/src/app/store/editorWorkspace.ts @@ -1486,9 +1486,20 @@ function getTabOpenBehavior() { return useSettingsStore.getState().tabOpenBehavior; } +const DEFAULT_TERMINAL_TITLE_PATTERN = /^Terminal(?: (\d+))?$/; + function getNextTerminalTitle(tabs: readonly Tab[]) { - const count = tabs.filter((tab) => isTerminalTab(tab)).length; - return `Terminal ${count + 1}`; + const maxExistingIndex = tabs.reduce((maxIndex, tab) => { + if (!isTerminalTab(tab)) return maxIndex; + + const match = tab.title.trim().match(DEFAULT_TERMINAL_TITLE_PATTERN); + if (!match) return maxIndex; + + const index = match[1] ? Number(match[1]) : 1; + return Number.isFinite(index) ? Math.max(maxIndex, index) : maxIndex; + }, 0); + + return `Terminal ${maxExistingIndex + 1}`; } function resolvePinnedAwareTabReorder( diff --git a/apps/desktop/src/app/store/settingsStore.test.ts b/apps/desktop/src/app/store/settingsStore.test.ts index ef2061e3..a5105e47 100644 --- a/apps/desktop/src/app/store/settingsStore.test.ts +++ b/apps/desktop/src/app/store/settingsStore.test.ts @@ -6,7 +6,7 @@ import { } from "./settingsStore"; import { useVaultStore } from "./vaultStore"; -describe("settingsStore developer mode", () => { +describe("settingsStore", () => { beforeEach(() => { disposeSettingsStoreRuntime(); initializeSettingsStore(); @@ -26,9 +26,18 @@ describe("settingsStore developer mode", () => { disposeSettingsStoreRuntime(); }); - it("defaults developerModeEnabled to false", () => { - expect(useSettingsStore.getState().developerModeEnabled).toBe(false); - expect(useSettingsStore.getState().developerTerminalEnabled).toBe(true); + it("defaults app settings", () => { + expect(useSettingsStore.getState().terminalFontFamily).toBe(""); + expect(useSettingsStore.getState().terminalFontSize).toBe(13); + expect(useSettingsStore.getState().claudeCodeOptimized).toBe(false); + expect(useSettingsStore.getState().claudeCodeSkipPermissions).toBe( + false, + ); + expect(useSettingsStore.getState().claudeCodeModel).toBe(""); + expect(useSettingsStore.getState().claudeCodeContinueSession).toBe( + false, + ); + expect(useSettingsStore.getState().claudeCodeMaxTurns).toBe(0); expect(useSettingsStore.getState().inlineReviewEnabled).toBe(true); expect(useSettingsStore.getState().pdfFilter).toBe("none"); expect(useSettingsStore.getState().editorSpellcheck).toBe(false); @@ -39,23 +48,15 @@ describe("settingsStore developer mode", () => { expect(useSettingsStore.getState().fileTreeExtensionFilter).toEqual([]); }); - it("persists developerModeEnabled per vault", () => { + it("persists settings per vault", () => { useVaultStore.setState({ vaultPath: "/vaults/devtools" }); - useSettingsStore.getState().setSetting("developerModeEnabled", true); - useSettingsStore - .getState() - .setSetting("developerTerminalEnabled", false); useSettingsStore.getState().setSetting("inlineReviewEnabled", false); useSettingsStore.getState().setSetting("pdfFilter", "sepia"); useSettingsStore.getState().setSetting("fileTreeStickyFolders", false); useSettingsStore.getState().setSetting("agentsSidebarScale", 125); useSettingsStore.getState().setSetting("editorAutosaveDelayMs", 750); - expect(useSettingsStore.getState().developerModeEnabled).toBe(true); - expect(useSettingsStore.getState().developerTerminalEnabled).toBe( - false, - ); expect(useSettingsStore.getState().inlineReviewEnabled).toBe(false); expect(useSettingsStore.getState().pdfFilter).toBe("sepia"); expect(useSettingsStore.getState().fileTreeStickyFolders).toBe(false); @@ -67,8 +68,6 @@ describe("settingsStore developer mode", () => { ), ).toMatchObject({ state: { - developerModeEnabled: true, - developerTerminalEnabled: false, inlineReviewEnabled: false, pdfFilter: "sepia", fileTreeStickyFolders: false, @@ -78,6 +77,61 @@ describe("settingsStore developer mode", () => { }); }); + it("persists terminal settings per vault", () => { + useVaultStore.setState({ vaultPath: "/vaults/terminal" }); + + useSettingsStore + .getState() + .setSetting("terminalFontFamily", "FiraCode Nerd Font"); + useSettingsStore.getState().setSetting("terminalFontSize", 16); + useSettingsStore.getState().setSetting("claudeCodeOptimized", true); + useSettingsStore + .getState() + .setSetting("claudeCodeSkipPermissions", true); + useSettingsStore + .getState() + .setSetting("claudeCodeModel", "claude-sonnet-4-6"); + useSettingsStore + .getState() + .setSetting("claudeCodeContinueSession", true); + useSettingsStore.getState().setSetting("claudeCodeMaxTurns", 12); + + expect( + JSON.parse( + localStorage.getItem("neverwrite:settings:/vaults/terminal") ?? + "", + ), + ).toMatchObject({ + state: { + terminalFontFamily: "FiraCode Nerd Font", + terminalFontSize: 16, + claudeCodeOptimized: true, + claudeCodeSkipPermissions: true, + claudeCodeModel: "claude-sonnet-4-6", + claudeCodeContinueSession: true, + claudeCodeMaxTurns: 12, + }, + }); + }); + + it("normalizes persisted terminal numeric settings", () => { + localStorage.setItem( + "neverwrite:settings", + JSON.stringify({ + state: { + terminalFontSize: 99, + claudeCodeMaxTurns: -3, + }, + }), + ); + + disposeSettingsStoreRuntime(); + initializeSettingsStore(); + + expect(useSettingsStore.getState().terminalFontSize).toBe(24); + expect(useSettingsStore.getState().claudeCodeMaxTurns).toBe(0); + }); + it("persists custom spellcheck language tags as plain strings", () => { useSettingsStore .getState() @@ -155,7 +209,6 @@ describe("settingsStore developer mode", () => { useSettingsStore .getState() .setSetting("spellcheckSecondaryLanguage", "en-US"); - useSettingsStore.getState().setSetting("developerModeEnabled", true); useSettingsStore.getState().setSetting("inlineReviewEnabled", false); useVaultStore.setState({ vaultPath: "/vaults/two" }); @@ -166,7 +219,6 @@ describe("settingsStore developer mode", () => { expect(useSettingsStore.getState().spellcheckSecondaryLanguage).toBe( null, ); - expect(useSettingsStore.getState().developerModeEnabled).toBe(false); expect(useSettingsStore.getState().inlineReviewEnabled).toBe(true); useSettingsStore @@ -181,7 +233,6 @@ describe("settingsStore developer mode", () => { expect(useSettingsStore.getState().spellcheckSecondaryLanguage).toBe( "en-US", ); - expect(useSettingsStore.getState().developerModeEnabled).toBe(true); expect(useSettingsStore.getState().inlineReviewEnabled).toBe(false); }); @@ -199,17 +250,11 @@ describe("settingsStore developer mode", () => { useVaultStore.setState({ vaultPath: "/vaults/new" }); expect(useSettingsStore.getState().editorSpellcheck).toBe(false); - expect(useSettingsStore.getState().developerModeEnabled).toBe(true); - expect( - JSON.parse( - localStorage.getItem("neverwrite:settings:/vaults/new") ?? "", - ), - ).toMatchObject({ - state: { - editorSpellcheck: false, - developerModeEnabled: true, - }, - }); + const stored = JSON.parse( + localStorage.getItem("neverwrite:settings:/vaults/new") ?? "", + ) as { state: Record }; + expect(stored.state.editorSpellcheck).toBe(false); + expect(stored.state).not.toHaveProperty("developerModeEnabled"); }); it("migrates legacy global spellcheck settings into existing vault settings", () => { @@ -226,7 +271,7 @@ describe("settingsStore developer mode", () => { "neverwrite:settings:/vaults/migrated", JSON.stringify({ state: { - developerModeEnabled: true, + inlineReviewEnabled: false, }, }), ); @@ -239,14 +284,14 @@ describe("settingsStore developer mode", () => { expect(useSettingsStore.getState().spellcheckSecondaryLanguage).toBe( "en-US", ); - expect(useSettingsStore.getState().developerModeEnabled).toBe(true); + expect(useSettingsStore.getState().inlineReviewEnabled).toBe(false); expect( JSON.parse( localStorage.getItem("neverwrite:settings:/vaults/migrated") ?? "", ), ).toMatchObject({ state: { - developerModeEnabled: true, + inlineReviewEnabled: false, spellcheckPrimaryLanguage: "es-CL", spellcheckSecondaryLanguage: "en-US", }, diff --git a/apps/desktop/src/app/store/settingsStore.ts b/apps/desktop/src/app/store/settingsStore.ts index 18b170ea..211e78f8 100644 --- a/apps/desktop/src/app/store/settingsStore.ts +++ b/apps/desktop/src/app/store/settingsStore.ts @@ -35,9 +35,16 @@ export interface Settings { fileTreeStickyFolders: boolean; tabOpenBehavior: TabOpenBehavior; + // Terminal + terminalFontFamily: string; + terminalFontSize: number; // 8–24 + claudeCodeOptimized: boolean; + claudeCodeSkipPermissions: boolean; + claudeCodeModel: string; // "" = Claude Code default + claudeCodeContinueSession: boolean; + claudeCodeMaxTurns: number; // 0 = unlimited + // Developers - developerModeEnabled: boolean; - developerTerminalEnabled: boolean; fileTreeContentMode: "notes_only" | "all_files"; fileTreeShowExtensions: boolean; fileTreeExtensionFilter: string[]; @@ -175,8 +182,13 @@ const defaults: Settings = { agentsSidebarScale: 100, fileTreeStickyFolders: true, tabOpenBehavior: "history", - developerModeEnabled: false, - developerTerminalEnabled: true, + terminalFontFamily: "", + terminalFontSize: 13, + claudeCodeOptimized: false, + claudeCodeSkipPermissions: false, + claudeCodeModel: "", + claudeCodeContinueSession: false, + claudeCodeMaxTurns: 0, fileTreeContentMode: "notes_only", fileTreeShowExtensions: false, fileTreeExtensionFilter: [], @@ -408,12 +420,35 @@ function extractSettingsFromStorage(raw: string | null): Settings | null { tabOpenBehavior: normalizeTabOpenBehavior( parsed.state.tabOpenBehavior, ), - developerModeEnabled: - parsed.state.developerModeEnabled ?? - defaults.developerModeEnabled, - developerTerminalEnabled: - parsed.state.developerTerminalEnabled ?? - defaults.developerTerminalEnabled, + terminalFontFamily: + typeof parsed.state.terminalFontFamily === "string" + ? parsed.state.terminalFontFamily + : defaults.terminalFontFamily, + terminalFontSize: normalizeIntInRange( + parsed.state.terminalFontSize, + defaults.terminalFontSize, + 8, + 24, + ), + claudeCodeOptimized: + parsed.state.claudeCodeOptimized ?? + defaults.claudeCodeOptimized, + claudeCodeSkipPermissions: + parsed.state.claudeCodeSkipPermissions ?? + defaults.claudeCodeSkipPermissions, + claudeCodeModel: + typeof parsed.state.claudeCodeModel === "string" + ? parsed.state.claudeCodeModel + : defaults.claudeCodeModel, + claudeCodeContinueSession: + parsed.state.claudeCodeContinueSession ?? + defaults.claudeCodeContinueSession, + claudeCodeMaxTurns: normalizeIntInRange( + parsed.state.claudeCodeMaxTurns, + defaults.claudeCodeMaxTurns, + 0, + 1000, + ), fileTreeContentMode: normalizeFileTreeContentMode( parsed.state.fileTreeContentMode, ), @@ -485,8 +520,13 @@ function pickSettings(state: SettingsStore): Settings { agentsSidebarScale: state.agentsSidebarScale, fileTreeStickyFolders: state.fileTreeStickyFolders, tabOpenBehavior: state.tabOpenBehavior, - developerModeEnabled: state.developerModeEnabled, - developerTerminalEnabled: state.developerTerminalEnabled, + terminalFontFamily: state.terminalFontFamily, + terminalFontSize: state.terminalFontSize, + claudeCodeOptimized: state.claudeCodeOptimized, + claudeCodeSkipPermissions: state.claudeCodeSkipPermissions, + claudeCodeModel: state.claudeCodeModel, + claudeCodeContinueSession: state.claudeCodeContinueSession, + claudeCodeMaxTurns: state.claudeCodeMaxTurns, fileTreeContentMode: state.fileTreeContentMode, fileTreeShowExtensions: state.fileTreeShowExtensions, fileTreeExtensionFilter: state.fileTreeExtensionFilter, diff --git a/apps/desktop/src/app/themes/index.test.ts b/apps/desktop/src/app/themes/index.test.ts index 3e5915d8..cb8ad359 100644 --- a/apps/desktop/src/app/themes/index.test.ts +++ b/apps/desktop/src/app/themes/index.test.ts @@ -26,6 +26,25 @@ const CODE_CSS_VAR_ENTRIES = Object.entries(CODE_CSS_VAR_MAP) as Array< [keyof CodeColorAnchors, string] >; +const TERMINAL_ANSI_CSS_VARS = [ + "--terminal-ansi-black", + "--terminal-ansi-red", + "--terminal-ansi-green", + "--terminal-ansi-yellow", + "--terminal-ansi-blue", + "--terminal-ansi-magenta", + "--terminal-ansi-cyan", + "--terminal-ansi-white", + "--terminal-ansi-bright-black", + "--terminal-ansi-bright-red", + "--terminal-ansi-bright-green", + "--terminal-ansi-bright-yellow", + "--terminal-ansi-bright-blue", + "--terminal-ansi-bright-magenta", + "--terminal-ansi-bright-cyan", + "--terminal-ansi-bright-white", +] as const; + function expectCodeVars(themeName: ThemeName, isDark: boolean) { applyThemeColors(themeName, isDark); @@ -64,4 +83,18 @@ describe("applyThemeColors", () => { themes.gruvbox.light.codeAnchors.keyword, ); }); + + it("publishes terminal ANSI vars for every theme and mode", () => { + for (const themeName of Object.keys(themes) as ThemeName[]) { + for (const isDark of [false, true]) { + applyThemeColors(themeName, isDark); + + for (const cssVar of TERMINAL_ANSI_CSS_VARS) { + expect( + document.documentElement.style.getPropertyValue(cssVar), + ).toMatch(/^#[0-9a-f]{6}$/i); + } + } + } + }); }); diff --git a/apps/desktop/src/app/themes/index.ts b/apps/desktop/src/app/themes/index.ts index beca78dd..a200a433 100644 --- a/apps/desktop/src/app/themes/index.ts +++ b/apps/desktop/src/app/themes/index.ts @@ -19,6 +19,7 @@ import { everforestTheme } from "./everforest"; import { synthwave84Theme } from "./synthwave84"; import { claudeTheme } from "./claude"; import { codexTheme } from "./codex"; +import { applyTerminalPalette } from "./terminalPalettes"; // 12 syntax-highlighting anchor colors that drive per-theme code and // markdown coloring across CodeMirror and the static highlighter. Each @@ -157,4 +158,6 @@ export function applyThemeColors(name: ThemeName, isDark: boolean) { colors.codeAnchors[key as keyof CodeColorAnchors], ); } + + applyTerminalPalette(name, isDark); } diff --git a/apps/desktop/src/app/themes/terminalPalettes.ts b/apps/desktop/src/app/themes/terminalPalettes.ts new file mode 100644 index 00000000..201fa4b4 --- /dev/null +++ b/apps/desktop/src/app/themes/terminalPalettes.ts @@ -0,0 +1,861 @@ +import type { ThemeName } from "./index"; + +export interface AnsiPalette { + black: string; + red: string; + green: string; + yellow: string; + blue: string; + magenta: string; + cyan: string; + white: string; + brightBlack: string; + brightRed: string; + brightGreen: string; + brightYellow: string; + brightBlue: string; + brightMagenta: string; + brightCyan: string; + brightWhite: string; +} + +type TerminalPaletteByMode = { + dark: AnsiPalette; + light: AnsiPalette; +}; + +// Maps a theme + isDark flag to an intentionally-designed 16-colour ANSI +// palette. Keep this exhaustive so new app themes cannot accidentally reuse +// stale terminal colors from the previously-applied theme. +const PALETTES: Record = { + catppuccin: { + dark: { + // Catppuccin Mocha + black: "#45475a", + red: "#f38ba8", + green: "#a6e3a1", + yellow: "#f9e2af", + blue: "#89b4fa", + magenta: "#cba6f7", + cyan: "#94e2d5", + white: "#bac2de", + brightBlack: "#585b70", + brightRed: "#f38ba8", + brightGreen: "#a6e3a1", + brightYellow: "#f9e2af", + brightBlue: "#89b4fa", + brightMagenta: "#cba6f7", + brightCyan: "#89dceb", + brightWhite: "#a6adc8", + }, + light: { + // Catppuccin Latte + black: "#9ca0b0", + red: "#d20f39", + green: "#40a02b", + yellow: "#df8e1d", + blue: "#1e66f5", + magenta: "#8839ef", + cyan: "#179299", + white: "#5c5f77", + brightBlack: "#acb0be", + brightRed: "#d20f39", + brightGreen: "#40a02b", + brightYellow: "#df8e1d", + brightBlue: "#1e66f5", + brightMagenta: "#8839ef", + brightCyan: "#179299", + brightWhite: "#4c4f69", + }, + }, + nord: { + dark: { + black: "#3b4252", + red: "#bf616a", + green: "#a3be8c", + yellow: "#ebcb8b", + blue: "#81a1c1", + magenta: "#b48ead", + cyan: "#88c0d0", + white: "#e5e9f0", + brightBlack: "#4c566a", + brightRed: "#bf616a", + brightGreen: "#a3be8c", + brightYellow: "#ebcb8b", + brightBlue: "#81a1c1", + brightMagenta: "#b48ead", + brightCyan: "#8fbcbb", + brightWhite: "#eceff4", + }, + light: { + black: "#3b4252", + red: "#bf616a", + green: "#a3be8c", + yellow: "#ebcb8b", + blue: "#5e81ac", + magenta: "#b48ead", + cyan: "#88c0d0", + white: "#434c5e", + brightBlack: "#4c566a", + brightRed: "#bf616a", + brightGreen: "#a3be8c", + brightYellow: "#ebcb8b", + brightBlue: "#81a1c1", + brightMagenta: "#b48ead", + brightCyan: "#8fbcbb", + brightWhite: "#2e3440", + }, + }, + solarized: { + dark: { + black: "#073642", + red: "#dc322f", + green: "#859900", + yellow: "#b58900", + blue: "#268bd2", + magenta: "#d33682", + cyan: "#2aa198", + white: "#eee8d5", + brightBlack: "#002b36", + brightRed: "#cb4b16", + brightGreen: "#586e75", + brightYellow: "#657b83", + brightBlue: "#839496", + brightMagenta: "#6c71c4", + brightCyan: "#93a1a1", + brightWhite: "#fdf6e3", + }, + light: { + black: "#eee8d5", + red: "#dc322f", + green: "#859900", + yellow: "#b58900", + blue: "#268bd2", + magenta: "#d33682", + cyan: "#2aa198", + white: "#073642", + brightBlack: "#fdf6e3", + brightRed: "#cb4b16", + brightGreen: "#93a1a1", + brightYellow: "#839496", + brightBlue: "#657b83", + brightMagenta: "#6c71c4", + brightCyan: "#586e75", + brightWhite: "#002b36", + }, + }, + tokyoNight: { + dark: { + black: "#15161e", + red: "#f7768e", + green: "#9ece6a", + yellow: "#e0af68", + blue: "#7aa2f7", + magenta: "#bb9af7", + cyan: "#7dcfff", + white: "#a9b1d6", + brightBlack: "#414868", + brightRed: "#f7768e", + brightGreen: "#9ece6a", + brightYellow: "#e0af68", + brightBlue: "#7aa2f7", + brightMagenta: "#bb9af7", + brightCyan: "#7dcfff", + brightWhite: "#c0caf5", + }, + light: { + black: "#e9e9ec", + red: "#8c4351", + green: "#485e30", + yellow: "#8f5e15", + blue: "#2959aa", + magenta: "#5a4a78", + cyan: "#0f4b6e", + white: "#6172b0", + brightBlack: "#a8aecb", + brightRed: "#8c4351", + brightGreen: "#485e30", + brightYellow: "#8f5e15", + brightBlue: "#2959aa", + brightMagenta: "#5a4a78", + brightCyan: "#0f4b6e", + brightWhite: "#343b58", + }, + }, + gruvbox: { + dark: { + black: "#282828", + red: "#cc241d", + green: "#98971a", + yellow: "#d79921", + blue: "#458588", + magenta: "#b16286", + cyan: "#689d6a", + white: "#a89984", + brightBlack: "#928374", + brightRed: "#fb4934", + brightGreen: "#b8bb26", + brightYellow: "#fabd2f", + brightBlue: "#83a598", + brightMagenta: "#d3869b", + brightCyan: "#8ec07c", + brightWhite: "#ebdbb2", + }, + light: { + black: "#fbf1c7", + red: "#9d0006", + green: "#79740e", + yellow: "#b57614", + blue: "#076678", + magenta: "#8f3f71", + cyan: "#427b58", + white: "#7c6f64", + brightBlack: "#928374", + brightRed: "#cc241d", + brightGreen: "#98971a", + brightYellow: "#d79921", + brightBlue: "#458588", + brightMagenta: "#b16286", + brightCyan: "#689d6a", + brightWhite: "#3c3836", + }, + }, + rosePine: { + dark: { + black: "#26233a", + red: "#eb6f92", + green: "#31748f", + yellow: "#f6c177", + blue: "#9ccfd8", + magenta: "#c4a7e7", + cyan: "#ebbcba", + white: "#e0def4", + brightBlack: "#403d52", + brightRed: "#eb6f92", + brightGreen: "#31748f", + brightYellow: "#f6c177", + brightBlue: "#9ccfd8", + brightMagenta: "#c4a7e7", + brightCyan: "#ebbcba", + brightWhite: "#e0def4", + }, + light: { + black: "#f2e9e1", + red: "#b4637a", + green: "#286983", + yellow: "#ea9d34", + blue: "#56949f", + magenta: "#907aa9", + cyan: "#d7827e", + white: "#575279", + brightBlack: "#9893a5", + brightRed: "#b4637a", + brightGreen: "#286983", + brightYellow: "#ea9d34", + brightBlue: "#56949f", + brightMagenta: "#907aa9", + brightCyan: "#d7827e", + brightWhite: "#575279", + }, + }, + kanagawa: { + dark: { + black: "#16161d", + red: "#c34043", + green: "#76946a", + yellow: "#c0a36e", + blue: "#7e9cd8", + magenta: "#957fb8", + cyan: "#6a9589", + white: "#c8c093", + brightBlack: "#717c7c", + brightRed: "#ff5d62", + brightGreen: "#98bb6c", + brightYellow: "#e6c384", + brightBlue: "#7fb4ca", + brightMagenta: "#938aa9", + brightCyan: "#7aa89f", + brightWhite: "#dcd7ba", + }, + light: { + black: "#f2ecbc", + red: "#c84053", + green: "#6f894e", + yellow: "#77713f", + blue: "#4d699b", + magenta: "#624c83", + cyan: "#4e8ca2", + white: "#545464", + brightBlack: "#8a8980", + brightRed: "#c84053", + brightGreen: "#6f894e", + brightYellow: "#836f4a", + brightBlue: "#4d699b", + brightMagenta: "#624c83", + brightCyan: "#4e8ca2", + brightWhite: "#43436c", + }, + }, + everforest: { + dark: { + black: "#374247", + red: "#e67e80", + green: "#a7c080", + yellow: "#dbbc7f", + blue: "#7fbbb3", + magenta: "#d699b6", + cyan: "#83c092", + white: "#d3c6aa", + brightBlack: "#475258", + brightRed: "#e67e80", + brightGreen: "#a7c080", + brightYellow: "#dbbc7f", + brightBlue: "#7fbbb3", + brightMagenta: "#d699b6", + brightCyan: "#83c092", + brightWhite: "#d3c6aa", + }, + light: { + black: "#f3ead3", + red: "#f85552", + green: "#8da101", + yellow: "#dfa000", + blue: "#3a94c5", + magenta: "#df69ba", + cyan: "#35a77c", + white: "#5c6a72", + brightBlack: "#a6b0a0", + brightRed: "#f85552", + brightGreen: "#8da101", + brightYellow: "#dfa000", + brightBlue: "#3a94c5", + brightMagenta: "#df69ba", + brightCyan: "#35a77c", + brightWhite: "#272d30", + }, + }, + ayu: { + dark: { + black: "#0a0e14", + red: "#ff3333", + green: "#b8cc52", + yellow: "#e7c547", + blue: "#36a3d9", + magenta: "#f07178", + cyan: "#95e6cb", + white: "#c7c7c7", + brightBlack: "#686868", + brightRed: "#f07178", + brightGreen: "#cae682", + brightYellow: "#ffd580", + brightBlue: "#73d0ff", + brightMagenta: "#f07178", + brightCyan: "#95e6cb", + brightWhite: "#ffffff", + }, + light: { + black: "#f0f0f0", + red: "#e65050", + green: "#48a223", + yellow: "#f28b28", + blue: "#399ee6", + magenta: "#a37acc", + cyan: "#4cbf99", + white: "#575f66", + brightBlack: "#8a9199", + brightRed: "#e65050", + brightGreen: "#86b300", + brightYellow: "#f28b28", + brightBlue: "#399ee6", + brightMagenta: "#a37acc", + brightCyan: "#4cbf99", + brightWhite: "#1a1f29", + }, + }, + nightOwl: { + dark: { + black: "#011627", + red: "#ef5350", + green: "#22da6e", + yellow: "#addb67", + blue: "#82aaff", + magenta: "#c792ea", + cyan: "#21c7a8", + white: "#d6deeb", + brightBlack: "#575656", + brightRed: "#ef5350", + brightGreen: "#22da6e", + brightYellow: "#ffeb95", + brightBlue: "#82aaff", + brightMagenta: "#c792ea", + brightCyan: "#7fdbca", + brightWhite: "#ffffff", + }, + light: { + black: "#f0f0f0", + red: "#de3d3b", + green: "#08916a", + yellow: "#e0a000", + blue: "#2d79c7", + magenta: "#7c4dff", + cyan: "#008997", + white: "#403f53", + brightBlack: "#989fb1", + brightRed: "#de3d3b", + brightGreen: "#08916a", + brightYellow: "#daaa01", + brightBlue: "#2d79c7", + brightMagenta: "#7c4dff", + brightCyan: "#008997", + brightWhite: "#090a0f", + }, + }, + vesper: { + dark: { + black: "#101010", + red: "#f04e4e", + green: "#5ab875", + yellow: "#f5a623", + blue: "#5f9cff", + magenta: "#cf7bdb", + cyan: "#2ebcb3", + white: "#cccccc", + brightBlack: "#555555", + brightRed: "#ff7171", + brightGreen: "#7dd496", + brightYellow: "#ffc966", + brightBlue: "#82b4ff", + brightMagenta: "#e39fe8", + brightCyan: "#4fd4cc", + brightWhite: "#eeeeee", + }, + light: { + black: "#f4f4f4", + red: "#c0392b", + green: "#27ae60", + yellow: "#d68910", + blue: "#2471a3", + magenta: "#8e44ad", + cyan: "#148f77", + white: "#555555", + brightBlack: "#999999", + brightRed: "#e74c3c", + brightGreen: "#2ecc71", + brightYellow: "#f39c12", + brightBlue: "#3498db", + brightMagenta: "#9b59b6", + brightCyan: "#1abc9c", + brightWhite: "#222222", + }, + }, + synthwave84: { + dark: { + black: "#191a28", + red: "#fe4450", + green: "#72f1b8", + yellow: "#fede5d", + blue: "#36f9f6", + magenta: "#e2a0ff", + cyan: "#72f1b8", + white: "#f9f9f9", + brightBlack: "#495495", + brightRed: "#f97e72", + brightGreen: "#72f1b8", + brightYellow: "#fede5d", + brightBlue: "#36f9f6", + brightMagenta: "#e2a0ff", + brightCyan: "#36f9f6", + brightWhite: "#fefefe", + }, + light: { + // Synthwave is inherently dark — use a softened version for light + black: "#f0eeff", + red: "#c0003c", + green: "#006b3e", + yellow: "#8a6500", + blue: "#0060a0", + magenta: "#7c00b8", + cyan: "#006b6b", + white: "#2d2357", + brightBlack: "#8080b0", + brightRed: "#fe4450", + brightGreen: "#00a860", + brightYellow: "#c0a000", + brightBlue: "#2060c8", + brightMagenta: "#b060d8", + brightCyan: "#00a0a0", + brightWhite: "#191a28", + }, + }, + // ── App-specific themes ─────────────────────────────────────────────────── + default: { + dark: { + black: "#252525", + red: "#f87171", + green: "#4ade80", + yellow: "#fbbf24", + blue: "#818cf8", + magenta: "#c084fc", + cyan: "#34d399", + white: "#d4d4d4", + brightBlack: "#525252", + brightRed: "#fca5a5", + brightGreen: "#86efac", + brightYellow: "#fde68a", + brightBlue: "#a5b4fc", + brightMagenta: "#d8b4fe", + brightCyan: "#6ee7b7", + brightWhite: "#e8e8e8", + }, + light: { + black: "#f5f5f5", + red: "#dc2626", + green: "#16a34a", + yellow: "#d97706", + blue: "#4f46e5", + magenta: "#7c3aed", + cyan: "#059669", + white: "#404040", + brightBlack: "#a3a3a3", + brightRed: "#ef4444", + brightGreen: "#22c55e", + brightYellow: "#f59e0b", + brightBlue: "#6366f1", + brightMagenta: "#8b5cf6", + brightCyan: "#10b981", + brightWhite: "#1c1c1c", + }, + }, + ocean: { + dark: { + black: "#1e293b", + red: "#f87171", + green: "#34d399", + yellow: "#fbbf24", + blue: "#38bdf8", + magenta: "#818cf8", + cyan: "#22d3ee", + white: "#cbd5e1", + brightBlack: "#475569", + brightRed: "#fca5a5", + brightGreen: "#6ee7b7", + brightYellow: "#fde68a", + brightBlue: "#7dd3fc", + brightMagenta: "#a5b4fc", + brightCyan: "#67e8f9", + brightWhite: "#e2e8f0", + }, + light: { + black: "#e2e8f0", + red: "#be123c", + green: "#047857", + yellow: "#b45309", + blue: "#0284c7", + magenta: "#4338ca", + cyan: "#0e7490", + white: "#334155", + brightBlack: "#94a3b8", + brightRed: "#dc2626", + brightGreen: "#059669", + brightYellow: "#d97706", + brightBlue: "#0ea5e9", + brightMagenta: "#4f46e5", + brightCyan: "#0891b2", + brightWhite: "#0f172a", + }, + }, + forest: { + dark: { + black: "#1a2820", + red: "#f87171", + green: "#34d399", + yellow: "#fbbf24", + blue: "#60a5fa", + magenta: "#a78bfa", + cyan: "#2dd4bf", + white: "#d1fae5", + brightBlack: "#374c3d", + brightRed: "#fca5a5", + brightGreen: "#6ee7b7", + brightYellow: "#fde68a", + brightBlue: "#93c5fd", + brightMagenta: "#c4b5fd", + brightCyan: "#5eead4", + brightWhite: "#ecfdf5", + }, + light: { + black: "#f0fdf4", + red: "#b91c1c", + green: "#047857", + yellow: "#92400e", + blue: "#1d4ed8", + magenta: "#6d28d9", + cyan: "#0f766e", + white: "#14532d", + brightBlack: "#86efac", + brightRed: "#ef4444", + brightGreen: "#10b981", + brightYellow: "#d97706", + brightBlue: "#3b82f6", + brightMagenta: "#7c3aed", + brightCyan: "#14b8a6", + brightWhite: "#052e16", + }, + }, + rose: { + dark: { + black: "#1a1215", + red: "#fb7185", + green: "#4ade80", + yellow: "#fbbf24", + blue: "#60a5fa", + magenta: "#e879f9", + cyan: "#34d399", + white: "#fce7f3", + brightBlack: "#4c1d3a", + brightRed: "#fda4af", + brightGreen: "#86efac", + brightYellow: "#fde68a", + brightBlue: "#93c5fd", + brightMagenta: "#f0abfc", + brightCyan: "#6ee7b7", + brightWhite: "#fff1f2", + }, + light: { + black: "#fff1f2", + red: "#be123c", + green: "#047857", + yellow: "#b45309", + blue: "#1d4ed8", + magenta: "#a21caf", + cyan: "#0f766e", + white: "#9f1239", + brightBlack: "#fda4af", + brightRed: "#f43f5e", + brightGreen: "#10b981", + brightYellow: "#f59e0b", + brightBlue: "#3b82f6", + brightMagenta: "#d946ef", + brightCyan: "#14b8a6", + brightWhite: "#500724", + }, + }, + amber: { + dark: { + black: "#1a1710", + red: "#f87171", + green: "#4ade80", + yellow: "#f59e0b", + blue: "#60a5fa", + magenta: "#e879f9", + cyan: "#34d399", + white: "#fef3c7", + brightBlack: "#44390a", + brightRed: "#fca5a5", + brightGreen: "#86efac", + brightYellow: "#fde68a", + brightBlue: "#93c5fd", + brightMagenta: "#f0abfc", + brightCyan: "#6ee7b7", + brightWhite: "#fffbeb", + }, + light: { + black: "#fffbeb", + red: "#b91c1c", + green: "#047857", + yellow: "#b45309", + blue: "#1d4ed8", + magenta: "#7c3aed", + cyan: "#0f766e", + white: "#78350f", + brightBlack: "#fde68a", + brightRed: "#ef4444", + brightGreen: "#10b981", + brightYellow: "#d97706", + brightBlue: "#3b82f6", + brightMagenta: "#8b5cf6", + brightCyan: "#14b8a6", + brightWhite: "#451a03", + }, + }, + lavender: { + dark: { + black: "#18141f", + red: "#f87171", + green: "#4ade80", + yellow: "#fbbf24", + blue: "#818cf8", + magenta: "#a78bfa", + cyan: "#34d399", + white: "#ede9fe", + brightBlack: "#3b2f5e", + brightRed: "#fca5a5", + brightGreen: "#86efac", + brightYellow: "#fde68a", + brightBlue: "#a5b4fc", + brightMagenta: "#c4b5fd", + brightCyan: "#6ee7b7", + brightWhite: "#f5f3ff", + }, + light: { + black: "#f5f3ff", + red: "#b91c1c", + green: "#047857", + yellow: "#92400e", + blue: "#4338ca", + magenta: "#6d28d9", + cyan: "#0f766e", + white: "#4c1d95", + brightBlack: "#ddd6fe", + brightRed: "#ef4444", + brightGreen: "#10b981", + brightYellow: "#d97706", + brightBlue: "#6366f1", + brightMagenta: "#8b5cf6", + brightCyan: "#14b8a6", + brightWhite: "#2e1065", + }, + }, + sunset: { + dark: { + black: "#1a1410", + red: "#fb7185", + green: "#4ade80", + yellow: "#fbbf24", + blue: "#60a5fa", + magenta: "#e879f9", + cyan: "#34d399", + white: "#ffedd5", + brightBlack: "#431407", + brightRed: "#fda4af", + brightGreen: "#86efac", + brightYellow: "#fde68a", + brightBlue: "#93c5fd", + brightMagenta: "#f0abfc", + brightCyan: "#6ee7b7", + brightWhite: "#fff7ed", + }, + light: { + black: "#fff7ed", + red: "#be123c", + green: "#047857", + yellow: "#b45309", + blue: "#1d4ed8", + magenta: "#7c3aed", + cyan: "#0f766e", + white: "#7c2d12", + brightBlack: "#fed7aa", + brightRed: "#ef4444", + brightGreen: "#10b981", + brightYellow: "#f59e0b", + brightBlue: "#3b82f6", + brightMagenta: "#8b5cf6", + brightCyan: "#14b8a6", + brightWhite: "#431407", + }, + }, + claude: { + dark: { + black: "#1a1917", + red: "#e57373", + green: "#81c784", + yellow: "#ffb74d", + blue: "#64b5f6", + magenta: "#ce93d8", + cyan: "#4db6ac", + white: "#f4f3ee", + brightBlack: "#4a4540", + brightRed: "#d97757", + brightGreen: "#a5d6a7", + brightYellow: "#ffe0b2", + brightBlue: "#90caf9", + brightMagenta: "#e1bee7", + brightCyan: "#80cbc4", + brightWhite: "#fdfcfa", + }, + light: { + black: "#faf9f5", + red: "#c0392b", + green: "#1a7340", + yellow: "#8a5c00", + blue: "#1565c0", + magenta: "#6a1b9a", + cyan: "#00695c", + white: "#3e2723", + brightBlack: "#bcaaa4", + brightRed: "#c15f3c", + brightGreen: "#388e3c", + brightYellow: "#f57f17", + brightBlue: "#1976d2", + brightMagenta: "#7b1fa2", + brightCyan: "#00796b", + brightWhite: "#141413", + }, + }, + codex: { + dark: { + black: "#1e1e20", + red: "#ef5350", + green: "#10a37f", + yellow: "#ff9800", + blue: "#42a5f5", + magenta: "#ab47bc", + cyan: "#26c6da", + white: "#e0e0e0", + brightBlack: "#424242", + brightRed: "#ff7043", + brightGreen: "#26d198", + brightYellow: "#ffb74d", + brightBlue: "#64b5f6", + brightMagenta: "#ce93d8", + brightCyan: "#4dd0e1", + brightWhite: "#f5f5f5", + }, + light: { + black: "#f5f5f5", + red: "#c62828", + green: "#00695c", + yellow: "#e65100", + blue: "#1565c0", + magenta: "#6a1b9a", + cyan: "#00838f", + white: "#212121", + brightBlack: "#9e9e9e", + brightRed: "#ef5350", + brightGreen: "#10a37f", + brightYellow: "#ff9800", + brightBlue: "#42a5f5", + brightMagenta: "#ab47bc", + brightCyan: "#26c6da", + brightWhite: "#111111", + }, + }, +}; + +const SLOT_TO_CSS: Record = { + black: "--terminal-ansi-black", + red: "--terminal-ansi-red", + green: "--terminal-ansi-green", + yellow: "--terminal-ansi-yellow", + blue: "--terminal-ansi-blue", + magenta: "--terminal-ansi-magenta", + cyan: "--terminal-ansi-cyan", + white: "--terminal-ansi-white", + brightBlack: "--terminal-ansi-bright-black", + brightRed: "--terminal-ansi-bright-red", + brightGreen: "--terminal-ansi-bright-green", + brightYellow: "--terminal-ansi-bright-yellow", + brightBlue: "--terminal-ansi-bright-blue", + brightMagenta: "--terminal-ansi-bright-magenta", + brightCyan: "--terminal-ansi-bright-cyan", + brightWhite: "--terminal-ansi-bright-white", +}; + +export function applyTerminalPalette(name: ThemeName, isDark: boolean) { + if (typeof document === "undefined") return; + const palette = isDark ? PALETTES[name].dark : PALETTES[name].light; + const style = document.documentElement.style; + for (const [slot, cssVar] of Object.entries(SLOT_TO_CSS) as [keyof AnsiPalette, string][]) { + style.setProperty(cssVar, palette[slot]); + } +} diff --git a/apps/desktop/src/features/ai/AIChatWorkspaceHost.tsx b/apps/desktop/src/features/ai/AIChatWorkspaceHost.tsx index 7ce0bb4e..97f33dc4 100644 --- a/apps/desktop/src/features/ai/AIChatWorkspaceHost.tsx +++ b/apps/desktop/src/features/ai/AIChatWorkspaceHost.tsx @@ -21,6 +21,8 @@ import { } from "./chatPaneMovement"; import { useChatStore } from "./store/chatStore"; import { useAiChatEventBridge } from "./useAiChatEventBridge"; +import { CLAUDE_TERMINAL_RUNTIME_ID } from "./utils/runtimeMetadata"; +import { openClaudeCodeTerminalWithContext } from "../terminal/claudeCodeTerminal"; function hasVisibleAiComposerDropZone(targetSessionId?: string) { const selector = targetSessionId @@ -309,6 +311,14 @@ export function AIChatWorkspaceHost({ .detail; if (detail.phase !== "attach") return; + if ( + useChatStore.getState().getDefaultNewChatRuntimeId() === + CLAUDE_TERMINAL_RUNTIME_ID + ) { + void openClaudeCodeTerminalWithContext(detail); + return; + } + void createNewChatInWorkspace().then((sessionId) => { if (!sessionId) return; replayAttachAfterComposerMount(detail, sessionId); diff --git a/apps/desktop/src/features/ai/AgentsSidebarPanel.test.tsx b/apps/desktop/src/features/ai/AgentsSidebarPanel.test.tsx index a9d9bd63..cd3c68af 100644 --- a/apps/desktop/src/features/ai/AgentsSidebarPanel.test.tsx +++ b/apps/desktop/src/features/ai/AgentsSidebarPanel.test.tsx @@ -12,14 +12,19 @@ import { AGENT_SIDEBAR_DRAG_EVENT, type AgentSidebarDragDetail, } from "./agentSidebarDragEvents"; +import { CLAUDE_TERMINAL_RUNTIME_ID } from "./utils/runtimeMetadata"; const chatPaneMovementMock = vi.hoisted(() => ({ createNewChatInWorkspace: vi.fn(), openChatHistoryInWorkspace: vi.fn(), openChatSessionInWorkspace: vi.fn(), })); +const claudeCodeTerminalMock = vi.hoisted(() => ({ + openClaudeCodeTerminalWithContext: vi.fn(async () => undefined), +})); vi.mock("./chatPaneMovement", () => chatPaneMovementMock); +vi.mock("../terminal/claudeCodeTerminal", () => claudeCodeTerminalMock); function createSession( sessionId: string, @@ -145,6 +150,55 @@ describe("AgentsSidebarPanel", () => { ).toBeNull(); }); + it("opens Claude Code from the plus menu as a terminal runtime", async () => { + useChatStore.setState({ + runtimes: [ + { + runtime: { + id: "codex-acp", + name: "Codex ACP", + description: "", + capabilities: [], + }, + models: [], + modes: [], + configOptions: [], + }, + { + runtime: { + id: "claude-code-terminal", + name: "Claude Code", + description: "", + capabilities: [], + }, + models: [], + modes: [], + configOptions: [], + }, + ], + selectedRuntimeId: "codex-acp", + }); + + renderComponent(); + + fireEvent.click(screen.getByRole("button", { name: "New chat" })); + fireEvent.click( + await screen.findByRole("button", { name: "Claude Code" }), + ); + + await waitFor(() => { + expect( + claudeCodeTerminalMock.openClaudeCodeTerminalWithContext, + ).toHaveBeenCalledTimes(1); + }); + expect( + chatPaneMovementMock.createNewChatInWorkspace, + ).not.toHaveBeenCalled(); + expect(useChatStore.getState().selectedRuntimeId).toBe( + CLAUDE_TERMINAL_RUNTIME_ID, + ); + }); + it("keeps open working agents in the order they became busy", async () => { const alpha = createSession( "session-alpha", diff --git a/apps/desktop/src/features/ai/AgentsSidebarPanel.tsx b/apps/desktop/src/features/ai/AgentsSidebarPanel.tsx index bf5a60f8..4682f6c8 100644 --- a/apps/desktop/src/features/ai/AgentsSidebarPanel.tsx +++ b/apps/desktop/src/features/ai/AgentsSidebarPanel.tsx @@ -32,6 +32,7 @@ import { openChatHistoryInWorkspace, openChatSessionInWorkspace, } from "./chatPaneMovement"; +import { openClaudeCodeTerminalWithContext } from "../terminal/claudeCodeTerminal"; import { emitAgentSidebarDrag } from "./agentSidebarDragEvents"; import { getSessionPreview, @@ -48,7 +49,10 @@ import { import { useChatStore } from "./store/chatStore"; import { usePinnedChatsStore } from "./store/pinnedChatsStore"; import type { AIChatSession } from "./types"; -import { getRuntimeDisplayName } from "./utils/runtimeMetadata"; +import { + CLAUDE_TERMINAL_RUNTIME_ID, + getRuntimeDisplayName, +} from "./utils/runtimeMetadata"; import { useInlineRename } from "./components/useInlineRename"; import { AgentsSidebarItem, @@ -492,6 +496,11 @@ export function AgentsSidebarPanel() { return sortedRuntimes.map((runtime) => ({ label: getRuntimeMenuLabel(runtime.runtime.name), action: () => { + useChatStore.getState().setSelectedRuntime(runtime.runtime.id); + if (runtime.runtime.id === CLAUDE_TERMINAL_RUNTIME_ID) { + void openClaudeCodeTerminalWithContext(); + return; + } void createNewChatInWorkspace(runtime.runtime.id); }, })); diff --git a/apps/desktop/src/features/ai/api.ts b/apps/desktop/src/features/ai/api.ts index 356c0600..8024a817 100644 --- a/apps/desktop/src/features/ai/api.ts +++ b/apps/desktop/src/features/ai/api.ts @@ -33,6 +33,7 @@ import type { PersistedSessionHistoryPage, } from "./types"; import { buildFallbackRuntimeDescriptors } from "./utils/runtimeMetadata"; +import { isClaudeTerminalAuthMethodId } from "./utils/authMethods"; const FALLBACK_RUNTIMES: AIRuntimeDescriptor[] = buildFallbackRuntimeDescriptors(); @@ -151,15 +152,34 @@ function normalizeRuntimeDescriptor( function normalizeRuntimeSetupStatus( status: AIBackendRuntimeSetupStatusPayload, ): AIRuntimeSetupStatus { + let authMethods = status.auth_methods; + let authReady = status.auth_ready; + let authMethod = status.auth_method ?? undefined; + + // Subscription-based auth (claude-ai-login, console-login, claude-login) + // only works with the Claude Code CLI, not the ACP sidecar. Strip these + // methods from claude-acp and mark as not-ready when the current auth is + // subscription-based so the provider shows as "Not configured" and the user + // is directed to use an API key instead. + if (status.runtime_id === "claude-acp") { + authMethods = authMethods.filter( + (m) => !isClaudeTerminalAuthMethodId(m.id), + ); + if (isClaudeTerminalAuthMethodId(authMethod)) { + authReady = false; + authMethod = undefined; + } + } + return { runtimeId: status.runtime_id, binaryReady: status.binary_ready, binaryPath: status.binary_path ?? undefined, binarySource: status.binary_source, hasCustomBinaryPath: status.has_custom_binary_path ?? false, - authReady: status.auth_ready, - authMethod: status.auth_method ?? undefined, - authMethods: status.auth_methods, + authReady, + authMethod, + authMethods, hasGatewayConfig: status.has_gateway_config ?? false, hasGatewayUrl: status.has_gateway_url ?? false, onboardingRequired: status.onboarding_required, diff --git a/apps/desktop/src/features/ai/chatPaneMovement.test.ts b/apps/desktop/src/features/ai/chatPaneMovement.test.ts index c0aae02d..d1ce1998 100644 --- a/apps/desktop/src/features/ai/chatPaneMovement.test.ts +++ b/apps/desktop/src/features/ai/chatPaneMovement.test.ts @@ -11,13 +11,16 @@ import { useVaultStore } from "../../app/store/vaultStore"; import { createDeferred, setEditorTabs } from "../../test/test-utils"; import { createNewChatInWorkspace, + ensureWorkspaceChatSession, openOrMoveChatSessionAtDropTarget, } from "./chatPaneMovement"; import { resetChatStore, useChatStore } from "./store/chatStore"; import { resetChatTabsStore } from "./store/chatTabsStore"; import type { AIChatSession, AIRuntimeSetupStatus } from "./types"; +import { CLAUDE_TERMINAL_RUNTIME_ID } from "./utils/runtimeMetadata"; const invokeMock = vi.mocked(invoke); +const AI_PREFS_KEY = "neverwrite.ai.preferences"; const runtimeDescriptor = { runtime: { @@ -93,6 +96,48 @@ const claudeRuntimeDescriptor = { ], }; +const claudeTerminalRuntimeDescriptor = { + runtime: { + id: CLAUDE_TERMINAL_RUNTIME_ID, + name: "Claude Code", + description: "Claude Code terminal pseudo-runtime", + capabilities: ["create_session"], + }, + models: [ + { + id: "claude-code-terminal-model", + runtimeId: CLAUDE_TERMINAL_RUNTIME_ID, + name: "Claude Code", + description: "Terminal runtime model placeholder", + }, + ], + modes: [ + { + id: "default", + runtimeId: CLAUDE_TERMINAL_RUNTIME_ID, + name: "Default", + description: "Default mode", + disabled: false, + }, + ], + configOptions: [ + { + id: "model", + runtimeId: CLAUDE_TERMINAL_RUNTIME_ID, + category: "model" as const, + label: "Model", + type: "select" as const, + value: "claude-code-terminal-model", + options: [ + { + value: "claude-code-terminal-model", + label: "Claude Code", + }, + ], + }, + ], +}; + const setupStatusPayload = { runtime_id: "codex-acp", binary_ready: true, @@ -198,6 +243,7 @@ function seedChatSessions(...sessions: AIChatSession[]) { describe("createNewChatInWorkspace", () => { beforeEach(() => { + localStorage.removeItem(AI_PREFS_KEY); resetChatStore(); resetChatTabsStore(); setEditorTabs([], null); @@ -211,6 +257,7 @@ describe("createNewChatInWorkspace", () => { afterEach(() => { vi.restoreAllMocks(); + localStorage.removeItem(AI_PREFS_KEY); resetChatStore(); resetChatTabsStore(); setEditorTabs([], null); @@ -405,6 +452,129 @@ describe("createNewChatInWorkspace", () => { ).toBeDefined(); }); }); + + it("does not create an ACP chat when the selected runtime is Claude Code terminal", async () => { + useChatStore.setState((state) => ({ + ...state, + runtimes: [runtimeDescriptor, claudeTerminalRuntimeDescriptor], + selectedRuntimeId: CLAUDE_TERMINAL_RUNTIME_ID, + setupStatusByRuntimeId: { + "codex-acp": readySetupStatusState, + [CLAUDE_TERMINAL_RUNTIME_ID]: { + ...readySetupStatusState, + runtimeId: CLAUDE_TERMINAL_RUNTIME_ID, + authMethod: "claude-code", + }, + }, + })); + const newSession = vi.spyOn(useChatStore.getState(), "newSession"); + const upsertSession = vi.spyOn(useChatStore.getState(), "upsertSession"); + const openChat = vi.spyOn(useEditorStore.getState(), "openChat"); + + await expect(createNewChatInWorkspace()).resolves.toBeNull(); + + expect(newSession).not.toHaveBeenCalled(); + expect(upsertSession).not.toHaveBeenCalled(); + expect(openChat).not.toHaveBeenCalled(); + expect(selectEditorWorkspaceTabs(useEditorStore.getState())).toEqual([]); + }); + + it("uses the selected native runtime over a stale Claude Code terminal preference", async () => { + localStorage.setItem( + AI_PREFS_KEY, + JSON.stringify({ defaultRuntimeId: CLAUDE_TERMINAL_RUNTIME_ID }), + ); + useChatStore.setState((state) => ({ + ...state, + runtimes: [runtimeDescriptor, claudeTerminalRuntimeDescriptor], + selectedRuntimeId: "codex-acp", + setupStatusByRuntimeId: { + "codex-acp": readySetupStatusState, + [CLAUDE_TERMINAL_RUNTIME_ID]: { + ...readySetupStatusState, + runtimeId: CLAUDE_TERMINAL_RUNTIME_ID, + authMethod: "claude-code", + }, + }, + })); + const newSession = vi.spyOn(useChatStore.getState(), "newSession"); + const upsertSession = vi.spyOn(useChatStore.getState(), "upsertSession"); + const openChat = vi.spyOn(useEditorStore.getState(), "openChat"); + + const sessionId = await createNewChatInWorkspace(); + + expect(sessionId).toMatch(/^pending:/); + expect(newSession).toHaveBeenCalledWith("codex-acp", sessionId); + expect(upsertSession).toHaveBeenCalled(); + expect(openChat).toHaveBeenCalled(); + }); + + it("does not create a pending chat when Claude Code terminal is the only ready runtime", async () => { + localStorage.setItem( + AI_PREFS_KEY, + JSON.stringify({ defaultRuntimeId: "missing-runtime" }), + ); + useChatStore.setState((state) => ({ + ...state, + runtimes: [claudeTerminalRuntimeDescriptor], + selectedRuntimeId: null, + setupStatusByRuntimeId: { + [CLAUDE_TERMINAL_RUNTIME_ID]: { + ...readySetupStatusState, + runtimeId: CLAUDE_TERMINAL_RUNTIME_ID, + authMethod: "claude-code", + }, + }, + })); + const newSession = vi.spyOn(useChatStore.getState(), "newSession"); + const upsertSession = vi.spyOn(useChatStore.getState(), "upsertSession"); + const openChat = vi.spyOn(useEditorStore.getState(), "openChat"); + + await expect(createNewChatInWorkspace()).resolves.toBeNull(); + + expect(newSession).not.toHaveBeenCalled(); + expect(upsertSession).not.toHaveBeenCalled(); + expect(openChat).not.toHaveBeenCalled(); + }); + + it("does not create an ACP chat when the active session uses Claude Code terminal", async () => { + const terminalSession = createStoredSession( + "claude-terminal-session", + "Claude Code terminal", + CLAUDE_TERMINAL_RUNTIME_ID, + ); + seedChatSessions(terminalSession); + useChatStore.setState((state) => ({ + ...state, + activeSessionId: terminalSession.sessionId, + lastFocusedSessionId: terminalSession.sessionId, + })); + const newSession = vi.spyOn(useChatStore.getState(), "newSession"); + const upsertSession = vi.spyOn(useChatStore.getState(), "upsertSession"); + const openChat = vi.spyOn(useEditorStore.getState(), "openChat"); + + await expect(createNewChatInWorkspace()).resolves.toBeNull(); + + expect(newSession).not.toHaveBeenCalled(); + expect(upsertSession).not.toHaveBeenCalled(); + expect(openChat).not.toHaveBeenCalled(); + }); + + it("does not create an ACP chat when ensure is explicitly asked for Claude Code terminal", async () => { + const newSession = vi.spyOn(useChatStore.getState(), "newSession"); + const upsertSession = vi.spyOn(useChatStore.getState(), "upsertSession"); + const openChat = vi.spyOn(useEditorStore.getState(), "openChat"); + + await expect( + ensureWorkspaceChatSession({ + runtimeId: CLAUDE_TERMINAL_RUNTIME_ID, + }), + ).resolves.toBeNull(); + + expect(newSession).not.toHaveBeenCalled(); + expect(upsertSession).not.toHaveBeenCalled(); + expect(openChat).not.toHaveBeenCalled(); + }); }); describe("openOrMoveChatSessionAtDropTarget", () => { diff --git a/apps/desktop/src/features/ai/chatPaneMovement.ts b/apps/desktop/src/features/ai/chatPaneMovement.ts index 78bf28a8..12408977 100644 --- a/apps/desktop/src/features/ai/chatPaneMovement.ts +++ b/apps/desktop/src/features/ai/chatPaneMovement.ts @@ -9,6 +9,7 @@ import { getSessionTitle } from "./sessionPresentation"; import { useChatStore } from "./store/chatStore"; import { useChatTabsStore } from "./store/chatTabsStore"; import { getPreferredWorkspaceChatSessionIdForSession } from "./chatWorkspaceSelectors"; +import { CLAUDE_TERMINAL_RUNTIME_ID } from "./utils/runtimeMetadata"; import type { AIChatSession, AIRuntimeDescriptor, @@ -39,8 +40,19 @@ function isRuntimeSetupReady(setupStatus?: AIRuntimeSetupStatus | null) { return setupStatus?.authReady === true && !setupStatus.onboardingRequired; } +function isClaudeTerminalRuntimeId(runtimeId?: string | null) { + return runtimeId === CLAUDE_TERMINAL_RUNTIME_ID; +} + function resolvePendingRuntime(runtimeId?: string) { const state = useChatStore.getState(); + if (isClaudeTerminalRuntimeId(runtimeId)) { + return null; + } + if (!runtimeId && isClaudeTerminalRuntimeId(state.selectedRuntimeId)) { + return null; + } + const getRuntime = (candidateRuntimeId?: string | null) => candidateRuntimeId ? (state.runtimes.find( @@ -48,25 +60,36 @@ function resolvePendingRuntime(runtimeId?: string) { descriptor.runtime.id === candidateRuntimeId, ) ?? null) : null; - const firstReadyRuntime = state.runtimes.find((descriptor) => - isRuntimeSetupReady( - state.setupStatusByRuntimeId[descriptor.runtime.id], - ), + const firstReadyRuntime = state.runtimes.find( + (descriptor) => + !isClaudeTerminalRuntimeId(descriptor.runtime.id) && + isRuntimeSetupReady( + state.setupStatusByRuntimeId[descriptor.runtime.id], + ), ); const selectedRuntime = getRuntime(state.selectedRuntimeId); const readySelectedRuntimeId = selectedRuntime && + !isClaudeTerminalRuntimeId(selectedRuntime.runtime.id) && isRuntimeSetupReady( state.setupStatusByRuntimeId[selectedRuntime.runtime.id], ) ? selectedRuntime.runtime.id : null; + const selectedRuntimeId = !isClaudeTerminalRuntimeId( + state.selectedRuntimeId, + ) + ? state.selectedRuntimeId + : null; + const firstConfiguredRuntimeId = state.runtimes.find( + (descriptor) => !isClaudeTerminalRuntimeId(descriptor.runtime.id), + )?.runtime.id; const resolvedRuntimeId = runtimeId ?? readySelectedRuntimeId ?? firstReadyRuntime?.runtime.id ?? - state.selectedRuntimeId ?? - state.runtimes[0]?.runtime.id; + selectedRuntimeId ?? + firstConfiguredRuntimeId; if (!resolvedRuntimeId) { return null; } @@ -82,6 +105,26 @@ function resolvePendingRuntime(runtimeId?: string) { }; } +function resolveStoreNewSessionRuntimeId(runtimeId?: string | null) { + if (runtimeId) { + return runtimeId; + } + + const state = useChatStore.getState(); + const firstReadyRuntimeId = state.runtimes.find((descriptor) => + isRuntimeSetupReady( + state.setupStatusByRuntimeId[descriptor.runtime.id], + ), + )?.runtime.id; + + return ( + state.selectedRuntimeId ?? + firstReadyRuntimeId ?? + state.runtimes[0]?.runtime.id ?? + null + ); +} + function getSessionRuntimeId(sessionId?: string | null) { if (!sessionId) { return null; @@ -94,6 +137,12 @@ function resolveWorkspaceNewChatRuntimeId(runtimeId?: string) { return runtimeId; } + const chatState = useChatStore.getState(); + const defaultRuntimeId = chatState.getDefaultNewChatRuntimeId(); + if (isClaudeTerminalRuntimeId(defaultRuntimeId)) { + return defaultRuntimeId; + } + const focusedTab = selectFocusedEditorTab(useEditorStore.getState()); const focusedChatRuntimeId = focusedTab && isChatTab(focusedTab) @@ -103,7 +152,6 @@ function resolveWorkspaceNewChatRuntimeId(runtimeId?: string) { return focusedChatRuntimeId; } - const chatState = useChatStore.getState(); return ( getSessionRuntimeId(chatState.lastFocusedSessionId) ?? getSessionRuntimeId(chatState.activeSessionId) ?? @@ -279,8 +327,19 @@ export async function createNewChatInWorkspace( options?: OpenChatInWorkspaceOptions, ) { const resolvedRuntimeId = resolveWorkspaceNewChatRuntimeId(runtimeId); + // The claude-terminal pseudo-runtime has no ACP backend — callers that + // detect it should route to openClaudeCodeTerminalWithContext instead. + if (resolvedRuntimeId === CLAUDE_TERMINAL_RUNTIME_ID) return null; const pendingSession = createPendingWorkspaceSession(resolvedRuntimeId); if (!pendingSession) { + if ( + isClaudeTerminalRuntimeId( + resolveStoreNewSessionRuntimeId(resolvedRuntimeId), + ) + ) { + return null; + } + const createdSessionId = await useChatStore .getState() .newSession(resolvedRuntimeId); @@ -306,15 +365,25 @@ export async function createNewChatInWorkspace( export async function ensureWorkspaceChatSession( options?: OpenChatInWorkspaceOptions & { runtimeId?: string }, ) { + if (isClaudeTerminalRuntimeId(options?.runtimeId)) { + return null; + } + const visibleSessionId = getPreferredWorkspaceChatSessionIdForSession( useChatStore.getState().lastFocusedSessionId, ); if (visibleSessionId) { + if (isClaudeTerminalRuntimeId(getSessionRuntimeId(visibleSessionId))) { + return null; + } return visibleSessionId; } const activeSessionId = useChatStore.getState().activeSessionId; if (activeSessionId) { + if (isClaudeTerminalRuntimeId(getSessionRuntimeId(activeSessionId))) { + return null; + } return openChatSessionInWorkspace(activeSessionId, options); } diff --git a/apps/desktop/src/features/ai/components/AIAuthTerminalModal.tsx b/apps/desktop/src/features/ai/components/AIAuthTerminalModal.tsx index b3674f4c..65e26f09 100644 --- a/apps/desktop/src/features/ai/components/AIAuthTerminalModal.tsx +++ b/apps/desktop/src/features/ai/components/AIAuthTerminalModal.tsx @@ -10,12 +10,12 @@ import { listenToAiAuthTerminalStarted, } from "../api"; import type { AIAuthTerminalSessionSnapshot } from "../types"; -import { appendTerminalRawOutput } from "../../devtools/terminal/terminalRawOutput"; -import { TerminalViewport } from "../../devtools/terminal/TerminalViewport"; +import { appendTerminalRawOutput } from "../../terminal/terminalRawOutput"; +import { TerminalViewport } from "../../terminal/TerminalViewport"; import { EMPTY_TERMINAL_SNAPSHOT, type TerminalSessionView, -} from "../../devtools/terminal/terminalTypes"; +} from "../../terminal/terminalTypes"; import { APP_BRAND_NAME } from "../../../app/utils/branding"; interface AIAuthTerminalModalProps { diff --git a/apps/desktop/src/features/ai/store/chatStore.test.ts b/apps/desktop/src/features/ai/store/chatStore.test.ts index ef9d51af..fa95abce 100644 --- a/apps/desktop/src/features/ai/store/chatStore.test.ts +++ b/apps/desktop/src/features/ai/store/chatStore.test.ts @@ -44,6 +44,8 @@ import { resetExternalReloadBaselinesForTests, } from "../../editor/externalReloadBaselineCache"; import { useChatRowUiStore } from "./chatRowUiStore"; +import { resetClaudeCodeInstalledCacheForTests } from "../../terminal/claudeCodeTerminal"; +import { CLAUDE_TERMINAL_RUNTIME_ID } from "../utils/runtimeMetadata"; const invokeMock = vi.mocked(invoke); const AI_PREFS_KEY = "neverwrite.ai.preferences"; @@ -346,6 +348,10 @@ function getMockTrackedFilePatchInputs( } async function defaultInvokeImplementation(command: string, args?: unknown) { + if (command === "devtools_check_binary") { + return { found: false }; + } + if (command === "ai_list_runtimes") { return runtimePayload; } @@ -518,6 +524,7 @@ describe("chatStore", () => { beforeEach(() => { disposeChatStoreRuntime(); initializeChatStoreRuntime(); + resetClaudeCodeInstalledCacheForTests(); resetChatStore(); resetChatTabsStore(); resetExternalReloadBaselinesForTests(); @@ -827,13 +834,124 @@ describe("chatStore", () => { expect(state.runtimeConnectionByRuntimeId["codex-acp"]?.status).toBe( "ready", ); - expect(state.runtimes).toHaveLength(1); + // One backend runtime + the claude-code-terminal pseudo-runtime. + expect(state.runtimes).toHaveLength(2); expect(state.activeSessionId).toBe("codex-session-1"); expect(state.sessionsById["codex-session-1"]?.runtimeId).toBe( "codex-acp", ); }); + it("keeps Claude Code available without making it the implicit default", async () => { + localStorage.removeItem(AI_PREFS_KEY); + invokeMock.mockImplementation(async (command, args) => { + if (command === "devtools_check_binary") { + return { found: true }; + } + + if (command === "ai_create_session") { + const runtimeId = + typeof args === "object" && + args !== null && + "input" in args + ? (args.input as { runtime_id?: string }).runtime_id + : null; + + expect(runtimeId).toBe("codex-acp"); + return sessionPayload; + } + + return defaultInvokeImplementation(command, args); + }); + + await useChatStore.getState().initialize(); + + const state = useChatStore.getState(); + expect(state.runtimes.map((runtime) => runtime.runtime.id)).toContain( + CLAUDE_TERMINAL_RUNTIME_ID, + ); + expect( + state.setupStatusByRuntimeId[CLAUDE_TERMINAL_RUNTIME_ID], + ).toMatchObject({ + authReady: true, + binaryReady: true, + }); + expect(state.selectedRuntimeId).toBe("codex-acp"); + expect(state.activeSessionId).toBe("codex-session-1"); + expect(state.getDefaultNewChatRuntimeId()).toBe("codex-acp"); + expect( + JSON.parse(localStorage.getItem(AI_PREFS_KEY) ?? "{}") + .defaultRuntimeId, + ).toBeUndefined(); + }); + + it("respects an explicit Claude Code default preference", async () => { + localStorage.setItem( + AI_PREFS_KEY, + JSON.stringify({ + defaultRuntimeId: CLAUDE_TERMINAL_RUNTIME_ID, + }), + ); + invokeMock.mockImplementation(async (command, args) => { + if (command === "devtools_check_binary") { + return { found: true }; + } + + return defaultInvokeImplementation(command, args); + }); + + await useChatStore + .getState() + .initialize({ createDefaultSession: false }); + + const state = useChatStore.getState(); + expect(state.selectedRuntimeId).toBe(CLAUDE_TERMINAL_RUNTIME_ID); + expect(state.getDefaultNewChatRuntimeId()).toBe( + CLAUDE_TERMINAL_RUNTIME_ID, + ); + expect( + JSON.parse(localStorage.getItem(AI_PREFS_KEY) ?? "{}") + .defaultRuntimeId, + ).toBe(CLAUDE_TERMINAL_RUNTIME_ID); + }); + + it("falls back when a persisted default runtime is no longer available", async () => { + localStorage.setItem( + AI_PREFS_KEY, + JSON.stringify({ defaultRuntimeId: "missing-runtime" }), + ); + + await useChatStore.getState().initialize(); + + const state = useChatStore.getState(); + expect(state.selectedRuntimeId).toBe("codex-acp"); + expect(state.getDefaultNewChatRuntimeId()).toBe("codex-acp"); + expect(state.sessionsById["codex-session-1"]?.runtimeId).toBe( + "codex-acp", + ); + }); + + it("falls back when a persisted Claude Code default is no longer ready", async () => { + localStorage.setItem( + AI_PREFS_KEY, + JSON.stringify({ + defaultRuntimeId: CLAUDE_TERMINAL_RUNTIME_ID, + }), + ); + + await useChatStore.getState().initialize(); + + const state = useChatStore.getState(); + expect( + state.setupStatusByRuntimeId[CLAUDE_TERMINAL_RUNTIME_ID], + ).toMatchObject({ + authReady: false, + binaryReady: false, + }); + expect(state.selectedRuntimeId).toBe("codex-acp"); + expect(state.getDefaultNewChatRuntimeId()).toBe("codex-acp"); + }); + it("selects the first configured runtime on fresh boot", async () => { const claudeRuntimePayload = { runtime: { @@ -864,7 +982,7 @@ describe("chatStore", () => { return { ...readySetupStatus, runtime_id: "claude-acp", - auth_method: "claude-login", + auth_method: "anthropic-api-key", }; } diff --git a/apps/desktop/src/features/ai/store/chatStore.ts b/apps/desktop/src/features/ai/store/chatStore.ts index 7bd60aaf..cb0ef775 100644 --- a/apps/desktop/src/features/ai/store/chatStore.ts +++ b/apps/desktop/src/features/ai/store/chatStore.ts @@ -149,6 +149,12 @@ import { subscribeSafeStorage, } from "../../../app/utils/safeStorage"; import { logDebug, logError, logWarn } from "../../../app/utils/runtimeLog"; +import { CLAUDE_TERMINAL_RUNTIME_ID } from "../utils/runtimeMetadata"; +import { + CLAUDE_TERMINAL_DESCRIPTOR, + buildClaudeTerminalSetupStatus, +} from "../utils/claudeTerminalRuntime"; +import { checkClaudeCodeInstalled } from "../../terminal/claudeCodeTerminal"; const AI_PREFS_KEY = "neverwrite.ai.preferences"; const AI_RUNTIME_CACHE_KEY = "neverwrite.ai.runtime-catalog"; @@ -197,6 +203,7 @@ interface AiPreferences { editDiffZoom?: number; historyRetentionDays?: number; screenshotRetentionSeconds?: number; + defaultRuntimeId?: string; } interface NormalizedAiPreferences { @@ -1179,6 +1186,7 @@ interface ChatStore { ) => Promise; syncAutoContextForVault: (vaultPath: string | null) => void; setSelectedRuntime: (runtimeId: string | null) => void; + getDefaultNewChatRuntimeId: () => string | null; refreshSetupStatus: (runtimeId?: string) => Promise; saveSetup: (input: { runtimeId?: string; @@ -5036,6 +5044,10 @@ function isRuntimeSetupReady(setupStatus?: AIRuntimeSetupStatus | null) { return setupStatus?.authReady === true && !setupStatus.onboardingRequired; } +function isClaudeTerminalRuntimeId(runtimeId?: string | null) { + return runtimeId === CLAUDE_TERMINAL_RUNTIME_ID; +} + function getDefaultRuntimeId( runtimes: AIRuntimeDescriptor[], setupStatusByRuntimeId?: Record, @@ -5051,6 +5063,34 @@ function getDefaultRuntimeId( return readyRuntime?.runtime.id ?? runtimes[0]?.runtime.id ?? null; } +function getImplicitDefaultAcpRuntimeId( + runtimes: AIRuntimeDescriptor[], + setupStatusByRuntimeId?: Record, +) { + return getDefaultRuntimeId( + runtimes.filter( + (runtime) => !isClaudeTerminalRuntimeId(runtime.runtime.id), + ), + setupStatusByRuntimeId, + ); +} + +function getSelectableDefaultRuntimeId( + runtimeId: string | null | undefined, + runtimes: AIRuntimeDescriptor[], + setupStatusByRuntimeId?: Record, +) { + if (!runtimeId) return null; + if (!runtimes.some((runtime) => runtime.runtime.id === runtimeId)) { + return null; + } + + if (!setupStatusByRuntimeId) return runtimeId; + return isRuntimeSetupReady(setupStatusByRuntimeId[runtimeId]) + ? runtimeId + : null; +} + function runtimeSupportsCapability( runtimes: AIRuntimeDescriptor[], runtimeId: string, @@ -6394,6 +6434,27 @@ export const useChatStore = create((set, get) => { setSelectedRuntime: (runtimeId) => { set({ selectedRuntimeId: runtimeId }); + saveAiPreferences({ defaultRuntimeId: runtimeId ?? undefined }); + }, + + getDefaultNewChatRuntimeId: () => { + const state = get(); + return ( + getSelectableDefaultRuntimeId( + state.selectedRuntimeId, + state.runtimes, + state.setupStatusByRuntimeId, + ) ?? + getSelectableDefaultRuntimeId( + loadAiPreferences().defaultRuntimeId, + state.runtimes, + state.setupStatusByRuntimeId, + ) ?? + getImplicitDefaultAcpRuntimeId( + state.runtimes, + state.setupStatusByRuntimeId, + ) + ); }, initialize: async (options) => { @@ -6407,17 +6468,17 @@ export const useChatStore = create((set, get) => { set({ isInitializing: true }); try { - const runtimes = hydrateRuntimesFromCache( + const backendRuntimes = hydrateRuntimesFromCache( await aiListRuntimes(), ); - const runtimeIds = runtimes.map( + const runtimeIds = backendRuntimes.map( (descriptor) => descriptor.runtime.id, ); const setupResults = await Promise.allSettled( runtimeIds.map((runtimeId) => aiGetSetupStatus(runtimeId)), ); const runtimeConnectionByRuntimeId = buildRuntimeConnectionMap( - runtimes, + backendRuntimes, get().runtimeConnectionByRuntimeId, ); const setupStatuses: AIRuntimeSetupStatus[] = []; @@ -6438,11 +6499,32 @@ export const useChatStore = create((set, get) => { ), }; }); - const setupStatusByRuntimeId = - buildSetupStatusMap(setupStatuses); + const claudeFound = await checkClaudeCodeInstalled(); + const runtimes = [ + ...backendRuntimes, + CLAUDE_TERMINAL_DESCRIPTOR, + ]; + const setupStatusByRuntimeId = { + ...buildSetupStatusMap(setupStatuses), + [CLAUDE_TERMINAL_RUNTIME_ID]: + buildClaudeTerminalSetupStatus(claudeFound), + }; + // Persisted explicit selection wins only while that runtime is + // still available and ready. Otherwise stay on ACP runtimes; + // Claude Code remains available but is not promoted to the + // default just because the binary exists in PATH. + const persistedRuntimeId = + getSelectableDefaultRuntimeId( + loadAiPreferences().defaultRuntimeId, + runtimes, + setupStatusByRuntimeId, + ); const defaultRuntimeId = - get().selectedRuntimeId ?? - getDefaultRuntimeId(runtimes, setupStatusByRuntimeId); + persistedRuntimeId ?? + getImplicitDefaultAcpRuntimeId( + runtimes, + setupStatusByRuntimeId, + ); set({ runtimes, @@ -10229,7 +10311,11 @@ export const useChatStore = create((set, get) => { const runtimes = get().runtimes; const nextRuntimeId = runtimeId ?? - get().selectedRuntimeId ?? + getSelectableDefaultRuntimeId( + get().selectedRuntimeId, + runtimes, + get().setupStatusByRuntimeId, + ) ?? getDefaultRuntimeId(runtimes, get().setupStatusByRuntimeId); if (!nextRuntimeId) return null; diff --git a/apps/desktop/src/features/ai/utils/claudeTerminalRuntime.ts b/apps/desktop/src/features/ai/utils/claudeTerminalRuntime.ts new file mode 100644 index 00000000..7e7a5928 --- /dev/null +++ b/apps/desktop/src/features/ai/utils/claudeTerminalRuntime.ts @@ -0,0 +1,30 @@ +import type { AIRuntimeDescriptor, AIRuntimeSetupStatus } from "../types"; +import { CLAUDE_TERMINAL_RUNTIME_ID } from "./runtimeMetadata"; + +export const CLAUDE_TERMINAL_DESCRIPTOR: AIRuntimeDescriptor = { + runtime: { + id: CLAUDE_TERMINAL_RUNTIME_ID, + name: "Claude Code", + description: "Claude Code CLI running in an integrated terminal tab.", + capabilities: ["attachments"], + }, + models: [], + modes: [], + configOptions: [], +}; + +export function buildClaudeTerminalSetupStatus( + binaryFound: boolean, +): AIRuntimeSetupStatus { + return { + runtimeId: CLAUDE_TERMINAL_RUNTIME_ID, + binaryReady: binaryFound, + binarySource: "env", + authReady: binaryFound, + authMethods: [], + onboardingRequired: false, + message: binaryFound + ? undefined + : "claude not found in PATH. Install via: npm install -g @anthropic-ai/claude-code", + }; +} diff --git a/apps/desktop/src/features/ai/utils/runtimeMetadata.ts b/apps/desktop/src/features/ai/utils/runtimeMetadata.ts index af64b65e..b51705bb 100644 --- a/apps/desktop/src/features/ai/utils/runtimeMetadata.ts +++ b/apps/desktop/src/features/ai/utils/runtimeMetadata.ts @@ -1,5 +1,7 @@ import type { AIRuntimeDescriptor } from "../types"; +export const CLAUDE_TERMINAL_RUNTIME_ID = "claude-code-terminal"; + interface RuntimeMetadata { id: string; name: string; @@ -71,13 +73,14 @@ const RUNTIME_METADATA: RuntimeMetadata[] = [ }, ]; -export const PROVIDER_CATALOG = RUNTIME_METADATA.map( - ({ id, name, company }) => ({ - id, - name, - company, - }), -); +export const PROVIDER_CATALOG = [ + ...RUNTIME_METADATA.map(({ id, name, company }) => ({ id, name, company })), + { + id: CLAUDE_TERMINAL_RUNTIME_ID, + name: "Claude Code", + company: "Anthropic", + }, +]; export function getRuntimeDisplayName( runtimeId?: string | null, @@ -92,6 +95,8 @@ export function getRuntimeDisplayName( return "Assistant"; } + if (runtimeId === CLAUDE_TERMINAL_RUNTIME_ID) return "Claude Code"; + return ( RUNTIME_METADATA.find((runtime) => runtime.id === runtimeId)?.name ?? runtimeId diff --git a/apps/desktop/src/features/devtools/terminal/terminalTheme.ts b/apps/desktop/src/features/devtools/terminal/terminalTheme.ts deleted file mode 100644 index 34d861a5..00000000 --- a/apps/desktop/src/features/devtools/terminal/terminalTheme.ts +++ /dev/null @@ -1,32 +0,0 @@ -export interface TerminalTheme { - background: string; - panelBackground: string; - border: string; - text: string; - mutedText: string; - accent: string; - cursor: string; - fontFamily: string; - fontSize: number; - lineHeight: number; -} - -export function getTerminalTheme(element: HTMLElement | null): TerminalTheme { - const computed = window.getComputedStyle( - element ?? document.documentElement, - ); - - return { - background: computed.getPropertyValue("--bg-primary").trim(), - panelBackground: computed.getPropertyValue("--bg-secondary").trim(), - border: computed.getPropertyValue("--border").trim(), - text: computed.getPropertyValue("--text-primary").trim(), - mutedText: computed.getPropertyValue("--text-secondary").trim(), - accent: computed.getPropertyValue("--accent").trim(), - cursor: computed.getPropertyValue("--accent").trim(), - fontFamily: - '"SFMono-Regular", "Cascadia Code", "JetBrains Mono", Menlo, Monaco, Consolas, monospace', - fontSize: 13, - lineHeight: 1.4, - }; -} diff --git a/apps/desktop/src/features/editor/EditorPaneBar.test.tsx b/apps/desktop/src/features/editor/EditorPaneBar.test.tsx index f3c0f751..4fa324cf 100644 --- a/apps/desktop/src/features/editor/EditorPaneBar.test.tsx +++ b/apps/desktop/src/features/editor/EditorPaneBar.test.tsx @@ -1279,10 +1279,6 @@ describe("EditorPaneBar", () => { it("creates a workspace terminal from the pane plus-button context menu", async () => { useVaultStore.setState({ vaultPath: "/vault" }); - useSettingsStore.setState({ - developerModeEnabled: true, - developerTerminalEnabled: true, - }); renderComponent(); diff --git a/apps/desktop/src/features/editor/EditorPaneBar.tsx b/apps/desktop/src/features/editor/EditorPaneBar.tsx index 54161b03..9a8dd502 100644 --- a/apps/desktop/src/features/editor/EditorPaneBar.tsx +++ b/apps/desktop/src/features/editor/EditorPaneBar.tsx @@ -125,12 +125,6 @@ export function EditorPaneBar({ paneId, isFocused }: EditorPaneBarProps) { (state) => state.fileTreeShowExtensions, ); const tabOpenBehavior = useSettingsStore((state) => state.tabOpenBehavior); - const developerModeEnabled = useSettingsStore( - (state) => state.developerModeEnabled, - ); - const developerTerminalEnabled = useSettingsStore( - (state) => state.developerTerminalEnabled, - ); const vaultPath = useVaultStore((state) => state.vaultPath); const [tabContextMenu, setTabContextMenu] = useState setNewTabContextMenu(null)} - entries={buildNewTabContextMenuEntries({ - paneId, - developerModeEnabled, - developerTerminalEnabled, - })} + entries={buildNewTabContextMenuEntries({ paneId })} /> )} diff --git a/apps/desktop/src/features/editor/EditorPaneContent.test.tsx b/apps/desktop/src/features/editor/EditorPaneContent.test.tsx index b1d28b47..c9b0a8f9 100644 --- a/apps/desktop/src/features/editor/EditorPaneContent.test.tsx +++ b/apps/desktop/src/features/editor/EditorPaneContent.test.tsx @@ -2,7 +2,7 @@ import { act, screen } from "@testing-library/react"; import { EditorView } from "@codemirror/view"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useEditorStore } from "../../app/store/editorStore"; -import type { TerminalSessionSnapshot } from "../devtools/terminal/terminalTypes"; +import type { TerminalSessionSnapshot } from "../terminal/terminalTypes"; import { getXtermMockInstances, flushPromises, diff --git a/apps/desktop/src/features/editor/UnifiedBar.test.tsx b/apps/desktop/src/features/editor/UnifiedBar.test.tsx index 1e1dc8da..08376701 100644 --- a/apps/desktop/src/features/editor/UnifiedBar.test.tsx +++ b/apps/desktop/src/features/editor/UnifiedBar.test.tsx @@ -813,7 +813,6 @@ describe("UnifiedBar tab strip drop", () => { }, ]); setVaultEntries([]); - useSettingsStore.setState({ developerModeEnabled: false }); const { UnifiedBar } = await import("./UnifiedBar"); const { container } = renderComponent(); @@ -854,7 +853,6 @@ describe("UnifiedBar tab strip drop", () => { content: "alpha", }, ]); - useSettingsStore.setState({ developerModeEnabled: false }); const { UnifiedBar } = await import("./UnifiedBar"); const { container } = renderComponent(); @@ -962,7 +960,7 @@ describe("UnifiedBar tab strip drop", () => { } }); - it("creates a workspace terminal from the plus-button context menu in developer terminal mode", async () => { + it("creates a workspace terminal from the plus-button context menu", async () => { const user = userEvent.setup(); setEditorTabs([ { @@ -974,10 +972,6 @@ describe("UnifiedBar tab strip drop", () => { }, ]); setVaultEntries([]); - useSettingsStore.setState({ - developerModeEnabled: true, - developerTerminalEnabled: true, - }); const { UnifiedBar } = await import("./UnifiedBar"); const { container } = renderComponent(); diff --git a/apps/desktop/src/features/editor/UnifiedBar.tsx b/apps/desktop/src/features/editor/UnifiedBar.tsx index 8bbf5767..8001beaa 100644 --- a/apps/desktop/src/features/editor/UnifiedBar.tsx +++ b/apps/desktop/src/features/editor/UnifiedBar.tsx @@ -167,12 +167,6 @@ export function UnifiedBar({ windowMode }: UnifiedBarProps) { (s) => s.navigateToHistoryIndex, ); const tabOpenBehavior = useSettingsStore((s) => s.tabOpenBehavior); - const developerModeEnabled = useSettingsStore( - (s) => s.developerModeEnabled, - ); - const developerTerminalEnabled = useSettingsStore( - (s) => s.developerTerminalEnabled, - ); const fileTreeShowExtensions = useSettingsStore( (s) => s.fileTreeShowExtensions, ); @@ -1780,8 +1774,6 @@ export function UnifiedBar({ windowMode }: UnifiedBarProps) { onClose={() => setNewTabContextMenu(null)} entries={buildNewTabContextMenuEntries({ paneId: focusedPaneId ?? undefined, - developerModeEnabled, - developerTerminalEnabled, })} /> )} diff --git a/apps/desktop/src/features/editor/newTabMenuActions.test.ts b/apps/desktop/src/features/editor/newTabMenuActions.test.ts new file mode 100644 index 00000000..db90d4c6 --- /dev/null +++ b/apps/desktop/src/features/editor/newTabMenuActions.test.ts @@ -0,0 +1,109 @@ +import { waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ContextMenuEntry } from "../../components/context-menu/ContextMenu"; +import { resetChatStore, useChatStore } from "../ai/store/chatStore"; +import { CLAUDE_TERMINAL_RUNTIME_ID } from "../ai/utils/runtimeMetadata"; +import { buildNewTabContextMenuEntries } from "./newTabMenuActions"; + +type ContextMenuItem = Extract; + +const chatPaneMovementMock = vi.hoisted(() => ({ + createNewChatInWorkspace: vi.fn(async () => undefined), +})); +const claudeCodeTerminalMock = vi.hoisted(() => ({ + openClaudeCodeTerminalWithContext: vi.fn(async () => undefined), +})); + +vi.mock("../ai/chatPaneMovement", () => chatPaneMovementMock); +vi.mock("../terminal/claudeCodeTerminal", () => claudeCodeTerminalMock); + +function seedRuntimes() { + useChatStore.setState({ + runtimes: [ + { + runtime: { + id: "codex-acp", + name: "Codex ACP", + description: "", + capabilities: [], + }, + models: [], + modes: [], + configOptions: [], + }, + { + runtime: { + id: CLAUDE_TERMINAL_RUNTIME_ID, + name: "Claude Code", + description: "", + capabilities: [], + }, + models: [], + modes: [], + configOptions: [], + }, + ], + selectedRuntimeId: "codex-acp", + }); +} + +function isContextMenuItem(entry: ContextMenuEntry): entry is ContextMenuItem { + return "label" in entry; +} + +function getNewAgentChild(label: string): ContextMenuItem { + const newAgent = buildNewTabContextMenuEntries({ + paneId: "secondary", + }).find( + (entry): entry is ContextMenuItem => + isContextMenuItem(entry) && entry.label === "New Agent", + ); + const child = newAgent?.children?.find( + (entry): entry is ContextMenuItem => + isContextMenuItem(entry) && entry.label === label, + ); + expect(child).toBeDefined(); + return child!; +} + +describe("newTabMenuActions", () => { + beforeEach(() => { + resetChatStore(); + vi.clearAllMocks(); + seedRuntimes(); + }); + + it("opens Claude Code agent entries as terminal sessions in the target pane", async () => { + getNewAgentChild("Claude Code").action?.(); + + await waitFor(() => { + expect( + claudeCodeTerminalMock.openClaudeCodeTerminalWithContext, + ).toHaveBeenCalledWith(undefined, "secondary"); + }); + expect( + chatPaneMovementMock.createNewChatInWorkspace, + ).not.toHaveBeenCalled(); + expect(useChatStore.getState().selectedRuntimeId).toBe( + CLAUDE_TERMINAL_RUNTIME_ID, + ); + }); + + it("keeps ACP agent entries on the normal chat creation path", async () => { + useChatStore.setState({ + selectedRuntimeId: CLAUDE_TERMINAL_RUNTIME_ID, + }); + + getNewAgentChild("Codex").action?.(); + + await waitFor(() => { + expect( + chatPaneMovementMock.createNewChatInWorkspace, + ).toHaveBeenCalledWith("codex-acp", { paneId: "secondary" }); + }); + expect( + claudeCodeTerminalMock.openClaudeCodeTerminalWithContext, + ).not.toHaveBeenCalled(); + expect(useChatStore.getState().selectedRuntimeId).toBe("codex-acp"); + }); +}); diff --git a/apps/desktop/src/features/editor/newTabMenuActions.ts b/apps/desktop/src/features/editor/newTabMenuActions.ts index a7528c7e..d962e4d5 100644 --- a/apps/desktop/src/features/editor/newTabMenuActions.ts +++ b/apps/desktop/src/features/editor/newTabMenuActions.ts @@ -11,6 +11,8 @@ import { } from "../../app/store/editorStore"; import { createNewChatInWorkspace } from "../ai/chatPaneMovement"; import { useChatStore } from "../ai/store/chatStore"; +import { CLAUDE_TERMINAL_RUNTIME_ID } from "../ai/utils/runtimeMetadata"; +import { openClaudeCodeTerminalWithContext } from "../terminal/claudeCodeTerminal"; import { isSearchTab, SEARCH_NOTE_ID, @@ -84,13 +86,8 @@ function openGraph(paneId?: string) { export function buildNewTabContextMenuEntries(options?: { paneId?: string; - developerModeEnabled?: boolean; - developerTerminalEnabled?: boolean; }): ContextMenuEntry[] { const paneId = options?.paneId; - const developerModeEnabled = options?.developerModeEnabled ?? false; - const developerTerminalEnabled = - options?.developerTerminalEnabled ?? false; const chatState = useChatStore.getState(); const runtimes = [...chatState.runtimes]; const selectedRuntimeId = chatState.selectedRuntimeId; @@ -118,7 +115,23 @@ export function buildNewTabContextMenuEntries(options?: { ? runtimes.map((runtime) => ({ label: getRuntimeMenuLabel(runtime.runtime.name), action: () => { - void createNewChat(runtime.runtime.id, paneId); + useChatStore + .getState() + .setSelectedRuntime(runtime.runtime.id); + if ( + runtime.runtime.id === + CLAUDE_TERMINAL_RUNTIME_ID + ) { + void openClaudeCodeTerminalWithContext( + undefined, + paneId, + ); + } else { + void createNewChat( + runtime.runtime.id, + paneId, + ); + } }, })) : [ @@ -134,14 +147,10 @@ export function buildNewTabContextMenuEntries(options?: { }, ]; - if (developerModeEnabled) { - if (developerTerminalEnabled) { - entries.push({ - label: "New Terminal", - action: () => createNewTerminal(paneId), - }); - } - } + entries.push({ + label: "New Terminal", + action: () => createNewTerminal(paneId), + }); return entries; } diff --git a/apps/desktop/src/features/settings/AIProvidersSettings.tsx b/apps/desktop/src/features/settings/AIProvidersSettings.tsx index 359600ee..90724303 100644 --- a/apps/desktop/src/features/settings/AIProvidersSettings.tsx +++ b/apps/desktop/src/features/settings/AIProvidersSettings.tsx @@ -1,4 +1,5 @@ import { Fragment, useCallback, useEffect, useState } from "react"; +import { openUrl } from "@neverwrite/runtime"; import { useVaultStore } from "../../app/store/vaultStore"; import { aiGetEnvironmentDiagnostics, @@ -15,9 +16,16 @@ import { isIntegratedTerminalAuthMethod, } from "../ai/utils/authMethods"; import { + CLAUDE_TERMINAL_RUNTIME_ID, getRuntimeDisplayName, PROVIDER_CATALOG, } from "../ai/utils/runtimeMetadata"; +import { + CLAUDE_TERMINAL_DESCRIPTOR, + buildClaudeTerminalSetupStatus, +} from "../ai/utils/claudeTerminalRuntime"; +import { checkClaudeCodeInstalled } from "../terminal/claudeCodeTerminal"; +import { useChatStore } from "../ai/store/chatStore"; import { getClaudeGatewayUrlValidationMessage } from "../ai/utils/claudeGatewayUrl"; import { EMPTY_SEARCH_QUERY, @@ -827,6 +835,8 @@ export function AIProvidersSettings({ searchQuery?: SettingsSearchQuery; }) { const vaultPath = useVaultStore((s) => s.vaultPath); + const selectedRuntimeId = useChatStore((s) => s.selectedRuntimeId); + const setSelectedRuntime = useChatStore((s) => s.setSelectedRuntime); const [runtimes, setRuntimes] = useState([]); const [setupStatusMap, setSetupStatusMap] = useState< Record @@ -894,7 +904,6 @@ export function AIProvidersSettings({ try { const descriptors = await aiListRuntimes(); if (cancelled) return; - setRuntimes(descriptors); const results = await Promise.allSettled( descriptors.map((d) => aiGetSetupStatus(d.runtime.id)), @@ -916,6 +925,20 @@ export function AIProvidersSettings({ } }); + // Inject Claude Code CLI as a first-class runtime. + const claudeFound = await checkClaudeCodeInstalled(); + if (cancelled) return; + + statuses[CLAUDE_TERMINAL_RUNTIME_ID] = + buildClaudeTerminalSetupStatus(claudeFound); + + // Only include Claude Code in the INSTALLED list if the binary is + // present; otherwise it will appear in ALL with an Install button. + const allDescriptors = claudeFound + ? [...descriptors, CLAUDE_TERMINAL_DESCRIPTOR] + : descriptors; + + setRuntimes(allDescriptors); setSetupStatusMap(statuses); setErrorMap(errors); } catch { @@ -1189,8 +1212,125 @@ export function AIProvidersSettings({ /* ── Render ── */ + // Providers available to be set as default (binary/auth ready). + const selectableProviders = PROVIDER_CATALOG.filter( + (p) => setupStatusMap[p.id]?.authReady === true, + ); + const showDefaultSection = + !isLoading && + selectableProviders.length > 0 && + matchesSettingsSearch( + searchQuery, + "Default agent", + "Default", + "Agent", + "Provider", + "Claude Code", + ...selectableProviders.flatMap((p) => [p.name, p.id]), + ); + return ( <> + {/* ── Default agent ── */} + {showDefaultSection && ( + <> +
+ Default agent +
+
+
+

+ The default agent opens when you start a new chat + or use{" "} + + Add to chat + {" "} + from the file tree. Select{" "} + + Claude Code + {" "} + to route notes and files directly into a terminal + session — no API key required. +

+ + {selectedRuntimeId === CLAUDE_TERMINAL_RUNTIME_ID && ( +

+ Claude Code will open in a new terminal tab. + Attached files appear as @mentions in the + input — add your question and press Enter. +

+ )} +
+
+ + )} + {/* ── Installed ── */} {showInstalledSection ? ( <> @@ -1227,7 +1367,11 @@ export function AIProvidersSettings({ ) : ( filteredInstalledProviders.map((provider, i) => { - const isExpanded = expandedId === provider.id; + const isTerminalRuntime = + provider.id === CLAUDE_TERMINAL_RUNTIME_ID; + const isExpanded = + !isTerminalRuntime && + expandedId === provider.id; const isSaving = savingId === provider.id; const connected = provider.setupStatus?.authReady === true; @@ -1254,29 +1398,51 @@ export function AIProvidersSettings({ > {/* Header row */}
- setExpandedId((prev) => - prev === provider.id - ? null - : provider.id, - ) + role={ + isTerminalRuntime + ? undefined + : "button" + } + aria-expanded={ + isTerminalRuntime + ? undefined + : isExpanded + } + tabIndex={ + isTerminalRuntime ? -1 : 0 + } + onClick={ + isTerminalRuntime + ? undefined + : () => + setExpandedId( + (prev) => + prev === + provider.id + ? null + : provider.id, + ) + } + onKeyDown={ + isTerminalRuntime + ? undefined + : (e) => { + if ( + e.key === + "Enter" || + e.key === " " + ) { + e.preventDefault(); + setExpandedId( + (prev) => + prev === + provider.id + ? null + : provider.id, + ); + } + } } - onKeyDown={(e) => { - if ( - e.key === "Enter" || - e.key === " " - ) { - e.preventDefault(); - setExpandedId((prev) => - prev === provider.id - ? null - : provider.id, - ); - } - }} style={{ display: "flex", alignItems: "center", @@ -1284,7 +1450,9 @@ export function AIProvidersSettings({ "space-between", height: 48, padding: "0 14px", - cursor: "pointer", + cursor: isTerminalRuntime + ? "default" + : "pointer", }} >
- - {isExpanded ? "▾" : "▸"} - + {!isTerminalRuntime && ( + + {isExpanded + ? "▾" + : "▸"} + + )}
- {connected - ? "Connected" - : "Not configured"} + {isTerminalRuntime + ? "Ready" + : connected + ? "Connected" + : "Not configured"}
- {/* Expanded content */} - {isExpanded && + {/* Claude Code note */} + {isTerminalRuntime && ( +
+ Model, skip permissions, + max turns, and other Claude + Code options are in{" "} + + Settings → Terminal + + . +
+ )} + + {/* Expanded content — not shown for terminal runtime */} + {!isTerminalRuntime && + isExpanded && + provider.id === "claude-acp" && ( +
+ + Claude subscription + {" "} + authentication only + works with{" "} + + Claude Code + {" "} + in the terminal. To use + this provider, configure + an{" "} + + Anthropic API key + {" "} + below. +
+ )} + {!isTerminalRuntime && isExpanded && (provider.setupStatus ? ( { + if ( + provider.id === + CLAUDE_TERMINAL_RUNTIME_ID + ) { + void openUrl( + "https://claude.ai/code", + ); + } + }} style={{ padding: "4px 10px", borderRadius: 6, diff --git a/apps/desktop/src/features/settings/SettingsPanel.test.tsx b/apps/desktop/src/features/settings/SettingsPanel.test.tsx index 77a51151..07e77897 100644 --- a/apps/desktop/src/features/settings/SettingsPanel.test.tsx +++ b/apps/desktop/src/features/settings/SettingsPanel.test.tsx @@ -228,6 +228,22 @@ describe("SettingsPanel", () => { ]); }); + it("does not render obsolete developer toggles", () => { + renderComponent( {}} />); + + fireEvent.click(screen.getByRole("button", { name: "Developers" })); + + expect( + screen.queryByText(["Enable", "Developer", "Mode"].join(" ")), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(["Enable", "Integrated", "Terminal"].join(" ")), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/integrated terminal/i), + ).not.toBeInTheDocument(); + }); + it("renders and persists app zoom as a percentage stepper", async () => { localStorage.setItem(APP_ZOOM_STORAGE_KEY, "1.1"); @@ -566,6 +582,75 @@ describe("SettingsPanel", () => { expect(toggle).toHaveAttribute("aria-checked", "false"); }); + it("renders and persists terminal and Claude Code settings", async () => { + mockInvoke().mockImplementation(async (command) => { + if (command === "devtools_check_binary") { + return { found: true }; + } + return undefined; + }); + + renderComponent( {}} />); + + fireEvent.click(screen.getByRole("button", { name: "Terminal" })); + + fireEvent.change( + screen.getByPlaceholderText("e.g. FiraCode Nerd Font"), + { + target: { value: "FiraCode Nerd Font" }, + }, + ); + expect(useSettingsStore.getState().terminalFontFamily).toBe( + "FiraCode Nerd Font", + ); + + const fullscreenRow = + screen.getByText("Fullscreen rendering (experimental)") + .parentElement?.parentElement; + expect(fullscreenRow).not.toBeNull(); + fireEvent.click(within(fullscreenRow as HTMLElement).getByRole("switch")); + expect(useSettingsStore.getState().claudeCodeOptimized).toBe(true); + + expect(await screen.findByText("Skip permissions")).toBeInTheDocument(); + const skipPermissionsRow = + screen.getByText("Skip permissions").parentElement?.parentElement; + expect(skipPermissionsRow).not.toBeNull(); + fireEvent.click( + within(skipPermissionsRow as HTMLElement).getByRole("switch"), + ); + expect(useSettingsStore.getState().claudeCodeSkipPermissions).toBe( + true, + ); + + const modelRow = screen.getByText("Model").parentElement?.parentElement; + expect(modelRow).not.toBeNull(); + fireEvent.change(within(modelRow as HTMLElement).getByRole("combobox"), { + target: { value: "claude-sonnet-4-6" }, + }); + expect(useSettingsStore.getState().claudeCodeModel).toBe( + "claude-sonnet-4-6", + ); + + const continueRow = + screen.getByText("Continue last session").parentElement + ?.parentElement; + expect(continueRow).not.toBeNull(); + fireEvent.click(within(continueRow as HTMLElement).getByRole("switch")); + expect(useSettingsStore.getState().claudeCodeContinueSession).toBe( + true, + ); + + const maxTurnsRow = + screen.getByText("Max turns").parentElement?.parentElement; + expect(maxTurnsRow).not.toBeNull(); + const maxTurnsInput = + within(maxTurnsRow as HTMLElement).getByDisplayValue("0"); + fireEvent.focus(maxTurnsInput); + fireEvent.change(maxTurnsInput, { target: { value: "12" } }); + fireEvent.keyDown(maxTurnsInput, { key: "Enter" }); + expect(useSettingsStore.getState().claudeCodeMaxTurns).toBe(12); + }); + it("checks updater metadata manually without starting an install", async () => { mockInvoke().mockImplementation(async (command) => { if (command === "get_app_update_configuration") { diff --git a/apps/desktop/src/features/settings/SettingsPanel.tsx b/apps/desktop/src/features/settings/SettingsPanel.tsx index ddcc2ebe..32a1df3b 100644 --- a/apps/desktop/src/features/settings/SettingsPanel.tsx +++ b/apps/desktop/src/features/settings/SettingsPanel.tsx @@ -39,6 +39,7 @@ import { SETTINGS_OPEN_SECTION_EVENT } from "../../app/detachedWindows"; import { getDesktopPlatform } from "../../app/utils/platform"; import { readSearchParam } from "../../app/utils/safeBrowser"; import { subscribeSafeStorage } from "../../app/utils/safeStorage"; +import { checkClaudeCodeInstalled } from "../terminal/claudeCodeTerminal"; import { APP_BRAND_NAME } from "../../app/utils/branding"; import { APP_ZOOM_STEP, @@ -3045,35 +3046,260 @@ function resolveStatusDescription({ } } -function DevelopersSettings({ +const CLAUDE_CODE_MODEL_OPTIONS = [ + { value: "", label: "Default (Claude Code decides)" }, + { value: "claude-opus-4-7", label: "Opus 4.7 — most capable" }, + { value: "claude-sonnet-4-6", label: "Sonnet 4.6 — balanced" }, + { value: "claude-haiku-4-5", label: "Haiku 4.5 — fast" }, +] as const; + +function TerminalSettings({ searchQuery, }: { searchQuery: SettingsSearchQuery; }) { const { - developerModeEnabled, - developerTerminalEnabled, - lineWrapping, - fileTreeContentMode, - fileTreeShowExtensions, - fileTreeExtensionFilter, + terminalFontFamily, + terminalFontSize, + claudeCodeOptimized, + claudeCodeSkipPermissions, + claudeCodeModel, + claudeCodeContinueSession, + claudeCodeMaxTurns, setSetting, } = useSettingsStore(); - const showDeveloperMode = sectionHasSettingsSearchMatches( + + const [claudeCodeReady, setClaudeCodeReady] = useState(false); + useEffect(() => { + void checkClaudeCodeInstalled().then(setClaudeCodeReady); + }, []); + + const showFont = sectionHasSettingsSearchMatches(searchQuery, "Font", [ + [ + "Font family", + "Monospace font used in the terminal. Must be installed on this system. Nerd Fonts are supported.", + ], + ["Font size", "Terminal text size in pixels."], + ]); + const showShell = sectionHasSettingsSearchMatches( searchQuery, - "Developer Mode", + "Shell Environment", [ [ - "Enable Developer Mode", - "Show experimental developer-facing surfaces such as the integrated terminal.", + "Fullscreen rendering", + "Sets CLAUDE_CODE_NO_FLICKER=1 when opening a new terminal. Improves rendering stability for Claude Code but disables scrollback. Only applies to newly opened terminals.", ], + ], + ); + const showClaudeCode = + claudeCodeReady && + sectionHasSettingsSearchMatches(searchQuery, "Claude Code", [ [ - "Enable Integrated Terminal", - "Enable terminal tabs in the editor workspace and related commands.", - "terminal", + "Skip permissions", + "Passes --dangerously-skip-permissions. Claude Code will not ask for approval before running tools. Only enable if you trust the session context.", + "yolo", + "dangerously-skip-permissions", ], - ], + [ + "Model", + "Which Claude model to use. Leave blank to let Claude Code choose.", + "opus", + "sonnet", + "haiku", + ...CLAUDE_CODE_MODEL_OPTIONS.map((o) => o.label), + ], + [ + "Continue last session", + "Passes --continue. Resumes your most recent Claude Code conversation instead of starting fresh.", + ], + [ + "Max turns", + "Passes --max-turns. Stops an agentic session after this many turns. Set to 0 for no limit.", + ], + ]); + + if (!showFont && !showShell && !showClaudeCode) { + return ; + } + + const selectStyle = { + width: 220, + padding: "6px 8px", + fontSize: 12, + fontFamily: "inherit", + borderRadius: 6, + border: "1px solid var(--border)", + backgroundColor: "var(--bg-secondary)", + color: "var(--text-primary)", + cursor: "pointer", + outline: "none", + } as const; + + return ( +
+ {showFont ? Font : null} + {showFont && ( + + setSetting("terminalFontFamily", e.target.value) + } + style={{ + width: 200, + padding: "6px 8px", + fontSize: 12, + fontFamily: "inherit", + borderRadius: 6, + border: "1px solid var(--border)", + backgroundColor: "var(--bg-secondary)", + color: "var(--text-primary)", + outline: "none", + }} + /> + } + /> + )} + {showFont && ( + setSetting("terminalFontSize", v)} + /> + } + /> + )} + {showShell ? ( + Shell Environment + ) : null} + {showShell && ( + + setSetting("claudeCodeOptimized", value) + } + /> + } + /> + )} + {showClaudeCode ? ( + Claude Code + ) : null} + {showClaudeCode && ( + + setSetting("claudeCodeSkipPermissions", v) + } + /> + } + /> + )} + {showClaudeCode && ( + + setSetting("claudeCodeModel", e.target.value) + } + style={selectStyle} + > + {CLAUDE_CODE_MODEL_OPTIONS.map((o) => ( + + ))} + + } + /> + )} + {showClaudeCode && ( + + setSetting("claudeCodeContinueSession", v) + } + /> + } + /> + )} + {showClaudeCode && ( + + setSetting("claudeCodeMaxTurns", v) + } + /> + } + /> + )} +
); +} + +function DevelopersSettings({ + searchQuery, +}: { + searchQuery: SettingsSearchQuery; +}) { + const { + lineWrapping, + fileTreeContentMode, + fileTreeShowExtensions, + fileTreeExtensionFilter, + setSetting, + } = useSettingsStore(); const showEditor = sectionHasSettingsSearchMatches(searchQuery, "Editor", [ ["Line wrapping", "Wrap long lines to fit the editor width."], ]); @@ -3102,47 +3328,12 @@ function DevelopersSettings({ ], ); - if (!showDeveloperMode && !showEditor && !showFileTree) { + if (!showEditor && !showFileTree) { return ; } return (
- {showDeveloperMode ? ( - Developer Mode - ) : null} - - setSetting("developerModeEnabled", value) - } - /> - } - /> - - setSetting("developerTerminalEnabled", value) - } - /> - } - /> - {showEditor ? Editor : null} ), }, + { + id: "terminal", + label: "Terminal", + icon: ( + + + + + ), + }, { id: "developers", label: "Developers", @@ -3813,6 +4029,7 @@ const CATEGORY_DESCRIPTIONS: Record = { editor: "Typography and text editing behavior", spellcheck: "Languages and dictionary management", updates: "Manual update checks and appcast configuration", + terminal: "Font, size, and shell environment settings", developers: "Advanced developer-facing file tree options", vault: "Current vault and recent history", shortcuts: "Keyboard shortcuts reference", @@ -3890,11 +4107,31 @@ const STATIC_CATEGORY_SEARCH_VALUES: Record = "appcast", "release feed", ], + terminal: [ + "Terminal", + "Font family", + "Font size", + "Nerd Font", + "FiraCode", + "JetBrains Mono", + "Claude Code", + "Fullscreen rendering", + "CLAUDE_CODE_NO_FLICKER", + "Skip permissions", + "yolo", + "dangerously-skip-permissions", + "Model", + "opus", + "sonnet", + "haiku", + "Continue last session", + "resume", + "Max turns", + "agentic", + "shell", + "monospace", + ], developers: [ - "Developer Mode", - "Enable Developer Mode", - "Enable Integrated Terminal", - "terminal", "Editor", "Line wrapping", "File Tree", @@ -4018,6 +4255,8 @@ function getDynamicCategorySearchValues( context.updateStatus.status?.update?.body, context.updateStatus.error, ]; + case "terminal": + return []; case "developers": return []; case "vault": @@ -4555,6 +4794,12 @@ export function SettingsPanel({ searchQuery={activeSearchQuery} /> )} + {filteredCategories.length > 0 && + activeCategory === "terminal" && ( + + )} {filteredCategories.length > 0 && activeCategory === "developers" && ( ) { cursor: theme.cursor, cursorAccent: theme.background, foreground: theme.text, - selectionBackground: "rgba(120, 138, 158, 0.28)", + black: theme.black, + red: theme.red, + green: theme.green, + yellow: theme.yellow, + blue: theme.blue, + magenta: theme.magenta, + cyan: theme.cyan, + white: theme.white, + brightBlack: theme.brightBlack, + brightRed: theme.brightRed, + brightGreen: theme.brightGreen, + brightYellow: theme.brightYellow, + brightBlue: theme.brightBlue, + brightMagenta: theme.brightMagenta, + brightCyan: theme.brightCyan, + brightWhite: theme.brightWhite, + selectionBackground: theme.selectionBackground, + scrollbarSliderBackground: theme.scrollbarSliderBackground, + scrollbarSliderHoverBackground: theme.scrollbarSliderHoverBackground, + scrollbarSliderActiveBackground: theme.scrollbarSliderActiveBackground, }; } @@ -101,7 +122,19 @@ export function TerminalViewport({ useState | null>(null); useThemeStore((state) => `${state.themeName}:${state.isDark}`); - const theme = getTerminalTheme(null); + // Track right panel state so we can re-fit when it opens/closes/peeks. + // The peek overlay is position:absolute and doesn't trigger ResizeObserver. + const rightPanelKey = useLayoutStore( + (s) => `${s.rightPanelCollapsed}:${s.rightPanelWidth}`, + ); + const terminalFontFamily = useSettingsStore( + (state) => state.terminalFontFamily, + ); + const terminalFontSize = useSettingsStore((state) => state.terminalFontSize); + const theme = getTerminalTheme(null, { + fontFamily: terminalFontFamily, + fontSize: terminalFontSize, + }); const focusTerminal = useCallback(() => { shouldRestoreFocusRef.current = true; @@ -256,58 +289,94 @@ export function TerminalViewport({ } return true; }); - terminal.open(host); - terminalRef.current = terminal; - fitAddonRef.current = fitAddon; - searchAddonRef.current = searchAddon; - - const onDataDisposable = terminal.onData((data) => { - void writeInputRef - .current(data) - .catch((error) => - console.error("[terminal] writeInput error:", error), - ); - }); - const onSelectionDisposable = terminal.onSelectionChange(syncSelection); - const onSearchResultsDisposable = searchAddon.onDidChangeResults( - (event) => { - setSearchResultIndex(event.resultIndex); - setSearchResultCount(event.resultCount); - }, - ); - const textarea = terminal.textarea; - const handleFocus = () => { - shouldRestoreFocusRef.current = true; - setFocused(true); - }; - const handleBlur = (event: FocusEvent) => { - const nextTarget = event.relatedTarget; - const nextInsideSearch = - nextTarget instanceof Node && - searchPanelRef.current?.contains(nextTarget); - - if (!nextInsideSearch) { - shouldRestoreFocusRef.current = false; - } - setFocused(false); - searchAddon.clearActiveDecoration(); - }; - - textarea?.addEventListener("focus", handleFocus); - textarea?.addEventListener("blur", handleBlur); + let cancelled = false; + let onDataDisposable: ReturnType | null = null; + let onSelectionDisposable: ReturnType< + typeof terminal.onSelectionChange + > | null = null; + let onSearchResultsDisposable: ReturnType< + typeof searchAddon.onDidChangeResults + > | null = null; + let textarea: HTMLTextAreaElement | null = null; + let handleFocus: (() => void) | null = null; + let handleBlur: ((event: FocusEvent) => void) | null = null; + let observer: ResizeObserver | null = null; + + const finishOpen = () => { + if (cancelled) return; + + terminal.open(host); + terminalRef.current = terminal; + fitAddonRef.current = fitAddon; + searchAddonRef.current = searchAddon; + + onDataDisposable = terminal.onData((data) => { + void writeInputRef + .current(data) + .catch((error) => + console.error("[terminal] writeInput error:", error), + ); + }); + onSelectionDisposable = + terminal.onSelectionChange(syncSelection); + onSearchResultsDisposable = searchAddon.onDidChangeResults( + (event) => { + setSearchResultIndex(event.resultIndex); + setSearchResultCount(event.resultCount); + }, + ); + + textarea = terminal.textarea ?? null; + handleFocus = () => { + shouldRestoreFocusRef.current = true; + setFocused(true); + }; + handleBlur = (event: FocusEvent) => { + const nextTarget = event.relatedTarget; + const nextInsideSearch = + nextTarget instanceof Node && + searchPanelRef.current?.contains(nextTarget); + if (!nextInsideSearch) { + shouldRestoreFocusRef.current = false; + } + setFocused(false); + searchAddon.clearActiveDecoration(); + }; + textarea?.addEventListener("focus", handleFocus); + textarea?.addEventListener("blur", handleBlur); - syncSize(); + syncSize(); + observer = new ResizeObserver(syncSize); + observer.observe(host); + }; - const observer = new ResizeObserver(syncSize); - observer.observe(host); + const fontFamily = theme.fontFamily.trim(); + // Only await font loading for custom fonts (fallback stack is always available). + const isCustomFont = + fontFamily.length > 0 && + !fontFamily.startsWith('"SFMono-Regular"'); + if (isCustomFont) { + const spec = `${theme.fontSize}px "${fontFamily.split(",")[0].trim().replace(/^"|"$/g, "")}"`; + Promise.all([ + document.fonts.load(spec), + document.fonts.load(`bold ${spec}`), + ]) + .catch(() => undefined) + .then(finishOpen); + } else { + finishOpen(); + } return () => { - observer.disconnect(); - onSearchResultsDisposable.dispose(); - onSelectionDisposable.dispose(); - textarea?.removeEventListener("blur", handleBlur); - textarea?.removeEventListener("focus", handleFocus); - onDataDisposable.dispose(); + cancelled = true; + observer?.disconnect(); + onSearchResultsDisposable?.dispose(); + onSelectionDisposable?.dispose(); + if (textarea && handleBlur) + textarea.removeEventListener("blur", handleBlur); + if (textarea && handleFocus) + textarea.removeEventListener("focus", handleFocus); + onDataDisposable?.dispose(); terminal.dispose(); syncSizeRef.current = () => undefined; terminalRef.current = null; @@ -347,6 +416,14 @@ export function TerminalViewport({ return () => cancelAnimationFrame(frame); }, [active, autoFocus, focusTerminal, snapshot.sessionId]); + // Re-fit after right panel open/close/peek. The peek overlay is + // position:absolute so it doesn't trigger the ResizeObserver on the + // terminal host. We wait 210ms to let the 190ms CSS transition finish. + useEffect(() => { + const timer = setTimeout(() => syncSizeRef.current(), 210); + return () => clearTimeout(timer); + }, [rightPanelKey]); + useEffect(() => { const terminal = terminalRef.current; if (!terminal) return; @@ -357,14 +434,7 @@ export function TerminalViewport({ terminal.options.theme = createXtermTheme(theme); fitAddonRef.current?.fit(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - theme.background, - theme.cursor, - theme.fontFamily, - theme.fontSize, - theme.lineHeight, - theme.text, - ]); + }, [JSON.stringify(theme)]); useEffect(() => { const terminal = terminalRef.current; @@ -556,7 +626,7 @@ export function TerminalViewport({ >
{searchOpen && ( diff --git a/apps/desktop/src/features/terminal/WorkspaceTerminalHost.test.tsx b/apps/desktop/src/features/terminal/WorkspaceTerminalHost.test.tsx index e8e5db3f..b0faf713 100644 --- a/apps/desktop/src/features/terminal/WorkspaceTerminalHost.test.tsx +++ b/apps/desktop/src/features/terminal/WorkspaceTerminalHost.test.tsx @@ -7,7 +7,7 @@ import { DEV_TERMINAL_EXITED_EVENT, DEV_TERMINAL_OUTPUT_EVENT, DEV_TERMINAL_STARTED_EVENT, -} from "../devtools/terminal/terminalTypes"; +} from "./terminalTypes"; import { resetTerminalRuntimeStoreForTests, useTerminalRuntimeStore, @@ -94,6 +94,7 @@ describe("WorkspaceTerminalHost", () => { cwd: "/vault", cols: 120, rows: 24, + extraEnv: {}, }, }, ); diff --git a/apps/desktop/src/features/terminal/WorkspaceTerminalHost.tsx b/apps/desktop/src/features/terminal/WorkspaceTerminalHost.tsx index 11c58066..4ba6cd1d 100644 --- a/apps/desktop/src/features/terminal/WorkspaceTerminalHost.tsx +++ b/apps/desktop/src/features/terminal/WorkspaceTerminalHost.tsx @@ -13,7 +13,7 @@ import { type TerminalErrorEventPayload, type TerminalOutputEventPayload, type TerminalSessionSnapshot, -} from "../devtools/terminal/terminalTypes"; +} from "./terminalTypes"; import { useTerminalRuntimeStore } from "./terminalRuntimeStore"; import { useEffect } from "react"; import { useShallow } from "zustand/react/shallow"; diff --git a/apps/desktop/src/features/terminal/WorkspaceTerminalView.tsx b/apps/desktop/src/features/terminal/WorkspaceTerminalView.tsx index f586a507..661bd038 100644 --- a/apps/desktop/src/features/terminal/WorkspaceTerminalView.tsx +++ b/apps/desktop/src/features/terminal/WorkspaceTerminalView.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import type { TerminalTab } from "../../app/store/editorStore"; -import { TerminalViewport } from "../devtools/terminal/TerminalViewport"; +import { TerminalViewport } from "./TerminalViewport"; import { createTerminalSessionView, useTerminalRuntimeStore, diff --git a/apps/desktop/src/features/terminal/claudeCodeTerminal.test.ts b/apps/desktop/src/features/terminal/claudeCodeTerminal.test.ts new file mode 100644 index 00000000..77b30dd0 --- /dev/null +++ b/apps/desktop/src/features/terminal/claudeCodeTerminal.test.ts @@ -0,0 +1,252 @@ +import { invoke } from "../../app/runtime"; +import { + selectEditorWorkspaceTabs, + type TerminalTab, + useEditorStore, +} from "../../app/store/editorStore"; +import { isTerminalTab } from "../../app/store/editorTabs"; +import { useSettingsStore } from "../../app/store/settingsStore"; +import { useVaultStore } from "../../app/store/vaultStore"; +import type { FileTreeNoteDragDetail } from "../ai/dragEvents"; +import type { TerminalSessionSnapshot } from "./terminalTypes"; +import { openClaudeCodeTerminalWithContext } from "./claudeCodeTerminal"; +import { + resetTerminalRuntimeStoreForTests, + useTerminalRuntimeStore, +} from "./terminalRuntimeStore"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../app/runtime", () => ({ + invoke: vi.fn(async () => undefined), +})); + +function makeRunningSnapshot( + overrides: Partial = {}, +): TerminalSessionSnapshot { + return { + sessionId: "devterm-1", + program: "/bin/zsh", + status: "running", + displayName: "zsh", + cwd: "/vault root", + cols: 120, + rows: 24, + exitCode: null, + errorMessage: null, + ...overrides, + }; +} + +async function attachOpenedTerminalRuntime() { + await Promise.resolve(); + const editorState = useEditorStore.getState(); + const tab = selectEditorWorkspaceTabs(editorState).find( + (candidate): candidate is TerminalTab => + isTerminalTab(candidate) && candidate.id === editorState.activeTabId, + ); + expect(tab).toBeDefined(); + useTerminalRuntimeStore.setState({ + runtimesById: { + [tab!.terminalId]: { + terminalId: tab!.terminalId, + tabId: tab!.id, + sessionId: "devterm-1", + snapshot: makeRunningSnapshot(), + rawOutput: "", + busy: false, + launchError: null, + }, + }, + }); + await Promise.resolve(); + return tab!; +} + +function getWrittenInputs() { + return vi + .mocked(invoke) + .mock.calls.filter( + ([command]) => command === "devtools_write_terminal_session", + ) + .map(([, payload]) => { + return ( + payload as { + input: { + data: string; + }; + } + ).input.data; + }); +} + +describe("openClaudeCodeTerminalWithContext", () => { + beforeEach(() => { + vi.useRealTimers(); + vi.mocked(invoke).mockClear(); + useSettingsStore.getState().reset(); + useVaultStore.setState({ vaultPath: "/vault root" }); + useEditorStore.getState().hydrateWorkspace( + [ + { + id: "primary", + tabs: [], + activeTabId: null, + }, + ], + "primary", + ); + resetTerminalRuntimeStoreForTests(); + }); + + afterEach(() => { + vi.useRealTimers(); + resetTerminalRuntimeStoreForTests(); + }); + + it("opens a workspace terminal and launches Claude Code with configured flags", async () => { + useSettingsStore.setState({ + claudeCodeSkipPermissions: true, + claudeCodeModel: " claude-sonnet-4-6 ", + claudeCodeContinueSession: true, + claudeCodeMaxTurns: 7, + }); + + const opening = openClaudeCodeTerminalWithContext(); + await attachOpenedTerminalRuntime(); + await opening; + + const terminalTab = selectEditorWorkspaceTabs( + useEditorStore.getState(), + ).find(isTerminalTab); + expect(terminalTab).toMatchObject({ + title: "Claude Code 1", + cwd: "/vault root", + }); + expect(getWrittenInputs()).toEqual([ + "cd '/vault root'\n", + "claude --dangerously-skip-permissions --model claude-sonnet-4-6 --continue --max-turns 7\n", + ]); + }); + + it("ignores unsupported persisted Claude Code models before writing to the shell", async () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + useSettingsStore.setState({ + claudeCodeModel: "claude-sonnet-4-6\nsay injected", + claudeCodeContinueSession: true, + }); + + const opening = openClaudeCodeTerminalWithContext(); + await attachOpenedTerminalRuntime(); + await opening; + + expect(getWrittenInputs()).toEqual([ + "cd '/vault root'\n", + "claude --continue\n", + ]); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Ignoring unsupported Claude Code model setting", + ), + ); + warnSpy.mockRestore(); + }); + + it("prefills vault-relative @mentions after Claude Code settles", async () => { + vi.useFakeTimers(); + const detail: FileTreeNoteDragDetail = { + phase: "attach", + x: 0, + y: 0, + notes: [ + { + id: "note-1", + title: "One note", + path: "/vault root/Project Notes/One note.md", + }, + ], + files: [ + { + fileName: "chart (v1).png", + filePath: "/vault root/assets/chart (v1).png", + mimeType: "image/png", + }, + { + fileName: 'he said "yes".md', + filePath: '/vault root/assets/he said "yes".md', + mimeType: "text/markdown", + }, + ], + folder: { + name: "Draft Folder", + path: "Draft Folder", + }, + folders: [ + { + name: "Draft Folder", + path: "Draft Folder", + }, + ], + }; + + const opening = openClaudeCodeTerminalWithContext(detail); + await attachOpenedTerminalRuntime(); + await vi.advanceTimersByTimeAsync(3_500); + await opening; + + expect(getWrittenInputs()).toEqual([ + "cd '/vault root/Draft Folder'\n", + "claude\n", + [ + '@"Project Notes/One note.md"', + '@"assets/chart (v1).png"', + '@"assets/he said \\"yes\\".md"', + ].join(" "), + ]); + }); + + it("numbers Claude Code terminals independently from regular terminals", async () => { + useEditorStore.getState().hydrateWorkspace( + [ + { + id: "primary", + tabs: [ + { + id: "terminal-tab-1", + kind: "terminal", + terminalId: "terminal-1", + title: "Terminal 1", + cwd: "/vault root", + }, + { + id: "claude-code-tab-1", + kind: "terminal", + terminalId: "claude-code-1", + title: "Claude Code 1", + cwd: "/vault root", + }, + ], + activeTabId: "claude-code-tab-1", + }, + ], + "primary", + ); + + const opening = openClaudeCodeTerminalWithContext(); + await attachOpenedTerminalRuntime(); + await opening; + + const terminalTitles = selectEditorWorkspaceTabs( + useEditorStore.getState(), + ) + .filter(isTerminalTab) + .map((tab) => tab.title); + + expect(terminalTitles).toEqual([ + "Terminal 1", + "Claude Code 1", + "Claude Code 2", + ]); + }); +}); diff --git a/apps/desktop/src/features/terminal/claudeCodeTerminal.ts b/apps/desktop/src/features/terminal/claudeCodeTerminal.ts new file mode 100644 index 00000000..05933752 --- /dev/null +++ b/apps/desktop/src/features/terminal/claudeCodeTerminal.ts @@ -0,0 +1,234 @@ +import { invoke } from "../../app/runtime"; +import { + selectEditorWorkspaceTabs, + useEditorStore, +} from "../../app/store/editorStore"; +import { isTerminalTab } from "../../app/store/editorTabs"; +import { useSettingsStore } from "../../app/store/settingsStore"; +import { useVaultStore } from "../../app/store/vaultStore"; +import type { FileTreeNoteDragDetail } from "../ai/dragEvents"; +import { useTerminalRuntimeStore } from "./terminalRuntimeStore"; + +// Module-level cache so chatStore, AIProvidersSettings, and TerminalSettings +// all share one shell spawn rather than each issuing their own. +let _binaryCheckCache: boolean | null = null; + +export async function checkClaudeCodeInstalled(): Promise { + if (_binaryCheckCache !== null) return _binaryCheckCache; + try { + const result = await invoke<{ found: boolean }>( + "devtools_check_binary", + { name: "claude" }, + ); + _binaryCheckCache = result.found; + return _binaryCheckCache; + } catch { + return false; + } +} + +export function resetClaudeCodeInstalledCacheForTests() { + _binaryCheckCache = null; +} + +// Milliseconds to wait for the terminal PTY to reach "running" state. +const TERMINAL_READY_TIMEOUT_MS = 10_000; +// Fixed delay waiting for Claude Code's TUI to finish initialising. This is a +// best-effort heuristic — a cold start (first auth, slow disk) can take longer. +// A proper fix would watch rawOutput for a stable ready marker, but that depends +// on Claude Code's output format staying stable across versions. +const CLAUDE_TUI_SETTLE_MS = 3_500; +const CLAUDE_CODE_TERMINAL_TITLE = "Claude Code"; +const CLAUDE_CODE_TERMINAL_TITLE_PATTERN = /^Claude Code(?: (\d+))?$/; +const ALLOWED_CLAUDE_CODE_MODELS = new Set([ + "claude-opus-4-7", + "claude-sonnet-4-6", + "claude-haiku-4-5", +]); + +function shellQuoteArg(arg: string): string { + if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(arg)) { + return arg; + } + return `'${arg.replace(/'/g, "'\\''")}'`; +} + +function buildShellCommand(args: string[]): string { + return `${args.map(shellQuoteArg).join(" ")}\n`; +} + +function getSafeClaudeCodeModel(model: string): string | null { + const trimmed = model.trim(); + if (!trimmed) return null; + if (ALLOWED_CLAUDE_CODE_MODELS.has(trimmed)) return trimmed; + + console.warn( + `[terminal] Ignoring unsupported Claude Code model setting: ${JSON.stringify(trimmed)}`, + ); + return null; +} + +function getNextClaudeCodeTerminalTitle(): string { + const maxExistingIndex = selectEditorWorkspaceTabs( + useEditorStore.getState(), + ).reduce((maxIndex, tab) => { + if (!isTerminalTab(tab)) return maxIndex; + + const match = tab.title.trim().match(CLAUDE_CODE_TERMINAL_TITLE_PATTERN); + if (!match) return maxIndex; + + const index = match[1] ? Number(match[1]) : 1; + return Number.isFinite(index) ? Math.max(maxIndex, index) : maxIndex; + }, 0); + + return `${CLAUDE_CODE_TERMINAL_TITLE} ${maxExistingIndex + 1}`; +} + +// Quote a path for a Claude Code @mention. Use double quotes around any path +// that contains characters outside the safe unquoted set so the mention parser +// doesn't split on spaces, parens, brackets, etc. +function quoteForMention(path: string): string { + return /^[A-Za-z0-9_./-]+$/.test(path) ? path : JSON.stringify(path); +} + +// Strip the vault root prefix so @mentions are vault-relative rather than +// exposing absolute filesystem paths in the terminal input history. +function toVaultRelativePath(path: string, vaultPath: string | null): string { + if (!vaultPath) return path; + const prefix = vaultPath.endsWith("/") ? vaultPath : `${vaultPath}/`; + return path.startsWith(prefix) ? path.slice(prefix.length) : path; +} + +function buildContextArgs( + detail: FileTreeNoteDragDetail, + vaultPath: string | null, +): string { + // Notes and files only — folders aren't dereferenceable as file context. + // detail.folder and detail.folders refer to the same entry; skip both to + // avoid duplication (the cd already scopes the session to the folder). + const paths: string[] = [ + ...detail.notes.map((n) => toVaultRelativePath(n.path, vaultPath)), + ...(detail.files ?? []).map((f) => + toVaultRelativePath(f.filePath, vaultPath), + ), + ]; + return paths.map((p) => `@${quoteForMention(p)}`).join(" "); +} + +// Determine the best directory to cd into for the given context. +// If exactly one folder is attached, cd into it; otherwise cd to vault root. +// Folder paths in the detail are vault-relative, so we join with vaultPath. +function resolveCdTarget( + detail: FileTreeNoteDragDetail | undefined, + vaultPath: string | null, +): string | null { + // detail.folder is set only when exactly one folder is selected (see + // FileTree.tsx handleAddChatTargetsToChat). detail.folders contains the + // same entry — use the singular to avoid double-counting. + if (detail?.folder && vaultPath) { + return `${vaultPath}/${detail.folder.path}`; + } + return vaultPath; +} + +function waitForTerminalRunning(terminalId: string): Promise { + return new Promise((resolve) => { + const check = (): "ready" | "failed" | null => { + const status = + useTerminalRuntimeStore.getState().runtimesById[terminalId] + ?.snapshot.status; + if (status === "running") return "ready"; + if (status === "error" || status === "exited") return "failed"; + return null; + }; + + // Check synchronously first to avoid missing a transition that + // already happened between openTerminal() and subscribe(). + const immediate = check(); + if (immediate !== null) { + resolve(immediate === "ready"); + return; + } + + const deadline = setTimeout(() => { + unsub(); + console.warn( + `[terminal] Timed out waiting for terminal ${terminalId} to start`, + ); + resolve(false); + }, TERMINAL_READY_TIMEOUT_MS); + + const unsub = useTerminalRuntimeStore.subscribe(() => { + const result = check(); + if (result !== null) { + clearTimeout(deadline); + unsub(); + resolve(result === "ready"); + } + }); + }); +} + +export async function openClaudeCodeTerminalWithContext( + detail?: FileTreeNoteDragDetail, + paneId?: string, +): Promise { + const vaultPath = useVaultStore.getState().vaultPath; + const tabId = useEditorStore + .getState() + .openTerminal({ + cwd: vaultPath ?? undefined, + paneId, + title: getNextClaudeCodeTerminalTitle(), + }); + if (!tabId) return; + + const tab = selectEditorWorkspaceTabs(useEditorStore.getState()).find( + (t) => t.id === tabId, + ); + if (!tab || !isTerminalTab(tab)) return; + + const { terminalId } = tab; + const ready = await waitForTerminalRunning(terminalId); + if (!ready) return; + + const store = useTerminalRuntimeStore.getState(); + + // cd into the target directory so the user can see where claude starts, + // and so relative @mentions resolve correctly. + const cdTarget = resolveCdTarget(detail, vaultPath); + if (cdTarget) { + // Single-quote the path so $, backticks, and backslash are inert. + // Escape any embedded single quotes as '\'' (end-quote, literal, re-open). + const cdQuoted = `'${cdTarget.replace(/'/g, "'\\''")}'`; + await store.writeInput(terminalId, `cd ${cdQuoted}\n`); + } + + // Build the claude command from settings. Treat persisted settings as data, + // not trusted shell text, before writing into the interactive PTY. + const { + claudeCodeSkipPermissions, + claudeCodeModel, + claudeCodeContinueSession, + claudeCodeMaxTurns, + } = useSettingsStore.getState(); + + const args = ["claude"]; + if (claudeCodeSkipPermissions) args.push("--dangerously-skip-permissions"); + const safeModel = getSafeClaudeCodeModel(claudeCodeModel); + if (safeModel) args.push("--model", safeModel); + if (claudeCodeContinueSession) args.push("--continue"); + if (claudeCodeMaxTurns > 0) args.push("--max-turns", String(claudeCodeMaxTurns)); + + await store.writeInput(terminalId, buildShellCommand(args)); + + if (!detail) return; + + const contextArgs = buildContextArgs(detail, vaultPath); + if (!contextArgs) return; + + // Wait for Claude Code's TUI to finish initialising, then pre-fill the + // input buffer with the @mentions so the user can complete their prompt. + await new Promise((resolve) => setTimeout(resolve, CLAUDE_TUI_SETTLE_MS)); + await store.writeInput(terminalId, contextArgs); +} diff --git a/apps/desktop/src/features/terminal/legacyTerminalMigration.ts b/apps/desktop/src/features/terminal/legacyTerminalMigration.ts index 996ea332..f48ab3aa 100644 --- a/apps/desktop/src/features/terminal/legacyTerminalMigration.ts +++ b/apps/desktop/src/features/terminal/legacyTerminalMigration.ts @@ -8,7 +8,7 @@ import { safeStorageGetItem, safeStorageSetItem, } from "../../app/utils/safeStorage"; -import { readPersistedTerminalWorkspace } from "../devtools/terminal/useTerminalTabs"; +import { readPersistedTerminalWorkspace } from "./useTerminalTabs"; const LEGACY_TERMINAL_MIGRATION_KEY_PREFIX = "neverwrite.workspace.terminal.legacyMigrated:"; diff --git a/apps/desktop/src/features/devtools/terminal/terminalRawOutput.ts b/apps/desktop/src/features/terminal/terminalRawOutput.ts similarity index 100% rename from apps/desktop/src/features/devtools/terminal/terminalRawOutput.ts rename to apps/desktop/src/features/terminal/terminalRawOutput.ts diff --git a/apps/desktop/src/features/terminal/terminalRuntimeStore.test.ts b/apps/desktop/src/features/terminal/terminalRuntimeStore.test.ts index d1f13bc0..96829e85 100644 --- a/apps/desktop/src/features/terminal/terminalRuntimeStore.test.ts +++ b/apps/desktop/src/features/terminal/terminalRuntimeStore.test.ts @@ -1,6 +1,7 @@ import { invoke } from "../../app/runtime"; import type { TerminalTab } from "../../app/store/editorStore"; -import type { TerminalSessionSnapshot } from "../devtools/terminal/terminalTypes"; +import { useSettingsStore } from "../../app/store/settingsStore"; +import type { TerminalSessionSnapshot } from "./terminalTypes"; import { resetTerminalRuntimeStoreForTests, useTerminalRuntimeStore, @@ -62,6 +63,7 @@ function getRuntime(terminalId = "terminal-1") { describe("terminalRuntimeStore", () => { beforeEach(() => { resetTerminalRuntimeStoreForTests(); + useSettingsStore.getState().reset(); vi.mocked(invoke).mockReset(); }); @@ -92,6 +94,30 @@ describe("terminalRuntimeStore", () => { }); }); + it("adds Claude Code fullscreen rendering env to newly created sessions when enabled", async () => { + useSettingsStore.setState({ claudeCodeOptimized: true }); + vi.mocked(invoke).mockResolvedValue( + makeSnapshot({ sessionId: "devterm-1" }), + ); + + useTerminalRuntimeStore.getState().ensureTerminal(makeTerminalTab()); + await flushPromises(); + + expect(vi.mocked(invoke)).toHaveBeenCalledWith( + "devtools_create_terminal_session", + { + input: { + cwd: "/vault", + cols: 120, + rows: 24, + extraEnv: { + CLAUDE_CODE_NO_FLICKER: "1", + }, + }, + }, + ); + }); + it("ignores output from retired sessions after closing a terminal tab", async () => { vi.mocked(invoke).mockResolvedValue( makeSnapshot({ sessionId: "devterm-1" }), diff --git a/apps/desktop/src/features/terminal/terminalRuntimeStore.ts b/apps/desktop/src/features/terminal/terminalRuntimeStore.ts index 07c1b3fd..dea2c6e8 100644 --- a/apps/desktop/src/features/terminal/terminalRuntimeStore.ts +++ b/apps/desktop/src/features/terminal/terminalRuntimeStore.ts @@ -1,11 +1,12 @@ import { invoke } from "../../app/runtime"; import type { TerminalTab } from "../../app/store/editorStore"; -import { appendTerminalRawOutput } from "../devtools/terminal/terminalRawOutput"; +import { useSettingsStore } from "../../app/store/settingsStore"; +import { appendTerminalRawOutput } from "./terminalRawOutput"; import { allocateTabSessionVersion, collectSessionIdsToClose, deleteTabSessionVersions, -} from "../devtools/terminal/terminalSessionTracking"; +} from "./terminalSessionTracking"; import { EMPTY_TERMINAL_SNAPSHOT, type TerminalErrorEventPayload, @@ -13,7 +14,7 @@ import { type TerminalSessionCreateInput, type TerminalSessionSnapshot, type TerminalSessionView, -} from "../devtools/terminal/terminalTypes"; +} from "./terminalTypes"; import { create } from "zustand"; export interface WorkspaceTerminalRuntime { @@ -136,6 +137,11 @@ async function createSessionForTerminal( const requestVersion = allocateTerminalSessionVersion(terminalId); try { + const { claudeCodeOptimized } = useSettingsStore.getState(); + const extraEnv: Record = { + ...(claudeCodeOptimized && { CLAUDE_CODE_NO_FLICKER: "1" }), + ...input?.extraEnv, + }; const next = await invoke( "devtools_create_terminal_session", { @@ -143,6 +149,7 @@ async function createSessionForTerminal( cwd: input?.cwd ?? null, cols: input?.cols, rows: input?.rows, + extraEnv, }, }, ); diff --git a/apps/desktop/src/features/devtools/terminal/terminalSessionTracking.test.ts b/apps/desktop/src/features/terminal/terminalSessionTracking.test.ts similarity index 100% rename from apps/desktop/src/features/devtools/terminal/terminalSessionTracking.test.ts rename to apps/desktop/src/features/terminal/terminalSessionTracking.test.ts diff --git a/apps/desktop/src/features/devtools/terminal/terminalSessionTracking.ts b/apps/desktop/src/features/terminal/terminalSessionTracking.ts similarity index 100% rename from apps/desktop/src/features/devtools/terminal/terminalSessionTracking.ts rename to apps/desktop/src/features/terminal/terminalSessionTracking.ts diff --git a/apps/desktop/src/features/terminal/terminalTheme.ts b/apps/desktop/src/features/terminal/terminalTheme.ts new file mode 100644 index 00000000..c8905f17 --- /dev/null +++ b/apps/desktop/src/features/terminal/terminalTheme.ts @@ -0,0 +1,86 @@ +export interface TerminalTheme { + background: string; + panelBackground: string; + border: string; + text: string; + mutedText: string; + accent: string; + cursor: string; + fontFamily: string; + fontSize: number; + lineHeight: number; + // ANSI 16-color palette + black: string; + red: string; + green: string; + yellow: string; + blue: string; + magenta: string; + cyan: string; + white: string; + brightBlack: string; + brightRed: string; + brightGreen: string; + brightYellow: string; + brightBlue: string; + brightMagenta: string; + brightCyan: string; + brightWhite: string; + // Selection and scrollbar + selectionBackground: string; + scrollbarSliderBackground: string; + scrollbarSliderHoverBackground: string; + scrollbarSliderActiveBackground: string; +} + +const FALLBACK_FONT_STACK = + '"SFMono-Regular", "Cascadia Code", "JetBrains Mono", Menlo, Monaco, Consolas, monospace'; + +export function getTerminalTheme( + element: HTMLElement | null, + opts?: { fontFamily?: string; fontSize?: number }, +): TerminalTheme { + const computed = window.getComputedStyle( + element ?? document.documentElement, + ); + const v = (name: string) => computed.getPropertyValue(name).trim(); + + // Read a terminal ANSI slot: prefer the per-theme custom property set by + // applyTerminalPalette(), fall back to the Catppuccin icon token which is + // always present and provides a reasonable default for unlisted themes. + const ansi = (cssVar: string, fallback: string) => + v(cssVar) || v(fallback); + + return { + background: v("--bg-primary"), + panelBackground: v("--bg-secondary"), + border: v("--border"), + text: v("--text-primary"), + mutedText: v("--text-secondary"), + accent: v("--accent"), + cursor: v("--accent"), + fontFamily: opts?.fontFamily?.trim() || FALLBACK_FONT_STACK, + fontSize: opts?.fontSize ?? 13, + lineHeight: 1.4, + black: ansi("--terminal-ansi-black", "--bg-secondary"), + red: ansi("--terminal-ansi-red", "--catppuccin-icon-red"), + green: ansi("--terminal-ansi-green", "--catppuccin-icon-green"), + yellow: ansi("--terminal-ansi-yellow", "--catppuccin-icon-yellow"), + blue: ansi("--terminal-ansi-blue", "--catppuccin-icon-blue"), + magenta: ansi("--terminal-ansi-magenta", "--catppuccin-icon-mauve"), + cyan: ansi("--terminal-ansi-cyan", "--catppuccin-icon-teal"), + white: ansi("--terminal-ansi-white", "--text-primary"), + brightBlack: ansi("--terminal-ansi-bright-black", "--text-secondary"), + brightRed: ansi("--terminal-ansi-bright-red", "--catppuccin-icon-maroon"), + brightGreen: ansi("--terminal-ansi-bright-green", "--catppuccin-icon-green"), + brightYellow: ansi("--terminal-ansi-bright-yellow", "--catppuccin-icon-peach"), + brightBlue: ansi("--terminal-ansi-bright-blue", "--catppuccin-icon-lavender"), + brightMagenta: ansi("--terminal-ansi-bright-magenta", "--catppuccin-icon-pink"), + brightCyan: ansi("--terminal-ansi-bright-cyan", "--catppuccin-icon-sky"), + brightWhite: ansi("--terminal-ansi-bright-white", "--text-heading"), + selectionBackground: v("--highlight-bg"), + scrollbarSliderBackground: v("--scrollbar-thumb-active"), + scrollbarSliderHoverBackground: v("--scrollbar-thumb-hover"), + scrollbarSliderActiveBackground: v("--scrollbar-thumb-active"), + }; +} diff --git a/apps/desktop/src/features/devtools/terminal/terminalTypes.ts b/apps/desktop/src/features/terminal/terminalTypes.ts similarity index 97% rename from apps/desktop/src/features/devtools/terminal/terminalTypes.ts rename to apps/desktop/src/features/terminal/terminalTypes.ts index cc77deb3..3cbfb1c3 100644 --- a/apps/desktop/src/features/devtools/terminal/terminalTypes.ts +++ b/apps/desktop/src/features/terminal/terminalTypes.ts @@ -31,6 +31,7 @@ export interface TerminalSessionCreateInput { cwd?: string | null; cols?: number; rows?: number; + extraEnv?: Record; } export const DEV_TERMINAL_OUTPUT_EVENT = "devtools://terminal-output"; diff --git a/apps/desktop/src/features/devtools/terminal/useTerminalTabs.ts b/apps/desktop/src/features/terminal/useTerminalTabs.ts similarity index 98% rename from apps/desktop/src/features/devtools/terminal/useTerminalTabs.ts rename to apps/desktop/src/features/terminal/useTerminalTabs.ts index 20700044..a6bbf164 100644 --- a/apps/desktop/src/features/devtools/terminal/useTerminalTabs.ts +++ b/apps/desktop/src/features/terminal/useTerminalTabs.ts @@ -1,11 +1,12 @@ import { invoke } from "@neverwrite/runtime"; import { listen } from "@neverwrite/runtime"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useVaultStore } from "../../../app/store/vaultStore"; +import { useSettingsStore } from "../../app/store/settingsStore"; +import { useVaultStore } from "../../app/store/vaultStore"; import { safeStorageGetItem, safeStorageSetItem, -} from "../../../app/utils/safeStorage"; +} from "../../app/utils/safeStorage"; import { appendTerminalRawOutput, normalizePersistedTerminalRawOutput, @@ -380,6 +381,11 @@ export function useTerminalTabs(enabled: boolean): UseTerminalTabsResult { async (tabId: string, input?: TerminalSessionCreateInput) => { const requestVersion = bumpTabSessionVersion(tabId); try { + const { claudeCodeOptimized } = useSettingsStore.getState(); + const extraEnv: Record = { + ...(claudeCodeOptimized && { CLAUDE_CODE_NO_FLICKER: "1" }), + ...input?.extraEnv, + }; const next = await invoke( "devtools_create_terminal_session", { @@ -387,6 +393,7 @@ export function useTerminalTabs(enabled: boolean): UseTerminalTabsResult { cwd: input?.cwd ?? vaultPath, cols: input?.cols, rows: input?.rows, + extraEnv, }, }, ); diff --git a/apps/desktop/src/features/vault/FileTree.test.tsx b/apps/desktop/src/features/vault/FileTree.test.tsx index 1076be9b..17dcb672 100644 --- a/apps/desktop/src/features/vault/FileTree.test.tsx +++ b/apps/desktop/src/features/vault/FileTree.test.tsx @@ -1064,7 +1064,6 @@ describe("FileTree", () => { act(() => { useSettingsStore.getState().reset(); useSettingsStore.setState({ - developerModeEnabled: true, fileTreeContentMode: "all_files", fileTreeShowExtensions: true, }); diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index 116188fc..125e3114 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -644,18 +644,18 @@ body.dragging-tab * { } } -.devtools-terminal-surface { +.terminal-surface { height: 100%; min-height: 0; box-sizing: border-box; padding: 8px 12px; } -.devtools-terminal-surface .xterm { +.terminal-surface .xterm { height: 100%; } -.devtools-terminal-surface .xterm-viewport { +.terminal-surface .xterm-viewport { overflow-y: auto !important; scrollbar-width: thin; } diff --git a/docs/terminal-followups.md b/docs/terminal-followups.md new file mode 100644 index 00000000..bf45149e --- /dev/null +++ b/docs/terminal-followups.md @@ -0,0 +1,211 @@ +# Terminal Integration — Follow-up Items + +From the Opus code review of `feature/terminal-first-class`. These are not blockers +for merge but should be addressed in follow-up PRs. + +--- + +## 1. Cache the binary check — eliminate three redundant `sh` spawns + +**What:** `checkClaudeCodeInstalled()` is called independently from three places: +`chatStore.initialize`, `AIProvidersSettings`, and `TerminalSettings`. Each spawns +`sh -lc 'command -v claude'` (~40–80ms on macOS). They can disagree mid-session and +waste 3× the startup time. + +**Fix:** Add a module-level cache in `claudeCodeTerminal.ts`: + +```ts +let _cached: boolean | null = null; + +export async function checkClaudeCodeInstalled(): Promise { + if (_cached !== null) return _cached; + try { + const result = await invoke<{ found: boolean }>( + "devtools_check_binary", { name: "claude" } + ); + _cached = result.found; + return _cached; + } catch { return false; } +} +``` + +Additionally, `TerminalSettings` and `AIProvidersSettings` should read +`setupStatusByRuntimeId[CLAUDE_TERMINAL_RUNTIME_ID]?.binaryReady` from the chatStore +when the store is already initialized, rather than issuing their own IPC calls. The +module-level cache handles the cold path (settings opened before store finishes). + +**Effort:** Small — 1–2 file changes. + +--- + +## 2. De-duplicate `CLAUDE_TERMINAL_DESCRIPTOR` and setup status builder + +**What:** The descriptor (`{ runtime: { id, name, description, capabilities }, ... }`) +and `buildClaudeTerminalSetupStatus` are inline in `chatStore.ts`. The message text +slightly diverges from what `AIProvidersSettings` builds locally. Same shape, two +sources. + +**Fix:** Move both to a new `features/ai/utils/claudeTerminalRuntime.ts` file and +import from `chatStore.ts` and `AIProvidersSettings.tsx`. Unify the "not found" message +copy at the same time. + +**Effort:** Small — 1 new file, 2 import updates. + +--- + +## 3. Replace `waitForTerminalRunning` poll with Zustand subscribe + +**What:** The function uses `setInterval(100ms)` + `setTimeout(10s)` to detect when +the PTY reaches `"running"` state. Polling introduces up to 100ms extra lag and is +stylistically wrong given Zustand's synchronous subscription API. + +**Fix:** + +```ts +function waitForTerminalRunning(terminalId: string): Promise { + return new Promise((resolve) => { + const check = () => { + const status = + useTerminalRuntimeStore.getState().runtimesById[terminalId] + ?.snapshot.status; + if (status === "running") return "ready"; + if (status === "error" || status === "exited") return "failed"; + return null; + }; + + // Synchronous check before subscribing — avoids missing events + // that fired between openTerminal() and subscribe(). + const immediate = check(); + if (immediate) { resolve(immediate === "ready"); return; } + + const deadline = setTimeout(() => { + unsub(); + resolve(false); + }, TERMINAL_READY_TIMEOUT_MS); + + const unsub = useTerminalRuntimeStore.subscribe(() => { + const result = check(); + if (result) { + clearTimeout(deadline); + unsub(); + resolve(result === "ready"); + } + }); + }); +} +``` + +Also: when the timeout fires (terminal never became ready), surface a visible error +rather than silently abandoning. A console.warn is the minimum; a toast or tab +error state is better. + +**Effort:** Small — one function replacement. + +--- + +## 4. Raise `CLAUDE_TUI_SETTLE_MS` and document the limitation + +**What:** The 2-second fixed delay before pre-filling @mentions is a guess that's too +short on cold starts (slow disk, first auth) and wastes 1.6s on warm ones. The right +fix is detecting Claude Code's ready state from its output. + +**Short-term fix:** Raise the constant to 3.5s and add a comment explaining why it +exists and what a proper fix would look like: + +```ts +// Fixed delay waiting for Claude Code's TUI to finish initialising. This is a +// best-effort heuristic — a cold start (first auth, slow disk) may need more time. +// A proper fix would watch terminal rawOutput for a stable "ready" marker, or +// use a Claude Code CLI flag for initial prompt injection once one exists. +const CLAUDE_TUI_SETTLE_MS = 3_500; +``` + +**Long-term fix:** Watch `rawOutput` from the terminal session for a string that +reliably indicates Claude Code is ready for input (e.g., the presence of the `>` +prompt block or the "Try" hint line). This depends on Claude Code's output format +staying stable — flag it as a known fragility. + +**Effort:** Trivial (constant bump) or Medium (output watching). + +--- + +## 5. Persist the auto-selected default; verify it's actually Claude Code + +**What:** When `claude` is found in PATH and no explicit preference exists, the app +auto-defaults to Claude Code each launch by re-running the binary check. Two problems: + +1. If the binary becomes unavailable after first use, behavior changes silently on + next launch. +2. A tool named `claude` that is not Claude Code would be auto-selected — unlikely + but possible (e.g., a local script or AUR package). + +**Fix:** + +*Persistence:* When `claudeFound === true` and `persistedRuntimeId === null` in +`chatStore.initialize`, write the auto-selected default to `AiPreferences` just as +`setSelectedRuntime` would. This makes the choice stable and visible in Settings. + +```ts +const defaultRuntimeId = + persistedRuntimeId ?? + (claudeFound ? CLAUDE_TERMINAL_RUNTIME_ID : null) ?? + getDefaultRuntimeId(runtimes, setupStatusByRuntimeId); + +// Persist auto-selection so it survives binary removal / reinstall cleanly. +if (!persistedRuntimeId && claudeFound && defaultRuntimeId === CLAUDE_TERMINAL_RUNTIME_ID) { + saveAiPreferences({ defaultRuntimeId: CLAUDE_TERMINAL_RUNTIME_ID }); +} +``` + +*Verification:* Run `claude --version` and check the output contains "Claude" or +matches a known version pattern before auto-selecting. This is a second shell spawn +on startup but prevents false positives. Can be skipped if the team decides the +risk is acceptable. + +**Effort:** Small (persistence only) or Medium (persistence + version check). + +--- + +## 6. Separate `selectedRuntimeId` from `userDefaultRuntimeId` in chatStore + +**What:** `selectedRuntimeId` in the chatStore serves two conflated purposes: +- The runtime displayed in the chat header for the current session +- The user's default for new chats + +This forced `getDefaultNewChatRuntimeId()` to reach around the store and read from +`AiPreferences` directly, because the active session's runtime was overwriting the +user's default in the second `set()` call inside `initialize()`. + +**Fix:** Introduce `userDefaultRuntimeId: string | null` as a separate, first-class +store field: + +- Set during `initialize()` (from persisted pref or auto-detection), never overridden + by session restore +- Written via `setUserDefaultRuntime(id)` (which also persists to `AiPreferences`) +- Read directly in `handleAttachToNewChat`, `ai:new-agent`, and anywhere else that + needs "what does the user want for new chats" +- `selectedRuntimeId` retains its existing role as the UI-visible "active session + runtime" and is NOT persisted + +`getDefaultNewChatRuntimeId()` can be deleted once this is in place. The AI Providers +"Default agent" dropdown would bind to `userDefaultRuntimeId`. + +**Effort:** Medium — touches chatStore interface, initialize, setSelectedRuntime, +and 3–4 call sites. No new behaviour, pure refactor. Worth doing before the store +gets any larger. + +--- + +## Sequencing + +| # | Item | Effort | Priority | Blocks | +|---|---|---|---|---| +| 2 | De-duplicate descriptor | Small | Low | Nothing | +| 3 | Subscribe-based terminal ready | Small | Medium | Nothing | +| 1 | Cache binary check | Small | Medium | Informed by #6 | +| 4 | Raise settle delay | Trivial→Medium | Medium | Nothing | +| 5 | Persist auto-selection | Small | Medium | Nothing | +| 6 | Split selectedRuntimeId | Medium | High | #1 simplifies after | + +Items 2, 3, 4, 5 can be done in any order in a single small PR. +Item 6 is the architectural cleanup — worth its own PR once the dust settles. diff --git a/docs/terminal-integration.md b/docs/terminal-integration.md new file mode 100644 index 00000000..8b4d49c7 --- /dev/null +++ b/docs/terminal-integration.md @@ -0,0 +1,272 @@ +# Terminal: First-Class Integration Plan + +Related issue: [jsgrrchg/NeverWrite#107](https://github.com/jsgrrchg/NeverWrite/issues/107) + +## Background + +The terminal is now a first-class workspace surface. It is available from workspace commands and tab menus without requiring Developer Mode. + +**The PTY backend is a Rust sidecar** (`apps/desktop/native-backend/src/devtools.rs`) using `portable-pty`, spawned and managed by `nativeBackend.ts` over JSON-line stdio. There is no node-pty. This matters for Step 6: any change to env vars or spawn options crosses a language boundary and requires the sidecar binary to be rebuilt and repackaged. `TERM=xterm-256color` is already set at `devtools.rs:345`. `COLORTERM=truecolor` is not. + +The IPC struct is `DevTerminalCreateInput` (Rust, `devtools.rs:55`), currently with only `cwd`, `cols`, `rows`. Adding env var passthrough requires extending this struct in Rust and the corresponding TypeScript call sites. + +The rendering layer (xterm.js v6 in `TerminalViewport.tsx`) is sound but has three gaps: font is hardcoded, the ANSI color palette is only partially wired to theme tokens, and `COLORTERM` is missing from the PTY environment. + +There is no viable drop-in replacement for xterm.js today. The most promising future alternative — libghostty-vt (Ghostty's VT parser as a C/WASM library) — is alpha with no usable web bindings yet. We stay on xterm.js and improve the integration. + +## Goals + +1. Terminal is a first-class workspace feature, usable without enabling Developer Mode. +2. Font family and font size are user-configurable in Settings. +3. The terminal looks like it belongs in the app — full ANSI palette from theme tokens. +4. Claude Code runs correctly inside the terminal without user-side workarounds. +5. Terminal code lives in a coherent location, not split across `features/terminal/` and `features/devtools/terminal/`. + +## Non-goals + +- PTY architecture changes (utilityProcess migration, flow control). The sidecar approach is fine. +- Bundling fonts. Users provide their own. +- Enumerating system fonts in the UI. No clean cross-platform API without native modules. +- xterm.js WebGL renderer upgrade. Separate concern, not blocking. + +--- + +## Step 1 — Keep the terminal ungated + +**Files:** `src/App.tsx:921-943`, `src/features/editor/newTabMenuActions.ts:85-147`, `src/features/editor/EditorPaneBar.tsx` + +The "New Terminal" action should stay available from the workspace command palette entry and every pane's `+` menu unconditionally. + +Do not add a Developer Mode setting that promises to enable or disable terminal tabs. Terminal availability is not user-gated anymore. + +Restart remains a recovery action for active terminal tabs and should not depend on Developer Mode. + +**Also do:** +- Keep the command id as `workspace:new-terminal-tab` and the category as `"Workspace"` — "first-class" means it shows up in the right palette group. +- Assign a keyboard shortcut. Check for collisions in the existing shortcut registry. + +**Do this step last** — only ungate once the full experience (Steps 2–7) is ready. + +--- + +## Step 2 — Add terminal settings to the store + +**File:** `src/app/store/settingsStore.ts` + +Add to the settings interface and default object: + +```ts +terminalFontFamily: string // default: "" +terminalFontSize: number // default: 13 +claudeCodeOptimized: boolean // default: false +``` + +The existing persistence merge pattern at `settingsStore.ts:388-393` handles new fields via `?? defaults.X` — follow the same pattern. Empty `terminalFontFamily` means "use the built-in fallback stack" everywhere it's read; never pass an empty string to xterm.js. + +--- + +## Step 3 — Expose settings in a Terminal section + +**File:** `src/features/settings/SettingsPanel.tsx` + +`Category` is a tagged union (`SettingsPanel.tsx:3570-3580`) with a matching `CATEGORIES` array and a render switch (~`4497+`). Adding "Terminal" means: + +1. Extend the union with `"terminal"`. +2. Add an entry to `CATEGORIES` (needs an icon — pick from the existing icon set). +3. Create a `` component. +4. Add `case "terminal"` in the render switch. + +Section contents: +- **Font family** — text input. Placeholder: `"JetBrainsMono Nerd Font"`. Hint: "Font must be installed on this system." +- **Font size** — number input, range 8–24. Check whether a number input control already exists in the settings component library before building a new one. +- **Optimize for Claude Code** — toggle. Label: "Fullscreen rendering (experimental)". Hint: "Sets CLAUDE_CODE_NO_FLICKER=1. Improves rendering but disables scrollback. Only applies to new terminals." Wired to `claudeCodeOptimized`. + +Do not reintroduce a Developer settings toggle for terminal availability. Terminal tabs are always part of the workspace. + +--- + +## Step 4 — Font loading in TerminalViewport + +**Files:** `src/features/devtools/terminal/terminalTheme.ts`, `src/features/devtools/terminal/TerminalViewport.tsx` + +### terminalTheme.ts + +`getTerminalTheme` currently returns a hardcoded font stack. Accept settings values: + +```ts +export function getTerminalTheme( + element: HTMLElement | null, + opts?: { fontFamily?: string; fontSize?: number } +): TerminalTheme { + const fallback = '"SFMono-Regular", "Cascadia Code", "JetBrains Mono", Menlo, Monaco, Consolas, monospace'; + return { + // ... + fontFamily: opts?.fontFamily?.trim() || fallback, + fontSize: opts?.fontSize ?? 13, + }; +} +``` + +### TerminalViewport.tsx + +Before calling `terminal.open(containerEl)`, load the font if one is configured: + +```ts +const { terminalFontFamily, terminalFontSize } = useSettingsStore.getState(); +const fontFamily = terminalFontFamily.trim(); + +if (fontFamily) { + try { + await Promise.all([ + document.fonts.load(`normal ${terminalFontSize}px "${fontFamily}"`), + document.fonts.load(`bold ${terminalFontSize}px "${fontFamily}"`), + ]); + // document.fonts.load() resolves even when the font is absent. + // Check that it actually loaded before trusting it. + if (!document.fonts.check(`normal ${terminalFontSize}px "${fontFamily}"`)) { + console.warn(`[terminal] Font "${fontFamily}" not found, using fallback`); + // fontFamily falls back to empty → getTerminalTheme uses fallback stack + } + } catch { + console.warn(`[terminal] Font load failed for "${fontFamily}", using fallback`); + } +} + +terminal.open(containerEl); +fitAddon.fit(); +``` + +The existing `useEffect` at `TerminalViewport.tsx:354-367` that updates `terminal.options.fontFamily` / `fontSize` on settings changes needs `fitAddon.fit()` appended — font changes alter cell metrics and the viewport must reflow. Also update the dep array to include the new settings fields. + +--- + +## Step 5 — Wire up the full ANSI palette + +**File:** `src/features/devtools/terminal/terminalTheme.ts` + +xterm.js v6 `ITheme` accepts all 16 ANSI colors (normal + bright), cursor, selection, and scrollbar. Currently `getTerminalTheme` maps only `--bg-primary`, `--text-primary`, `--accent`, a selection color hardcoded to `rgba(120, 138, 158, 0.28)` (`TerminalViewport.tsx:43`), and nothing else. + +**Audit first:** read the existing CSS custom properties in `src/index.css` and the theme definitions to find what color tokens exist. If ANSI-specific tokens exist (e.g. `--ansi-red`, `--syntax-string`), map to them. If not, define a fixed palette per theme variant (light/dark) that draws from the existing semantic tokens — don't attempt to compute 16 colors from 3. + +**At minimum, set:** +- `black` / `brightBlack` — from `--bg-secondary` / `--text-secondary` or equivalent +- `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white` and their bright variants — from syntax/status color tokens if present +- `cursor` — `--accent` +- `selectionBackground` — replace the hardcoded rgba with a token or derived value +- `scrollbarSliderBackground` / `scrollbarSliderHoverBackground` / `scrollbarSliderActiveBackground` + +**Reactivity:** the dep array on the terminal options effect (`TerminalViewport.tsx:360-367`) currently watches only background/cursor/font/text. Adding 16 colors means updating that dep array. CSS vars read via `getComputedStyle` aren't reactive — they only re-read when the React render runs. Verify theme switching actually triggers a re-render (the `useThemeStore` subscription at `TerminalViewport.tsx:104` should handle this, but test it). + +--- + +## Step 6a — Rust: extend the PTY spawn input + +**File:** `apps/desktop/native-backend/src/devtools.rs` + +Extend `DevTerminalCreateInput` to accept additional environment variables: + +```rust +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DevTerminalCreateInput { + cwd: Option, + cols: Option, + rows: Option, + #[serde(default)] + extra_env: HashMap, +} +``` + +`#[serde(default)]` makes it backwards-compatible — existing callers that don't send `extraEnv` get an empty map, no IPC break. + +In `spawn_session`, after the existing `command.env("TERM", "xterm-256color")`, add: + +```rust +command.env("COLORTERM", "truecolor"); +for (key, value) in &input.extra_env { + command.env(key, value); +} +``` + +**Sidecar rebuild:** this requires rebuilding the native backend binary. Update CI to run the Rust build step and ensure the rebuilt binary is included in the package. On macOS, verify code signing still applies to the new binary. + +## Step 6b — TypeScript: thread extra_env through and add the Claude Code toggle + +**Files:** `src/features/terminal/terminalRuntimeStore.ts`, `src/features/devtools/terminal/useTerminalTabs.ts` + +Update the `devtools_create_terminal_session` call sites to pass `extraEnv` when `claudeCodeOptimized` is set in settings: + +```ts +const { claudeCodeOptimized } = useSettingsStore.getState(); +const extraEnv = claudeCodeOptimized ? { CLAUDE_CODE_NO_FLICKER: "1" } : {}; + +await invoke("devtools_create_terminal_session", { cwd, cols, rows, extraEnv }); +``` + +`CLAUDE_CODE_NO_FLICKER=1` applies only at session creation. Document this in the Settings UI hint: the toggle only affects newly opened terminals. + +--- + +## Step 7 — Consolidate terminal code + +**This is a refactor, not a pure rename.** Two directories exist and already cross-import each other: + +- `src/features/terminal/` — `WorkspaceTerminalHost`, `WorkspaceTerminalView`, `terminalRuntimeStore`, `legacyTerminalMigration` (plus tests) +- `src/features/devtools/terminal/` — `TerminalViewport`, `terminalTypes`, `terminalTheme`, `useTerminalTabs`, `terminalSessionTracking` (plus tests) + +**Move** the `devtools/terminal/` files into `src/features/terminal/`. Update all imports — including files not in the terminal directories: + +- `src/App.tsx` +- `src/features/editor/EditorPaneBar.tsx` +- `src/features/ai/components/AIAuthTerminalModal.tsx` ← easy to miss; imports `terminalTypes` +- Any test files referencing the old paths + +**CSS:** `.devtools-terminal-surface` at `src/index.css:607` is referenced by `TerminalViewport.tsx:559`. Decide: rename the CSS class to `.terminal-surface` (update both files) or leave it as-is. If renaming, do it in this commit. + +**Event names:** `devtools://terminal-output`, `devtools://terminal-started`, etc. are constants defined in Rust and matched in TypeScript. Leave them as-is — the `devtools://` prefix is in the IPC protocol, not the file path. Document this decision so future readers don't wonder. + +**Dead code:** `useTerminalTabs.ts` is only called by `legacyTerminalMigration.ts` for `readPersistedTerminalWorkspace`. Audit whether other exports are still used before moving; delete unused ones rather than dragging them forward. + +**Do this as a standalone commit** with zero logic changes so the diff is reviewable and bisectable. + +--- + +## Step 8 — Update tests + +Several test files assert the developer-gate behaviour and will break after Step 1: + +- `src/App.noteWindow.test.tsx` — asserts terminal tab behaviour, references `openTerminal()` +- `src/features/terminal/WorkspaceTerminalHost.test.tsx` +- `src/features/terminal/terminalRuntimeStore.test.ts` +- `src/features/settings/SettingsPanel.test.tsx` — may enumerate categories +- `src/app/store/settingsStore.ts` — `settingsStore.test.ts` for new fields + +Update assertions to reflect ungated behaviour and new settings fields. Add tests for font loading fallback path and `claudeCodeOptimized` env passthrough. + +--- + +## Sequence + +| Step | Scope | Dependency | Risk | +|---|---|---|---| +| 7 — consolidate | Refactor, imports | None | Low — no logic changes | +| 6a — Rust env passthrough | Rust + sidecar build | None | Medium — crosses language boundary, needs CI | +| 2 — settings fields | TS store only | None | Low | +| 5 — full ANSI palette | `terminalTheme.ts` | None | Low — visual only | +| 6b — TS Claude Code toggle | TS call sites | 6a, 2 | Low | +| 4 — font loading | `TerminalViewport.tsx` | 2 | Medium — async open() path | +| 3 — Settings UI | `SettingsPanel.tsx` | 2 | Low | +| 8 — tests | Test files | All above | Low | +| 1 — ungate | `App.tsx`, menus | All above | Low | + +Steps 7, 6a, 2, and 5 have no dependencies on each other and can be done in parallel. + +--- + +## Future / out of scope for this iteration + +- **libghostty-vt** — Ghostty's VT parser as a standalone C/WASM library (alpha, Sept 2025). No web bindings yet. When it ships, it's the most accurate available parser; worth evaluating as a drop-in under xterm.js's rendering layer. +- **WebGL renderer** (`@xterm/addon-webgl`) — up to 9x faster than canvas under heavy output. Not loaded currently. Add after this work settles. +- **utilityProcess PTY isolation** — migrate the Rust sidecar invocation to use Electron's `utilityProcess` API so sidecar crashes can't bring down the main process. Not urgent for a single-window app. +- **`CLAUDE_CODE_NO_FLICKER=1` fullscreen rendering** — already exposed as a toggle in Step 3, but Anthropic still marks it experimental. Promote the toggle to non-experimental once Anthropic stabilises scrollback behaviour. +- **Keyboard shortcut for New Terminal** — decide on a shortcut and register it. Deferred to avoid shortcut collision analysis blocking the main work.