From 16a36bf22b9fdb95fab37f6823a0a344e3694695 Mon Sep 17 00:00:00 2001 From: fishuke Date: Sun, 18 Oct 2026 14:28:08 +0200 Subject: [PATCH 1/6] feat: run on Windows Terminals start PowerShell (7 when installed) over ConPTY, and the pty host listens on a named pipe instead of a unix socket. PowerShell's prompt is wrapped to report its folder with OSC 9;9, which stands in for lsof. Commands main runs "as the user would" go through PowerShell, and npm .cmd shims for claude and codex are unwrapped to node so multi-line prompts survive. Keybinds take Windows Terminal-style defaults (Ctrl+Shift, Alt+Shift; no Ctrl+Alt, which is AltGr) so Ctrl+T/W/D still reach the shell, hints show the platform's chords, Ctrl+C copies a selection and Ctrl+V pastes. The window overlays native controls on the titlebar, the tray uses the app icon, and paths shown in the UI split on either separator. Adds an NSIS target, a Windows release job, and a CI workflow that runs typecheck, tests, the pty smoke test and packaging on macOS and Windows. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 28 +++++++ .github/workflows/release.yml | 16 ++++ README.md | 4 +- package.json | 12 +++ scripts/pty-smoke.mjs | 48 ++++++----- src/main/agentTurn.ts | 24 ++---- src/main/ask.ts | 3 +- src/main/files.ts | 2 +- src/main/index.ts | 23 ++++-- src/main/projectRuntime.ts | 17 ++-- src/main/pty.ts | 26 +++--- src/main/ptyCwd.ts | 11 +++ src/main/ptyHost.ts | 18 ++++- src/main/userShell.ts | 76 ++++++++++++++++++ src/main/worktrees.ts | 13 +++ src/renderer/src/App.tsx | 4 +- src/renderer/src/agents/attention.ts | 3 +- src/renderer/src/board/PrComments.tsx | 5 +- src/renderer/src/chrome/DisplayMode.tsx | 3 +- src/renderer/src/chrome/GeneralSettings.tsx | 3 +- src/renderer/src/chrome/KeybindsSettings.tsx | 11 +-- src/renderer/src/chrome/Onboarding.tsx | 5 +- src/renderer/src/chrome/SessionArchive.tsx | 3 +- src/renderer/src/chrome/Sidebar.tsx | 14 ++-- src/renderer/src/chrome/Titlebar.tsx | 16 ++-- src/renderer/src/lib/useGitSummary.ts | 2 +- src/renderer/src/search/SearchOverlay.tsx | 10 ++- src/renderer/src/terminal/CanvasPanel.tsx | 3 +- src/renderer/src/terminal/ChangesPanel.tsx | 3 +- src/renderer/src/terminal/ContextBar.tsx | 8 +- src/renderer/src/terminal/FileExplorer.tsx | 5 +- src/renderer/src/terminal/TerminalPane.tsx | 23 ++++-- src/renderer/src/terminal/TerminalView.tsx | 23 +++--- src/shared/agents.ts | 6 +- src/shared/keybinds.ts | 83 ++++++++++++-------- src/shared/platform.ts | 26 ++++++ tests/platform.test.ts | 73 +++++++++++++++++ tests/windows-shell.test.ts | 39 +++++++++ 38 files changed, 530 insertions(+), 162 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/main/userShell.ts create mode 100644 src/shared/platform.ts create mode 100644 tests/platform.test.ts create mode 100644 tests/windows-shell.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c79e818 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + strategy: + fail-fast: false + matrix: + runner: [macos-latest, windows-latest] + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm test + - run: npm run test:pty + # Packaging catches native modules that will not load on the platform. + - run: npx electron-builder --dir --publish never + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b945bba..a748ee1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,3 +31,19 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_IDENTITY_AUTO_DISCOVERY: "false" + + windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm run build + - run: npx electron-builder --win --x64 --publish always + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_IDENTITY_AUTO_DISCOVERY: "false" diff --git a/README.md b/README.md index 2006e70..8acacd1 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ The builds are not notarized with Apple yet, so macOS will refuse to open the ap xattr -cr /Applications/Deck.app ``` +**Windows.** Download `Deck--win-x64-setup.exe` from the same page and run it. The installer is not code-signed yet, so SmartScreen asks first: **More info → Run anyway**. Terminals run PowerShell (7 when installed, else Windows PowerShell). Shortcuts follow Windows Terminal: Ctrl+Shift where the Mac uses ⌘, so Ctrl+T, Ctrl+W and Ctrl+D still reach the shell; Ctrl+C copies a selection and Ctrl+V pastes. Live status hooks and the canvas and review skills run `bash` and `curl`, which [Git for Windows](https://gitforwindows.org) provides. Not on Windows yet: telling a running program from the prompt (the context bar always assumes a prompt) and hiding from the taskbar. + **First run.** Deck opens on a short setup for the thing it is built around: the summon hotkey. Pick the keystroke, choose whether it drops down as a quake panel over the top of the screen, and press it once to see it work. Skipping is fine, and **Settings → General → run setup again** brings it back. ## Run from source @@ -103,7 +105,7 @@ npm install npm run dev ``` -**Requirements.** Node 22 or newer. +**Requirements.** Node 22 or newer. On Windows, also the Visual Studio C++ build tools, for the native modules; `npm run dist:win` builds the installer. Install and authenticate [`claude`](https://docs.anthropic.com/en/docs/claude-code) and/or [`codex`](https://github.com/openai/codex) separately. Pick the default agent in **Settings → General**; new-session menus and PR review screens let you choose either. diff --git a/package.json b/package.json index db60a33..117fbd2 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test:pty": "npm run build && node scripts/pty-smoke.mjs", "demo:gif": "node scripts/readme-gif.mjs", "dist": "electron-vite build && electron-builder --mac --publish never", + "dist:win": "electron-vite build && electron-builder --win --publish never", "install:app": "electron-vite build && electron-builder --mac dir && node scripts/install-app.mjs" }, "dependencies": { @@ -111,6 +112,17 @@ "dmg": { "sign": false }, + "win": { + "icon": "resources/icon.png", + "target": [ + { + "target": "nsis" + } + ] + }, + "nsis": { + "artifactName": "${productName}-${version}-win-${arch}-setup.${ext}" + }, "publish": { "provider": "github", "owner": "fishuke", diff --git a/scripts/pty-smoke.mjs b/scripts/pty-smoke.mjs index d4cff34..4d4972b 100644 --- a/scripts/pty-smoke.mjs +++ b/scripts/pty-smoke.mjs @@ -8,7 +8,8 @@ import assert from "node:assert/strict"; import electron from "electron"; const directory = await mkdtemp(path.join(os.tmpdir(), "deck-pty-")); -const socketPath = path.join(directory, "host.sock"); +const windows = process.platform === "win32"; +const socketPath = windows ? `\\\\.\\pipe\\deck-pty-smoke-${process.pid}` : path.join(directory, "host.sock"); const host = spawn(electron, ["out/main/ptyHost.js", socketPath], { env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, stdio: ["ignore", "pipe", "pipe"] }); host.stderr.pipe(process.stderr); let socket; @@ -39,12 +40,15 @@ try { }; // A deterministic shell process exercises PTY creation, metadata, input, // streaming and replay without starting an agent or reading user profiles. - send({ type: "create", req: 1, spawn: { shell: "/bin/sh", args: ["-c", 'printf "ready:%s\\n" "$DECK_TERM_ID"; exec /bin/cat'], cwd: directory, env: { PATH: "/usr/bin:/bin", TERM: "xterm-256color" }, agent: "codex", sessionId: "codex:fixture" } }); + const echo = windows + ? { shell: "powershell.exe", args: ["-NoLogo", "-NoProfile", "-Command", 'Write-Output "ready:$env:DECK_TERM_ID"; while ($true) { [Console]::ReadLine() }'], env: { ...process.env, TERM: "xterm-256color" } } + : { shell: "/bin/sh", args: ["-c", 'printf "ready:%s\\n" "$DECK_TERM_ID"; exec /bin/cat'], env: { PATH: "/usr/bin:/bin", TERM: "xterm-256color" } }; + send({ type: "create", req: 1, spawn: { ...echo, cwd: directory, agent: "codex", sessionId: "codex:fixture" } }); const { meta } = await receive((message) => message.type === "created"); assert.equal(meta.agent, "codex"); assert.equal(meta.sessionId, "codex:fixture"); await receive((message) => message.type === "data" && message.data.includes(`ready:${meta.id}`)); - send({ type: "input", id: meta.id, data: "roundtrip\n" }); + send({ type: "input", id: meta.id, data: windows ? "roundtrip\r" : "roundtrip\n" }); await receive((message) => message.type === "data" && message.data.includes("roundtrip")); send({ type: "attach", req: 2, id: meta.id }); const replay = await receive((message) => message.type === "attached"); @@ -58,23 +62,27 @@ try { assert.deepEqual(restored[0], { ...meta, foregroundProcess: restored[0].foregroundProcess, foregroundCommand: "/bin/cat" }); send({ type: "kill", id: meta.id }); await receive((message) => message.type === "exit"); - - // A native fixture named codex stays idle without any hooks or user prompt. - const fixture = path.join(directory, "codex"); - await copyFile("/bin/cat", fixture); - if (process.platform === "darwin") execFileSync("/usr/bin/codesign", ["--force", "--sign", "-", fixture], { stdio: "pipe" }); - send({ type: "create", req: 4, spawn: { shell: "/bin/sh", args: ["-i"], cwd: directory, env: { PATH: "/usr/bin:/bin", TERM: "xterm-256color" } } }); - const shell = (await receive((message) => message.type === "created")).meta; - assert.equal(shell.agent, undefined); - send({ type: "input", id: shell.id, data: "./codex\r" }); - await receive((message) => message.type === "foreground" && message.meta.id === shell.id && message.meta.foregroundProcess === "codex"); - send({ type: "list", req: 5 }); - assert.equal((await receive((message) => message.type === "list")).terms[0].foregroundProcess, "codex"); - send({ type: "input", id: shell.id, data: "\x03" }); - await receive((message) => message.type === "foreground" && message.meta.id === shell.id && message.meta.foregroundProcess !== "codex"); - send({ type: "kill", id: shell.id }); - await receive((message) => message.type === "exit" && message.id === shell.id); - console.log("PTY smoke passed: native shell, provider metadata, input/output, replay, restore list, idle Codex detection and termination."); + if (windows) { + // ConPTY reports no foreground process, so there is no idle Codex to detect. + console.log("PTY smoke passed: PowerShell over a named pipe, provider metadata, input/output, replay, restore list and termination."); + } else { + // A native fixture named codex stays idle without any hooks or user prompt. + const fixture = path.join(directory, "codex"); + await copyFile("/bin/cat", fixture); + if (process.platform === "darwin") execFileSync("/usr/bin/codesign", ["--force", "--sign", "-", fixture], { stdio: "pipe" }); + send({ type: "create", req: 4, spawn: { shell: "/bin/sh", args: ["-i"], cwd: directory, env: { PATH: "/usr/bin:/bin", TERM: "xterm-256color" } } }); + const shell = (await receive((message) => message.type === "created")).meta; + assert.equal(shell.agent, undefined); + send({ type: "input", id: shell.id, data: "./codex\r" }); + await receive((message) => message.type === "foreground" && message.meta.id === shell.id && message.meta.foregroundProcess === "codex"); + send({ type: "list", req: 5 }); + assert.equal((await receive((message) => message.type === "list")).terms[0].foregroundProcess, "codex"); + send({ type: "input", id: shell.id, data: "\x03" }); + await receive((message) => message.type === "foreground" && message.meta.id === shell.id && message.meta.foregroundProcess !== "codex"); + send({ type: "kill", id: shell.id }); + await receive((message) => message.type === "exit" && message.id === shell.id); + console.log("PTY smoke passed: native shell, provider metadata, input/output, replay, restore list, idle Codex detection and termination."); + } } finally { clearTimeout(timeout); socket?.destroy(); diff --git a/src/main/agentTurn.ts b/src/main/agentTurn.ts index 832723e..5391661 100644 --- a/src/main/agentTurn.ts +++ b/src/main/agentTurn.ts @@ -1,10 +1,8 @@ import { type Agent } from "../shared/agents.js"; -import { execFile, spawn } from "node:child_process"; +import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { promisify } from "node:util"; import { markInternalSession } from "./sessions.js"; - -const exec = promisify(execFile); +import { resolveCommand } from "./userShell.js"; // One headless turn of a coding agent, driven by deck itself: the "Ask deck" // orchestrator and the PR review assistant both run through here. Built-in @@ -54,19 +52,6 @@ export interface TurnOutcome { conversation: Conversation; } -const resolvedBins = new Map>(); - -/** A packaged app inherits a bare PATH, so claude is resolved the way the - * user's own shell would resolve it. */ -function agentBin(agent: Agent): Promise { - let resolved = resolvedBins.get(agent); - if (!resolved) { - resolved = exec(process.env.SHELL ?? "/bin/zsh", ["-lc", `command -v ${agent}`], { timeout: 10_000 }) - .then(({ stdout }) => stdout.trim().split("\n").pop() || agent).catch(() => agent); - resolvedBins.set(agent, resolved); - } - return resolved; -} /** Runs one turn and returns the conversation state to keep for the next. */ export function runTurn(options: TurnOptions): Promise { @@ -131,13 +116,14 @@ const toolEvent = (name: string, input: unknown, server: string): AskEvent => ({ async function run(args: string[], onEvent: OnEvent, agent: Agent, cwd: string, server: string): Promise { const onDelta = (text: string) => onEvent({ type: "text", text }); - const bin = await agentBin(agent); + const bin = await resolveCommand(agent); return new Promise((resolve) => { // stdin is closed, not piped: claude waits three seconds for piped input // before giving up on it. - const child = spawn(bin, args, { + const child = spawn(bin.file, [...bin.args, ...args], { cwd, env: process.env, + windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }); const timer = setTimeout(() => child.kill(), TURN_TIMEOUT_MS); diff --git a/src/main/ask.ts b/src/main/ask.ts index 05af50c..97528ca 100644 --- a/src/main/ask.ts +++ b/src/main/ask.ts @@ -1,4 +1,5 @@ import { type Agent } from "../shared/agents.js"; +import { baseName } from "../shared/platform.js"; import { kvGet, kvSet } from "./db.js"; import { getSettings } from "./settings.js"; import { boardConfigured, getBoardCache } from "./board/board.js"; @@ -49,7 +50,7 @@ function ago(ts: number): string { } function project(cwd: string | null): string { - return cwd?.split("/").filter(Boolean).pop() ?? "~"; + return (cwd && baseName(cwd)) || "~"; } const WAITING: AgentSession["status"][] = ["needs_input", "needs_review"]; diff --git a/src/main/files.ts b/src/main/files.ts index 3214db4..68303d0 100644 --- a/src/main/files.ts +++ b/src/main/files.ts @@ -18,7 +18,7 @@ export async function listFiles(root: string, directory = ""): Promise !entry.name.startsWith(".") && entry.name !== "node_modules") - .map((entry) => ({ name: entry.name, path: path.join(directory, entry.name), directory: entry.isDirectory() })) + .map((entry) => ({ name: entry.name, path: path.posix.join(directory, entry.name), directory: entry.isDirectory() })) .sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name)); } diff --git a/src/main/index.ts b/src/main/index.ts index 2b3eb00..90e655a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -110,6 +110,7 @@ import { listSessions, onSessionsChanged, removeSession } from "./sessions.js"; import { clearFrames, getFrames, onCanvasChanged, removeFrame, toggleTask, updateFrame } from "./canvas.js"; import { invalidateSessionPullRequests, sessionPullRequests } from "./sessionPrs.js"; import { getSettings, updateSettings } from "./settings.js"; +import { isMac } from "../shared/platform.js"; /** Which action brought a window up. Mapped to a window role by windowMode. */ type EntryPoint = "hotkey" | "manual"; @@ -200,8 +201,11 @@ function createWindow(role: WindowRole, from?: BrowserWindow): BrowserWindow { minHeight: 480, show: false, backgroundColor: "#0c0c0e", - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 14, y: 14 }, + // The titlebar is deck's own; macOS insets its traffic lights into it, + // Windows and Linux overlay their window controls on its right end. + ...(isMac + ? { titleBarStyle: "hiddenInset" as const, trafficLightPosition: { x: 14, y: 14 } } + : { titleBarStyle: "hidden" as const, titleBarOverlay: { color: "#00000000", symbolColor: "#a1a1aa", height: 44 } }), webPreferences: { preload: path.join(import.meta.dirname, "../preload/index.mjs"), // ESM preload scripts require an unsandboxed renderer. @@ -273,8 +277,9 @@ function hideWindow(w: BrowserWindow): void { } quakeWins.delete(w); w.hide(); - // Only leave the app when no other deck window stays visible. - if (BrowserWindow.getAllWindows().every((other) => !other.isVisible())) + // Only leave the app when no other deck window stays visible. Elsewhere + // hiding the window already hands focus back. + if (isMac && BrowserWindow.getAllWindows().every((other) => !other.isVisible())) app.hide(); } @@ -334,7 +339,9 @@ function applyDockVisibility(): void { else if (!app.dock.isVisible()) app.dock.show(); } -function createTray(): void { +function trayImage(): Electron.NativeImage { + // The black template glyph would vanish on a dark Windows taskbar. + if (!isMac) return nativeImage.createFromPath(appIcon).resize({ width: 16, height: 16 }); // Template images are tinted by macOS to match the menu bar theme. The @2x // file is attached explicitly so Retina bars get the sharp version. const icon = nativeImage.createFromPath(trayIcon); @@ -343,7 +350,11 @@ function createTray(): void { dataURL: nativeImage.createFromPath(trayIcon2x).toDataURL(), }); icon.setTemplateImage(true); - tray = new Tray(icon); + return icon; +} + +function createTray(): void { + tray = new Tray(trayImage()); tray.setToolTip("Deck"); tray.on("click", () => toggleWindow("manual")); } diff --git a/src/main/projectRuntime.ts b/src/main/projectRuntime.ts index 3e9af28..004d550 100644 --- a/src/main/projectRuntime.ts +++ b/src/main/projectRuntime.ts @@ -1,10 +1,8 @@ -import { execFile } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { promisify } from "node:util"; - -const exec = promisify(execFile); +import { isWindows } from "../shared/platform.js"; +import { runInUserShell } from "./userShell.js"; // The runtime a folder's project actually runs on, for the terminal's context // bar. Resolved through a login shell so version managers (nvm, pyenv, asdf) @@ -17,11 +15,7 @@ export interface ProjectRuntime { version: string; } -/** Runs a command the way the user's own prompt would, so version managers apply. */ -async function shell(command: string, cwd: string): Promise { - const { stdout } = await exec(process.env.SHELL ?? "/bin/zsh", ["-lc", command], { cwd, timeout: 5000 }); - return stdout; -} +const shell = (command: string, cwd: string): Promise => runInUserShell(command, { cwd, timeout: 5000 }); function expandHome(p: string): string { return p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p; @@ -29,8 +23,9 @@ function expandHome(p: string): string { const runtimes: { marker: string; label: string; command: string }[] = [ { marker: "package.json", label: "node", command: "node --version" }, - { marker: "pyproject.toml", label: "python", command: "python3 --version" }, - { marker: "requirements.txt", label: "python", command: "python3 --version" }, + // Windows installs python without the 3. + { marker: "pyproject.toml", label: "python", command: isWindows ? "python --version" : "python3 --version" }, + { marker: "requirements.txt", label: "python", command: isWindows ? "python --version" : "python3 --version" }, { marker: "Cargo.toml", label: "rust", command: "rustc --version" }, { marker: "go.mod", label: "go", command: "go version" }, ]; diff --git a/src/main/pty.ts b/src/main/pty.ts index 053505f..1e75135 100644 --- a/src/main/pty.ts +++ b/src/main/pty.ts @@ -2,6 +2,7 @@ import { terminalEnv } from "./ptyEnv.js"; import { agentCommand, sessionAgent, sessionKey, type AgentLaunch } from "../shared/agents.js"; import { app, BrowserWindow, ipcMain, type WebContents } from "electron"; import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import net from "node:net"; import os from "node:os"; @@ -12,6 +13,8 @@ import { clearTermLinks, linkTermToIssue, linkTermToWorkspace, moveTermSessions, import { getSettings } from "./settings.js"; import { workspaceOf, type WindowRole } from "../shared/settings.js"; import { LegacyAgentDetector } from "./legacyAgentDetection.js"; +import { shellArgs, userShell } from "./userShell.js"; +import { isWindows } from "../shared/platform.js"; /** Role of each renderer, so terminals can be tagged with the window that * opened them and, when the hotkey window keeps its own tabs, filtered. */ @@ -69,15 +72,13 @@ export interface TermCreateOptions extends AgentLaunch { } function spawnRequest(opts: TermCreateOptions): SpawnRequest { - const shell = process.env.SHELL ?? "/bin/zsh"; const agent = opts.agent ?? (opts.sessionId ? sessionAgent(opts.sessionId) : opts.prompt ? getSettings().defaultAgent : undefined); const sessionId = opts.sessionId && agent ? sessionKey(agent, opts.sessionId) : opts.sessionId; const command = agent ? agentCommand({ agent, sessionId, prompt: opts.prompt }) : opts.command; return { - shell, - // A command still runs inside a login shell so PATH and profile apply, - // and the tab drops back to the prompt when it exits. - args: command ? ["-l", "-i", "-c", `${command}; exec ${shell} -l`] : ["-l"], + shell: userShell, + // The tab drops back to the prompt when the command exits. + args: shellArgs(command), cwd: startCwd(opts.cwd), env: { ...terminalEnv(process.env), @@ -85,7 +86,8 @@ function spawnRequest(opts: TermCreateOptions): SpawnRequest { // shell's own profile builds PATH. Inheriting an already-built PATH // makes "add if missing" guards in rc files skip their prepends, // resolving different binaries than the user's real terminal. - PATH: "/usr/bin:/bin:/usr/sbin:/sbin", + // Windows has no login shell; its PATH comes whole from the registry. + ...(isWindows ? {} : { PATH: "/usr/bin:/bin:/usr/sbin:/sbin" }), TERM_PROGRAM: "deck", COLORTERM: "truecolor", // Agent hooks read the port from here, so they report to the deck @@ -115,7 +117,11 @@ class PtyHostClient { private readonly owners = new Map>(); private readonly sequences = new Map(); - private readonly socketPath = path.join(app.getPath("userData"), "pty.sock"); + // Windows has no unix sockets. Pipe names are machine-wide, so the pipe is + // named after the userData folder, which is per user and per channel. + private readonly socketPath = isWindows + ? `\\\\.\\pipe\\deck-pty-${createHash("sha256").update(app.getPath("userData")).digest("hex").slice(0, 16)}` + : path.join(app.getPath("userData"), "pty.sock"); private readonly logPath = path.join(app.getPath("userData"), "pty-host.log"); async request(msg: RequestBody, onReply?: () => void): Promise> { @@ -174,12 +180,13 @@ class PtyHostClient { private spawnHost(reason: string | undefined): void { // ECONNREFUSED means the file outlived its host; a new host can't bind // over it. - if (reason === "ECONNREFUSED") fs.rmSync(this.socketPath, { force: true }); + if (reason === "ECONNREFUSED" && !isWindows) fs.rmSync(this.socketPath, { force: true }); const log = fs.openSync(this.logPath, "a"); // The Electron binary as plain Node keeps the electron-rebuilt node-pty // ABI-compatible; detached so our exit (or SIGKILL) never reaches it. const child = spawn(process.execPath, [path.join(import.meta.dirname, "ptyHost.js"), this.socketPath], { detached: true, + windowsHide: true, stdio: ["ignore", log, log], env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, }); @@ -291,7 +298,8 @@ export async function startPtyHost(): Promise { clearTermLinks(terms.map((t) => t.id)); for (const t of terms) if (t.workspace && !termWorkspace(t.id)) linkTermToWorkspace(t.id, t.workspace); for (const term of terms) if (term.foregroundProcess) updateForegroundSession(term); - stopLegacyDetection = startLegacyDetection(client); + // Hosts that predate foreground reporting are a macOS-era thing; ps is too. + if (!isWindows) stopLegacyDetection = startLegacyDetection(client); ipcMain.handle("term:create", (event, opts: TermCreateOptions = {}) => createTerm({ ...opts, windowRole: windowRoles.get(event.sender) })); ipcMain.handle("term:list", async (event): Promise => { diff --git a/src/main/ptyCwd.ts b/src/main/ptyCwd.ts index f5affba..cf22ca6 100644 --- a/src/main/ptyCwd.ts +++ b/src/main/ptyCwd.ts @@ -19,6 +19,17 @@ export function parseLsofCwds(output: string): Map { return cwds; } +// OSC 9;9 ("current working directory", from Windows Terminal), which the +// PowerShell prompt deck wraps prints before every prompt. +const OSC_CWD = /\x1b\]9;9;"?([^"\x07\x1b]+)"?(?:\x07|\x1b\\)/g; + +/** The last folder a chunk of terminal output reports, if any. */ +export function reportedCwd(data: string): string | undefined { + let cwd: string | undefined; + for (const match of data.matchAll(OSC_CWD)) cwd = match[1]; + return cwd; +} + export async function readCwds(pids: number[]): Promise> { if (!pids.length) return new Map(); if (process.platform !== "darwin") { diff --git a/src/main/ptyHost.ts b/src/main/ptyHost.ts index f8238fe..28ab934 100644 --- a/src/main/ptyHost.ts +++ b/src/main/ptyHost.ts @@ -8,8 +8,9 @@ import type { WindowRole } from "../shared/settings.js"; import net from "node:net"; import { randomUUID } from "node:crypto"; import pty, { type IPty } from "node-pty"; -import { readCwds } from "./ptyCwd.js"; +import { readCwds, reportedCwd } from "./ptyCwd.js"; import { readForegroundCommands, ttyName } from "./ptyForeground.js"; +import { baseName, isWindows } from "../shared/platform.js"; export interface TermMeta extends AgentLaunch { id: string; @@ -106,11 +107,19 @@ function create(spawn: SpawnRequest): TermMeta { cwd: spawn.cwd, env: { ...spawn.env, DECK_TERM_ID: id }, }); - const shell = spawn.shell.split("/").pop(); + // On Windows node-pty reports the terminal name, not a process, so there is + // no telling a prompt from a program: the shell is whatever it reports. + const shell = isWindows ? proc.process : baseName(spawn.shell); const meta: TermMeta = { id, cwd: spawn.cwd, foregroundProcess: proc.process, busy: proc.process !== shell, command: spawn.command, issueKey: spawn.issueKey, agent: spawn.agent, sessionId: spawn.sessionId, prompt: spawn.prompt, windowRole: spawn.windowRole, workspace: spawn.workspace }; const term: Term = { proc, meta, shell, tty: ttyName((proc as { ptsName?: string }).ptsName ?? ""), chunks: [], buffered: 0 }; proc.onData((data) => { + // PowerShell reports its folder in the output stream (see userShell.ts). + const cwd = isWindows ? reportedCwd(data) : undefined; + if (cwd && cwd !== term.meta.cwd) { + term.meta.cwd = cwd; + broadcast({ type: "cwd", id, cwd }); + } term.chunks.push(data); term.buffered += data.length; // Drop whole chunks so a replay doesn't start mid escape sequence. @@ -167,8 +176,9 @@ setInterval(() => { }, 500).unref(); // Slower than the foreground poll: it shells out, and a cd only matters to -// the chrome showing the folder, its branch and its diff. -setInterval(() => { +// the chrome showing the folder, its branch and its diff. Windows terminals +// report their folder themselves. +if (!isWindows) setInterval(() => { const live = [...terms.values()]; // Same program name, new command ("sleep 1; sleep 2"): catch up here. void refreshForegroundCommands(live); diff --git a/src/main/userShell.ts b/src/main/userShell.ts new file mode 100644 index 0000000..4f5e5d7 --- /dev/null +++ b/src/main/userShell.ts @@ -0,0 +1,76 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { promisify } from "node:util"; +import { isWindows } from "../shared/platform.js"; + +const exec = promisify(execFile); + +// The shell deck's terminals run and the way main runs commands "as the user +// would": a login shell on macOS and Linux, PowerShell on Windows. + +/** PowerShell 7 when installed, else the Windows PowerShell every Windows has. */ +function windowsShell(): string { + const pwsh = path.join(process.env.ProgramFiles ?? "C:\\Program Files", "PowerShell", "7", "pwsh.exe"); + return fs.existsSync(pwsh) ? pwsh : "powershell.exe"; +} + +export const userShell: string = isWindows ? windowsShell() : process.env.SHELL ?? "/bin/zsh"; + +// PowerShell has no way to tell a terminal its folder, so the prompt is +// wrapped to report it with the OSC 9;9 sequence Windows Terminal reads. +const REPORT_CWD = "$__deckPrompt = $function:prompt; function global:prompt { \"$([char]27)]9;9;`\"$($PWD.ProviderPath)`\"$([char]7)\" + (& $__deckPrompt) }"; + +/** Arguments that start the user's shell, running `command` first when given + * and dropping back to a prompt when it exits. */ +export function shellArgs(command?: string): string[] { + if (isWindows) return ["-NoLogo", "-NoExit", "-Command", command ? `${REPORT_CWD}; ${command}` : REPORT_CWD]; + // A command still runs inside a login shell so PATH and profile apply. + return command ? ["-l", "-i", "-c", `${command}; exec ${userShell} -l`] : ["-l"]; +} + +/** Runs a command the way the user's own prompt would, so version managers apply. */ +export async function runInUserShell(command: string, options: { cwd?: string; timeout: number }): Promise { + const args = isWindows ? ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command] : ["-lc", command]; + const { stdout } = await exec(userShell, args, { ...options, windowsHide: true }); + return stdout; +} + +export interface ResolvedCommand { + file: string; + /** Arguments that go before the caller's own, e.g. the script an npm shim runs. */ + args: string[]; +} + +/** npm installs CLIs on Windows as .cmd shims, which Node cannot spawn + * without a shell, and cmd.exe cannot pass multi-line arguments. The shim + * only runs node on a script, so that is run directly instead. */ +export function unwrapCmdShim(shim: string, contents: string): ResolvedCommand | undefined { + const script = /"%~?dp0%\\?([^"]+\.[cm]?js)"/i.exec(contents)?.[1]; + if (!script) return undefined; + const dir = path.win32.dirname(shim); + const bundledNode = path.win32.join(dir, "node.exe"); + return { file: fs.existsSync(bundledNode) ? bundledNode : "node", args: [path.win32.join(dir, script)] }; +} + +const resolved = new Map>(); + +/** A packaged app on macOS inherits a bare PATH, so a CLI is resolved the way + * the user's own shell would resolve it. */ +export function resolveCommand(name: string): Promise { + let found = resolved.get(name); + if (!found) { + found = isWindows + ? exec("where.exe", [name], { timeout: 10_000, windowsHide: true }).then(({ stdout }) => { + const candidates = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const exe = candidates.find((file) => /\.exe$/i.test(file)); + if (exe) return { file: exe, args: [] }; + const shim = candidates.find((file) => /\.cmd$/i.test(file)); + return (shim && unwrapCmdShim(shim, fs.readFileSync(shim, "utf8"))) || { file: name, args: [] }; + }) + : runInUserShell(`command -v ${name}`, { timeout: 10_000 }).then((stdout) => ({ file: stdout.trim().split("\n").pop() || name, args: [] })); + found = found.catch(() => ({ file: name, args: [] })); + resolved.set(name, found); + } + return found; +} diff --git a/src/main/worktrees.ts b/src/main/worktrees.ts index f79d654..6aabb3e 100644 --- a/src/main/worktrees.ts +++ b/src/main/worktrees.ts @@ -1,8 +1,10 @@ import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { listRepos } from "./providers.js"; +import { isWindows } from "../shared/platform.js"; const exec = promisify(execFile); @@ -104,6 +106,17 @@ export async function pruneWorktrees(): Promise { } async function directorySize(dir: string): Promise { + // Windows has no du; adding up the files is slower but gets there. + if (isWindows) { + try { + const entries = await fs.readdir(dir, { recursive: true, withFileTypes: true }); + const sizes = await Promise.all(entries.filter((entry) => entry.isFile()) + .map((entry) => fs.stat(path.join(entry.parentPath, entry.name)).then((stat) => stat.size, () => 0))); + return sizes.reduce((total, size) => total + size, 0); + } catch { + return 0; + } + } try { const { stdout } = await exec("du", ["-sk", dir], { maxBuffer: 1024 * 1024 }); return Number(stdout.split("\t")[0]) * 1024 || 0; diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 8888ff9..ac4cee7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9,7 +9,7 @@ import { ReviewsView } from "./board/ReviewsView.js"; import { SettingsView, type SettingsSection } from "./chrome/SettingsView.js"; import { SettingsProvider, useSettings } from "./lib/useSettings.js"; import { isRecordingKeys } from "./lib/useKeybinds.js"; -import { matchKeybind, resolveKeybinds, type KeybindCommand } from "../../shared/keybinds.js"; +import { matchKeybind, primaryModifier, resolveKeybinds, type KeybindCommand } from "../../shared/keybinds.js"; import { defaultSettings } from "../../shared/settings.js"; import { fontSize as safeFontSize } from "../../shared/terminal.js"; import { useTerminalAppearance } from "./lib/useTerminalAppearance.js"; @@ -188,7 +188,7 @@ function Shell() { return; } if (searchOpen) return; // the overlay handles its own keys - if (e.metaKey && !e.shiftKey && !e.altKey && /^[1-9]$/.test(e.key)) { + if (primaryModifier(e) && !e.shiftKey && !e.altKey && /^[1-9]$/.test(e.key)) { const tab = tabs[Number(e.key) - 1]; if (tab) { focusTab(tab.termId); setView("terminal"); } } else if (!command || !runCommand(command)) { diff --git a/src/renderer/src/agents/attention.ts b/src/renderer/src/agents/attention.ts index 3d3c0b0..e074e42 100644 --- a/src/renderer/src/agents/attention.ts +++ b/src/renderer/src/agents/attention.ts @@ -1,4 +1,5 @@ import type { AgentSession } from "../../../main/sessions.js"; +import { baseName } from "../../../shared/platform.js"; import { useAgentSessions } from "../lib/useSessions.js"; // What the agent page and the sidebar badge count as "needs me". @@ -8,7 +9,7 @@ export const WAITING: AgentSession["status"][] = ["needs_input", "needs_review"] export const isWaiting = (s: AgentSession): boolean => WAITING.includes(s.status); export function project(cwd?: string | null): string { - return cwd?.split("/").filter(Boolean).pop() ?? "~"; + return (cwd && baseName(cwd)) || "~"; } /** Waiting sessions: the badge on the agent page, dock and sidebar. */ diff --git a/src/renderer/src/board/PrComments.tsx b/src/renderer/src/board/PrComments.tsx index 315215e..ad9f73a 100644 --- a/src/renderer/src/board/PrComments.tsx +++ b/src/renderer/src/board/PrComments.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { primaryHint } from "../../../shared/keybinds.js"; import type { PrComment } from "../../../main/github.js"; import type { ReviewDraft } from "../../../main/review.js"; import { Icon } from "./icons.js"; @@ -10,7 +11,7 @@ export interface ThreadActions { onResolve: (threadId: string, resolved: boolean) => void; } -/** Textarea + send button; ⌘↵ sends, Esc cancels. Shared by every composer here. */ +/** Textarea + send button; ⌘↵ (Ctrl+↵ off the Mac) sends, Esc cancels. Shared by every composer here. */ function TextBox({ placeholder, sendLabel, @@ -66,7 +67,7 @@ function TextBox({ : "border-accent/40 text-accent hover:border-accent" }`} > - {busy ? "sending…" : sendLabel} ⌘↵ + {busy ? "sending…" : sendLabel} {primaryHint}↵ {onCancel && ( } } - + ; } diff --git a/src/renderer/src/chrome/GeneralSettings.tsx b/src/renderer/src/chrome/GeneralSettings.tsx index fc39d34..1ff5db8 100644 --- a/src/renderer/src/chrome/GeneralSettings.tsx +++ b/src/renderer/src/chrome/GeneralSettings.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { isMac } from "../../../shared/platform.js"; import { askModels, type DeckSettings, type StartCwd } from "../../../shared/settings.js"; import { AgentSelect } from "../agents/AgentSelect.js"; import { Card, control, Field, Toggle } from "./settingsUi.js"; @@ -115,7 +116,7 @@ export function GeneralSettings() { - void update({ hideFromDock })}>Hide Deck from the Dock and Cmd-Tab + {isMac && void update({ hideFromDock })}>Hide Deck from the Dock and Cmd-Tab} void update({ showTips })}>Show one-off tips about shortcuts you could use diff --git a/src/renderer/src/chrome/KeybindsSettings.tsx b/src/renderer/src/chrome/KeybindsSettings.tsx index f11348e..299682e 100644 --- a/src/renderer/src/chrome/KeybindsSettings.tsx +++ b/src/renderer/src/chrome/KeybindsSettings.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; -import { chordOf, defaultKeybinds, formatChord, keybindInfos, resolveKeybinds, type KeybindCommand, type KeybindInfo } from "../../../shared/keybinds.js"; +import { isMac } from "../../../shared/platform.js"; +import { chordOf, defaultKeybinds, formatChord, keybindInfos, primaryHint, resolveKeybinds, type KeybindCommand, type KeybindInfo } from "../../../shared/keybinds.js"; import { RECORDING_ATTRIBUTE } from "../lib/useKeybinds.js"; import { useSettings } from "../lib/useSettings.js"; @@ -10,8 +11,8 @@ const fixedGroups: { title: string; description: string; bindings: Fixed[] }[] = title: "Terminal tabs", description: "Fixed.", bindings: [ - [["⌘1", "…", "⌘9"], "Switch to tab by position"], - [["⌘⏎"], "Run the multiline input"], + [[`${primaryHint}1`, "…", `${primaryHint}9`], "Switch to tab by position"], + [[`${primaryHint}⏎`], "Run the multiline input"], [["esc"], "Leave Zen or Presentation view"], ], }, @@ -21,7 +22,7 @@ const fixedGroups: { title: string; description: string; bindings: Fixed[] }[] = bindings: [ [["↑", "↓"], "Move the selection"], [["⏎"], "Open the selected item"], - [["⌘⏎"], "Open the selected item in a new pane"], + [[`${primaryHint}⏎`], "Open the selected item in a new pane"], [["esc"], "Close the palette"], ], }, @@ -101,7 +102,7 @@ export function KeybindsSettings() {

