diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a90d2cc4..e13c732f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,3 +92,29 @@ jobs: run: npm ci - name: Tauri debug build run: npm run tauri -- build --debug --no-bundle + + build-windows: + runs-on: windows-latest + needs: + - lint + - typecheck + - test-js + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + - uses: dtolnay/rust-toolchain@stable + - name: Install LLVM (bindgen) + run: choco install llvm -y --no-progress + - name: Configure LLVM (bindgen) + run: | + echo "LIBCLANG_PATH=C:\\Program Files\\LLVM\\bin" >> $env:GITHUB_ENV + echo "C:\\Program Files\\LLVM\\bin" >> $env:GITHUB_PATH + - name: Install dependencies + run: npm ci + - name: Doctor (Windows) + run: npm run doctor:win + - name: Tauri debug build (Windows) + run: npm run tauri -- build --debug --no-bundle --config src-tauri/tauri.windows.conf.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6025a9f9..85f084de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -219,12 +219,74 @@ jobs: name: appimage-${{ matrix.arch }} path: src-tauri/target/release/bundle/appimage/*.AppImage* + build_windows: + runs-on: windows-latest + environment: release + env: + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_SIGNING_PRIVATE_KEY_B64: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_B64 }} + steps: + - uses: actions/checkout@v4 + + - name: setup node + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: npm + + - name: install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Install LLVM (bindgen) + run: choco install llvm -y --no-progress + + - name: Configure LLVM (bindgen) + run: | + echo "LIBCLANG_PATH=C:\\Program Files\\LLVM\\bin" >> $env:GITHUB_ENV + echo "C:\\Program Files\\LLVM\\bin" >> $env:GITHUB_PATH + + - name: install frontend dependencies + run: npm ci + + - name: Write Tauri signing key + shell: bash + run: | + set -euo pipefail + python - <<'PY' + import base64 + import os + from pathlib import Path + + raw = base64.b64decode(os.environ["TAURI_SIGNING_PRIVATE_KEY_B64"]) + home = Path.home() + target = home / ".tauri" + target.mkdir(parents=True, exist_ok=True) + (target / "codexmonitor.key").write_bytes(raw) + PY + + - name: build windows bundles + shell: bash + run: | + set -euo pipefail + export TAURI_SIGNING_PRIVATE_KEY + TAURI_SIGNING_PRIVATE_KEY="$(cat "$HOME/.tauri/codexmonitor.key")" + npm run tauri:build:win + + - name: Upload Windows artifacts + uses: actions/upload-artifact@v4 + with: + name: windows-artifacts + path: | + src-tauri/target/release/bundle/nsis/*.exe* + src-tauri/target/release/bundle/msi/*.msi* + release: runs-on: ubuntu-latest environment: release needs: - build_macos - build_linux + - build_windows steps: - name: Checkout uses: actions/checkout@v4 @@ -244,6 +306,12 @@ jobs: path: release-artifacts merge-multiple: true + - name: Download Windows artifacts + uses: actions/download-artifact@v4 + with: + name: windows-artifacts + path: release-artifacts + - name: Build latest.json run: | set -euo pipefail @@ -345,6 +413,27 @@ jobs: "signature": sig_path.read_text().strip(), } + exe_candidates = sorted(artifacts_dir.rglob("*.exe"), key=lambda p: p.name.lower()) + windows_installer = None + for candidate in exe_candidates: + lowered = candidate.name.lower() + if "setup" in lowered or "installer" in lowered: + windows_installer = candidate + break + if windows_installer is None and exe_candidates: + windows_installer = exe_candidates[0] + if windows_installer is None: + raise SystemExit("No Windows installer (.exe) found for latest.json") + + win_sig_path = windows_installer.with_suffix(windows_installer.suffix + ".sig") + if not win_sig_path.exists(): + raise SystemExit(f"Missing signature for {windows_installer.name}") + + platforms["windows-x86_64"] = { + "url": f"https://github.com/Dimillian/CodexMonitor/releases/download/v${VERSION}/{windows_installer.name}", + "signature": win_sig_path.read_text().strip(), + } + payload = { "version": "${VERSION}", "notes": notes, @@ -378,6 +467,8 @@ jobs: release-artifacts/CodexMonitor.app.tar.gz \ release-artifacts/CodexMonitor.app.tar.gz.sig \ release-artifacts/*.AppImage* \ + release-artifacts/nsis/*.exe* \ + release-artifacts/msi/*.msi* \ release-artifacts/latest.json - name: Bump version and open PR diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..8909d902 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,58 @@ +# Windows Support Execution Plan + +Source of truth for requirements: `SPEC.md`. +This plan is **live** and tracks what has landed and what remains for the Windows milestone (auto-updater + dictation). + +## Workstream + +### 1) Docs + +- [x] Add `SPEC.md` (Windows scope, updater + dictation required). +- [x] Keep `PLAN.md` current as work lands. + +### 2) Git safety + PR + +- [x] Push `feature/windows-support` to a fork remote and set upstream to avoid accidental `main` pushes. +- [x] Open a draft PR early so CI runs on every push. + +### 3) Windows UX + path correctness + +- [x] Make “Reveal in Finder” platform-aware (Explorer on Windows). +- [x] Fix path joining in the frontend so Windows absolute/relative paths behave. +- [x] Make backend `open_workspace_in` work cross-platform (macOS/Windows/Linux). +- [x] Make default “Open in” targets sensible on Windows (Explorer + command-based editors). +- [x] Make default shortcuts Windows-friendly (Ctrl/Alt; avoid Cmd+Ctrl collapse). + +### 4) Dictation on Windows (required) + +- [x] Enable Whisper dictation on Windows (`whisper-rs` + `cpal`) by removing the Windows stub. +- [x] Update Windows build checks (`doctor:win`) to require LLVM/Clang + CMake. +- [x] Fix `doctor:win` dependency detection on Unix (no shell builtins). + +### 5) CI (required) + +- [x] Add a Windows CI job that runs a Tauri debug build with `src-tauri/tauri.windows.conf.json`. + +### 6) Release + updater (required) + +- [x] Enable Windows updater artifacts in `src-tauri/tauri.windows.conf.json`. +- [x] Add a Windows release build job to `.github/workflows/release.yml`. +- [x] Extend `latest.json` generation to include Windows URL + signature. + +## Validation (run after each step) + +- `npm run lint` +- `npm run test` +- `npm run typecheck` +- Rust checks are executed in CI for macOS + Windows jobs added by this plan. + +## Manual checklist (Windows) + +- [ ] `npm run tauri:build:win` succeeds on Windows 10/11. +- [ ] App launches and can open workspaces. +- [ ] Adding a workspace succeeds when `codex --version` works in Windows Terminal. +- [ ] “Reveal in Explorer” opens the right folder. +- [ ] Shortcut hints use Ctrl/Alt labels and work on Windows. +- [ ] Theme dropdown options are readable in Dark/Dim with Reduce Transparency off. +- [ ] Auto-updater finds and applies the latest release. +- [ ] Dictation works end-to-end (download → hold-to-talk → transcript). diff --git a/README.md b/README.md index 7773e668..4f50829a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![CodexMonitor](screenshot.png) -CodexMonitor is a macOS Tauri app for orchestrating multiple Codex agents across local workspaces. It provides a sidebar to manage projects, a home screen for quick actions, and a conversation view backed by the Codex app-server protocol. +CodexMonitor is a Tauri app for orchestrating multiple Codex agents across local workspaces. It provides a sidebar to manage projects, a home screen for quick actions, and a conversation view backed by the Codex app-server protocol. ## Features @@ -31,7 +31,7 @@ CodexMonitor is a macOS Tauri app for orchestrating multiple Codex agents across ### Files & Prompts -- File tree with search, file-type icons, and Reveal in Finder. +- File tree with search, file-type icons, and Reveal in Finder/Explorer. - Prompt library for global/workspace prompts: create/edit/delete/move and run in current or new threads. ### UI & Experience @@ -40,13 +40,14 @@ CodexMonitor is a macOS Tauri app for orchestrating multiple Codex agents across - Responsive layouts (desktop/tablet/phone) with tabbed navigation. - Sidebar usage and credits meter for account rate limits plus a home usage snapshot. - Terminal dock with multiple tabs for background commands (experimental). -- In-app updates with toast-driven download/install, debug panel copy/clear, sound notifications, and macOS overlay title bar with vibrancy + reduced transparency toggle. +- In-app updates with toast-driven download/install, debug panel copy/clear, sound notifications, plus platform-specific window effects (macOS overlay title bar + vibrancy) and a reduced transparency toggle. ## Requirements - Node.js + npm - Rust toolchain (stable) -- CMake (required for native dependencies; Whisper/dictation uses it on non-Windows) +- CMake (required for native dependencies; dictation/Whisper uses it) +- LLVM/Clang (required on Windows to build dictation dependencies via bindgen) - Codex installed on your system and available as `codex` in `PATH` - Git CLI (used for worktree operations) - GitHub CLI (`gh`) for the Issues panel (optional) @@ -74,13 +75,13 @@ npm run tauri dev ## Release Build -Build the production Tauri bundle (app + dmg): +Build the production Tauri bundle: ```bash npm run tauri build ``` -The macOS app bundle will be in `src-tauri/target/release/bundle/macos/`. +Artifacts will be in `src-tauri/target/release/bundle/` (platform-specific subfolders). ### Windows (opt-in) @@ -94,8 +95,8 @@ Artifacts will be in: - `src-tauri/target/release/bundle/nsis/` (installer exe) - `src-tauri/target/release/bundle/msi/` (msi) - -Note: dictation is currently disabled on Windows builds (to avoid requiring LLVM/libclang for `whisper-rs`/bindgen). + +Note: building from source on Windows requires LLVM/Clang (for `bindgen` / `libclang`) in addition to CMake. ## Type Checking diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 00000000..66fdfed1 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,92 @@ +# Windows Support Spec + +## Goal + +Ship a **fully functioning Windows version** of CodexMonitor with: + +- **Auto-updater** enabled and wired into the release workflow (`latest.json` includes Windows). +- **Dictation** working on Windows (same UX + model management as other platforms). + +This spec is the source of truth for Windows support requirements and acceptance criteria. + +## Target Platforms + +- Windows 10/11, **x86_64** (GitHub Actions `windows-latest`). + +## Non-Goals (for this milestone) + +- Authenticode / EV code signing (nice-to-have; not required to be functional). +- Windows ARM64 builds. + +## Functional Requirements + +### Updater (Required) + +- Windows release artifacts must be produced by CI and attached to GitHub Releases. +- `latest.json` must include a **Windows platform entry** with: + - correct asset URL + - signature generated by Tauri updater signing +- In-app updater must be enabled for Windows builds (same endpoints + pubkey as other platforms). +- The updater entry for Windows uses the **NSIS installer** (`.exe` + `.exe.sig`) as the updater bundle. + +### Dictation (Required) + +Dictation must work on Windows with the same surface area as other platforms: + +- Model status: missing/downloading/ready/error +- Model download/cancel/remove +- Session lifecycle: start/listening/stop->processing/transcript/cancel +- Emits the same Tauri events: + - `dictation-download` + - `dictation-event` + +Implementation choice for this milestone: + +- Use the existing Whisper-based implementation on Windows (via `whisper-rs` + `cpal`). + +### Windows UX Correctness (Required) + +- “Reveal in Finder” strings must be platform-aware (“Explorer” on Windows). +- Opening paths in an editor or file manager must behave correctly on Windows. +- Shortcut hints and formatting must be platform-aware on Windows (Ctrl/Alt labels; no macOS-only glyphs). +- Settings selects (for example Theme) must remain readable in Dark/Dim with Reduce Transparency off. + +## Build & Tooling Requirements + +### Windows Build Prereqs (dev + CI) + +Required on Windows to build dictation (Whisper + bindgen): + +- CMake +- LLVM/Clang (for `bindgen` / `libclang`) + +`npm run doctor:win` must fail fast with actionable instructions if missing. +`npm run doctor:win` must correctly detect installed dependencies on Windows/macOS/Linux. + +## CI / Release Requirements + +### CI (Required) + +- Add a Windows job to `.github/workflows/ci.yml` that: + - installs deps + - runs a Windows Tauri debug build (`--no-bundle`) using the Windows config + +### Release (Required) + +Update `.github/workflows/release.yml` to include Windows: + +- Build Windows bundles on `windows-latest` +- Upload `.msi` / `.exe` plus updater `.sig` artifacts +- Include Windows in generated `latest.json` + +## Acceptance Criteria + +- `npm run tauri:build:win` succeeds on Windows. +- Windows release workflow publishes installers and `latest.json` that enables in-app updates. +- Dictation works on Windows end-to-end (model download → hold-to-talk → transcript). +- Adding a workspace works when `codex --version` runs in Windows Terminal (Codex PATH handling is correct). +- Repo checks pass: + - `npm run lint` + - `npm run test` + - `npm run typecheck` + - Rust checks executed in CI for Windows/macOS as configured diff --git a/docs/changelog.html b/docs/changelog.html index 52df64ca..d90c6c33 100644 --- a/docs/changelog.html +++ b/docs/changelog.html @@ -81,7 +81,7 @@

Changelog

Codex Monitor app icon Codex Monitor -

macOS Codex agents orchestration, built by and for individuals who ship fast.

+

Desktop Codex agent orchestration, built by and for individuals who ship fast.

@@ -129,7 +129,7 @@

Skills + prompts

Updater + polish

-

Toast-driven updates, resizable panels, and a macOS overlay title bar.

+

Toast-driven updates, resizable panels, and platform-specific window chrome.

@@ -412,7 +412,7 @@

Ready to monitor every agent run?

Codex Monitor app icon Codex Monitor -

macOS Codex agents orchestration, built by and for individuals who ship fast.

+

Desktop Codex agent orchestration, built by and for individuals who ship fast.

diff --git a/src/features/app/components/OpenAppMenu.tsx b/src/features/app/components/OpenAppMenu.tsx index 06d54f02..51fed084 100644 --- a/src/features/app/components/OpenAppMenu.tsx +++ b/src/features/app/components/OpenAppMenu.tsx @@ -62,9 +62,14 @@ export function OpenAppMenu({ const fallbackTarget: OpenTarget = { id: DEFAULT_OPEN_APP_ID, - label: DEFAULT_OPEN_APP_TARGETS[0]?.label ?? "Open", + label: + DEFAULT_OPEN_APP_TARGETS.find((target) => target.id === DEFAULT_OPEN_APP_ID) + ?.label ?? + DEFAULT_OPEN_APP_TARGETS[0]?.label ?? + "Open", icon: getKnownOpenAppIcon(DEFAULT_OPEN_APP_ID) ?? GENERIC_APP_ICON, target: + DEFAULT_OPEN_APP_TARGETS.find((target) => target.id === DEFAULT_OPEN_APP_ID) ?? DEFAULT_OPEN_APP_TARGETS[0] ?? { id: DEFAULT_OPEN_APP_ID, label: "VS Code", diff --git a/src/features/app/constants.ts b/src/features/app/constants.ts index 1f06f4ad..dacc6d2a 100644 --- a/src/features/app/constants.ts +++ b/src/features/app/constants.ts @@ -1,50 +1,99 @@ import type { OpenAppTarget } from "../../types"; +import { + fileManagerName, + isMacPlatform, + isWindowsPlatform, +} from "../../utils/platformPaths"; export const OPEN_APP_STORAGE_KEY = "open-workspace-app"; -export const DEFAULT_OPEN_APP_ID = "vscode"; +export const DEFAULT_OPEN_APP_ID = isWindowsPlatform() ? "finder" : "vscode"; export type OpenAppId = string; -export const DEFAULT_OPEN_APP_TARGETS: OpenAppTarget[] = [ - { - id: "vscode", - label: "VS Code", - kind: "app", - appName: "Visual Studio Code", - args: [], - }, - { - id: "cursor", - label: "Cursor", - kind: "app", - appName: "Cursor", - args: [], - }, - { - id: "zed", - label: "Zed", - kind: "app", - appName: "Zed", - args: [], - }, - { - id: "ghostty", - label: "Ghostty", - kind: "app", - appName: "Ghostty", - args: [], - }, - { - id: "antigravity", - label: "Antigravity", - kind: "app", - appName: "Antigravity", - args: [], - }, - { - id: "finder", - label: "Finder", - kind: "finder", - args: [], - }, -]; +export const DEFAULT_OPEN_APP_TARGETS: OpenAppTarget[] = isMacPlatform() + ? [ + { + id: "vscode", + label: "VS Code", + kind: "app", + appName: "Visual Studio Code", + args: [], + }, + { + id: "cursor", + label: "Cursor", + kind: "app", + appName: "Cursor", + args: [], + }, + { + id: "zed", + label: "Zed", + kind: "app", + appName: "Zed", + args: [], + }, + { + id: "ghostty", + label: "Ghostty", + kind: "app", + appName: "Ghostty", + args: [], + }, + { + id: "antigravity", + label: "Antigravity", + kind: "app", + appName: "Antigravity", + args: [], + }, + { + id: "finder", + label: fileManagerName(), + kind: "finder", + args: [], + }, + ] + : [ + { + id: "vscode", + label: "VS Code", + kind: "command", + command: "code", + args: [], + }, + { + id: "cursor", + label: "Cursor", + kind: "command", + command: "cursor", + args: [], + }, + { + id: "zed", + label: "Zed", + kind: "command", + command: "zed", + args: [], + }, + { + id: "ghostty", + label: "Ghostty", + kind: "command", + command: "ghostty", + args: [], + }, + { + id: "antigravity", + label: "Antigravity", + kind: "command", + command: "antigravity", + args: [], + }, + { + id: "finder", + label: fileManagerName(), + kind: "finder", + args: [], + }, + ]; diff --git a/src/features/app/utils/openAppIcons.ts b/src/features/app/utils/openAppIcons.ts index 82d8e967..836520b9 100644 --- a/src/features/app/utils/openAppIcons.ts +++ b/src/features/app/utils/openAppIcons.ts @@ -4,6 +4,7 @@ import antigravityIcon from "../../../assets/app-icons/antigravity.png"; import ghosttyIcon from "../../../assets/app-icons/ghostty.png"; import vscodeIcon from "../../../assets/app-icons/vscode.png"; import zedIcon from "../../../assets/app-icons/zed.png"; +import { isMacPlatform } from "../../../utils/platformPaths"; const GENERIC_APP_SVG = ""; @@ -12,6 +13,13 @@ export const GENERIC_APP_ICON = `data:image/svg+xml;utf8,${encodeURIComponent( GENERIC_APP_SVG, )}`; +const GENERIC_FOLDER_SVG = + ""; + +export const GENERIC_FOLDER_ICON = `data:image/svg+xml;utf8,${encodeURIComponent( + GENERIC_FOLDER_SVG, +)}`; + export function getKnownOpenAppIcon(id: string): string | null { switch (id) { case "vscode": @@ -25,7 +33,7 @@ export function getKnownOpenAppIcon(id: string): string | null { case "antigravity": return antigravityIcon; case "finder": - return finderIcon; + return isMacPlatform() ? finderIcon : GENERIC_FOLDER_ICON; default: return null; } diff --git a/src/features/files/components/FileTreePanel.tsx b/src/features/files/components/FileTreePanel.tsx index 98c8fab9..c8a259f8 100644 --- a/src/features/files/components/FileTreePanel.tsx +++ b/src/features/files/components/FileTreePanel.tsx @@ -30,6 +30,7 @@ import { PanelTabs, type PanelTabId } from "../../layout/components/PanelTabs"; import { readWorkspaceFile } from "../../../services/tauri"; import type { OpenAppTarget } from "../../../types"; import { languageFromPath } from "../../../utils/syntax"; +import { joinWorkspacePath, revealInFileManagerLabel } from "../../../utils/platformPaths"; import { FilePreviewPopover } from "./FilePreviewPopover"; type FileTreeNode = { @@ -370,10 +371,7 @@ export function FileTreePanel({ const resolvePath = useCallback( (relativePath: string) => { - const base = workspacePath.endsWith("/") - ? workspacePath.slice(0, -1) - : workspacePath; - return `${base}/${relativePath}`; + return joinWorkspacePath(workspacePath, relativePath); }, [workspacePath], ); @@ -585,7 +583,7 @@ export function FileTreePanel({ }, }), await MenuItem.new({ - text: "Reveal in Finder", + text: revealInFileManagerLabel(), action: async () => { await revealItemInDir(resolvePath(relativePath)); }, diff --git a/src/features/home/components/Home.tsx b/src/features/home/components/Home.tsx index d9664021..a3c56b75 100644 --- a/src/features/home/components/Home.tsx +++ b/src/features/home/components/Home.tsx @@ -1,3 +1,4 @@ +import FolderOpen from "lucide-react/dist/esm/icons/folder-open"; import RefreshCw from "lucide-react/dist/esm/icons/refresh-cw"; import type { LocalUsageSnapshot } from "../../../types"; import { formatRelativeTime } from "../../../utils/time"; @@ -243,7 +244,7 @@ export function Home({ data-tauri-drag-region="false" > - ⌘ + Open Project diff --git a/src/features/messages/hooks/useFileLinkOpener.ts b/src/features/messages/hooks/useFileLinkOpener.ts index 94d3e31e..879d5259 100644 --- a/src/features/messages/hooks/useFileLinkOpener.ts +++ b/src/features/messages/hooks/useFileLinkOpener.ts @@ -8,6 +8,11 @@ import * as Sentry from "@sentry/react"; import { openWorkspaceIn } from "../../../services/tauri"; import { pushErrorToast } from "../../../services/toasts"; import type { OpenAppTarget } from "../../../types"; +import { + isAbsolutePath, + joinWorkspacePath, + revealInFileManagerLabel, +} from "../../../utils/platformPaths"; type OpenTarget = { id: string; @@ -44,11 +49,10 @@ function resolveFilePath(path: string, workspacePath?: string | null) { if (!workspacePath) { return trimmed; } - if (trimmed.startsWith("/") || trimmed.startsWith("~/")) { + if (isAbsolutePath(trimmed)) { return trimmed; } - const base = workspacePath.replace(/\/+$/, ""); - return `${base}/${trimmed}`; + return joinWorkspacePath(workspacePath, trimmed); } function stripLineSuffix(path: string) { @@ -56,20 +60,6 @@ function stripLineSuffix(path: string) { return match ? match[1] : path; } -function revealLabel() { - const platform = - (navigator as Navigator & { userAgentData?: { platform?: string } }) - .userAgentData?.platform ?? navigator.platform ?? ""; - const normalized = platform.toLowerCase(); - if (normalized.includes("mac")) { - return "Reveal in Finder"; - } - if (normalized.includes("win")) { - return "Show in Explorer"; - } - return "Reveal in File Manager"; -} - export function useFileLinkOpener( workspacePath: string | null, openTargets: OpenAppTarget[], @@ -164,7 +154,7 @@ export function useFileLinkOpener( const canOpen = canOpenTarget(target); const openLabel = target.kind === "finder" - ? revealLabel() + ? revealInFileManagerLabel() : target.kind === "command" ? command ? `Open in ${target.label}` @@ -184,7 +174,7 @@ export function useFileLinkOpener( ? [] : [ await MenuItem.new({ - text: revealLabel(), + text: revealInFileManagerLabel(), action: async () => { try { await revealItemInDir(resolvedPath); diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 8c00912d..f7cf3687 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -48,9 +48,9 @@ const baseSettings: AppSettings = { theme: "system", usageShowRemaining: false, uiFontFamily: - "\"SF Pro Text\", \"SF Pro Display\", -apple-system, \"Helvetica Neue\", sans-serif", + 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', codeFontFamily: - "\"SF Mono\", \"SFMono-Regular\", Menlo, Monaco, monospace", + 'ui-monospace, "Cascadia Mono", "Segoe UI Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', codeFontSize: 11, notificationSoundsEnabled: true, systemNotificationsEnabled: true, @@ -316,12 +316,12 @@ describe("SettingsView Display", () => { await waitFor(() => { expect(onUpdateAppSettings).toHaveBeenCalledWith( expect.objectContaining({ - uiFontFamily: expect.stringContaining("SF Pro Text"), + uiFontFamily: expect.stringContaining("system-ui"), }), ); expect(onUpdateAppSettings).toHaveBeenCalledWith( expect.objectContaining({ - codeFontFamily: expect.stringContaining("SF Mono"), + codeFontFamily: expect.stringContaining("ui-monospace"), }), ); }); diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index 88386bbf..5e0d3972 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -25,6 +25,12 @@ import type { WorkspaceInfo, } from "../../../types"; import { formatDownloadSize } from "../../../utils/formatting"; +import { + fileManagerName, + isMacPlatform, + isWindowsPlatform, + openInFileManagerLabel, +} from "../../../utils/platformPaths"; import { buildShortcutValue, formatShortcut, @@ -425,6 +431,12 @@ export function SettingsView({ const globalConfigSaveLabel = globalConfigExists ? "Save" : "Create"; const globalConfigSaveDisabled = globalConfigLoading || globalConfigSaving || !globalConfigDirty; const globalConfigRefreshDisabled = globalConfigLoading || globalConfigSaving; + const optionKeyLabel = isMacPlatform() ? "Option" : "Alt"; + const metaKeyLabel = isMacPlatform() + ? "Command" + : isWindowsPlatform() + ? "Windows" + : "Meta"; const selectedDictationModel = useMemo(() => { return ( DICTATION_MODELS.find( @@ -1571,7 +1583,7 @@ export function SettingsView({
System notifications
- Show a macOS notification when a long-running agent finishes while the window is unfocused. + Show a system notification when a long-running agent finishes while the window is unfocused.
- Default: {formatShortcut("cmd+ctrl+a")} + Default:{" "} + {formatShortcut(isMacPlatform() ? "cmd+ctrl+a" : "ctrl+alt+a")}
@@ -2379,7 +2392,10 @@ export function SettingsView({
- Default: {formatShortcut("cmd+ctrl+down")} + Default:{" "} + {formatShortcut( + isMacPlatform() ? "cmd+ctrl+down" : "ctrl+alt+down", + )}
@@ -2403,7 +2419,10 @@ export function SettingsView({
- Default: {formatShortcut("cmd+ctrl+up")} + Default:{" "} + {formatShortcut( + isMacPlatform() ? "cmd+ctrl+up" : "ctrl+alt+up", + )}
@@ -2427,7 +2446,12 @@ export function SettingsView({
- Default: {formatShortcut("cmd+shift+down")} + Default:{" "} + {formatShortcut( + isMacPlatform() + ? "cmd+shift+down" + : "ctrl+alt+shift+down", + )}
@@ -2451,7 +2475,10 @@ export function SettingsView({
- Default: {formatShortcut("cmd+shift+up")} + Default:{" "} + {formatShortcut( + isMacPlatform() ? "cmd+shift+up" : "ctrl+alt+shift+up", + )}
@@ -2531,7 +2558,7 @@ export function SettingsView({ > - + {target.kind === "app" && ( @@ -2658,8 +2685,10 @@ export function SettingsView({ Add app
- Commands receive the selected path as the final argument. Apps use macOS open - with optional args. + Commands receive the selected path as the final argument.{" "} + {isMacPlatform() + ? "Apps open via `open -a` with optional args." + : "Apps run as an executable with optional args."}
@@ -3139,11 +3168,11 @@ export function SettingsView({
Config file
- Open the Codex config in Finder. + Open the Codex config in {fileManagerName()}.
{openConfigError && ( diff --git a/src/features/settings/hooks/useAppSettings.test.ts b/src/features/settings/hooks/useAppSettings.test.ts index 5a1ccbd7..44f70840 100644 --- a/src/features/settings/hooks/useAppSettings.test.ts +++ b/src/features/settings/hooks/useAppSettings.test.ts @@ -49,8 +49,8 @@ describe("useAppSettings", () => { expect(result.current.settings.uiScale).toBe(UI_SCALE_MAX); expect(result.current.settings.theme).toBe("system"); - expect(result.current.settings.uiFontFamily).toContain("SF Pro Text"); - expect(result.current.settings.codeFontFamily).toContain("SF Mono"); + expect(result.current.settings.uiFontFamily).toContain("system-ui"); + expect(result.current.settings.codeFontFamily).toContain("ui-monospace"); expect(result.current.settings.codeFontSize).toBe(16); expect(result.current.settings.experimentalPersonality).toBe("default"); expect(result.current.settings.backendMode).toBe("remote"); @@ -66,8 +66,8 @@ describe("useAppSettings", () => { expect(result.current.settings.uiScale).toBe(UI_SCALE_DEFAULT); expect(result.current.settings.theme).toBe("system"); - expect(result.current.settings.uiFontFamily).toContain("SF Pro Text"); - expect(result.current.settings.codeFontFamily).toContain("SF Mono"); + expect(result.current.settings.uiFontFamily).toContain("system-ui"); + expect(result.current.settings.codeFontFamily).toContain("ui-monospace"); expect(result.current.settings.backendMode).toBe("local"); expect(result.current.settings.dictationModelId).toBe("base"); expect(result.current.settings.interruptShortcut).toBeTruthy(); @@ -110,8 +110,8 @@ describe("useAppSettings", () => { expect.objectContaining({ theme: "system", uiScale: 0.1, - uiFontFamily: expect.stringContaining("SF Pro Text"), - codeFontFamily: expect.stringContaining("SF Mono"), + uiFontFamily: expect.stringContaining("system-ui"), + codeFontFamily: expect.stringContaining("ui-monospace"), codeFontSize: 9, notificationSoundsEnabled: false, }), diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index 49641cc9..438003ba 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -15,11 +15,13 @@ import { OPEN_APP_STORAGE_KEY, } from "../../app/constants"; import { normalizeOpenAppTargets } from "../../app/utils/openApp"; -import { getDefaultInterruptShortcut } from "../../../utils/shortcuts"; +import { getDefaultInterruptShortcut, isMacPlatform } from "../../../utils/shortcuts"; const allowedThemes = new Set(["system", "light", "dark", "dim"]); const allowedPersonality = new Set(["default", "friendly", "pragmatic"]); +const isMac = isMacPlatform(); + const defaultSettings: AppSettings = { codexBin: null, codexArgs: null, @@ -28,23 +30,23 @@ const defaultSettings: AppSettings = { remoteBackendToken: null, defaultAccessMode: "current", reviewDeliveryMode: "inline", - composerModelShortcut: "cmd+shift+m", - composerAccessShortcut: "cmd+shift+a", - composerReasoningShortcut: "cmd+shift+r", + composerModelShortcut: isMac ? "cmd+shift+m" : "ctrl+shift+m", + composerAccessShortcut: isMac ? "cmd+shift+a" : "ctrl+shift+a", + composerReasoningShortcut: isMac ? "cmd+shift+r" : "ctrl+shift+r", composerCollaborationShortcut: "shift+tab", interruptShortcut: getDefaultInterruptShortcut(), - newAgentShortcut: "cmd+n", - newWorktreeAgentShortcut: "cmd+shift+n", - newCloneAgentShortcut: "cmd+alt+n", - archiveThreadShortcut: "cmd+ctrl+a", - toggleProjectsSidebarShortcut: "cmd+shift+p", - toggleGitSidebarShortcut: "cmd+shift+g", - toggleDebugPanelShortcut: "cmd+shift+d", - toggleTerminalShortcut: "cmd+shift+t", - cycleAgentNextShortcut: "cmd+ctrl+down", - cycleAgentPrevShortcut: "cmd+ctrl+up", - cycleWorkspaceNextShortcut: "cmd+shift+down", - cycleWorkspacePrevShortcut: "cmd+shift+up", + newAgentShortcut: isMac ? "cmd+n" : "ctrl+n", + newWorktreeAgentShortcut: isMac ? "cmd+shift+n" : "ctrl+shift+n", + newCloneAgentShortcut: isMac ? "cmd+alt+n" : "ctrl+alt+n", + archiveThreadShortcut: isMac ? "cmd+ctrl+a" : "ctrl+alt+a", + toggleProjectsSidebarShortcut: isMac ? "cmd+shift+p" : "ctrl+shift+p", + toggleGitSidebarShortcut: isMac ? "cmd+shift+g" : "ctrl+shift+g", + toggleDebugPanelShortcut: isMac ? "cmd+shift+d" : "ctrl+shift+d", + toggleTerminalShortcut: isMac ? "cmd+shift+t" : "ctrl+shift+t", + cycleAgentNextShortcut: isMac ? "cmd+ctrl+down" : "ctrl+alt+down", + cycleAgentPrevShortcut: isMac ? "cmd+ctrl+up" : "ctrl+alt+up", + cycleWorkspaceNextShortcut: isMac ? "cmd+shift+down" : "ctrl+alt+shift+down", + cycleWorkspacePrevShortcut: isMac ? "cmd+shift+up" : "ctrl+alt+shift+up", lastComposerModelId: null, lastComposerReasoningEffort: null, uiScale: UI_SCALE_DEFAULT, diff --git a/src/main.tsx b/src/main.tsx index 21b7dc69..5da8c2a3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,26 @@ import ReactDOM from "react-dom/client"; import * as Sentry from "@sentry/react"; import App from "./App"; +function sentryPlatformLabel(): string { + if (typeof navigator === "undefined") { + return "unknown"; + } + const platform = + (navigator as Navigator & { userAgentData?: { platform?: string } }) + .userAgentData?.platform ?? navigator.platform ?? ""; + const normalized = platform.toLowerCase(); + if (normalized.includes("mac")) { + return "macos"; + } + if (normalized.includes("win")) { + return "windows"; + } + if (normalized.includes("linux")) { + return "linux"; + } + return "unknown"; +} + const sentryDsn = import.meta.env.VITE_SENTRY_DSN ?? "https://8ab67175daed999e8c432a93d8f98e49@o4510750015094784.ingest.us.sentry.io/4510750016012288"; @@ -16,7 +36,7 @@ Sentry.init({ Sentry.metrics.count("app_open", 1, { attributes: { env: import.meta.env.MODE, - platform: "macos", + platform: sentryPlatformLabel(), }, }); diff --git a/src/styles/approval-toasts.css b/src/styles/approval-toasts.css index ff71a5a1..a488dfab 100644 --- a/src/styles/approval-toasts.css +++ b/src/styles/approval-toasts.css @@ -77,7 +77,7 @@ } .approval-toast-detail-code { - font-family: "SF Mono", Menlo, monospace; + font-family: var(--code-font-family); font-size: 11px; color: var(--text-muted); white-space: pre-wrap; diff --git a/src/styles/plan.css b/src/styles/plan.css index 9f4770de..162e96cd 100644 --- a/src/styles/plan.css +++ b/src/styles/plan.css @@ -60,7 +60,7 @@ } .plan-step-status { - font-family: "SF Mono", Menlo, monospace; + font-family: var(--code-font-family); font-size: 11px; color: var(--text-faint); flex: 0 0 auto; diff --git a/src/styles/settings.css b/src/styles/settings.css index 14e6fe55..eb4e2fa4 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -259,7 +259,7 @@ } .settings-input--shortcut { - font-family: "SF Mono", "Fira Mono", "Menlo", monospace; + font-family: var(--code-font-family); letter-spacing: 0.02em; } @@ -286,6 +286,11 @@ font-size: 12px; } +.settings-select option { + background-color: var(--surface-popover); + color: var(--text-strong); +} + .settings-select--compact { padding: 6px 8px; font-size: 11px; diff --git a/src/styles/themes.dark.css b/src/styles/themes.dark.css index 73ac753c..6fb78809 100644 --- a/src/styles/themes.dark.css +++ b/src/styles/themes.dark.css @@ -1,14 +1,15 @@ :root { - font-family: "SF Pro Text", "SF Pro Display", -apple-system, "Helvetica Neue", - sans-serif; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; color: var(--text-primary); background-color: transparent; font-synthesis: none; text-rendering: optimizeLegibility; color-scheme: light dark; - --ui-font-family: "SF Pro Text", "SF Pro Display", -apple-system, "Helvetica Neue", - sans-serif; - --code-font-family: "SF Mono", "SFMono-Regular", Menlo, Monaco, monospace; + --ui-font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + --code-font-family: ui-monospace, "Cascadia Mono", "Segoe UI Mono", Menlo, + Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --code-font-size: 11px; --code-font-weight: 400; --code-line-height: 1.28; diff --git a/src/styles/update-toasts.css b/src/styles/update-toasts.css index 79768a83..581fd3b4 100644 --- a/src/styles/update-toasts.css +++ b/src/styles/update-toasts.css @@ -71,7 +71,7 @@ } .update-toast-error { - font-family: "SF Mono", Menlo, monospace; + font-family: var(--code-font-family); font-size: 11px; color: var(--text-muted); white-space: pre-wrap; diff --git a/src/utils/fonts.ts b/src/utils/fonts.ts index 2a1a4584..f086f62d 100644 --- a/src/utils/fonts.ts +++ b/src/utils/fonts.ts @@ -1,8 +1,8 @@ export const DEFAULT_UI_FONT_FAMILY = - "\"SF Pro Text\", \"SF Pro Display\", -apple-system, \"Helvetica Neue\", sans-serif"; + 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'; export const DEFAULT_CODE_FONT_FAMILY = - "\"SF Mono\", \"SFMono-Regular\", Menlo, Monaco, monospace"; + 'ui-monospace, "Cascadia Mono", "Segoe UI Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'; export const CODE_FONT_SIZE_DEFAULT = 11; export const CODE_FONT_SIZE_MIN = 9; diff --git a/src/utils/platformPaths.ts b/src/utils/platformPaths.ts new file mode 100644 index 00000000..7d7799de --- /dev/null +++ b/src/utils/platformPaths.ts @@ -0,0 +1,113 @@ +type PlatformKind = "mac" | "windows" | "linux" | "unknown"; + +function platformKind(): PlatformKind { + if (typeof navigator === "undefined") { + return "unknown"; + } + const platform = + (navigator as Navigator & { userAgentData?: { platform?: string } }) + .userAgentData?.platform ?? navigator.platform ?? ""; + const normalized = platform.toLowerCase(); + if (normalized.includes("mac")) { + return "mac"; + } + if (normalized.includes("win")) { + return "windows"; + } + if (normalized.includes("linux")) { + return "linux"; + } + return "unknown"; +} + +export function isMacPlatform(): boolean { + return platformKind() === "mac"; +} + +export function isWindowsPlatform(): boolean { + return platformKind() === "windows"; +} + +export function fileManagerName(): string { + const platform = platformKind(); + if (platform === "mac") { + return "Finder"; + } + if (platform === "windows") { + return "Explorer"; + } + return "File Manager"; +} + +export function revealInFileManagerLabel(): string { + const platform = platformKind(); + if (platform === "mac") { + return "Reveal in Finder"; + } + if (platform === "windows") { + return "Show in Explorer"; + } + return "Reveal in File Manager"; +} + +export function openInFileManagerLabel(): string { + return `Open in ${fileManagerName()}`; +} + +function looksLikeWindowsAbsolutePath(value: string): boolean { + if (/^[A-Za-z]:[\\/]/.test(value)) { + return true; + } + if (value.startsWith("\\\\") || value.startsWith("//")) { + return true; + } + if (value.startsWith("\\\\?\\")) { + return true; + } + return false; +} + +export function isAbsolutePath(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + if (trimmed.startsWith("/") || trimmed.startsWith("~/") || trimmed.startsWith("~\\")) { + return true; + } + return looksLikeWindowsAbsolutePath(trimmed); +} + +function stripTrailingSeparators(value: string) { + return value.replace(/[\\/]+$/, ""); +} + +function stripLeadingSeparators(value: string) { + return value.replace(/^[\\/]+/, ""); +} + +function looksLikeWindowsPathPrefix(value: string): boolean { + const trimmed = value.trim(); + return looksLikeWindowsAbsolutePath(trimmed) || trimmed.includes("\\"); +} + +export function joinWorkspacePath(base: string, path: string): string { + const trimmedBase = base.trim(); + const trimmedPath = path.trim(); + if (!trimmedBase) { + return trimmedPath; + } + if (!trimmedPath || isAbsolutePath(trimmedPath)) { + return trimmedPath; + } + + const isWindows = looksLikeWindowsPathPrefix(trimmedBase); + const baseWithoutTrailing = stripTrailingSeparators(trimmedBase); + const pathWithoutLeading = stripLeadingSeparators(trimmedPath); + if (isWindows) { + const normalizedRelative = pathWithoutLeading.replace(/\//g, "\\"); + return `${baseWithoutTrailing}\\${normalizedRelative}`; + } + const normalizedRelative = pathWithoutLeading.replace(/\\/g, "/"); + return `${baseWithoutTrailing}/${normalizedRelative}`; +} diff --git a/src/utils/shortcuts.ts b/src/utils/shortcuts.ts index 0d6eb1cf..8bbaac93 100644 --- a/src/utils/shortcuts.ts +++ b/src/utils/shortcuts.ts @@ -7,13 +7,20 @@ export type ShortcutDefinition = { }; const MODIFIER_ORDER = ["cmd", "ctrl", "alt", "shift"] as const; -const MODIFIER_LABELS: Record = { +const MODIFIER_LABELS_MAC: Record = { cmd: "⌘", ctrl: "⌃", alt: "⌥", shift: "⇧", }; +const MODIFIER_LABELS_OTHER: Record = { + cmd: "Ctrl", + ctrl: "Ctrl", + alt: "Alt", + shift: "Shift", +}; + const KEY_LABELS: Record = { " ": "Space", space: "Space", @@ -85,25 +92,32 @@ export function formatShortcut(value: string | null | undefined): string { if (!parsed) { return value; } + const useSymbols = isMacPlatform(); + const modifierLabels = useSymbols ? MODIFIER_LABELS_MAC : MODIFIER_LABELS_OTHER; const modifiers = MODIFIER_ORDER.flatMap((modifier) => { if (modifier === "cmd" && parsed.meta) { - return MODIFIER_LABELS.cmd; + return modifierLabels.cmd; } if (modifier === "ctrl" && parsed.ctrl) { - return MODIFIER_LABELS.ctrl; + return modifierLabels.ctrl; } if (modifier === "alt" && parsed.alt) { - return MODIFIER_LABELS.alt; + return modifierLabels.alt; } if (modifier === "shift" && parsed.shift) { - return MODIFIER_LABELS.shift; + return modifierLabels.shift; } return []; }); + const uniqueModifiers = useSymbols + ? modifiers + : modifiers.filter((modifier, index) => modifiers.indexOf(modifier) === index); const keyLabel = KEY_LABELS[parsed.key] ?? (parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key); - return [...modifiers, keyLabel].join(""); + return useSymbols + ? [...uniqueModifiers, keyLabel].join("") + : [...uniqueModifiers, keyLabel].join("+"); } export function buildShortcutValue(event: KeyboardEvent): string | null { @@ -141,12 +155,22 @@ export function matchesShortcut(event: KeyboardEvent, value: string | null | und if (!key || key !== parsed.key) { return false; } - return ( - parsed.meta === event.metaKey && - parsed.ctrl === event.ctrlKey && - parsed.alt === event.altKey && - parsed.shift === event.shiftKey - ); + const isMac = isMacPlatform(); + const metaMatches = parsed.meta + ? isMac + ? event.metaKey + : event.ctrlKey || event.metaKey + : !event.metaKey; + if (!metaMatches) { + return false; + } + + const ctrlMatches = parsed.ctrl + ? event.ctrlKey + : parsed.meta && !isMac + ? true + : !event.ctrlKey; + return ctrlMatches && parsed.alt === event.altKey && parsed.shift === event.shiftKey; } export function isMacPlatform(): boolean {