From 2f7dbf943990ea51bf3ebcf0b116ee092b337b10 Mon Sep 17 00:00:00 2001 From: Simon Pamies Date: Wed, 20 May 2026 12:08:27 +0200 Subject: [PATCH 01/20] feat(terminal): consolidate terminal modules from devtools/ into features/terminal/ Move TerminalViewport, terminalTheme, terminalTypes, terminalRawOutput, terminalSessionTracking, and useTerminalTabs out of features/devtools/terminal/ into features/terminal/ alongside the existing runtime store and host components. Update all imports, rename the CSS class from .devtools-terminal-surface to .terminal-surface, and expand terminalTheme to include the full 16-colour ANSI palette (mapped from the app's Catppuccin icon tokens) and font-options support. Includes the implementation plan in docs/terminal-integration.md. --- .../ai/components/AIAuthTerminalModal.tsx | 6 +- .../devtools/terminal/terminalTheme.ts | 32 -- .../terminal/TerminalViewport.test.tsx | 2 +- .../terminal/TerminalViewport.tsx | 192 ++++++++---- .../terminal/WorkspaceTerminalHost.tsx | 2 +- .../terminal/WorkspaceTerminalView.tsx | 2 +- .../terminal/legacyTerminalMigration.ts | 2 +- .../terminal/terminalRawOutput.ts | 0 .../terminal/terminalSessionTracking.test.ts | 0 .../terminal/terminalSessionTracking.ts | 0 .../src/features/terminal/terminalTheme.ts | 82 ++++++ .../{devtools => }/terminal/terminalTypes.ts | 1 + .../terminal/useTerminalTabs.ts | 11 +- apps/desktop/src/index.css | 6 +- docs/terminal-integration.md | 274 ++++++++++++++++++ 15 files changed, 507 insertions(+), 105 deletions(-) delete mode 100644 apps/desktop/src/features/devtools/terminal/terminalTheme.ts rename apps/desktop/src/features/{devtools => }/terminal/TerminalViewport.test.tsx (99%) rename apps/desktop/src/features/{devtools => }/terminal/TerminalViewport.tsx (80%) rename apps/desktop/src/features/{devtools => }/terminal/terminalRawOutput.ts (100%) rename apps/desktop/src/features/{devtools => }/terminal/terminalSessionTracking.test.ts (100%) rename apps/desktop/src/features/{devtools => }/terminal/terminalSessionTracking.ts (100%) create mode 100644 apps/desktop/src/features/terminal/terminalTheme.ts rename apps/desktop/src/features/{devtools => }/terminal/terminalTypes.ts (97%) rename apps/desktop/src/features/{devtools => }/terminal/useTerminalTabs.ts (98%) create mode 100644 docs/terminal-integration.md 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/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/devtools/terminal/TerminalViewport.test.tsx b/apps/desktop/src/features/terminal/TerminalViewport.test.tsx similarity index 99% rename from apps/desktop/src/features/devtools/terminal/TerminalViewport.test.tsx rename to apps/desktop/src/features/terminal/TerminalViewport.test.tsx index 3b50bcd4..e8f31c3d 100644 --- a/apps/desktop/src/features/devtools/terminal/TerminalViewport.test.tsx +++ b/apps/desktop/src/features/terminal/TerminalViewport.test.tsx @@ -4,7 +4,7 @@ import { flushPromises, getXtermMockInstances, renderComponent, -} from "../../../test/test-utils"; +} from "../../test/test-utils"; import { TerminalViewport } from "./TerminalViewport"; import type { TerminalSessionView } from "./terminalTypes"; diff --git a/apps/desktop/src/features/devtools/terminal/TerminalViewport.tsx b/apps/desktop/src/features/terminal/TerminalViewport.tsx similarity index 80% rename from apps/desktop/src/features/devtools/terminal/TerminalViewport.tsx rename to apps/desktop/src/features/terminal/TerminalViewport.tsx index 3174a293..481d5976 100644 --- a/apps/desktop/src/features/devtools/terminal/TerminalViewport.tsx +++ b/apps/desktop/src/features/terminal/TerminalViewport.tsx @@ -14,12 +14,14 @@ import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent, } from "react"; -import { useThemeStore } from "../../../app/store/themeStore"; +import { useSettingsStore } from "../../app/store/settingsStore"; +import { useThemeStore } from "../../app/store/themeStore"; +import { useLayoutStore } from "../../app/store/layoutStore"; import { ContextMenu, type ContextMenuEntry, type ContextMenuState, -} from "../../../components/context-menu/ContextMenu"; +} from "../../components/context-menu/ContextMenu"; import { getTerminalTheme } from "./terminalTheme"; import type { TerminalSessionView } from "./terminalTypes"; @@ -40,7 +42,26 @@ function createXtermTheme(theme: ReturnType) { 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; + 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.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/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/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..4f6813d6 --- /dev/null +++ b/apps/desktop/src/features/terminal/terminalTheme.ts @@ -0,0 +1,82 @@ +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(); + + 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, + // ANSI palette derived from Catppuccin icon tokens (defined for both + // light and dark themes) — gives consistent, intentional colours. + black: v("--bg-secondary"), + red: v("--catppuccin-icon-red"), + green: v("--catppuccin-icon-green"), + yellow: v("--catppuccin-icon-yellow"), + blue: v("--catppuccin-icon-blue"), + magenta: v("--catppuccin-icon-mauve"), + cyan: v("--catppuccin-icon-teal"), + white: v("--text-primary"), + brightBlack: v("--text-secondary"), + brightRed: v("--catppuccin-icon-maroon"), + brightGreen: v("--catppuccin-icon-green"), + brightYellow: v("--catppuccin-icon-peach"), + brightBlue: v("--catppuccin-icon-lavender"), + brightMagenta: v("--catppuccin-icon-pink"), + brightCyan: v("--catppuccin-icon-sky"), + brightWhite: v("--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/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-integration.md b/docs/terminal-integration.md new file mode 100644 index 00000000..f5f3f5bd --- /dev/null +++ b/docs/terminal-integration.md @@ -0,0 +1,274 @@ +# Terminal: First-Class Integration Plan + +Related issue: [jsgrrchg/NeverWrite#107](https://github.com/jsgrrchg/NeverWrite/issues/107) + +## Background + +The terminal currently works but sits behind a double gate: `developerModeEnabled` must be on, then `developerTerminalEnabled` inside it. Both flags live in the Developer section of Settings. + +**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 — Ungate the terminal + +**Files:** `src/App.tsx:921-943`, `src/features/editor/newTabMenuActions.ts:85-147`, `src/features/editor/EditorPaneBar.tsx` + +Remove the `developerModeEnabled` guard from the `developer:new-terminal-tab` command palette entry (`App.tsx:939`). Remove the `developerTerminalEnabled` check from `buildNewTabContextMenuEntries` (`newTabMenuActions.ts:138`) so "New Terminal" appears in every pane's `+` menu unconditionally. + +Note: `developerTerminalEnabled` already defaults to `true` (`settingsStore.ts:175`). No default flip needed — only the `developerModeEnabled` outer gate has to drop. + +Leave both toggles in the Developer settings section for users who want to hide the feature. + +The `developer:restart-terminal` command stays behind `developerModeEnabled`. But once terminal creation is ungated, restart becomes a usability need for ordinary users too. Add a right-click context menu entry on the terminal tab itself (already partially exists in `EditorPaneBar.tsx` tab context menu) so non-developer users have a recovery path that doesn't require dev mode. + +**Also do:** +- Change the command id from `developer:new-terminal-tab` to `workspace:new-terminal-tab` and the category from `"Developer"` to `"Workspace"` (or `"Tabs"`) — cosmetics, but "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`. + +The Developer section keeps `developerTerminalEnabled` and `developerModeEnabled`. Consider whether `developerTerminalEnabled` should be renamed `terminalEnabled` now that the terminal is first-class — if so, add a migration in the persistence merge. + +--- + +## 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. From f9ff007c55c04a6dd247ae405070fcbe4212bc2c Mon Sep 17 00:00:00 2001 From: Simon Pamies Date: Wed, 20 May 2026 12:08:43 +0200 Subject: [PATCH 02/20] feat(terminal): promote terminal to first-class workspace feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ungate the terminal from Developer Mode — New Terminal appears in every pane's + menu and the command palette without requiring developerModeEnabled. Settings → Terminal section: font family (with document.fonts.load() pre-check for correct xterm.js cell metrics), font size, and Claude Code rendering toggle (CLAUDE_CODE_NO_FLICKER=1). Right-panel layout watcher re-fits the terminal after peek overlays open/close. Rust sidecar: COLORTERM=truecolor added as default; extra_env passthrough for per-session env overrides; devtools_check_binary command for PATH-aware binary detection via login shell. TypeScript: extraEnv field threads through TerminalSessionCreateInput and both terminal session create call sites. --- apps/desktop/native-backend/src/devtools.rs | 25 ++ apps/desktop/native-backend/src/main.rs | 3 +- .../src-electron/main/nativeBackend.ts | 1 + apps/desktop/src/App.noteWindow.test.tsx | 9 +- apps/desktop/src/App.tsx | 5 +- apps/desktop/src/app/store/settingsStore.ts | 52 +++ apps/desktop/src/app/themes/index.ts | 3 + .../src/features/editor/EditorPaneBar.tsx | 12 +- .../src/features/editor/newTabMenuActions.ts | 31 +- .../src/features/settings/SettingsPanel.tsx | 301 ++++++++++++++++++ .../terminal/WorkspaceTerminalHost.test.tsx | 3 +- .../features/terminal/terminalRuntimeStore.ts | 13 +- 12 files changed, 419 insertions(+), 39 deletions(-) diff --git a/apps/desktop/native-backend/src/devtools.rs b/apps/desktop/native-backend/src/devtools.rs index 2f0b8a43..85f1103d 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,24 @@ 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"])?; + // 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 +207,7 @@ impl DevTerminalManager { cwd: Some(snapshot.cwd), cols: Some(snapshot.cols), rows: Some(snapshot.rows), + extra_env: HashMap::new(), }, ) } @@ -343,8 +364,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 ef024d6b..82247c71 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..510a10a9 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(); @@ -308,6 +304,7 @@ describe("App note window", () => { cwd: "/vault", cols: 120, rows: 24, + extraEnv: {}, }, }, ); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 3b13b876..3699bbdd 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -934,11 +934,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 +1045,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/store/settingsStore.ts b/apps/desktop/src/app/store/settingsStore.ts index ef05541d..3d4e853a 100644 --- a/apps/desktop/src/app/store/settingsStore.ts +++ b/apps/desktop/src/app/store/settingsStore.ts @@ -35,6 +35,15 @@ 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; @@ -174,6 +183,13 @@ const defaults: Settings = { agentsSidebarScale: 100, fileTreeStickyFolders: true, tabOpenBehavior: "history", + terminalFontFamily: "", + terminalFontSize: 13, + claudeCodeOptimized: false, + claudeCodeSkipPermissions: false, + claudeCodeModel: "", + claudeCodeContinueSession: false, + claudeCodeMaxTurns: 0, developerModeEnabled: false, developerTerminalEnabled: true, fileTreeContentMode: "notes_only", @@ -388,6 +404,35 @@ function extractSettingsFromStorage(raw: string | null): Settings | null { tabOpenBehavior: normalizeTabOpenBehavior( parsed.state.tabOpenBehavior, ), + 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, + ), developerModeEnabled: parsed.state.developerModeEnabled ?? defaults.developerModeEnabled, @@ -462,6 +507,13 @@ function pickSettings(state: SettingsStore): Settings { agentsSidebarScale: state.agentsSidebarScale, fileTreeStickyFolders: state.fileTreeStickyFolders, tabOpenBehavior: state.tabOpenBehavior, + terminalFontFamily: state.terminalFontFamily, + terminalFontSize: state.terminalFontSize, + claudeCodeOptimized: state.claudeCodeOptimized, + claudeCodeSkipPermissions: state.claudeCodeSkipPermissions, + claudeCodeModel: state.claudeCodeModel, + claudeCodeContinueSession: state.claudeCodeContinueSession, + claudeCodeMaxTurns: state.claudeCodeMaxTurns, developerModeEnabled: state.developerModeEnabled, developerTerminalEnabled: state.developerTerminalEnabled, fileTreeContentMode: state.fileTreeContentMode, 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/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/newTabMenuActions.ts b/apps/desktop/src/features/editor/newTabMenuActions.ts index a7528c7e..f2be6447 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,17 @@ export function buildNewTabContextMenuEntries(options?: { ? runtimes.map((runtime) => ({ label: getRuntimeMenuLabel(runtime.runtime.name), action: () => { - void createNewChat(runtime.runtime.id, paneId); + if ( + runtime.runtime.id === + CLAUDE_TERMINAL_RUNTIME_ID + ) { + void openClaudeCodeTerminalWithContext(); + } else { + void createNewChat( + runtime.runtime.id, + paneId, + ); + } }, })) : [ @@ -134,14 +141,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/SettingsPanel.tsx b/apps/desktop/src/features/settings/SettingsPanel.tsx index c38d86d6..3e2f9952 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, @@ -3044,6 +3045,248 @@ function resolveStatusDescription({ } } +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 { + terminalFontFamily, + terminalFontSize, + claudeCodeOptimized, + claudeCodeSkipPermissions, + claudeCodeModel, + claudeCodeContinueSession, + claudeCodeMaxTurns, + setSetting, + } = useSettingsStore(); + + 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, + "Shell Environment", + [ + [ + "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", [ + [ + "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, }: { @@ -3582,6 +3825,7 @@ type Category = | "editor" | "spellcheck" | "updates" + | "terminal" | "developers" | "vault" | "shortcuts" @@ -3678,6 +3922,30 @@ const CATEGORIES: { id: Category; label: string; icon: React.ReactNode }[] = [ ), }, + { + id: "terminal", + label: "Terminal", + icon: ( + + + + + ), + }, { id: "developers", label: "Developers", @@ -3790,6 +4058,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", @@ -3867,6 +4136,30 @@ 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", @@ -3995,6 +4288,8 @@ function getDynamicCategorySearchValues( context.updateStatus.status?.update?.body, context.updateStatus.error, ]; + case "terminal": + return []; case "developers": return []; case "vault": @@ -4532,6 +4827,12 @@ export function SettingsPanel({ searchQuery={activeSearchQuery} /> )} + {filteredCategories.length > 0 && + activeCategory === "terminal" && ( + + )} {filteredCategories.length > 0 && activeCategory === "developers" && ( { cwd: "/vault", cols: 120, rows: 24, + extraEnv: {}, }, }, ); 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, }, }, ); From 2ae4086da8bbc525b2f365e4863b82d95ca95f47 Mon Sep 17 00:00:00 2001 From: Simon Pamies Date: Wed, 20 May 2026 12:08:59 +0200 Subject: [PATCH 03/20] feat(terminal): add Claude Code CLI as built-in agent provider Registers claude-code-terminal as a pseudo-runtime injected alongside ACP providers in chatStore. Binary presence is detected via devtools_check_binary (login shell) at initialization; result drives binaryReady/authReady and the auto-default selection (Claude Code becomes the default new-chat target when found and no explicit preference is set). selectedRuntimeId persistence: saved to AI preferences so the choice survives restarts. getDefaultNewChatRuntimeId() reads prefs + binary status directly, bypassing the active-session override that would otherwise shadow the setting. Add to chat routing: handleAttachToNewChat checks getDefaultNewChatRuntimeId() and calls openClaudeCodeTerminalWithContext() when Claude Code is the target. Context (notes, files) is formatted as quoted @mentions; a cd command scopes the session to the vault root or the selected folder. AI Providers settings: Claude Code appears in the INSTALLED section with a "Ready" badge when found, and in ALL with an Install button (opens claude.ai/code) when not. A "Default agent" selector with documentation sits above the installed list. A note under the Claude Code row links to Terminal settings for model, skip-permissions, max-turns, and continue-session configuration. --- .../src/features/ai/AIChatWorkspaceHost.tsx | 10 + .../src/features/ai/chatPaneMovement.ts | 9 + .../src/features/ai/store/chatStore.test.ts | 3 +- .../src/features/ai/store/chatStore.ts | 76 ++++- .../src/features/ai/utils/runtimeMetadata.ts | 19 +- .../features/settings/AIProvidersSettings.tsx | 307 +++++++++++++++--- .../features/terminal/claudeCodeTerminal.ts | 145 +++++++++ 7 files changed, 515 insertions(+), 54 deletions(-) create mode 100644 apps/desktop/src/features/terminal/claudeCodeTerminal.ts 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/chatPaneMovement.ts b/apps/desktop/src/features/ai/chatPaneMovement.ts index 78bf28a8..6e3b6648 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, @@ -279,6 +280,14 @@ export async function createNewChatInWorkspace( options?: OpenChatInWorkspaceOptions, ) { const resolvedRuntimeId = resolveWorkspaceNewChatRuntimeId(runtimeId); + // The claude-terminal pseudo-runtime has no ACP backend. Guard both the + // explicitly passed ID and the user's effective default. + if ( + resolvedRuntimeId === CLAUDE_TERMINAL_RUNTIME_ID || + useChatStore.getState().getDefaultNewChatRuntimeId() === + CLAUDE_TERMINAL_RUNTIME_ID + ) + return null; const pendingSession = createPendingWorkspaceSession(resolvedRuntimeId); if (!pendingSession) { const createdSessionId = await useChatStore diff --git a/apps/desktop/src/features/ai/store/chatStore.test.ts b/apps/desktop/src/features/ai/store/chatStore.test.ts index ef9d51af..6bd56ecc 100644 --- a/apps/desktop/src/features/ai/store/chatStore.test.ts +++ b/apps/desktop/src/features/ai/store/chatStore.test.ts @@ -827,7 +827,8 @@ 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", diff --git a/apps/desktop/src/features/ai/store/chatStore.ts b/apps/desktop/src/features/ai/store/chatStore.ts index 7bd60aaf..b07b110e 100644 --- a/apps/desktop/src/features/ai/store/chatStore.ts +++ b/apps/desktop/src/features/ai/store/chatStore.ts @@ -149,6 +149,37 @@ import { subscribeSafeStorage, } from "../../../app/utils/safeStorage"; import { logDebug, logError, logWarn } from "../../../app/utils/runtimeLog"; +import { CLAUDE_TERMINAL_RUNTIME_ID } from "../utils/runtimeMetadata"; +import { checkClaudeCodeInstalled } from "../../terminal/claudeCodeTerminal"; + +const CLAUDE_TERMINAL_DESCRIPTOR = { + runtime: { + id: CLAUDE_TERMINAL_RUNTIME_ID, + name: "Claude Code", + description: + "Claude Code CLI running in an integrated terminal tab.", + capabilities: ["attachments"] as string[], + }, + models: [] as never[], + modes: [] as never[], + configOptions: [] as never[], +} satisfies import("../types").AIRuntimeDescriptor; + +function buildClaudeTerminalSetupStatus( + binaryFound: boolean, +): import("../types").AIRuntimeSetupStatus { + return { + runtimeId: CLAUDE_TERMINAL_RUNTIME_ID, + binaryReady: binaryFound, + binarySource: "env" as const, + authReady: binaryFound, + authMethods: [] as never[], + onboardingRequired: false, + message: binaryFound + ? undefined + : 'claude not found in PATH. Install via: npm install -g @anthropic-ai/claude-code', + }; +} const AI_PREFS_KEY = "neverwrite.ai.preferences"; const AI_RUNTIME_CACHE_KEY = "neverwrite.ai.runtime-catalog"; @@ -197,6 +228,7 @@ interface AiPreferences { editDiffZoom?: number; historyRetentionDays?: number; screenshotRetentionSeconds?: number; + defaultRuntimeId?: string; } interface NormalizedAiPreferences { @@ -1179,6 +1211,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; @@ -6394,6 +6427,23 @@ export const useChatStore = create((set, get) => { setSelectedRuntime: (runtimeId) => { set({ selectedRuntimeId: runtimeId }); + saveAiPreferences({ defaultRuntimeId: runtimeId ?? undefined }); + }, + + getDefaultNewChatRuntimeId: () => { + // Reads the user's persisted explicit choice. Falls back to + // Claude Code if the binary is available and no explicit override + // was set. Uses prefs directly so the active session's runtime + // (which drives selectedRuntimeId) can't shadow this. + const explicit = loadAiPreferences().defaultRuntimeId ?? null; + if (explicit !== null) return explicit; + const { setupStatusByRuntimeId } = get(); + if ( + setupStatusByRuntimeId[CLAUDE_TERMINAL_RUNTIME_ID]?.authReady + ) { + return CLAUDE_TERMINAL_RUNTIME_ID; + } + return get().selectedRuntimeId; }, initialize: async (options) => { @@ -6407,17 +6457,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,10 +6488,24 @@ 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. Otherwise auto-default to + // Claude Code if found in PATH, falling back to first ready ACP + // runtime. + const persistedRuntimeId = + loadAiPreferences().defaultRuntimeId ?? null; const defaultRuntimeId = - get().selectedRuntimeId ?? + persistedRuntimeId ?? + (claudeFound ? CLAUDE_TERMINAL_RUNTIME_ID : null) ?? getDefaultRuntimeId(runtimes, setupStatusByRuntimeId); set({ 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/settings/AIProvidersSettings.tsx b/apps/desktop/src/features/settings/AIProvidersSettings.tsx index 359600ee..2c171727 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,12 @@ import { isIntegratedTerminalAuthMethod, } from "../ai/utils/authMethods"; import { + CLAUDE_TERMINAL_RUNTIME_ID, getRuntimeDisplayName, PROVIDER_CATALOG, } from "../ai/utils/runtimeMetadata"; +import { checkClaudeCodeInstalled } from "../terminal/claudeCodeTerminal"; +import { useChatStore } from "../ai/store/chatStore"; import { getClaudeGatewayUrlValidationMessage } from "../ai/utils/claudeGatewayUrl"; import { EMPTY_SEARCH_QUERY, @@ -827,6 +831,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 +900,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 +921,40 @@ export function AIProvidersSettings({ } }); + // Inject Claude Code CLI as a first-class runtime. + const claudeFound = await checkClaudeCodeInstalled(); + if (cancelled) return; + + const claudeDescriptor: AIRuntimeDescriptor = { + runtime: { + id: CLAUDE_TERMINAL_RUNTIME_ID, + name: "Claude Code", + description: "Claude Code CLI in an integrated terminal.", + capabilities: ["attachments"], + }, + models: [], + modes: [], + configOptions: [], + }; + statuses[CLAUDE_TERMINAL_RUNTIME_ID] = { + runtimeId: CLAUDE_TERMINAL_RUNTIME_ID, + binaryReady: claudeFound, + binarySource: "env", + authReady: claudeFound, + authMethods: [], + onboardingRequired: false, + message: claudeFound + ? undefined + : "claude not found. Run: npm install -g @anthropic-ai/claude-code", + }; + + // 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, claudeDescriptor] + : descriptors; + + setRuntimes(allDescriptors); setSetupStatusMap(statuses); setErrorMap(errors); } catch { @@ -1189,8 +1228,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 +1383,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 +1414,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 +1466,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.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/terminal/claudeCodeTerminal.ts b/apps/desktop/src/features/terminal/claudeCodeTerminal.ts new file mode 100644 index 00000000..0601e8e3 --- /dev/null +++ b/apps/desktop/src/features/terminal/claudeCodeTerminal.ts @@ -0,0 +1,145 @@ +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"; + +export async function checkClaudeCodeInstalled(): Promise { + try { + const result = await invoke<{ found: boolean }>( + "devtools_check_binary", + { name: "claude" }, + ); + return result.found; + } catch { + return false; + } +} + +// Milliseconds to wait for the terminal PTY to reach "running" state. +const TERMINAL_READY_TIMEOUT_MS = 10_000; +// Milliseconds to wait for Claude Code's TUI to initialise before pre-filling. +const CLAUDE_TUI_SETTLE_MS = 2_000; + +// Wrap a path in double quotes if it contains spaces so Claude Code's +// @mention parser doesn't split it at the first space. +function quoteForMention(path: string): string { + return path.includes(" ") ? `"${path}"` : path; +} + +function buildContextArgs(detail: FileTreeNoteDragDetail): 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) => n.path), + ...(detail.files ?? []).map((f) => f.filePath), + ]; + 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) => { + let intervalId: ReturnType; + + const timeoutId = setTimeout(() => { + clearInterval(intervalId); + resolve(false); + }, TERMINAL_READY_TIMEOUT_MS); + + intervalId = setInterval(() => { + const status = + useTerminalRuntimeStore.getState().runtimesById[terminalId] + ?.snapshot.status; + if (status === "running") { + clearTimeout(timeoutId); + clearInterval(intervalId); + resolve(true); + } else if (status === "error" || status === "exited") { + clearTimeout(timeoutId); + clearInterval(intervalId); + resolve(false); + } + }, 100); + }); +} + +export async function openClaudeCodeTerminalWithContext( + detail?: FileTreeNoteDragDetail, +): Promise { + const vaultPath = useVaultStore.getState().vaultPath; + const tabId = useEditorStore + .getState() + .openTerminal({ cwd: vaultPath ?? undefined }); + 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) { + await store.writeInput( + terminalId, + `cd "${cdTarget.replace(/"/g, '\\"')}"\n`, + ); + } + + // Build the claude command from settings. + const { + claudeCodeSkipPermissions, + claudeCodeModel, + claudeCodeContinueSession, + claudeCodeMaxTurns, + } = useSettingsStore.getState(); + + const flags: string[] = []; + if (claudeCodeSkipPermissions) flags.push("--dangerously-skip-permissions"); + if (claudeCodeModel.trim()) flags.push("--model", claudeCodeModel.trim()); + if (claudeCodeContinueSession) flags.push("--continue"); + if (claudeCodeMaxTurns > 0) flags.push("--max-turns", String(claudeCodeMaxTurns)); + + const claudeCommand = + flags.length > 0 ? `claude ${flags.join(" ")}\n` : "claude\n"; + await store.writeInput(terminalId, claudeCommand); + + if (!detail) return; + + const contextArgs = buildContextArgs(detail); + 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); +} From 2f9452a37249b6c1b2fcfc953405c883e5430c9d Mon Sep 17 00:00:00 2001 From: Simon Pamies Date: Wed, 20 May 2026 12:15:52 +0200 Subject: [PATCH 04/20] feat(terminal): apply NeverWrite themes to xterm.js ANSI colour palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds terminalPalettes.ts: hand-tuned 16-colour ANSI palettes for all 20 NeverWrite themes (catppuccin, nord, solarized, gruvbox, tokyoNight, rosePine, kanagawa, everforest, ayu, nightOwl, vesper, synthwave84, and the app-specific default, ocean, forest, rose, amber, lavender, sunset, claude, codex). Palettes are applied as --terminal-ansi-* CSS custom properties synchronously inside applyThemeColors() — same call site, same tick, no effect-ordering issue. getTerminalTheme() reads --terminal-ansi-* first, falling back to the existing --catppuccin-icon-* tokens for any theme without a custom entry. When the user switches themes, the terminal colours update live via the existing useThemeStore subscription in TerminalViewport. --- apps/desktop/src/App.noteWindow.test.tsx | 28 - .../src/app/themes/terminalPalettes.ts | 858 ++++++++++++++++++ .../features/terminal/claudeCodeTerminal.ts | 4 +- .../src/features/terminal/terminalTheme.ts | 40 +- 4 files changed, 881 insertions(+), 49 deletions(-) create mode 100644 apps/desktop/src/app/themes/terminalPalettes.ts diff --git a/apps/desktop/src/App.noteWindow.test.tsx b/apps/desktop/src/App.noteWindow.test.tsx index 510a10a9..19856cf0 100644 --- a/apps/desktop/src/App.noteWindow.test.tsx +++ b/apps/desktop/src/App.noteWindow.test.tsx @@ -240,34 +240,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({ diff --git a/apps/desktop/src/app/themes/terminalPalettes.ts b/apps/desktop/src/app/themes/terminalPalettes.ts new file mode 100644 index 00000000..46545dd9 --- /dev/null +++ b/apps/desktop/src/app/themes/terminalPalettes.ts @@ -0,0 +1,858 @@ +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; +} + +// Maps a theme + isDark flag to an intentionally-designed 16-colour ANSI +// palette. Themes that don't have an entry fall back to the Catppuccin icon +// token CSS variables already defined globally. +const PALETTES: Partial> = { + 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 entry = PALETTES[name]; + if (!entry) return; // no palette → CSS Catppuccin fallbacks remain + const palette = isDark ? entry.dark : entry.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/terminal/claudeCodeTerminal.ts b/apps/desktop/src/features/terminal/claudeCodeTerminal.ts index 0601e8e3..b4dd6d29 100644 --- a/apps/desktop/src/features/terminal/claudeCodeTerminal.ts +++ b/apps/desktop/src/features/terminal/claudeCodeTerminal.ts @@ -61,14 +61,12 @@ function resolveCdTarget( function waitForTerminalRunning(terminalId: string): Promise { return new Promise((resolve) => { - let intervalId: ReturnType; - const timeoutId = setTimeout(() => { clearInterval(intervalId); resolve(false); }, TERMINAL_READY_TIMEOUT_MS); - intervalId = setInterval(() => { + const intervalId = setInterval(() => { const status = useTerminalRuntimeStore.getState().runtimesById[terminalId] ?.snapshot.status; diff --git a/apps/desktop/src/features/terminal/terminalTheme.ts b/apps/desktop/src/features/terminal/terminalTheme.ts index 4f6813d6..c8905f17 100644 --- a/apps/desktop/src/features/terminal/terminalTheme.ts +++ b/apps/desktop/src/features/terminal/terminalTheme.ts @@ -45,6 +45,12 @@ export function getTerminalTheme( ); 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"), @@ -56,24 +62,22 @@ export function getTerminalTheme( fontFamily: opts?.fontFamily?.trim() || FALLBACK_FONT_STACK, fontSize: opts?.fontSize ?? 13, lineHeight: 1.4, - // ANSI palette derived from Catppuccin icon tokens (defined for both - // light and dark themes) — gives consistent, intentional colours. - black: v("--bg-secondary"), - red: v("--catppuccin-icon-red"), - green: v("--catppuccin-icon-green"), - yellow: v("--catppuccin-icon-yellow"), - blue: v("--catppuccin-icon-blue"), - magenta: v("--catppuccin-icon-mauve"), - cyan: v("--catppuccin-icon-teal"), - white: v("--text-primary"), - brightBlack: v("--text-secondary"), - brightRed: v("--catppuccin-icon-maroon"), - brightGreen: v("--catppuccin-icon-green"), - brightYellow: v("--catppuccin-icon-peach"), - brightBlue: v("--catppuccin-icon-lavender"), - brightMagenta: v("--catppuccin-icon-pink"), - brightCyan: v("--catppuccin-icon-sky"), - brightWhite: v("--text-heading"), + 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"), From ae923d56581e19253eaf74b5a1dfd850ab675825 Mon Sep 17 00:00:00 2001 From: Simon Pamies Date: Wed, 20 May 2026 12:52:10 +0200 Subject: [PATCH 05/20] fix(providers): require API key for Claude ACP; remove subscription auth Subscription-based auth (claude-ai-login, claude-login, console-login) only works with the Claude Code CLI, not the ACP sidecar. Strip these methods from claude-acp in normalizeRuntimeSetupStatus() and mark the runtime as not-ready when the current auth is subscription-based. Adds a note in the Claude provider expanded panel directing users to configure an Anthropic API key. If only a subscription was previously configured, Claude ACP now shows as "Not configured" until a key is added. --- apps/desktop/src/features/ai/api.ts | 26 +++++++++-- .../src/features/ai/store/chatStore.test.ts | 2 +- .../features/settings/AIProvidersSettings.tsx | 43 +++++++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) 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/store/chatStore.test.ts b/apps/desktop/src/features/ai/store/chatStore.test.ts index 6bd56ecc..40fd3097 100644 --- a/apps/desktop/src/features/ai/store/chatStore.test.ts +++ b/apps/desktop/src/features/ai/store/chatStore.test.ts @@ -865,7 +865,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/settings/AIProvidersSettings.tsx b/apps/desktop/src/features/settings/AIProvidersSettings.tsx index 2c171727..55b03066 100644 --- a/apps/desktop/src/features/settings/AIProvidersSettings.tsx +++ b/apps/desktop/src/features/settings/AIProvidersSettings.tsx @@ -1575,6 +1575,49 @@ export function AIProvidersSettings({ )} {/* 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 ? ( Date: Wed, 20 May 2026 13:19:49 +0200 Subject: [PATCH 06/20] fix(terminal): address Opus code review findings Security: - devtools_check_binary: validate binary name against [A-Za-z0-9._-] before interpolating into sh -lc to prevent shell injection - cd command: switch from double-quote to single-quote escaping so $, backticks, and backslash in vault/folder names can't execute arbitrary shell code Bugs: - chatPaneMovement: remove the second guard clause (default-runtime check) that was silently returning null for explicit ACP runtime requests (e.g. clicking + Codex from the sidebar) when Claude Code was the default provider - ai:new-agent command/shortcut: route to openClaudeCodeTerminalWithContext() when Claude Code is the default instead of calling createNewChatInWorkspace() which would silently no-op Correctness: - buildContextArgs: strip vault root prefix from absolute note/file paths so @mentions are vault-relative rather than exposing full filesystem paths - @mention quoting: use safe-character whitelist instead of space-only check - Thread paneId through openClaudeCodeTerminalWithContext so terminals opened from a specific pane's + menu land in the right pane --- apps/desktop/native-backend/src/devtools.rs | 8 ++++ apps/desktop/src/App.tsx | 11 +++++- .../src/features/ai/chatPaneMovement.ts | 11 ++---- .../src/features/editor/newTabMenuActions.ts | 5 ++- .../features/terminal/claudeCodeTerminal.ts | 39 +++++++++++++------ 5 files changed, 52 insertions(+), 22 deletions(-) diff --git a/apps/desktop/native-backend/src/devtools.rs b/apps/desktop/native-backend/src/devtools.rs index 85f1103d..e5f2c8ef 100644 --- a/apps/desktop/native-backend/src/devtools.rs +++ b/apps/desktop/native-backend/src/devtools.rs @@ -160,6 +160,14 @@ impl DevTerminalManager { } "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)] diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 3699bbdd..f9994e93 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"; @@ -685,7 +687,14 @@ function useRegisterCommands( category: newAgentShortcut.category, when: hasVault, execute: () => { - void createNewChatInWorkspace(); + if ( + useChatStore.getState().getDefaultNewChatRuntimeId() === + CLAUDE_TERMINAL_RUNTIME_ID + ) { + void openClaudeCodeTerminalWithContext(); + } else { + void createNewChatInWorkspace(); + } }, }); diff --git a/apps/desktop/src/features/ai/chatPaneMovement.ts b/apps/desktop/src/features/ai/chatPaneMovement.ts index 6e3b6648..b22bc2f0 100644 --- a/apps/desktop/src/features/ai/chatPaneMovement.ts +++ b/apps/desktop/src/features/ai/chatPaneMovement.ts @@ -280,14 +280,9 @@ export async function createNewChatInWorkspace( options?: OpenChatInWorkspaceOptions, ) { const resolvedRuntimeId = resolveWorkspaceNewChatRuntimeId(runtimeId); - // The claude-terminal pseudo-runtime has no ACP backend. Guard both the - // explicitly passed ID and the user's effective default. - if ( - resolvedRuntimeId === CLAUDE_TERMINAL_RUNTIME_ID || - useChatStore.getState().getDefaultNewChatRuntimeId() === - CLAUDE_TERMINAL_RUNTIME_ID - ) - return null; + // 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) { const createdSessionId = await useChatStore diff --git a/apps/desktop/src/features/editor/newTabMenuActions.ts b/apps/desktop/src/features/editor/newTabMenuActions.ts index f2be6447..7ab652db 100644 --- a/apps/desktop/src/features/editor/newTabMenuActions.ts +++ b/apps/desktop/src/features/editor/newTabMenuActions.ts @@ -119,7 +119,10 @@ export function buildNewTabContextMenuEntries(options?: { runtime.runtime.id === CLAUDE_TERMINAL_RUNTIME_ID ) { - void openClaudeCodeTerminalWithContext(); + void openClaudeCodeTerminalWithContext( + undefined, + paneId, + ); } else { void createNewChat( runtime.runtime.id, diff --git a/apps/desktop/src/features/terminal/claudeCodeTerminal.ts b/apps/desktop/src/features/terminal/claudeCodeTerminal.ts index b4dd6d29..952f1527 100644 --- a/apps/desktop/src/features/terminal/claudeCodeTerminal.ts +++ b/apps/desktop/src/features/terminal/claudeCodeTerminal.ts @@ -26,19 +26,33 @@ const TERMINAL_READY_TIMEOUT_MS = 10_000; // Milliseconds to wait for Claude Code's TUI to initialise before pre-filling. const CLAUDE_TUI_SETTLE_MS = 2_000; -// Wrap a path in double quotes if it contains spaces so Claude Code's -// @mention parser doesn't split it at the first space. +// 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 path.includes(" ") ? `"${path}"` : path; + return /^[A-Za-z0-9_./-]+$/.test(path) ? path : `"${path}"`; } -function buildContextArgs(detail: FileTreeNoteDragDetail): string { +// 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) => n.path), - ...(detail.files ?? []).map((f) => f.filePath), + ...detail.notes.map((n) => toVaultRelativePath(n.path, vaultPath)), + ...(detail.files ?? []).map((f) => + toVaultRelativePath(f.filePath, vaultPath), + ), ]; return paths.map((p) => `@${quoteForMention(p)}`).join(" "); } @@ -85,11 +99,12 @@ function waitForTerminalRunning(terminalId: string): Promise { export async function openClaudeCodeTerminalWithContext( detail?: FileTreeNoteDragDetail, + paneId?: string, ): Promise { const vaultPath = useVaultStore.getState().vaultPath; const tabId = useEditorStore .getState() - .openTerminal({ cwd: vaultPath ?? undefined }); + .openTerminal({ cwd: vaultPath ?? undefined, paneId }); if (!tabId) return; const tab = selectEditorWorkspaceTabs(useEditorStore.getState()).find( @@ -107,10 +122,10 @@ export async function openClaudeCodeTerminalWithContext( // and so relative @mentions resolve correctly. const cdTarget = resolveCdTarget(detail, vaultPath); if (cdTarget) { - await store.writeInput( - terminalId, - `cd "${cdTarget.replace(/"/g, '\\"')}"\n`, - ); + // 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. @@ -133,7 +148,7 @@ export async function openClaudeCodeTerminalWithContext( if (!detail) return; - const contextArgs = buildContextArgs(detail); + const contextArgs = buildContextArgs(detail, vaultPath); if (!contextArgs) return; // Wait for Claude Code's TUI to finish initialising, then pre-fill the From baf99df254074e125ce7ccb098c8c531b24c52e1 Mon Sep 17 00:00:00 2001 From: Simon Pamies Date: Wed, 20 May 2026 13:35:02 +0200 Subject: [PATCH 07/20] docs: add terminal integration follow-up plan from Opus review --- docs/terminal-followups.md | 211 +++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 docs/terminal-followups.md 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. From b560831eddb80c8d82cfa9729afdb66dbe57f080 Mon Sep 17 00:00:00 2001 From: Simon Pamies Date: Wed, 20 May 2026 15:02:16 +0200 Subject: [PATCH 08/20] refactor(terminal): implement follow-up items from Opus review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De-duplicate descriptor (#2): Extract CLAUDE_TERMINAL_DESCRIPTOR and buildClaudeTerminalSetupStatus into features/ai/utils/claudeTerminalRuntime.ts. chatStore and AIProvidersSettings now share one definition with consistent copy. Subscribe-based terminal ready (#3): Replace setInterval+setTimeout polling in waitForTerminalRunning with a synchronous pre-check followed by useTerminalRuntimeStore.subscribe. No busy loop, no 100ms lag, logs a warning on timeout. Raise settle delay (#4): CLAUDE_TUI_SETTLE_MS 2000 → 3500 with a comment explaining the limitation and what a proper fix would require. Binary check cache (#1): Module-level cache in checkClaudeCodeInstalled() so chatStore, TerminalSettings, and AIProvidersSettings share one sh spawn rather than three. Persist auto-selection (#5): When Claude Code is auto-selected on first launch (binary found, no prior preference), persist it to AiPreferences so binary removal/reinstall doesn't silently change the default on next start. ACP runtime auto-selection is NOT persisted — only the Claude Code terminal selection is. --- .../src/features/ai/store/chatStore.ts | 43 +++++--------- .../ai/utils/claudeTerminalRuntime.ts | 30 ++++++++++ .../features/settings/AIProvidersSettings.tsx | 30 +++------- .../features/terminal/claudeCodeTerminal.ts | 59 +++++++++++++------ 4 files changed, 92 insertions(+), 70 deletions(-) create mode 100644 apps/desktop/src/features/ai/utils/claudeTerminalRuntime.ts diff --git a/apps/desktop/src/features/ai/store/chatStore.ts b/apps/desktop/src/features/ai/store/chatStore.ts index b07b110e..9e535f3a 100644 --- a/apps/desktop/src/features/ai/store/chatStore.ts +++ b/apps/desktop/src/features/ai/store/chatStore.ts @@ -150,37 +150,12 @@ import { } 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 CLAUDE_TERMINAL_DESCRIPTOR = { - runtime: { - id: CLAUDE_TERMINAL_RUNTIME_ID, - name: "Claude Code", - description: - "Claude Code CLI running in an integrated terminal tab.", - capabilities: ["attachments"] as string[], - }, - models: [] as never[], - modes: [] as never[], - configOptions: [] as never[], -} satisfies import("../types").AIRuntimeDescriptor; - -function buildClaudeTerminalSetupStatus( - binaryFound: boolean, -): import("../types").AIRuntimeSetupStatus { - return { - runtimeId: CLAUDE_TERMINAL_RUNTIME_ID, - binaryReady: binaryFound, - binarySource: "env" as const, - authReady: binaryFound, - authMethods: [] as never[], - onboardingRequired: false, - message: binaryFound - ? undefined - : 'claude not found in PATH. Install via: npm install -g @anthropic-ai/claude-code', - }; -} - const AI_PREFS_KEY = "neverwrite.ai.preferences"; const AI_RUNTIME_CACHE_KEY = "neverwrite.ai.runtime-catalog"; type PersistedSessionHistorySummary = Omit; @@ -6508,6 +6483,16 @@ export const useChatStore = create((set, get) => { (claudeFound ? CLAUDE_TERMINAL_RUNTIME_ID : null) ?? getDefaultRuntimeId(runtimes, setupStatusByRuntimeId); + // Persist Claude Code as the default when it was auto-selected + // (binary found, no prior explicit choice) so the pick is stable + // across launches — binary removal won't silently flip it. + if ( + !persistedRuntimeId && + defaultRuntimeId === CLAUDE_TERMINAL_RUNTIME_ID + ) { + saveAiPreferences({ defaultRuntimeId }); + } + set({ runtimes, selectedRuntimeId: defaultRuntimeId, 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/settings/AIProvidersSettings.tsx b/apps/desktop/src/features/settings/AIProvidersSettings.tsx index 55b03066..90724303 100644 --- a/apps/desktop/src/features/settings/AIProvidersSettings.tsx +++ b/apps/desktop/src/features/settings/AIProvidersSettings.tsx @@ -20,6 +20,10 @@ import { 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"; @@ -925,33 +929,13 @@ export function AIProvidersSettings({ const claudeFound = await checkClaudeCodeInstalled(); if (cancelled) return; - const claudeDescriptor: AIRuntimeDescriptor = { - runtime: { - id: CLAUDE_TERMINAL_RUNTIME_ID, - name: "Claude Code", - description: "Claude Code CLI in an integrated terminal.", - capabilities: ["attachments"], - }, - models: [], - modes: [], - configOptions: [], - }; - statuses[CLAUDE_TERMINAL_RUNTIME_ID] = { - runtimeId: CLAUDE_TERMINAL_RUNTIME_ID, - binaryReady: claudeFound, - binarySource: "env", - authReady: claudeFound, - authMethods: [], - onboardingRequired: false, - message: claudeFound - ? undefined - : "claude not found. Run: npm install -g @anthropic-ai/claude-code", - }; + 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, claudeDescriptor] + ? [...descriptors, CLAUDE_TERMINAL_DESCRIPTOR] : descriptors; setRuntimes(allDescriptors); diff --git a/apps/desktop/src/features/terminal/claudeCodeTerminal.ts b/apps/desktop/src/features/terminal/claudeCodeTerminal.ts index 952f1527..0b6d203c 100644 --- a/apps/desktop/src/features/terminal/claudeCodeTerminal.ts +++ b/apps/desktop/src/features/terminal/claudeCodeTerminal.ts @@ -9,13 +9,19 @@ 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" }, ); - return result.found; + _binaryCheckCache = result.found; + return _binaryCheckCache; } catch { return false; } @@ -23,8 +29,11 @@ export async function checkClaudeCodeInstalled(): Promise { // Milliseconds to wait for the terminal PTY to reach "running" state. const TERMINAL_READY_TIMEOUT_MS = 10_000; -// Milliseconds to wait for Claude Code's TUI to initialise before pre-filling. -const CLAUDE_TUI_SETTLE_MS = 2_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; // 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 @@ -75,25 +84,39 @@ function resolveCdTarget( function waitForTerminalRunning(terminalId: string): Promise { return new Promise((resolve) => { - const timeoutId = setTimeout(() => { - clearInterval(intervalId); - resolve(false); - }, TERMINAL_READY_TIMEOUT_MS); - - const intervalId = setInterval(() => { + const check = (): "ready" | "failed" | null => { const status = useTerminalRuntimeStore.getState().runtimesById[terminalId] ?.snapshot.status; - if (status === "running") { - clearTimeout(timeoutId); - clearInterval(intervalId); - resolve(true); - } else if (status === "error" || status === "exited") { - clearTimeout(timeoutId); - clearInterval(intervalId); - resolve(false); + 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"); } - }, 100); + }); }); } From d094662e0a7ec24a6c5274f98c8ec52a8cd04c3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 21 May 2026 09:13:58 -0400 Subject: [PATCH 09/20] Open Claude Code from agents sidebar --- apps/desktop/src/features/ai/AgentsSidebarPanel.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/ai/AgentsSidebarPanel.tsx b/apps/desktop/src/features/ai/AgentsSidebarPanel.tsx index bf5a60f8..e3792380 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,10 @@ export function AgentsSidebarPanel() { return sortedRuntimes.map((runtime) => ({ label: getRuntimeMenuLabel(runtime.runtime.name), action: () => { + if (runtime.runtime.id === CLAUDE_TERMINAL_RUNTIME_ID) { + void openClaudeCodeTerminalWithContext(); + return; + } void createNewChatInWorkspace(runtime.runtime.id); }, })); From cfaff3a1f187a72eb48b3016af4824a74059b13e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 21 May 2026 09:54:59 -0400 Subject: [PATCH 10/20] Harden terminal integration tests --- .../src/app/store/settingsStore.test.ts | 66 +++++++ .../features/ai/AgentsSidebarPanel.test.tsx | 50 +++++ .../editor/EditorPaneContent.test.tsx | 2 +- .../src/features/editor/UnifiedBar.tsx | 8 - .../features/editor/newTabMenuActions.test.ts | 101 +++++++++++ .../features/settings/SettingsPanel.test.tsx | 69 +++++++ .../features/terminal/TerminalViewport.tsx | 2 +- .../terminal/claudeCodeTerminal.test.ts | 171 ++++++++++++++++++ .../terminal/terminalRuntimeStore.test.ts | 28 ++- 9 files changed, 486 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/features/editor/newTabMenuActions.test.ts create mode 100644 apps/desktop/src/features/terminal/claudeCodeTerminal.test.ts diff --git a/apps/desktop/src/app/store/settingsStore.test.ts b/apps/desktop/src/app/store/settingsStore.test.ts index f7ffbb0f..1483c9b1 100644 --- a/apps/desktop/src/app/store/settingsStore.test.ts +++ b/apps/desktop/src/app/store/settingsStore.test.ts @@ -29,6 +29,17 @@ describe("settingsStore developer mode", () => { it("defaults developerModeEnabled to false", () => { expect(useSettingsStore.getState().developerModeEnabled).toBe(false); expect(useSettingsStore.getState().developerTerminalEnabled).toBe(true); + 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); @@ -77,6 +88,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() diff --git a/apps/desktop/src/features/ai/AgentsSidebarPanel.test.tsx b/apps/desktop/src/features/ai/AgentsSidebarPanel.test.tsx index a9d9bd63..9286003b 100644 --- a/apps/desktop/src/features/ai/AgentsSidebarPanel.test.tsx +++ b/apps/desktop/src/features/ai/AgentsSidebarPanel.test.tsx @@ -18,8 +18,12 @@ const chatPaneMovementMock = vi.hoisted(() => ({ 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 +149,52 @@ 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(); + }); + it("keeps open working agents in the order they became busy", async () => { const alpha = createSession( "session-alpha", 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.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..e79b45c6 --- /dev/null +++ b/apps/desktop/src/features/editor/newTabMenuActions.test.ts @@ -0,0 +1,101 @@ +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(); + }); + + it("keeps ACP agent entries on the normal chat creation path", async () => { + getNewAgentChild("Codex").action?.(); + + await waitFor(() => { + expect( + chatPaneMovementMock.createNewChatInWorkspace, + ).toHaveBeenCalledWith("codex-acp", { paneId: "secondary" }); + }); + expect( + claudeCodeTerminalMock.openClaudeCodeTerminalWithContext, + ).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/features/settings/SettingsPanel.test.tsx b/apps/desktop/src/features/settings/SettingsPanel.test.tsx index c376bfef..8fb7978e 100644 --- a/apps/desktop/src/features/settings/SettingsPanel.test.tsx +++ b/apps/desktop/src/features/settings/SettingsPanel.test.tsx @@ -540,6 +540,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/terminal/TerminalViewport.tsx b/apps/desktop/src/features/terminal/TerminalViewport.tsx index 481d5976..6abde83a 100644 --- a/apps/desktop/src/features/terminal/TerminalViewport.tsx +++ b/apps/desktop/src/features/terminal/TerminalViewport.tsx @@ -326,7 +326,7 @@ export function TerminalViewport({ }, ); - textarea = terminal.textarea; + textarea = terminal.textarea ?? null; handleFocus = () => { shouldRestoreFocusRef.current = true; setFocused(true); 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..3e53f8fc --- /dev/null +++ b/apps/desktop/src/features/terminal/claudeCodeTerminal.test.ts @@ -0,0 +1,171 @@ +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 tab = selectEditorWorkspaceTabs(useEditorStore.getState()).find( + (candidate): candidate is TerminalTab => isTerminalTab(candidate), + ); + 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({ + 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("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", + }, + ], + 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"', + ]); + }); +}); 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" }), From 28e9c183f7dc700c5dcac98ca12ba4be719baa78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 21 May 2026 10:08:52 -0400 Subject: [PATCH 11/20] Keep terminal tab numbering separate --- .../desktop/src/app/store/editorStore.test.ts | 34 +++++++++++++ apps/desktop/src/app/store/editorWorkspace.ts | 15 +++++- .../terminal/claudeCodeTerminal.test.ts | 51 ++++++++++++++++++- .../features/terminal/claudeCodeTerminal.ts | 24 ++++++++- 4 files changed, 119 insertions(+), 5 deletions(-) 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/features/terminal/claudeCodeTerminal.test.ts b/apps/desktop/src/features/terminal/claudeCodeTerminal.test.ts index 3e53f8fc..ce7cb001 100644 --- a/apps/desktop/src/features/terminal/claudeCodeTerminal.test.ts +++ b/apps/desktop/src/features/terminal/claudeCodeTerminal.test.ts @@ -39,8 +39,10 @@ function makeRunningSnapshot( async function attachOpenedTerminalRuntime() { await Promise.resolve(); - const tab = selectEditorWorkspaceTabs(useEditorStore.getState()).find( - (candidate): candidate is TerminalTab => isTerminalTab(candidate), + const editorState = useEditorStore.getState(); + const tab = selectEditorWorkspaceTabs(editorState).find( + (candidate): candidate is TerminalTab => + isTerminalTab(candidate) && candidate.id === editorState.activeTabId, ); expect(tab).toBeDefined(); useTerminalRuntimeStore.setState({ @@ -117,6 +119,7 @@ describe("openClaudeCodeTerminalWithContext", () => { useEditorStore.getState(), ).find(isTerminalTab); expect(terminalTab).toMatchObject({ + title: "Claude Code 1", cwd: "/vault root", }); expect(getWrittenInputs()).toEqual([ @@ -168,4 +171,48 @@ describe("openClaudeCodeTerminalWithContext", () => { '@"Project Notes/One note.md" @"assets/chart (v1).png"', ]); }); + + 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 index 0b6d203c..f8897d98 100644 --- a/apps/desktop/src/features/terminal/claudeCodeTerminal.ts +++ b/apps/desktop/src/features/terminal/claudeCodeTerminal.ts @@ -34,6 +34,24 @@ const TERMINAL_READY_TIMEOUT_MS = 10_000; // 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+))?$/; + +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 @@ -127,7 +145,11 @@ export async function openClaudeCodeTerminalWithContext( const vaultPath = useVaultStore.getState().vaultPath; const tabId = useEditorStore .getState() - .openTerminal({ cwd: vaultPath ?? undefined, paneId }); + .openTerminal({ + cwd: vaultPath ?? undefined, + paneId, + title: getNextClaudeCodeTerminalTitle(), + }); if (!tabId) return; const tab = selectEditorWorkspaceTabs(useEditorStore.getState()).find( From 164abc195a18d00d767aa8f28f072e95d4585b14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 21 May 2026 11:02:36 -0400 Subject: [PATCH 12/20] Prevent Claude Code from starting ACP chats --- .../src/features/ai/chatPaneMovement.test.ts | 165 ++++++++++++++++++ .../src/features/ai/chatPaneMovement.ts | 79 ++++++++- 2 files changed, 237 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/features/ai/chatPaneMovement.test.ts b/apps/desktop/src/features/ai/chatPaneMovement.test.ts index c0aae02d..d3195bc4 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,124 @@ 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("does not create an ACP chat when the default runtime is Claude Code terminal", async () => { + 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"); + + await expect(createNewChatInWorkspace()).resolves.toBeNull(); + + expect(newSession).not.toHaveBeenCalled(); + expect(upsertSession).not.toHaveBeenCalled(); + expect(openChat).not.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 b22bc2f0..12408977 100644 --- a/apps/desktop/src/features/ai/chatPaneMovement.ts +++ b/apps/desktop/src/features/ai/chatPaneMovement.ts @@ -40,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( @@ -49,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; } @@ -83,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; @@ -95,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) @@ -104,7 +152,6 @@ function resolveWorkspaceNewChatRuntimeId(runtimeId?: string) { return focusedChatRuntimeId; } - const chatState = useChatStore.getState(); return ( getSessionRuntimeId(chatState.lastFocusedSessionId) ?? getSessionRuntimeId(chatState.activeSessionId) ?? @@ -285,6 +332,14 @@ export async function createNewChatInWorkspace( 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); @@ -310,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); } From 685c6518ee8cb29e4256f00753a9b2a4cbcf215e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 21 May 2026 11:09:39 -0400 Subject: [PATCH 13/20] Make workspace terminal always available --- apps/desktop/src/App.noteWindow.test.tsx | 8 ----- apps/desktop/src/App.tsx | 6 +--- apps/desktop/src/app/shortcuts/format.test.ts | 2 +- apps/desktop/src/app/shortcuts/registry.ts | 2 +- .../src/app/store/settingsStore.test.ts | 8 ----- apps/desktop/src/app/store/settingsStore.ts | 6 ---- .../features/editor/EditorPaneBar.test.tsx | 4 --- .../src/features/editor/UnifiedBar.test.tsx | 6 +--- .../features/settings/SettingsPanel.test.tsx | 14 +++++++++ .../src/features/settings/SettingsPanel.tsx | 29 ++----------------- docs/terminal-integration.md | 16 +++++----- 11 files changed, 27 insertions(+), 74 deletions(-) diff --git a/apps/desktop/src/App.noteWindow.test.tsx b/apps/desktop/src/App.noteWindow.test.tsx index 19856cf0..f70a2fa7 100644 --- a/apps/desktop/src/App.noteWindow.test.tsx +++ b/apps/desktop/src/App.noteWindow.test.tsx @@ -210,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(); @@ -292,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 f9994e93..be2c88e1 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -590,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({ 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/settingsStore.test.ts b/apps/desktop/src/app/store/settingsStore.test.ts index 04a3e819..59263323 100644 --- a/apps/desktop/src/app/store/settingsStore.test.ts +++ b/apps/desktop/src/app/store/settingsStore.test.ts @@ -28,7 +28,6 @@ describe("settingsStore developer mode", () => { it("defaults developerModeEnabled to false", () => { expect(useSettingsStore.getState().developerModeEnabled).toBe(false); - expect(useSettingsStore.getState().developerTerminalEnabled).toBe(true); expect(useSettingsStore.getState().terminalFontFamily).toBe(""); expect(useSettingsStore.getState().terminalFontSize).toBe(13); expect(useSettingsStore.getState().claudeCodeOptimized).toBe(false); @@ -54,9 +53,6 @@ describe("settingsStore developer mode", () => { 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); @@ -64,9 +60,6 @@ describe("settingsStore developer mode", () => { 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); @@ -79,7 +72,6 @@ describe("settingsStore developer mode", () => { ).toMatchObject({ state: { developerModeEnabled: true, - developerTerminalEnabled: false, inlineReviewEnabled: false, pdfFilter: "sepia", fileTreeStickyFolders: false, diff --git a/apps/desktop/src/app/store/settingsStore.ts b/apps/desktop/src/app/store/settingsStore.ts index 40b3e0ab..a554259b 100644 --- a/apps/desktop/src/app/store/settingsStore.ts +++ b/apps/desktop/src/app/store/settingsStore.ts @@ -46,7 +46,6 @@ export interface Settings { // Developers developerModeEnabled: boolean; - developerTerminalEnabled: boolean; fileTreeContentMode: "notes_only" | "all_files"; fileTreeShowExtensions: boolean; fileTreeExtensionFilter: string[]; @@ -192,7 +191,6 @@ const defaults: Settings = { claudeCodeContinueSession: false, claudeCodeMaxTurns: 0, developerModeEnabled: false, - developerTerminalEnabled: true, fileTreeContentMode: "notes_only", fileTreeShowExtensions: false, fileTreeExtensionFilter: [], @@ -456,9 +454,6 @@ function extractSettingsFromStorage(raw: string | null): Settings | null { developerModeEnabled: parsed.state.developerModeEnabled ?? defaults.developerModeEnabled, - developerTerminalEnabled: - parsed.state.developerTerminalEnabled ?? - defaults.developerTerminalEnabled, fileTreeContentMode: normalizeFileTreeContentMode( parsed.state.fileTreeContentMode, ), @@ -538,7 +533,6 @@ function pickSettings(state: SettingsStore): Settings { claudeCodeContinueSession: state.claudeCodeContinueSession, claudeCodeMaxTurns: state.claudeCodeMaxTurns, developerModeEnabled: state.developerModeEnabled, - developerTerminalEnabled: state.developerTerminalEnabled, fileTreeContentMode: state.fileTreeContentMode, fileTreeShowExtensions: state.fileTreeShowExtensions, fileTreeExtensionFilter: state.fileTreeExtensionFilter, 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/UnifiedBar.test.tsx b/apps/desktop/src/features/editor/UnifiedBar.test.tsx index 1e1dc8da..b3101008 100644 --- a/apps/desktop/src/features/editor/UnifiedBar.test.tsx +++ b/apps/desktop/src/features/editor/UnifiedBar.test.tsx @@ -962,7 +962,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 +974,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/settings/SettingsPanel.test.tsx b/apps/desktop/src/features/settings/SettingsPanel.test.tsx index 6c608578..6387ff85 100644 --- a/apps/desktop/src/features/settings/SettingsPanel.test.tsx +++ b/apps/desktop/src/features/settings/SettingsPanel.test.tsx @@ -228,6 +228,20 @@ describe("SettingsPanel", () => { ]); }); + it("does not render a developer toggle for the first-class terminal", () => { + renderComponent( {}} />); + + fireEvent.click(screen.getByRole("button", { name: "Developers" })); + + expect(screen.getByText("Enable Developer Mode")).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"); diff --git a/apps/desktop/src/features/settings/SettingsPanel.tsx b/apps/desktop/src/features/settings/SettingsPanel.tsx index 00f9bdac..a3c2a0dc 100644 --- a/apps/desktop/src/features/settings/SettingsPanel.tsx +++ b/apps/desktop/src/features/settings/SettingsPanel.tsx @@ -3295,7 +3295,6 @@ function DevelopersSettings({ }) { const { developerModeEnabled, - developerTerminalEnabled, lineWrapping, fileTreeContentMode, fileTreeShowExtensions, @@ -3308,12 +3307,7 @@ function DevelopersSettings({ [ [ "Enable Developer Mode", - "Show experimental developer-facing surfaces such as the integrated terminal.", - ], - [ - "Enable Integrated Terminal", - "Enable terminal tabs in the editor workspace and related commands.", - "terminal", + "Show low-level developer-facing tools and diagnostics.", ], ], ); @@ -3358,7 +3352,7 @@ function DevelopersSettings({ searchQuery={searchQuery} section="Developer Mode" label="Enable Developer Mode" - description="Show experimental developer-facing surfaces such as the integrated terminal." + description="Show low-level developer-facing tools and diagnostics." control={ } /> - - setSetting("developerTerminalEnabled", value) - } - /> - } - /> {showEditor ? Editor : null} = developers: [ "Developer Mode", "Enable Developer Mode", - "Enable Integrated Terminal", - "terminal", "Editor", "Line wrapping", "File Tree", diff --git a/docs/terminal-integration.md b/docs/terminal-integration.md index f5f3f5bd..c46791c8 100644 --- a/docs/terminal-integration.md +++ b/docs/terminal-integration.md @@ -4,7 +4,7 @@ Related issue: [jsgrrchg/NeverWrite#107](https://github.com/jsgrrchg/NeverWrite/ ## Background -The terminal currently works but sits behind a double gate: `developerModeEnabled` must be on, then `developerTerminalEnabled` inside it. Both flags live in the Developer section of Settings. +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. @@ -31,20 +31,18 @@ There is no viable drop-in replacement for xterm.js today. The most promising fu --- -## Step 1 — Ungate the terminal +## Step 1 — Keep the terminal ungated **Files:** `src/App.tsx:921-943`, `src/features/editor/newTabMenuActions.ts:85-147`, `src/features/editor/EditorPaneBar.tsx` -Remove the `developerModeEnabled` guard from the `developer:new-terminal-tab` command palette entry (`App.tsx:939`). Remove the `developerTerminalEnabled` check from `buildNewTabContextMenuEntries` (`newTabMenuActions.ts:138`) so "New Terminal" appears in every pane's `+` menu unconditionally. +The "New Terminal" action should stay available from the workspace command palette entry and every pane's `+` menu unconditionally. -Note: `developerTerminalEnabled` already defaults to `true` (`settingsStore.ts:175`). No default flip needed — only the `developerModeEnabled` outer gate has to drop. +Do not add a Developer Mode setting that promises to enable or disable terminal tabs. Terminal availability is not user-gated anymore. -Leave both toggles in the Developer settings section for users who want to hide the feature. - -The `developer:restart-terminal` command stays behind `developerModeEnabled`. But once terminal creation is ungated, restart becomes a usability need for ordinary users too. Add a right-click context menu entry on the terminal tab itself (already partially exists in `EditorPaneBar.tsx` tab context menu) so non-developer users have a recovery path that doesn't require dev mode. +Restart remains a recovery action for active terminal tabs and should not depend on Developer Mode. **Also do:** -- Change the command id from `developer:new-terminal-tab` to `workspace:new-terminal-tab` and the category from `"Developer"` to `"Workspace"` (or `"Tabs"`) — cosmetics, but "first-class" means it shows up in the right palette group. +- 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. @@ -83,7 +81,7 @@ Section contents: - **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`. -The Developer section keeps `developerTerminalEnabled` and `developerModeEnabled`. Consider whether `developerTerminalEnabled` should be renamed `terminalEnabled` now that the terminal is first-class — if so, add a migration in the persistence merge. +The Developer section keeps `developerModeEnabled` only for low-level developer-facing tools. Do not reintroduce a terminal enablement toggle there. --- From 91d58a6af968b7c91ad6d5a758349ba853023054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 21 May 2026 11:14:21 -0400 Subject: [PATCH 14/20] Remove obsolete developer mode setting --- .../src/app/store/settingsStore.test.ts | 35 ++++++------------- apps/desktop/src/app/store/settingsStore.ts | 6 ---- .../src/features/editor/UnifiedBar.test.tsx | 2 -- .../features/settings/SettingsPanel.test.tsx | 6 ++-- .../src/features/settings/SettingsPanel.tsx | 33 +---------------- .../src/features/vault/FileTree.test.tsx | 1 - docs/terminal-integration.md | 2 +- 7 files changed, 17 insertions(+), 68 deletions(-) diff --git a/apps/desktop/src/app/store/settingsStore.test.ts b/apps/desktop/src/app/store/settingsStore.test.ts index 59263323..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,8 +26,7 @@ describe("settingsStore developer mode", () => { disposeSettingsStoreRuntime(); }); - it("defaults developerModeEnabled to false", () => { - expect(useSettingsStore.getState().developerModeEnabled).toBe(false); + it("defaults app settings", () => { expect(useSettingsStore.getState().terminalFontFamily).toBe(""); expect(useSettingsStore.getState().terminalFontSize).toBe(13); expect(useSettingsStore.getState().claudeCodeOptimized).toBe(false); @@ -49,17 +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("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().inlineReviewEnabled).toBe(false); expect(useSettingsStore.getState().pdfFilter).toBe("sepia"); expect(useSettingsStore.getState().fileTreeStickyFolders).toBe(false); @@ -71,7 +68,6 @@ describe("settingsStore developer mode", () => { ), ).toMatchObject({ state: { - developerModeEnabled: true, inlineReviewEnabled: false, pdfFilter: "sepia", fileTreeStickyFolders: false, @@ -213,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" }); @@ -224,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 @@ -239,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); }); @@ -257,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", () => { @@ -284,7 +271,7 @@ describe("settingsStore developer mode", () => { "neverwrite:settings:/vaults/migrated", JSON.stringify({ state: { - developerModeEnabled: true, + inlineReviewEnabled: false, }, }), ); @@ -297,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 a554259b..211e78f8 100644 --- a/apps/desktop/src/app/store/settingsStore.ts +++ b/apps/desktop/src/app/store/settingsStore.ts @@ -45,7 +45,6 @@ export interface Settings { claudeCodeMaxTurns: number; // 0 = unlimited // Developers - developerModeEnabled: boolean; fileTreeContentMode: "notes_only" | "all_files"; fileTreeShowExtensions: boolean; fileTreeExtensionFilter: string[]; @@ -190,7 +189,6 @@ const defaults: Settings = { claudeCodeModel: "", claudeCodeContinueSession: false, claudeCodeMaxTurns: 0, - developerModeEnabled: false, fileTreeContentMode: "notes_only", fileTreeShowExtensions: false, fileTreeExtensionFilter: [], @@ -451,9 +449,6 @@ function extractSettingsFromStorage(raw: string | null): Settings | null { 0, 1000, ), - developerModeEnabled: - parsed.state.developerModeEnabled ?? - defaults.developerModeEnabled, fileTreeContentMode: normalizeFileTreeContentMode( parsed.state.fileTreeContentMode, ), @@ -532,7 +527,6 @@ function pickSettings(state: SettingsStore): Settings { claudeCodeModel: state.claudeCodeModel, claudeCodeContinueSession: state.claudeCodeContinueSession, claudeCodeMaxTurns: state.claudeCodeMaxTurns, - developerModeEnabled: state.developerModeEnabled, fileTreeContentMode: state.fileTreeContentMode, fileTreeShowExtensions: state.fileTreeShowExtensions, fileTreeExtensionFilter: state.fileTreeExtensionFilter, diff --git a/apps/desktop/src/features/editor/UnifiedBar.test.tsx b/apps/desktop/src/features/editor/UnifiedBar.test.tsx index b3101008..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(); diff --git a/apps/desktop/src/features/settings/SettingsPanel.test.tsx b/apps/desktop/src/features/settings/SettingsPanel.test.tsx index 6387ff85..07e77897 100644 --- a/apps/desktop/src/features/settings/SettingsPanel.test.tsx +++ b/apps/desktop/src/features/settings/SettingsPanel.test.tsx @@ -228,12 +228,14 @@ describe("SettingsPanel", () => { ]); }); - it("does not render a developer toggle for the first-class terminal", () => { + it("does not render obsolete developer toggles", () => { renderComponent( {}} />); fireEvent.click(screen.getByRole("button", { name: "Developers" })); - expect(screen.getByText("Enable Developer Mode")).toBeInTheDocument(); + expect( + screen.queryByText(["Enable", "Developer", "Mode"].join(" ")), + ).not.toBeInTheDocument(); expect( screen.queryByText(["Enable", "Integrated", "Terminal"].join(" ")), ).not.toBeInTheDocument(); diff --git a/apps/desktop/src/features/settings/SettingsPanel.tsx b/apps/desktop/src/features/settings/SettingsPanel.tsx index a3c2a0dc..32a1df3b 100644 --- a/apps/desktop/src/features/settings/SettingsPanel.tsx +++ b/apps/desktop/src/features/settings/SettingsPanel.tsx @@ -3294,23 +3294,12 @@ function DevelopersSettings({ searchQuery: SettingsSearchQuery; }) { const { - developerModeEnabled, lineWrapping, fileTreeContentMode, fileTreeShowExtensions, fileTreeExtensionFilter, setSetting, } = useSettingsStore(); - const showDeveloperMode = sectionHasSettingsSearchMatches( - searchQuery, - "Developer Mode", - [ - [ - "Enable Developer Mode", - "Show low-level developer-facing tools and diagnostics.", - ], - ], - ); const showEditor = sectionHasSettingsSearchMatches(searchQuery, "Editor", [ ["Line wrapping", "Wrap long lines to fit the editor width."], ]); @@ -3339,30 +3328,12 @@ function DevelopersSettings({ ], ); - if (!showDeveloperMode && !showEditor && !showFileTree) { + if (!showEditor && !showFileTree) { return ; } return (
- {showDeveloperMode ? ( - Developer Mode - ) : null} - - setSetting("developerModeEnabled", value) - } - /> - } - /> - {showEditor ? Editor : null} = "monospace", ], developers: [ - "Developer Mode", - "Enable Developer Mode", "Editor", "Line wrapping", "File Tree", 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/docs/terminal-integration.md b/docs/terminal-integration.md index c46791c8..8b4d49c7 100644 --- a/docs/terminal-integration.md +++ b/docs/terminal-integration.md @@ -81,7 +81,7 @@ Section contents: - **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`. -The Developer section keeps `developerModeEnabled` only for low-level developer-facing tools. Do not reintroduce a terminal enablement toggle there. +Do not reintroduce a Developer settings toggle for terminal availability. Terminal tabs are always part of the workspace. --- From 656cac9fd9f8034d02fbb84dd3a38da20384a18e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 21 May 2026 11:23:10 -0400 Subject: [PATCH 15/20] Fix Claude Code default runtime selection --- .../src/features/ai/chatPaneMovement.test.ts | 6 +- .../src/features/ai/store/chatStore.test.ts | 80 +++++++++++++++++++ .../src/features/ai/store/chatStore.ts | 60 ++++++++------ .../features/terminal/claudeCodeTerminal.ts | 4 + 4 files changed, 123 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/features/ai/chatPaneMovement.test.ts b/apps/desktop/src/features/ai/chatPaneMovement.test.ts index d3195bc4..481bc7bb 100644 --- a/apps/desktop/src/features/ai/chatPaneMovement.test.ts +++ b/apps/desktop/src/features/ai/chatPaneMovement.test.ts @@ -479,7 +479,11 @@ describe("createNewChatInWorkspace", () => { expect(selectEditorWorkspaceTabs(useEditorStore.getState())).toEqual([]); }); - it("does not create an ACP chat when the default runtime is Claude Code terminal", async () => { + it("does not create an ACP chat when Claude Code terminal is the explicit default runtime", async () => { + localStorage.setItem( + AI_PREFS_KEY, + JSON.stringify({ defaultRuntimeId: CLAUDE_TERMINAL_RUNTIME_ID }), + ); useChatStore.setState((state) => ({ ...state, runtimes: [runtimeDescriptor, claudeTerminalRuntimeDescriptor], diff --git a/apps/desktop/src/features/ai/store/chatStore.test.ts b/apps/desktop/src/features/ai/store/chatStore.test.ts index 40fd3097..fac4edec 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(); @@ -835,6 +842,79 @@ describe("chatStore", () => { ); }); + 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("selects the first configured runtime on fresh boot", async () => { const claudeRuntimePayload = { runtime: { diff --git a/apps/desktop/src/features/ai/store/chatStore.ts b/apps/desktop/src/features/ai/store/chatStore.ts index 9e535f3a..67361581 100644 --- a/apps/desktop/src/features/ai/store/chatStore.ts +++ b/apps/desktop/src/features/ai/store/chatStore.ts @@ -5044,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, @@ -5059,6 +5063,18 @@ 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 runtimeSupportsCapability( runtimes: AIRuntimeDescriptor[], runtimeId: string, @@ -6406,19 +6422,19 @@ export const useChatStore = create((set, get) => { }, getDefaultNewChatRuntimeId: () => { - // Reads the user's persisted explicit choice. Falls back to - // Claude Code if the binary is available and no explicit override - // was set. Uses prefs directly so the active session's runtime - // (which drives selectedRuntimeId) can't shadow this. + // Reads the user's persisted explicit choice first. Claude Code is + // a terminal pseudo-runtime, so detection alone must not promote it + // over ACP runtimes for new chats. const explicit = loadAiPreferences().defaultRuntimeId ?? null; if (explicit !== null) return explicit; - const { setupStatusByRuntimeId } = get(); - if ( - setupStatusByRuntimeId[CLAUDE_TERMINAL_RUNTIME_ID]?.authReady - ) { - return CLAUDE_TERMINAL_RUNTIME_ID; - } - return get().selectedRuntimeId; + const state = get(); + return ( + state.selectedRuntimeId ?? + getImplicitDefaultAcpRuntimeId( + state.runtimes, + state.setupStatusByRuntimeId, + ) + ); }, initialize: async (options) => { @@ -6473,25 +6489,17 @@ export const useChatStore = create((set, get) => { [CLAUDE_TERMINAL_RUNTIME_ID]: buildClaudeTerminalSetupStatus(claudeFound), }; - // Persisted explicit selection wins. Otherwise auto-default to - // Claude Code if found in PATH, falling back to first ready ACP - // runtime. + // Persisted explicit selection wins. 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 = loadAiPreferences().defaultRuntimeId ?? null; const defaultRuntimeId = persistedRuntimeId ?? - (claudeFound ? CLAUDE_TERMINAL_RUNTIME_ID : null) ?? - getDefaultRuntimeId(runtimes, setupStatusByRuntimeId); - - // Persist Claude Code as the default when it was auto-selected - // (binary found, no prior explicit choice) so the pick is stable - // across launches — binary removal won't silently flip it. - if ( - !persistedRuntimeId && - defaultRuntimeId === CLAUDE_TERMINAL_RUNTIME_ID - ) { - saveAiPreferences({ defaultRuntimeId }); - } + getImplicitDefaultAcpRuntimeId( + runtimes, + setupStatusByRuntimeId, + ); set({ runtimes, diff --git a/apps/desktop/src/features/terminal/claudeCodeTerminal.ts b/apps/desktop/src/features/terminal/claudeCodeTerminal.ts index f8897d98..6d0c5da2 100644 --- a/apps/desktop/src/features/terminal/claudeCodeTerminal.ts +++ b/apps/desktop/src/features/terminal/claudeCodeTerminal.ts @@ -27,6 +27,10 @@ export async function checkClaudeCodeInstalled(): Promise { } } +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 From a042f6b418fb72f50b7b51c558ff8435df9526cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 21 May 2026 11:25:07 -0400 Subject: [PATCH 16/20] Harden Claude Code terminal launch args --- .../terminal/claudeCodeTerminal.test.ts | 25 +++++++++++ .../features/terminal/claudeCodeTerminal.ts | 45 +++++++++++++++---- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/features/terminal/claudeCodeTerminal.test.ts b/apps/desktop/src/features/terminal/claudeCodeTerminal.test.ts index ce7cb001..f2052aff 100644 --- a/apps/desktop/src/features/terminal/claudeCodeTerminal.test.ts +++ b/apps/desktop/src/features/terminal/claudeCodeTerminal.test.ts @@ -128,6 +128,31 @@ describe("openClaudeCodeTerminalWithContext", () => { ]); }); + 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 = { diff --git a/apps/desktop/src/features/terminal/claudeCodeTerminal.ts b/apps/desktop/src/features/terminal/claudeCodeTerminal.ts index 6d0c5da2..1554c047 100644 --- a/apps/desktop/src/features/terminal/claudeCodeTerminal.ts +++ b/apps/desktop/src/features/terminal/claudeCodeTerminal.ts @@ -40,6 +40,33 @@ const TERMINAL_READY_TIMEOUT_MS = 10_000; 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( @@ -177,7 +204,8 @@ export async function openClaudeCodeTerminalWithContext( await store.writeInput(terminalId, `cd ${cdQuoted}\n`); } - // Build the claude command from settings. + // Build the claude command from settings. Treat persisted settings as data, + // not trusted shell text, before writing into the interactive PTY. const { claudeCodeSkipPermissions, claudeCodeModel, @@ -185,15 +213,14 @@ export async function openClaudeCodeTerminalWithContext( claudeCodeMaxTurns, } = useSettingsStore.getState(); - const flags: string[] = []; - if (claudeCodeSkipPermissions) flags.push("--dangerously-skip-permissions"); - if (claudeCodeModel.trim()) flags.push("--model", claudeCodeModel.trim()); - if (claudeCodeContinueSession) flags.push("--continue"); - if (claudeCodeMaxTurns > 0) flags.push("--max-turns", String(claudeCodeMaxTurns)); + 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)); - const claudeCommand = - flags.length > 0 ? `claude ${flags.join(" ")}\n` : "claude\n"; - await store.writeInput(terminalId, claudeCommand); + await store.writeInput(terminalId, buildShellCommand(args)); if (!detail) return; From 091c59620172fe110554a5ed1604140ccd0565f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 21 May 2026 11:43:16 -0400 Subject: [PATCH 17/20] Respect last selected agent runtime --- .../src/features/ai/AgentsSidebarPanel.test.tsx | 4 ++++ apps/desktop/src/features/ai/AgentsSidebarPanel.tsx | 1 + apps/desktop/src/features/ai/chatPaneMovement.test.ts | 11 ++++++----- apps/desktop/src/features/ai/store/chatStore.ts | 6 +----- .../src/features/editor/newTabMenuActions.test.ts | 8 ++++++++ apps/desktop/src/features/editor/newTabMenuActions.ts | 3 +++ 6 files changed, 23 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/features/ai/AgentsSidebarPanel.test.tsx b/apps/desktop/src/features/ai/AgentsSidebarPanel.test.tsx index 9286003b..cd3c68af 100644 --- a/apps/desktop/src/features/ai/AgentsSidebarPanel.test.tsx +++ b/apps/desktop/src/features/ai/AgentsSidebarPanel.test.tsx @@ -12,6 +12,7 @@ import { AGENT_SIDEBAR_DRAG_EVENT, type AgentSidebarDragDetail, } from "./agentSidebarDragEvents"; +import { CLAUDE_TERMINAL_RUNTIME_ID } from "./utils/runtimeMetadata"; const chatPaneMovementMock = vi.hoisted(() => ({ createNewChatInWorkspace: vi.fn(), @@ -193,6 +194,9 @@ describe("AgentsSidebarPanel", () => { 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 () => { diff --git a/apps/desktop/src/features/ai/AgentsSidebarPanel.tsx b/apps/desktop/src/features/ai/AgentsSidebarPanel.tsx index e3792380..4682f6c8 100644 --- a/apps/desktop/src/features/ai/AgentsSidebarPanel.tsx +++ b/apps/desktop/src/features/ai/AgentsSidebarPanel.tsx @@ -496,6 +496,7 @@ 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; diff --git a/apps/desktop/src/features/ai/chatPaneMovement.test.ts b/apps/desktop/src/features/ai/chatPaneMovement.test.ts index 481bc7bb..d1ce1998 100644 --- a/apps/desktop/src/features/ai/chatPaneMovement.test.ts +++ b/apps/desktop/src/features/ai/chatPaneMovement.test.ts @@ -479,7 +479,7 @@ describe("createNewChatInWorkspace", () => { expect(selectEditorWorkspaceTabs(useEditorStore.getState())).toEqual([]); }); - it("does not create an ACP chat when Claude Code terminal is the explicit default runtime", async () => { + 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 }), @@ -501,11 +501,12 @@ describe("createNewChatInWorkspace", () => { const upsertSession = vi.spyOn(useChatStore.getState(), "upsertSession"); const openChat = vi.spyOn(useEditorStore.getState(), "openChat"); - await expect(createNewChatInWorkspace()).resolves.toBeNull(); + const sessionId = await createNewChatInWorkspace(); - expect(newSession).not.toHaveBeenCalled(); - expect(upsertSession).not.toHaveBeenCalled(); - expect(openChat).not.toHaveBeenCalled(); + 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 () => { diff --git a/apps/desktop/src/features/ai/store/chatStore.ts b/apps/desktop/src/features/ai/store/chatStore.ts index 67361581..f955be0d 100644 --- a/apps/desktop/src/features/ai/store/chatStore.ts +++ b/apps/desktop/src/features/ai/store/chatStore.ts @@ -6422,14 +6422,10 @@ export const useChatStore = create((set, get) => { }, getDefaultNewChatRuntimeId: () => { - // Reads the user's persisted explicit choice first. Claude Code is - // a terminal pseudo-runtime, so detection alone must not promote it - // over ACP runtimes for new chats. - const explicit = loadAiPreferences().defaultRuntimeId ?? null; - if (explicit !== null) return explicit; const state = get(); return ( state.selectedRuntimeId ?? + loadAiPreferences().defaultRuntimeId ?? getImplicitDefaultAcpRuntimeId( state.runtimes, state.setupStatusByRuntimeId, diff --git a/apps/desktop/src/features/editor/newTabMenuActions.test.ts b/apps/desktop/src/features/editor/newTabMenuActions.test.ts index e79b45c6..db90d4c6 100644 --- a/apps/desktop/src/features/editor/newTabMenuActions.test.ts +++ b/apps/desktop/src/features/editor/newTabMenuActions.test.ts @@ -84,9 +84,16 @@ describe("newTabMenuActions", () => { 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(() => { @@ -97,5 +104,6 @@ describe("newTabMenuActions", () => { 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 7ab652db..d962e4d5 100644 --- a/apps/desktop/src/features/editor/newTabMenuActions.ts +++ b/apps/desktop/src/features/editor/newTabMenuActions.ts @@ -115,6 +115,9 @@ export function buildNewTabContextMenuEntries(options?: { ? runtimes.map((runtime) => ({ label: getRuntimeMenuLabel(runtime.runtime.name), action: () => { + useChatStore + .getState() + .setSelectedRuntime(runtime.runtime.id); if ( runtime.runtime.id === CLAUDE_TERMINAL_RUNTIME_ID From 01db158a0067062eeefedca3d3e7d6c03fd109e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 21 May 2026 11:48:42 -0400 Subject: [PATCH 18/20] Require terminal palettes for all themes --- apps/desktop/src/app/themes/index.test.ts | 33 +++++++++++++++++++ .../src/app/themes/terminalPalettes.ts | 15 +++++---- 2 files changed, 42 insertions(+), 6 deletions(-) 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/terminalPalettes.ts b/apps/desktop/src/app/themes/terminalPalettes.ts index 46545dd9..201fa4b4 100644 --- a/apps/desktop/src/app/themes/terminalPalettes.ts +++ b/apps/desktop/src/app/themes/terminalPalettes.ts @@ -19,10 +19,15 @@ export interface AnsiPalette { brightWhite: string; } +type TerminalPaletteByMode = { + dark: AnsiPalette; + light: AnsiPalette; +}; + // Maps a theme + isDark flag to an intentionally-designed 16-colour ANSI -// palette. Themes that don't have an entry fall back to the Catppuccin icon -// token CSS variables already defined globally. -const PALETTES: Partial> = { +// 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 @@ -848,9 +853,7 @@ const SLOT_TO_CSS: Record = { export function applyTerminalPalette(name: ThemeName, isDark: boolean) { if (typeof document === "undefined") return; - const entry = PALETTES[name]; - if (!entry) return; // no palette → CSS Catppuccin fallbacks remain - const palette = isDark ? entry.dark : entry.light; + 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]); From 94db8a2aed645da6e60503413001d6bffa79af32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 21 May 2026 11:57:29 -0400 Subject: [PATCH 19/20] Ignore unavailable default agent runtime --- .../src/features/ai/store/chatStore.test.ts | 37 +++++++++++++++ .../src/features/ai/store/chatStore.ts | 47 ++++++++++++++++--- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/features/ai/store/chatStore.test.ts b/apps/desktop/src/features/ai/store/chatStore.test.ts index fac4edec..fa95abce 100644 --- a/apps/desktop/src/features/ai/store/chatStore.test.ts +++ b/apps/desktop/src/features/ai/store/chatStore.test.ts @@ -915,6 +915,43 @@ describe("chatStore", () => { ).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: { diff --git a/apps/desktop/src/features/ai/store/chatStore.ts b/apps/desktop/src/features/ai/store/chatStore.ts index f955be0d..cb0ef775 100644 --- a/apps/desktop/src/features/ai/store/chatStore.ts +++ b/apps/desktop/src/features/ai/store/chatStore.ts @@ -5075,6 +5075,22 @@ function getImplicitDefaultAcpRuntimeId( ); } +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, @@ -6424,8 +6440,16 @@ export const useChatStore = create((set, get) => { getDefaultNewChatRuntimeId: () => { const state = get(); return ( - state.selectedRuntimeId ?? - loadAiPreferences().defaultRuntimeId ?? + getSelectableDefaultRuntimeId( + state.selectedRuntimeId, + state.runtimes, + state.setupStatusByRuntimeId, + ) ?? + getSelectableDefaultRuntimeId( + loadAiPreferences().defaultRuntimeId, + state.runtimes, + state.setupStatusByRuntimeId, + ) ?? getImplicitDefaultAcpRuntimeId( state.runtimes, state.setupStatusByRuntimeId, @@ -6485,11 +6509,16 @@ export const useChatStore = create((set, get) => { [CLAUDE_TERMINAL_RUNTIME_ID]: buildClaudeTerminalSetupStatus(claudeFound), }; - // Persisted explicit selection wins. Otherwise stay on ACP - // runtimes; Claude Code remains available but is not promoted - // to the default just because the binary exists in PATH. + // 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 = - loadAiPreferences().defaultRuntimeId ?? null; + getSelectableDefaultRuntimeId( + loadAiPreferences().defaultRuntimeId, + runtimes, + setupStatusByRuntimeId, + ); const defaultRuntimeId = persistedRuntimeId ?? getImplicitDefaultAcpRuntimeId( @@ -10282,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; From cf7d56ccf54fbcfe6da7e736494e9ab14e67734f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 21 May 2026 11:57:44 -0400 Subject: [PATCH 20/20] Escape Claude Code file mentions --- .../src/features/terminal/claudeCodeTerminal.test.ts | 11 ++++++++++- .../src/features/terminal/claudeCodeTerminal.ts | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/terminal/claudeCodeTerminal.test.ts b/apps/desktop/src/features/terminal/claudeCodeTerminal.test.ts index f2052aff..77b30dd0 100644 --- a/apps/desktop/src/features/terminal/claudeCodeTerminal.test.ts +++ b/apps/desktop/src/features/terminal/claudeCodeTerminal.test.ts @@ -172,6 +172,11 @@ describe("openClaudeCodeTerminalWithContext", () => { 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", @@ -193,7 +198,11 @@ describe("openClaudeCodeTerminalWithContext", () => { expect(getWrittenInputs()).toEqual([ "cd '/vault root/Draft Folder'\n", "claude\n", - '@"Project Notes/One note.md" @"assets/chart (v1).png"', + [ + '@"Project Notes/One note.md"', + '@"assets/chart (v1).png"', + '@"assets/he said \\"yes\\".md"', + ].join(" "), ]); }); diff --git a/apps/desktop/src/features/terminal/claudeCodeTerminal.ts b/apps/desktop/src/features/terminal/claudeCodeTerminal.ts index 1554c047..05933752 100644 --- a/apps/desktop/src/features/terminal/claudeCodeTerminal.ts +++ b/apps/desktop/src/features/terminal/claudeCodeTerminal.ts @@ -88,7 +88,7 @@ function getNextClaudeCodeTerminalTitle(): string { // 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 : `"${path}"`; + return /^[A-Za-z0-9_./-]+$/.test(path) ? path : JSON.stringify(path); } // Strip the vault root prefix so @mentions are vault-relative rather than