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
16 changes: 12 additions & 4 deletions apps/desktop/src/main/editors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ type EditorSpec = InstalledEditor & {

type ScannedEditor = { editor: InstalledEditor; executable?: string };

function fileArgs(targetPath: string, editorId: string, line: number | undefined, column?: number): string[] {
if (line && ["cursor", "vscode", "antigravity", "windsurf", "vscode-insiders", "vscodium"].includes(editorId)) {
return ["-g", `${targetPath}:${line}${column ? `:${column}` : ""}`];
}
if (line && jetBrainsEditors.some(([id]) => id === editorId)) return ["--line", String(line), targetPath];
return [targetPath];
}

/** The platform's own file manager, named the way its own users would name it. */
export function fileManagerName(): string {
if (process.platform === "darwin") return "Finder";
Expand Down Expand Up @@ -374,11 +382,11 @@ export async function listInstalledEditors(): Promise<InstalledEditor[]> {
* `open -a`, which also hands the app the target path the way double-clicking
* a file in Finder would. Windows and Linux executables are launched directly.
*/
function launchEditor(executable: string, target: string): ChildProcess {
function launchEditor(executable: string, target: string, args = [target]): ChildProcess {
if (process.platform === "darwin" && executable.endsWith(".app")) {
return spawn("open", ["-a", executable, target], { detached: true, stdio: "ignore" });
}
return spawn(executable, [target], { detached: true, stdio: "ignore", windowsHide: true });
return spawn(executable, args, { detached: true, stdio: "ignore", windowsHide: true });
}

export async function openProjectIn(projectDir: string, editorId: string): Promise<void> {
Expand All @@ -399,7 +407,7 @@ export async function openProjectIn(projectDir: string, editorId: string): Promi
});
}

export async function openFileIn(projectDir: string, file: string, editorId: string): Promise<void> {
export async function openFileIn(projectDir: string, file: string, editorId: string, line?: number, column?: number): Promise<void> {
const targetPath = resolve(projectDir, file);
const relativePath = relative(projectDir, targetPath);
if (isAbsolute(relativePath) || relativePath === ".." || relativePath.startsWith(`..${sep}`)) {
Expand All @@ -413,7 +421,7 @@ export async function openFileIn(projectDir: string, file: string, editorId: str
if (!target?.executable) throw new Error("That editor is no longer installed.");
const executable = target.executable;
await new Promise<void>((resolveSpawn, reject) => {
const child = launchEditor(executable, targetPath);
const child = launchEditor(executable, targetPath, fileArgs(targetPath, target.editor.id, line, column));
child.once("spawn", resolveSpawn);
child.once("error", reject);
child.unref();
Expand Down
39 changes: 34 additions & 5 deletions apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ import {
closeTerminal,
closeProjectTerminals,
createTerminal,
listShellProfiles,
listTerminals,
liveTerminalProjects,
renameTerminal,
resizeTerminal,
restartTerminal,
stopAllTerminals,
terminalSnapshot,
writeTerminal,
Expand Down Expand Up @@ -306,7 +309,7 @@ function toSessionState(data: RpcSessionState): RpcSessionState {

const THINKING_LEVELS = new Set<ThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
const openProjectInParamsSchema = z.object({ projectDir: z.string().min(1), editorId: z.string().min(1) });
const openFileInParamsSchema = openProjectInParamsSchema.extend({ file: z.string().min(1) });
const openFileInParamsSchema = openProjectInParamsSchema.extend({ file: z.string().min(1), line: z.number().int().positive().optional(), column: z.number().int().positive().optional() });
const saveImageParamsSchema = z.object({
data: z.string().min(1).max(64 * 1024 * 1024),
mimeType: z.enum(["image/png", "image/jpeg", "image/gif", "image/webp"]),
Expand Down Expand Up @@ -371,6 +374,11 @@ const tuiApplyParamsSchema = tuiCompleteParamsSchema.extend({
prefix: z.string().max(200),
});
const terminalIdParamsSchema = projectDirParamsSchema.extend({ terminalId: z.string().min(1) });
const terminalCreateParamsSchema = projectDirParamsSchema.extend({
shellId: z.string().optional(),
name: z.string().optional(),
});
const terminalRenameParamsSchema = terminalIdParamsSchema.extend({ name: z.string().min(1).max(64) });
const terminalResizeParamsSchema = terminalIdParamsSchema.extend({
cols: z.number().int().min(2).max(1000),
rows: z.number().int().min(1).max(1000),
Expand Down Expand Up @@ -826,8 +834,8 @@ const handlers: HandlerMap = {
},
openFileIn: async (params) => {
try {
const { projectDir, file, editorId } = openFileInParamsSchema.parse(params);
await openFileIn(projectDir, file, editorId);
const { projectDir, file, editorId, line, column } = openFileInParamsSchema.parse(params);
await openFileIn(projectDir, file, editorId, line, column);
return { ok: true };
} catch (err) {
return { ok: false, error: errorMessage(err) };
Expand Down Expand Up @@ -949,29 +957,50 @@ const handlers: HandlerMap = {
},

terminalEnsure: (params) => {
const { projectDir } = projectDirParamsSchema.parse(params);
const { projectDir, shellId } = projectDirParamsSchema.extend({ shellId: z.string().optional() }).parse(params);
const existing = listTerminals(projectDir);
if (existing.length > 0) return { terminals: existing };
return {
terminals: [
createTerminal(
projectDir,
shellId,
undefined,
Comment on lines 965 to +968

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the preferred shell when ensuring the first terminal

When a user selects and persists Git Bash, WSL, or another profile, reopening the dock with no existing terminals still passes undefined as the shell ID, so terminalEnsure always starts the PowerShell fallback. The saved preference currently affects only subsequently created splits, making it ineffective for the normal single-terminal workflow.

Useful? React with 👍 / 👎.

(payload) => push("terminalData", payload),
(payload) => push("terminalExit", payload),
),
],
};
},
terminalCreate: (params) => {
const { projectDir } = projectDirParamsSchema.parse(params);
const { projectDir, shellId, name } = terminalCreateParamsSchema.parse(params);
return {
terminal: createTerminal(
projectDir,
shellId,
name,
(payload) => push("terminalData", payload),
(payload) => push("terminalExit", payload),
),
};
},
terminalListShells: () => ({ shells: listShellProfiles() }),
terminalRename: (params) => {
const { projectDir, terminalId, name } = terminalRenameParamsSchema.parse(params);
renameTerminal(projectDir, terminalId, name);
return { ok: true };
},
terminalRestart: (params) => {
const { projectDir, terminalId } = terminalIdParamsSchema.parse(params);
const terminal = restartTerminal(
projectDir,
terminalId,
(payload) => push("terminalData", payload),
(payload) => push("terminalExit", payload),
);
push("terminalRestart", { projectDir, terminal });
return { terminal };
},
terminalSnapshot: (params) => {
const { projectDir, terminalId } = terminalIdParamsSchema.parse(params);
return terminalSnapshot(projectDir, terminalId);
Expand Down
160 changes: 132 additions & 28 deletions apps/desktop/src/main/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { existsSync } from "node:fs";
import { delimiter, join } from "node:path";
import type { IPty } from "node-pty";
import { spawn } from "node-pty";
import type { TerminalSession } from "../shared/rpc-schema.ts";
import type { ShellProfile, TerminalSession } from "../shared/rpc-schema.ts";

const MAX_BUFFER_SIZE = 2 * 1024 * 1024;

Expand All @@ -16,19 +16,63 @@ type ManagedTerminal = TerminalSession & {

const terminals = new Map<string, ManagedTerminal>();

function resolveShell(): string {
if (process.platform !== "win32") return process.env["SHELL"] || "/bin/bash";
/**
* Shells NativePi knows how to look for. Detection is PATH-first with a few
* well-known install locations as a fallback, the same shape `editors.ts`
* uses for applications — adding a shell for another platform is one entry
* here, not a new branch.
*/
const SHELL_CANDIDATES: { id: string; name: string; executable: string; paths: string[]; args: string[] }[] = [
{ id: "pwsh", name: "PowerShell 7", executable: "pwsh.exe", paths: [], args: ["-NoLogo"] },
{ id: "powershell", name: "Windows PowerShell", executable: "powershell.exe", paths: [], args: ["-NoLogo"] },
{ id: "cmd", name: "Command Prompt", executable: "cmd.exe", paths: [], args: [] },
{
id: "gitbash",
name: "Git Bash",
executable: "bash.exe",
paths: [
join(process.env["ProgramFiles"] ?? "", "Git", "bin", "bash.exe"),
join(process.env["ProgramFiles(x86)"] ?? "", "Git", "bin", "bash.exe"),
],
args: ["--login", "-i"],
},
{ id: "wsl", name: "WSL", executable: "wsl.exe", paths: [], args: [] },
];

function findOnPath(executable: string): string | undefined {
const path = process.env["PATH"] ?? "";
for (const dir of path.split(delimiter)) {
if (!dir) continue;
const candidate = join(dir, "pwsh.exe");
const candidate = join(dir, executable);
if (existsSync(candidate)) return candidate;
}
return "powershell.exe";
return undefined;
}

function resolveCandidate(candidate: { executable: string; paths: string[] }): string | undefined {
return findOnPath(candidate.executable) ?? candidate.paths.find(existsSync);
}

export function listShellProfiles(): ShellProfile[] {
if (process.platform !== "win32") return [{ id: "default", name: process.env["SHELL"] || "Shell" }];
return SHELL_CANDIDATES.filter((candidate) => resolveCandidate(candidate) !== undefined).map(
({ id, name }) => ({ id, name }),
);
}

function shellArgs(): string[] {
return process.platform === "win32" ? ["-NoLogo"] : process.platform === "darwin" ? ["-l"] : [];
function resolveShell(shellId: string | undefined): { id: string; path: string; args: string[] } {
if (process.platform !== "win32") {
return {
id: "default",
path: process.env["SHELL"] || "/bin/bash",
args: process.platform === "darwin" ? ["-l"] : [],
};
}
const candidate = SHELL_CANDIDATES.find((entry) => entry.id === shellId);
const resolved = candidate ? resolveCandidate(candidate) : undefined;
if (candidate && resolved) return { id: candidate.id, path: resolved, args: candidate.args };
const path = findOnPath("pwsh.exe") ?? "powershell.exe";
return { id: "pwsh", path, args: ["-NoLogo"] };
}

function shellEnv(): Record<string, string> {
Expand All @@ -49,6 +93,8 @@ function publicSession(terminal: ManagedTerminal): TerminalSession {
return {
id: terminal.id,
projectDir: terminal.projectDir,
name: terminal.name,
shellId: terminal.shellId,
exited: terminal.exited,
exitCode: terminal.exitCode,
};
Expand All @@ -65,33 +111,39 @@ export function liveTerminalProjects(): string[] {
return [...terminals.values()].filter((terminal) => !terminal.exited).map((terminal) => terminal.projectDir);
}

export function createTerminal(
projectDir: string,
onData: (payload: { projectDir: string; terminalId: string; data: string; sequence: number }) => void,
onExit: (payload: { projectDir: string; terminalId: string; exitCode: number }) => void,
): TerminalSession {
const id = randomUUID();
const pty = spawn(resolveShell(), shellArgs(), {
function nextTerminalName(projectDir: string): string {
const used = new Set(
[...terminals.values()].filter((terminal) => terminal.projectDir === projectDir).map((terminal) => terminal.name),
);
let index = 1;
while (used.has(`Terminal ${index}`)) index += 1;
return `Terminal ${index}`;
}

function spawnPty(projectDir: string, shellId: string | undefined): { pty: IPty; resolvedShellId: string } {
const shell = resolveShell(shellId);
const pty = spawn(shell.path, shell.args, {
name: "xterm-256color",
cols: 100,
rows: 24,
cwd: projectDir,
env: shellEnv(),
});
const terminal: ManagedTerminal = {
id,
projectDir,
process: pty,
output: [],
outputSize: 0,
sequence: 0,
exited: false,
};
terminals.set(id, terminal);
return { pty, resolvedShellId: shell.id };
}

pty.onData((data) => {
function attachHandlers(
id: string,
projectDir: string,
onData: (payload: { projectDir: string; terminalId: string; data: string; sequence: number }) => void,
onExit: (payload: { projectDir: string; terminalId: string; exitCode: number }) => void,
): void {
const terminal = terminals.get(id);
if (!terminal) return;
const process = terminal.process;
process.onData((data) => {
const current = terminals.get(id);
if (!current) return;
if (!current || current.process !== process) return;
current.output.push(data);
current.outputSize += data.length;
while (current.outputSize > MAX_BUFFER_SIZE && current.output.length > 1) {
Expand All @@ -101,14 +153,66 @@ export function createTerminal(
onData({ projectDir, terminalId: id, data, sequence: current.sequence });
});

pty.onExit(({ exitCode }) => {
process.onExit(({ exitCode }) => {
const current = terminals.get(id);
if (!current) return;
if (!current || current.process !== process) return;
current.exited = true;
current.exitCode = exitCode;
onExit({ projectDir, terminalId: id, exitCode });
});
}

export function createTerminal(
projectDir: string,
shellId: string | undefined,
name: string | undefined,
onData: (payload: { projectDir: string; terminalId: string; data: string; sequence: number }) => void,
onExit: (payload: { projectDir: string; terminalId: string; exitCode: number }) => void,
): TerminalSession {
const id = randomUUID();
const { pty, resolvedShellId } = spawnPty(projectDir, shellId);
const terminal: ManagedTerminal = {
id,
projectDir,
name: name?.trim() || nextTerminalName(projectDir),
shellId: resolvedShellId,
process: pty,
output: [],
outputSize: 0,
sequence: 0,
exited: false,
};
terminals.set(id, terminal);
attachHandlers(id, projectDir, onData, onExit);
return publicSession(terminal);
}

export function renameTerminal(projectDir: string, terminalId: string, name: string): void {
const terminal = terminals.get(terminalId);
if (!terminal || terminal.projectDir !== projectDir) return;
const trimmed = name.trim();
if (trimmed) terminal.name = trimmed;
}

/** Kills the running shell and starts a fresh one under the same id, name, and profile. */
export function restartTerminal(
projectDir: string,
terminalId: string,
onData: (payload: { projectDir: string; terminalId: string; data: string; sequence: number }) => void,
onExit: (payload: { projectDir: string; terminalId: string; exitCode: number }) => void,
): TerminalSession {
const terminal = terminals.get(terminalId);
if (!terminal || terminal.projectDir !== projectDir) throw new Error("Terminal not found");
if (!terminal.exited) terminal.process.kill();
const { pty, resolvedShellId } = spawnPty(projectDir, terminal.shellId);
terminal.process = pty;
Comment on lines +206 to +208

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Detach the old PTY before replacing it

When a running terminal is restarted, kill() schedules the old process's exit callback, but the terminal map is immediately updated to the new process under the same ID. The old handler then looks up that ID, marks the new terminal as exited, emits terminalExit, and causes subsequent writes and resizes to be ignored. Bind handlers to a specific PTY instance or dispose/ignore the old handlers before replacing the process.

Useful? React with 👍 / 👎.

terminal.shellId = resolvedShellId;
terminal.output = [];
terminal.outputSize = 0;
terminal.sequence = 0;
terminal.exited = false;
terminal.exitCode = undefined;
attachHandlers(terminalId, projectDir, onData, onExit);
return publicSession(terminal);
}

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const allowedEvents: Record<HostEventName, true> = {
quitRequested: true,
terminalData: true,
terminalExit: true,
terminalRestart: true,
tuiFrame: true,
};

Expand Down
Loading