Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: CI

on:
pull_request:
push:
branches: [main]

jobs:
test:
strategy:
fail-fast: false
matrix:
runner: [macos-latest, windows-2022]
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"
16 changes: 16 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,19 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: "false"

windows:
runs-on: windows-2022
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"
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>-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: <kbd>Ctrl+Shift</kbd> where the Mac uses <kbd>⌘</kbd>, so <kbd>Ctrl+T</kbd>, <kbd>Ctrl+W</kbd> and <kbd>Ctrl+D</kbd> still reach the shell; <kbd>Ctrl+C</kbd> copies a selection and <kbd>Ctrl+V</kbd> 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
Expand All @@ -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.

Expand Down
12 changes: 12 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
58 changes: 34 additions & 24 deletions scripts/pty-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -39,45 +40,54 @@ 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");
assert.ok(replay.buffer.includes(`ready:${meta.id}`));
assert.ok(replay.buffer.includes("roundtrip"));
// cat took the terminal over from sh, and the host names its whole command line.
await receive((message) => message.type === "foreground" && message.meta.id === meta.id && message.meta.foregroundCommand === "/bin/cat");
// cat took the terminal over from sh, and the host names its whole command
// line. ConPTY reports no foreground process, so Windows has none to name.
if (!windows) await receive((message) => message.type === "foreground" && message.meta.id === meta.id && message.meta.foregroundCommand === "/bin/cat");
send({ type: "list", req: 3 });
const restored = (await receive((message) => message.type === "list")).terms;
assert.equal(restored.length, 1);
assert.deepEqual(restored[0], { ...meta, foregroundProcess: restored[0].foregroundProcess, foregroundCommand: "/bin/cat" });
assert.deepEqual(restored[0], { ...meta, foregroundProcess: restored[0].foregroundProcess, ...(windows ? {} : { 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();
host.kill();
await rm(directory, { recursive: true, force: true });
// Windows keeps the directory locked briefly after the host exits.
await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
}
24 changes: 5 additions & 19 deletions src/main/agentTurn.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -54,19 +52,6 @@ export interface TurnOutcome {
conversation: Conversation;
}

const resolvedBins = new Map<Agent, Promise<string>>();

/** 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<string> {
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<TurnOutcome> {
Expand Down Expand Up @@ -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<AskResult> {
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);
Expand Down
3 changes: 2 additions & 1 deletion src/main/ask.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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"];
Expand Down
2 changes: 1 addition & 1 deletion src/main/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export async function listFiles(root: string, directory = ""): Promise<FileEntry
const target = await withinRoot(root, directory);
const entries = await fs.readdir(target, { withFileTypes: true });
return entries.filter((entry) => !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));
}

Expand Down
23 changes: 17 additions & 6 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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);
Expand All @@ -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"));
}
Expand Down
17 changes: 6 additions & 11 deletions src/main/projectRuntime.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -17,20 +15,17 @@ 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<string> {
const { stdout } = await exec(process.env.SHELL ?? "/bin/zsh", ["-lc", command], { cwd, timeout: 5000 });
return stdout;
}
const shell = (command: string, cwd: string): Promise<string> => runInUserShell(command, { cwd, timeout: 5000 });

function expandHome(p: string): string {
return p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p;
}

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" },
];
Expand Down
Loading
Loading