diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd43a2f7..0259a942 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,3 +66,23 @@ jobs: - name: Verify packaged runtime dependencies run: pnpm --dir apps/desktop run verify:packaged-runtime-deps:linux + + desktop-package-windows: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + # Builds both x64 and arm64 NSIS installers (electron-builder.yml win arch). + - name: Package Windows installers + run: pnpm --filter @pi-gui/desktop run package:win diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8801e92..46b6560c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -162,3 +162,44 @@ jobs: apps/desktop/release/latest-linux*.yml prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') || contains(github.ref_name, 'rc') }} generate_release_notes: true + + release-windows: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + # Skip @electron/rebuild: node-pty ships win32 N-API prebuilds (rebuild + # fails on its bundled winpty). Installers are unsigned (no cert). + - name: Package Windows installers + shell: bash + run: | + cd apps/desktop + pnpm run build + pnpm exec electron-builder --win \ + -c.npmRebuild=false \ + --publish never \ + "-c.extraMetadata.version=${GITHUB_REF_NAME#v}" + + - name: Release + uses: softprops/action-gh-release@v2 + with: + files: | + apps/desktop/release/*.exe + apps/desktop/release/*.exe.blockmap + apps/desktop/release/latest.yml + prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') || contains(github.ref_name, 'rc') }} + generate_release_notes: true diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index f175455d..b7bca812 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -18,6 +18,7 @@ extraResources: asar: true asarUnpack: - "**/*.node" + - "**/*.exe" - "**/*.wasm" - "**/node_modules/node-pty/**/*" - "**/node_modules/.pnpm/node-pty*/node_modules/node-pty/**/*" @@ -47,6 +48,22 @@ linux: target: - target: AppImage +win: + icon: resources/icon.ico + target: + - target: nsis + arch: + - x64 + - arm64 + +nsis: + oneClick: false + perMachine: false + allowToChangeInstallationDirectory: true + createDesktopShortcut: true + createStartMenuShortcut: true + shortcutName: pi-gui + toolsets: appimage: "1.0.2" diff --git a/apps/desktop/electron/app-store-diff.ts b/apps/desktop/electron/app-store-diff.ts index c284ea1e..97bf5859 100644 --- a/apps/desktop/electron/app-store-diff.ts +++ b/apps/desktop/electron/app-store-diff.ts @@ -20,7 +20,7 @@ export function getChangedFiles(workspacePath: string): Promise { if (error) { resolve([]); @@ -56,14 +56,14 @@ export function getFileDiff(workspacePath: string, filePath: string): Promise { if (error || !stdout.trim()) { // Try staged diff execFile( "git", ["diff", "--cached", "--", filePath], - { cwd: workspacePath, maxBuffer: 5 * 1024 * 1024 }, + { cwd: workspacePath, maxBuffer: 5 * 1024 * 1024, windowsHide: true }, (error2, stdout2) => { if (!error2 && stdout2.trim()) { resolve(stdout2); @@ -73,7 +73,7 @@ export function getFileDiff(workspacePath: string, filePath: string): Promise { // git diff --no-index exits 1 when files differ, which is expected resolve(stdout3 || ""); @@ -95,7 +95,7 @@ export function stageFile(workspacePath: string, filePath: string): Promise { if (error) { reject(error); diff --git a/apps/desktop/electron/app-store-files.ts b/apps/desktop/electron/app-store-files.ts index c2812380..adb370ef 100644 --- a/apps/desktop/electron/app-store-files.ts +++ b/apps/desktop/electron/app-store-files.ts @@ -14,7 +14,7 @@ export function listWorkspaceFiles(workspacePath: string): Promise { execFile( "git", ["ls-files", "--cached", "--others", "--exclude-standard"], - { cwd: workspacePath, maxBuffer: 5 * 1024 * 1024 }, + { cwd: workspacePath, maxBuffer: 5 * 1024 * 1024, windowsHide: true }, (error, stdout) => { if (error) { resolve([]); diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index dbbcf24b..7c4337aa 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -1,3 +1,5 @@ +// Patch child_process for windowsHide before the pi runtime is imported below. Keep first. +import "./suppress-windows-console"; import { app, BrowserWindow, @@ -23,10 +25,10 @@ import { NotificationPermissionService, } from "./notification-permission"; import { checkForUpdate, initUpdateChecker } from "./update-checker"; -import { ThemeManager } from "./theme-manager"; +import { ThemeManager, windowsTitleBarOverlay } from "./theme-manager"; import { TerminalService } from "./terminal-service"; import type { DesktopAppState, ThemeMode } from "../src/desktop-state"; -import { desktopIpc, getDesktopCommandFromShortcut } from "../src/ipc"; +import { desktopCommands, desktopIpc, getDesktopCommandFromShortcut, type PiDesktopCommand } from "../src/ipc"; import { SUPPORTED_COMPOSER_IMAGE_TYPES } from "../src/composer-attachments"; import type { ComposerAttachment, @@ -118,17 +120,26 @@ function readClipboardImageAttachment(): ComposerImageAttachment | null { function createWindow(): BrowserWindow { const backgroundTestMode = windowTestMode === "background"; - const enableTransparency = store ? store.state.enableTransparency : false; + const isMac = process.platform === "darwin"; + // macOS: inset traffic lights + vibrancy. Windows: Window Controls Overlay. + // Linux: standard frame. + const useTransparency = isMac && (store ? store.state.enableTransparency : false); const window = new BrowserWindow({ width: 1480, height: 980, minWidth: 1200, minHeight: 760, - transparent: enableTransparency, - vibrancy: process.platform === "darwin" && enableTransparency ? "under-window" : undefined, - titleBarStyle: "hiddenInset", - backgroundColor: enableTransparency ? "#00000000" : "#f3f4f8", - trafficLightPosition: { x: 18, y: 18 }, + transparent: useTransparency, + vibrancy: useTransparency ? "under-window" : undefined, + backgroundColor: useTransparency ? "#00000000" : "#f3f4f8", + ...(isMac + ? { titleBarStyle: "hiddenInset" as const, trafficLightPosition: { x: 18, y: 18 } } + : process.platform === "win32" + ? { + titleBarStyle: "hidden" as const, + titleBarOverlay: windowsTitleBarOverlay(themeManager.getResolvedTheme()), + } + : {}), show: false, icon: appIcon, webPreferences: { @@ -184,6 +195,30 @@ function createWindow(): BrowserWindow { } }); + if (process.platform === "win32") { + // No menu bar on Windows: right-click edit menu for text fields. + window.webContents.on("context-menu", (_event, params) => { + const template: MenuItemConstructorOptions[] = params.isEditable + ? [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut", enabled: params.editFlags.canCut }, + { role: "copy", enabled: params.editFlags.canCopy }, + { role: "paste", enabled: params.editFlags.canPaste }, + { type: "separator" }, + { role: "selectAll" }, + ] + : params.selectionText.trim().length > 0 + ? [{ role: "copy" }, { type: "separator" }, { role: "selectAll" }] + : []; + if (template.length === 0) { + return; + } + Menu.buildFromTemplate(template).popup({ window }); + }); + } + if (isDev) { void window.loadURL(process.env.ELECTRON_RENDERER_URL as string); if (process.env.PI_APP_OPEN_DEVTOOLS !== "0") { @@ -326,6 +361,11 @@ async function runManualUpdateCheck(): Promise { } function installApplicationMenu(): void { + if (process.platform === "win32") { + // No menu bar on Windows; the in-app topbar is the only chrome. + Menu.setApplicationMenu(null); + return; + } if (process.platform !== "darwin") { return; } @@ -376,6 +416,67 @@ function installApplicationMenu(): void { Menu.setApplicationMenu(Menu.buildFromTemplate(template)); } +function sendDesktopCommand(command: PiDesktopCommand): void { + if (mainWindow && canPublishToWindow(mainWindow)) { + mainWindow.webContents.send(desktopIpc.appCommand, command); + } +} + +function showAboutDialog(): void { + const window = mainWindow && canPublishToWindow(mainWindow) ? mainWindow : undefined; + const options: MessageBoxOptions = { + type: "info", + title: "pi-gui", + message: "pi-gui", + detail: `Version ${app.getVersion()}\nElectron ${process.versions.electron}\nA Codex-style desktop app for pi.`, + buttons: ["OK"], + }; + if (window) { + void dialog.showMessageBox(window, options); + } else { + void dialog.showMessageBox(options); + } +} + +// Surfaced from the topbar menu button (popupAppMenu); Windows has no menu bar. +function buildWindowsAppMenu(): Menu { + const template: MenuItemConstructorOptions[] = [ + { + label: "File", + submenu: [ + { label: "New Thread", click: () => sendDesktopCommand(desktopCommands.openNewThread) }, + { + label: "Open Folder…", + click: () => { + void pickWorkspaceViaDialog(); + }, + }, + { type: "separator" }, + { label: "Settings", click: () => sendDesktopCommand(desktopCommands.openSettings) }, + { type: "separator" }, + { role: "quit" }, + ], + }, + { role: "editMenu" }, + { role: "viewMenu" }, + { role: "windowMenu" }, + { + label: "Help", + submenu: [ + { + id: CHECK_FOR_UPDATES_MENU_ITEM_ID, + label: "Check for Updates…", + click: () => { + void runManualUpdateCheck(); + }, + }, + { label: "About pi", click: () => showAboutDialog() }, + ], + }, + ]; + return Menu.buildFromTemplate(template); +} + app.setName("pi"); const configuredUserDataDir = process.env.PI_APP_USER_DATA_DIR?.trim() || app.getPath("userData"); @@ -735,6 +836,10 @@ app.whenReady().then(async () => { window.maximize(); }); + ipcMain.handle(desktopIpc.popupAppMenu, (event) => { + const window = BrowserWindow.fromWebContents(event.sender) ?? mainWindow ?? undefined; + buildWindowsAppMenu().popup(window ? { window } : undefined); + }); mainWindow = createWindow(); notificationManager.trackWindow(mainWindow); diff --git a/apps/desktop/electron/notification-permission.ts b/apps/desktop/electron/notification-permission.ts index d7d6915a..d85e5c57 100644 --- a/apps/desktop/electron/notification-permission.ts +++ b/apps/desktop/electron/notification-permission.ts @@ -255,6 +255,11 @@ async function openSystemNotificationSettingsInternal(): Promise { return; } + if (process.platform === "win32") { + await shell.openExternal("ms-settings:notifications"); + return; + } + if (process.platform !== "darwin") { await shell.openExternal("https://support.apple.com/guide/mac-help/change-notifications-settings-mh40583/mac"); return; diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 7af681bc..b4715759 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -257,6 +257,7 @@ contextBridge.exposeInMainWorld("piApp", { stageFile: (workspaceId: string, filePath: string) => ipcRenderer.invoke(desktopIpc.stageFile, workspaceId, filePath) as Promise, toggleWindowMaximize: () => ipcRenderer.invoke(desktopIpc.toggleWindowMaximize) as Promise, + popupAppMenu: () => ipcRenderer.invoke(desktopIpc.popupAppMenu) as Promise, openExternal: (url: string) => ipcRenderer.invoke(desktopIpc.openExternal, url) as Promise, getThemeMode: () => ipcRenderer.invoke(desktopIpc.getThemeMode) as Promise<"system" | "light" | "dark">, getResolvedTheme: () => ipcRenderer.invoke(desktopIpc.getResolvedTheme) as Promise<"light" | "dark">, diff --git a/apps/desktop/electron/suppress-windows-console.ts b/apps/desktop/electron/suppress-windows-console.ts new file mode 100644 index 00000000..f6e2b44b --- /dev/null +++ b/apps/desktop/electron/suppress-windows-console.ts @@ -0,0 +1,29 @@ +// A GUI process has no console, so spawning console programs (git, npm, bash) +// flashes a console window on Windows. Default windowsHide:true on every +// child_process spawn in the main process (no-op off Windows; explicit values win). +import childProcess from "node:child_process"; + +if (process.platform === "win32") { + const methods = ["spawn", "spawnSync", "exec", "execSync", "execFile", "execFileSync"] as const; + for (const name of methods) { + const original = (childProcess as unknown as Record)[name]; + if (typeof original !== "function") { + continue; + } + const wrapped = function patchedChildProcess(this: unknown, ...args: unknown[]) { + const hasTrailingCallback = typeof args[args.length - 1] === "function"; + const optionsIndex = hasTrailingCallback ? args.length - 2 : args.length - 1; + const candidate = optionsIndex >= 0 ? args[optionsIndex] : undefined; + if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) { + const options = candidate as Record; + if (options.windowsHide === undefined) { + options.windowsHide = true; + } + } else { + args.splice(hasTrailingCallback ? args.length - 1 : args.length, 0, { windowsHide: true }); + } + return (original as (...callArgs: unknown[]) => unknown).apply(this, args); + }; + (childProcess as unknown as Record)[name] = wrapped; + } +} diff --git a/apps/desktop/electron/terminal-service.ts b/apps/desktop/electron/terminal-service.ts index 40cc9eae..77ee54d7 100644 --- a/apps/desktop/electron/terminal-service.ts +++ b/apps/desktop/electron/terminal-service.ts @@ -375,7 +375,10 @@ export class TerminalService { private resolveShell(): string { const configuredShell = this.options.getIntegratedTerminalShell()?.trim(); - const shellPath = configuredShell || process.env.SHELL || defaultShellForPlatform(); + const shellPath = + configuredShell || + (process.platform === "win32" ? undefined : process.env.SHELL) || + defaultShellForPlatform(); if (process.platform !== "win32" && !path.isAbsolute(shellPath)) { throw new Error(`Integrated terminal shell must be an absolute path: ${shellPath}`); } diff --git a/apps/desktop/electron/theme-manager.ts b/apps/desktop/electron/theme-manager.ts index 740f5748..a9d4b3d3 100644 --- a/apps/desktop/electron/theme-manager.ts +++ b/apps/desktop/electron/theme-manager.ts @@ -2,6 +2,21 @@ import { nativeTheme, type BrowserWindow } from "electron"; import { desktopIpc } from "../src/ipc"; import type { ThemeMode } from "../src/desktop-state"; +// Window Controls Overlay caption colors for the Windows immersive title bar. +export function windowsTitleBarOverlay(theme: "light" | "dark"): { + color: string; + symbolColor: string; + height: number; +} { + // Transparent so the buttons blend with whatever view is behind them; only the + // glyph color is themed. + return { + color: "#00000000", + symbolColor: theme === "dark" ? "#e6e6e6" : "#2b2b2b", + height: 52, + }; +} + export class ThemeManager { private mode: ThemeMode = "system"; private window: BrowserWindow | null = null; @@ -14,6 +29,7 @@ export class ThemeManager { setWindow(win: BrowserWindow) { this.window = win; + this.applyTitleBarOverlay(); } getMode(): ThemeMode { @@ -38,6 +54,18 @@ export class ThemeManager { } private broadcast() { + this.applyTitleBarOverlay(); this.window?.webContents.send(desktopIpc.themeChanged, this.getResolvedTheme()); } + + private applyTitleBarOverlay() { + if (process.platform !== "win32" || !this.window || this.window.isDestroyed()) { + return; + } + try { + this.window.setTitleBarOverlay(windowsTitleBarOverlay(this.getResolvedTheme())); + } catch { + // Only valid when the window was created with an overlay enabled. + } + } } diff --git a/apps/desktop/electron/worktree-manager.ts b/apps/desktop/electron/worktree-manager.ts index 52b9c76b..d4363504 100644 --- a/apps/desktop/electron/worktree-manager.ts +++ b/apps/desktop/electron/worktree-manager.ts @@ -268,6 +268,7 @@ async function runGit(args: readonly string[]): Promise { const { stdout } = await execFileAsync("git", [...args], { encoding: "utf8", maxBuffer: 10 * 1024 * 1024, + windowsHide: true, }); return stdout; } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5e0405c9..276cc37a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -24,6 +24,8 @@ "bun:package:linux": "bun run bun:build && bun run scripts/bun-package-wrapper.mjs electron-builder --linux AppImage --publish never", "package:linux:dir": "pnpm run build && electron-builder --linux --dir --publish never", "bun:package:linux:dir": "bun run bun:build && bun run scripts/bun-package-wrapper.mjs electron-builder --linux --dir --publish never", + "package:win": "pnpm run build && electron-builder --win -c.npmRebuild=false --publish never", + "package:win:dir": "pnpm run build && electron-builder --win --dir -c.npmRebuild=false --publish never", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.electron.json --noEmit", "verify:runtime-model-registry": "node scripts/assert-runtime-model-registry.mjs", "verify:packaged-runtime-deps": "pnpm run verify:runtime-model-registry && node scripts/assert-packaged-runtime-deps.mjs", diff --git a/apps/desktop/resources/icon.ico b/apps/desktop/resources/icon.ico new file mode 100644 index 00000000..3c1d0993 Binary files /dev/null and b/apps/desktop/resources/icon.ico differ diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 7343456f..d7e57401 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1995,17 +1995,12 @@ export default function App() { ); } - const shellClassName = `shell${snapshot.sidebarCollapsed ? " shell--sidebar-collapsed" : ""}`; + const shellClassName = `shell${snapshot.sidebarCollapsed ? " shell--sidebar-collapsed" : ""}${ + api?.platform === "win32" ? " shell--win" : "" + }`; return (
- {primarySidebarToggleVisible ? ( - - ) : null} {!snapshot.sidebarCollapsed ? ( ) : null} + {/* Rendered last so its no-drag region wins over the full-width topbar drag + region on Windows (else clicks are swallowed as window drag). */} + {primarySidebarToggleVisible ? ( + + ) : null}
); } diff --git a/apps/desktop/src/icons.tsx b/apps/desktop/src/icons.tsx index 65e1baf2..6e3fb75f 100644 --- a/apps/desktop/src/icons.tsx +++ b/apps/desktop/src/icons.tsx @@ -16,6 +16,14 @@ export function PlusIcon() { ); } +export function MenuIcon() { + return ( + + + + ); +} + export function TerminalIcon() { return ( diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 233b3f7e..df716ea1 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -100,6 +100,7 @@ export const desktopIpc = { getSessionTree: "pi-gui:get-session-tree", navigateSessionTree: "pi-gui:navigate-session-tree", toggleWindowMaximize: "pi-gui:toggle-window-maximize", + popupAppMenu: "pi-gui:popup-app-menu", listWorkspaceFiles: "pi-gui:list-workspace-files", getChangedFiles: "pi-gui:get-changed-files", getFileDiff: "pi-gui:get-file-diff", @@ -326,6 +327,7 @@ export interface PiDesktopApi { getFileDiff(workspaceId: string, filePath: string): Promise; stageFile(workspaceId: string, filePath: string): Promise; toggleWindowMaximize(): Promise; + popupAppMenu(): Promise; openExternal(url: string): Promise; getThemeMode(): Promise<"system" | "light" | "dark">; getResolvedTheme(): Promise<"light" | "dark">; diff --git a/apps/desktop/src/styles/main.css b/apps/desktop/src/styles/main.css index 67c6ccba..ab89ec07 100644 --- a/apps/desktop/src/styles/main.css +++ b/apps/desktop/src/styles/main.css @@ -61,6 +61,33 @@ min-width: 0; } +/* Reserve room for the Windows window-controls overlay (top-right). */ +.shell--win .topbar { + padding-right: 148px; +} + +/* Thin scrollbars on Windows (macOS already uses thin overlay scrollbars). */ +.shell--win ::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.shell--win ::-webkit-scrollbar-track, +.shell--win ::-webkit-scrollbar-corner { + background: transparent; +} + +.shell--win ::-webkit-scrollbar-thumb { + background-color: rgba(140, 142, 152, 0.32); + border-radius: 8px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.shell--win ::-webkit-scrollbar-thumb:hover { + background-color: rgba(140, 142, 152, 0.55); +} + .environment-picker, .environment-picker * { -webkit-app-region: no-drag; @@ -240,6 +267,9 @@ display: grid; gap: 8px; pointer-events: none; + /* Opens upward over the draggable topbar on Windows; keep clicks from being + swallowed as window drag. */ + -webkit-app-region: no-drag; } .chat-header { @@ -1027,8 +1057,12 @@ .message__content { color: var(--text-strong); display: grid; + /* Cap the column to the container so wide code blocks scroll inside their own +
 and long tokens wrap, instead of stretching the transcript sideways. */
+  grid-template-columns: minmax(0, 1fr);
   gap: 9px;
   line-height: 1.65;
+  overflow-wrap: anywhere;
 }
 
 .message__content > :first-child {
@@ -1291,6 +1325,12 @@
   position: relative;
 }
 
+/* Dropdown opens upward over the draggable topbar on Windows; keep it clickable. */
+.model-selector,
+.model-selector * {
+  -webkit-app-region: no-drag;
+}
+
 .model-selector__anchor {
   position: relative;
   display: inline;
diff --git a/apps/desktop/src/styles/sidebar.css b/apps/desktop/src/styles/sidebar.css
index 093931ee..05676949 100644
--- a/apps/desktop/src/styles/sidebar.css
+++ b/apps/desktop/src/styles/sidebar.css
@@ -16,6 +16,11 @@
   background: var(--window);
 }
 
+/* No left-side traffic lights on Windows; tuck the sidebar toggle to the edge. */
+.shell--win {
+  --titlebar-toggle-left: 16px;
+}
+
 .shell--loading {
   grid-template-columns: 1fr;
   place-items: center;
diff --git a/apps/desktop/src/topbar.tsx b/apps/desktop/src/topbar.tsx
index ce314a07..e0a958da 100644
--- a/apps/desktop/src/topbar.tsx
+++ b/apps/desktop/src/topbar.tsx
@@ -1,6 +1,6 @@
 import type { MouseEvent as ReactMouseEvent, Dispatch, SetStateAction } from "react";
 import type { AppView, DesktopAppState, SessionRecord, WorkspaceRecord, WorktreeRecord } from "./desktop-state";
-import { DiffIcon, FolderIcon, TerminalIcon } from "./icons";
+import { DiffIcon, FolderIcon, MenuIcon, TerminalIcon } from "./icons";
 import { getDesktopShortcutLabel, type PiDesktopApi } from "./ipc";
 import type { WorkspaceMenuState } from "./hooks/use-workspace-menu";
 
@@ -133,6 +133,18 @@ export function Topbar(props: TopbarProps) {
       
 
       
+ {api.platform === "win32" ? ( + + ) : null}