{group}

{group === "Workbench" && customized && } -

Click a shortcut and press the new keys. Shortcuts need ⌘, ⌃ or ⌥ so they never clash with typing.

+

Click a shortcut and press the new keys. Shortcuts need {isMac ? "⌘, ⌃ or ⌥" : "Ctrl or Alt"} so they never clash with typing.

{keybindInfos.filter((info) => info.group === group).map(row)}
))} diff --git a/src/renderer/src/chrome/Onboarding.tsx b/src/renderer/src/chrome/Onboarding.tsx index 4f206f3..0ffd941 100644 --- a/src/renderer/src/chrome/Onboarding.tsx +++ b/src/renderer/src/chrome/Onboarding.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { isMac } from "../../../shared/platform.js"; import { acceleratorOf, chordOf, formatAccelerator } from "../../../shared/keybinds.js"; import { RECORDING_ATTRIBUTE } from "../lib/useKeybinds.js"; import { useSettings } from "../lib/useSettings.js"; @@ -125,9 +126,9 @@ export function Onboarding() { - update({ hideFromDock })}> + {isMac && update({ hideFromDock })}> Keep deck out of the Dock and ⌘-Tab — the tray and the hotkey are enough - + } )} diff --git a/src/renderer/src/chrome/SessionArchive.tsx b/src/renderer/src/chrome/SessionArchive.tsx index 7b7c8eb..bb008df 100644 --- a/src/renderer/src/chrome/SessionArchive.tsx +++ b/src/renderer/src/chrome/SessionArchive.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { baseName } from "../../../shared/platform.js"; import { agentLabels } from "../../../shared/agents.js"; import type { AgentSession } from "../../../main/sessions.js"; import { archiveGroups } from "../lib/sessionArchive.js"; @@ -45,7 +46,7 @@ export function SessionArchive({ sessions, onResume, onClose }: { className="flex w-full min-w-0 items-center gap-2.5 px-3 py-2 text-left hover:bg-card"> - {session.title || session.cwd.split("/").pop()} + {session.title || baseName(session.cwd)} {session.issue_key ? `${session.issue_key} · ` : ""}{agentLabels[session.agent]} · {shortPath(session.cwd)} {new Date(session.updated_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} diff --git a/src/renderer/src/chrome/Sidebar.tsx b/src/renderer/src/chrome/Sidebar.tsx index b0a4d2e..b9182d6 100644 --- a/src/renderer/src/chrome/Sidebar.tsx +++ b/src/renderer/src/chrome/Sidebar.tsx @@ -1,4 +1,6 @@ import { useEffect, useRef, useState } from "react"; +import { defaultKeybinds, formatChord, primaryHint } from "../../../shared/keybinds.js"; +import { baseName } from "../../../shared/platform.js"; import { agentLabels, type Agent } from "../../../shared/agents.js"; import type { AgentSession } from "../../../main/sessions.js"; import { SessionIcon, statusLabels, statusTones } from "./SessionIcon.js"; @@ -35,7 +37,7 @@ function SessionRow({ tab, session, index, onOpen }: { tab: TermTab; session?: A const cwd = session?.cwd || tab.cwd; const git = useGitSummary(cwd); const agent = session?.agent ?? tab.agent; - const title = tab.customTitle || session?.title || runningCommand(tab) || (tab.title === "shell" ? cwd?.split("/").pop() : tab.title) || "Terminal"; + const title = tab.customTitle || session?.title || runningCommand(tab) || (tab.title === "shell" && cwd ? baseName(cwd) : tab.title) || "Terminal"; const active = activeId === tab.termId; const waiting = session && ["needs_input", "needs_review"].includes(session.status); const tone = session ? statusTones[session.status] : undefined; @@ -63,7 +65,7 @@ function SessionRow({ tab, session, index, onOpen }: { tab: TermTab; session?: A {waiting && tone &&
{statusLabels[session.status]}
} - {index < 9 ? `⌘${index + 1}` : ""} + {index < 9 ? `${primaryHint}${index + 1}` : ""} @@ -179,7 +181,7 @@ export function Sidebar({ view, onView }: { view: View; onView: (view: View) => {menu &&
- + {(["claude", "codex"] as const).map((agent) => )}
@@ -197,7 +199,7 @@ export function Sidebar({ view, onView }: { view: View; onView: (view: View) =>
@@ -205,14 +207,14 @@ export function Sidebar({ view, onView }: { view: View; onView: (view: View) =>
{showMore && recent.slice(1).map((session) =>
- +
)} } {searchResults.length > 0 &&
OTHER SESSIONS
} {searchResults.map((session) => )} {searching && !visibleTabs.length && !searchResults.length &&
No matching sessions
} diff --git a/src/renderer/src/chrome/Titlebar.tsx b/src/renderer/src/chrome/Titlebar.tsx index c6c6014..4f927eb 100644 --- a/src/renderer/src/chrome/Titlebar.tsx +++ b/src/renderer/src/chrome/Titlebar.tsx @@ -6,6 +6,12 @@ import { useSettings } from "../lib/useSettings.js"; import { terminalAction } from "../terminal/actions.js"; import { useTips } from "../tips/TipsProvider.js"; import type { View } from "../App.js"; +import { isMac } from "../../../shared/platform.js"; +import { defaultKeybinds, formatChord } from "../../../shared/keybinds.js"; + +// Room for the window controls: macOS traffic lights on the left, the +// Windows and Linux overlay on the right. +const controlsPadding = isMac ? "pl-[100px] pr-3" : "pl-3 pr-[150px]"; export function Titlebar({ onSearch, onSidebar, onView, sidebarOpen }: { onSearch: () => void; onSidebar: () => void; onView: (view: View) => void; sidebarOpen: boolean }) { const { setMode } = useDisplayMode(); @@ -14,20 +20,20 @@ export function Titlebar({ onSearch, onSidebar, onView, sidebarOpen }: { onSearc const git = useGitSummary(tabs.find((tab) => tab.termId === activeId)?.cwd); const settings = useSettings(); const workspaces = settings?.workspaces ?? []; - return
- + return
+ {workspaces.length > 1 && }
{git && } - - + + D
; diff --git a/src/renderer/src/lib/useGitSummary.ts b/src/renderer/src/lib/useGitSummary.ts index c633cb3..cd76185 100644 --- a/src/renderer/src/lib/useGitSummary.ts +++ b/src/renderer/src/lib/useGitSummary.ts @@ -16,5 +16,5 @@ export function useGitSummary(cwd?: string) { } export function shortPath(cwd?: string): string { - return cwd?.replace(/^\/(?:Users|home)\/[^/]+/, "~") ?? "~"; + return cwd?.replace(/^\/(?:Users|home)\/[^/]+/, "~").replace(/^[A-Za-z]:\\Users\\[^\\]+/, "~") ?? "~"; } diff --git a/src/renderer/src/search/SearchOverlay.tsx b/src/renderer/src/search/SearchOverlay.tsx index 42e32d9..1d623b9 100644 --- a/src/renderer/src/search/SearchOverlay.tsx +++ b/src/renderer/src/search/SearchOverlay.tsx @@ -1,4 +1,6 @@ import type { View } from "../App.js"; +import { primaryHint, primaryModifier } from "../../../shared/keybinds.js"; +import { shortPath } from "../lib/useGitSummary.js"; import type { SettingsSection } from "../chrome/SettingsView.js"; import { useEffect, useMemo, useRef, useState } from "react"; import type { SearchHit } from "../../../main/indexer.js"; @@ -64,7 +66,7 @@ export function SearchOverlay({ onClose, onPreview, onView, onSettings, onSideba const position = tabs.indexOf(tab); return { icon: tab.agent ? "✳" : "❯", iconColor: "text-mut", title: tab.customTitle || runningCommand(tab) || tab.title, - meta: [tab.cwd?.replace(/^\/Users\/[^/]+/, "~"), position < 9 && `⌘${position + 1}`].filter(Boolean).join(" · "), + meta: [tab.cwd && shortPath(tab.cwd), position < 9 && `${primaryHint}${position + 1}`].filter(Boolean).join(" · "), open: () => { focusTab(tab.termId); onView("terminal"); }, }; }) }); @@ -132,7 +134,7 @@ export function SearchOverlay({ onClose, onPreview, onView, onSettings, onSideba e.preventDefault(); const item = flat[sel]; if (item) { - item.open(e.metaKey); + item.open(primaryModifier(e)); onClose(); } } @@ -175,7 +177,7 @@ export function SearchOverlay({ onClose, onPreview, onView, onSettings, onSideba key={`${g.label}${i}`} aria-label={item.title} onClick={(e) => { - item.open(e.metaKey); + item.open(primaryModifier(e)); onClose(); }} onMouseMove={() => setSel(i)} @@ -200,7 +202,7 @@ export function SearchOverlay({ onClose, onPreview, onView, onSettings, onSideba
↑↓ navigate ⏎ open - ⌘⏎ open in new session + {primaryHint}⏎ open in new session sessions · commands · settings · conversations
diff --git a/src/renderer/src/terminal/CanvasPanel.tsx b/src/renderer/src/terminal/CanvasPanel.tsx index 7ad1c83..8bb2eac 100644 --- a/src/renderer/src/terminal/CanvasPanel.tsx +++ b/src/renderer/src/terminal/CanvasPanel.tsx @@ -1,4 +1,5 @@ import mermaid from "mermaid"; +import { defaultKeybinds, formatChord } from "../../../shared/keybinds.js"; import { useEffect, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -51,7 +52,7 @@ export function CanvasPanel({ termId, frames, onClose }: { termId?: string; fram canvas {frame?.title ?? ""} {termId && frames.length > 0 && } - + {frames.length > 1 && (
diff --git a/src/renderer/src/terminal/ChangesPanel.tsx b/src/renderer/src/terminal/ChangesPanel.tsx index 6420623..9c00713 100644 --- a/src/renderer/src/terminal/ChangesPanel.tsx +++ b/src/renderer/src/terminal/ChangesPanel.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import { defaultKeybinds, formatChord } from "../../../shared/keybinds.js"; import { Diff, Hunk, parseDiff, type FileData } from "react-diff-view"; import "react-diff-view/style/index.css"; import type { AgentSession } from "../../../main/sessions.js"; @@ -78,7 +79,7 @@ export function ChangesPanel({ -
diff --git a/src/renderer/src/terminal/ContextBar.tsx b/src/renderer/src/terminal/ContextBar.tsx index 28b0058..dccfff4 100644 --- a/src/renderer/src/terminal/ContextBar.tsx +++ b/src/renderer/src/terminal/ContextBar.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { shellArg } from "../../../shared/platform.js"; import type { InstalledVersions, ProjectRuntime } from "../../../main/projectRuntime.js"; import { shortPath, useGitSummary } from "../lib/useGitSummary.js"; import { Icon, type IconName } from "../board/icons.js"; @@ -18,9 +19,6 @@ interface MenuItem { current?: boolean; } -function quote(value: string): string { - return /[^\w./-]/.test(value) ? `'${value.replace(/'/g, "'\\''")}'` : value; -} export function ContextBar({ termId, cwd, busy = false }: { termId: string; cwd?: string; busy?: boolean }) { const git = useGitSummary(cwd); @@ -45,8 +43,8 @@ export function ContextBar({ termId, cwd, busy = false }: { termId: string; cwd? const items: Record = { runtime: (versions?.versions ?? []).map((version) => ({ label: version, command: `${versions?.manager} use ${version}`, current: version === runtime?.version })), - folder: [{ label: "‥ parent folder", command: "cd .." }, ...folders.map((folder) => ({ label: folder, command: `cd ${quote(folder)}` }))], - branch: branches.map((branch) => ({ label: branch, command: `git checkout ${quote(branch)}`, current: branch === git?.branch })), + folder: [{ label: "‥ parent folder", command: "cd .." }, ...folders.map((folder) => ({ label: folder, command: `cd ${shellArg(folder)}` }))], + branch: branches.map((branch) => ({ label: branch, command: `git checkout ${shellArg(branch)}`, current: branch === git?.branch })), }; const run = (command: string) => { diff --git a/src/renderer/src/terminal/FileExplorer.tsx b/src/renderer/src/terminal/FileExplorer.tsx index e2f6379..1798929 100644 --- a/src/renderer/src/terminal/FileExplorer.tsx +++ b/src/renderer/src/terminal/FileExplorer.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { baseName } from "../../../shared/platform.js"; import type { FileEntry, LocalFile } from "../../../main/files.js"; import { Icon } from "../board/icons.js"; import { Markdown } from "../board/Markdown.js"; @@ -41,12 +42,12 @@ export function FileExplorer({ cwd: activeCwd, onClose }: { cwd?: string; onClos return
File explorer
{error &&
{error}
} - {dirty &&
Unsaved changes{cwd !== activeCwd ? " · " + cwd?.split("/").pop() : ""}
} + {dirty &&
Unsaved changes{cwd !== activeCwd ? " · " + (cwd && baseName(cwd)) : ""}
} {!cwd ?
Open a terminal to browse its project.
: selected && original ? <>
{selected}
{editing ?