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
14 changes: 13 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,24 @@ ipcMain.handle(
}

const id = `s${++sessionSeq}`;
const session = new RpcSession(id, cwd, sessionFile);
let session: RpcSession;
try {
session = new RpcSession(id, cwd, sessionFile);
} catch (err) {
const message = err instanceof Error ? err.message : 'could not start pi';
return { error: message };
}
session.onEvent = (sessionId, event) => sendToRenderer('pidex:session:event', { sessionId, event });
session.onExit = sessionId => sendToRenderer('pidex:session:exit', { sessionId });
sessions.set(id, session);

const [state, messages] = await Promise.all([session.getState(), session.getMessages()]);
const launchError = session.launchError();
if (launchError) {
sessions.delete(id);
session.dispose();
return { error: launchError };
}
return {
sessionId: id,
state: state.success ? state.data : null,
Expand Down
118 changes: 96 additions & 22 deletions src/main/rpc.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { spawn, type ChildProcess } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';

const PI_CANDIDATES = [
'pi',
'/opt/homebrew/bin/pi',
'/usr/local/bin/pi',
'/usr/bin/pi',
];
const PI_MISSING = 'Could not find the pi command. Install it, or set PIDEX_PI to the binary.';

type RpcEvent = Record<string, unknown> & { type: string };

Expand All @@ -19,15 +16,55 @@ type RpcResponse = {
error?: string;
};

function resolvePi(): string {
for (const candidate of PI_CANDIDATES) {
if (candidate.includes('/')) {
if (fs.existsSync(candidate)) return candidate;
} else {
return candidate;
function piBinNames(): string[] {
return process.platform === 'win32' ? ['pi.cmd', 'pi.exe', 'pi'] : ['pi'];
}

/** Directories a Finder launch does not inherit from the login shell. */
export function piPathDirs(home = os.homedir()): string[] {
return [
path.join(home, '.bun', 'bin'),
path.join(home, '.local', 'bin'),
'/opt/homebrew/bin',
'/usr/local/bin',
path.join(home, '.local', 'share', 'mise', 'shims'),
];
}

function looksAbsolute(file: string): boolean {
return path.isAbsolute(file);
}

export function resolvePi(
env: NodeJS.ProcessEnv = process.env,
exists: (file: string) => boolean = fs.existsSync,
home = os.homedir(),
): string | null {
const override = env.PIDEX_PI?.trim();
if (override && looksAbsolute(override) && exists(override)) return override;

for (const dir of piPathDirs(home)) {
for (const name of piBinNames()) {
const candidate = path.join(dir, name);
if (exists(candidate)) return candidate;
}
}
return 'pi';

const pathEnv = env.PATH ?? '';
for (const dir of pathEnv.split(path.delimiter)) {
if (!dir) continue;
for (const name of piBinNames()) {
const candidate = path.join(dir, name);
if (exists(candidate)) return candidate;
}
}
return null;
}

function childEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const current = (env.PATH ?? '').split(path.delimiter).filter(Boolean);
const extra = piPathDirs().filter(dir => !current.includes(dir));
return { ...env, PATH: [...extra, ...current].join(path.delimiter) };
}

class RpcSession {
Expand All @@ -36,35 +73,53 @@ class RpcSession {
private proc: ChildProcess;
private buffer = '';
private seq = 0;
private closed = false;
private failed: string | null = null;
private pending = new Map<string, { resolve: (r: RpcResponse) => void; timer: NodeJS.Timeout }>();
onEvent: (sessionId: string, event: RpcEvent) => void = () => {};
onExit: (sessionId: string, code: number | null) => void = () => {};

constructor(id: string, cwd: string, sessionFile?: string) {
this.id = id;
this.cwd = cwd;
const bin = resolvePi();
if (!bin) throw new Error(PI_MISSING);
const args = ['--mode', 'rpc'];
if (sessionFile) args.push('--session', sessionFile);
this.proc = spawn(resolvePi(), args, {
this.proc = spawn(bin, args, {
cwd,
env: { ...process.env },
env: childEnv(),
stdio: ['pipe', 'pipe', 'pipe'],
});

this.proc.stdout!.setEncoding('utf8');
this.proc.stdout!.on('data', chunk => this.handleChunk(chunk));
this.proc.stderr!.setEncoding('utf8');
this.proc.stderr!.on('data', () => {});
this.proc.stdin?.on('error', () => {});
this.proc.on('error', err => {
const code = (err as NodeJS.ErrnoException).code;
this.failed = code === 'ENOENT' ? PI_MISSING : err.message;
this.close(null);
});
this.proc.on('exit', code => {
for (const [pid, p] of this.pending) {
clearTimeout(p.timer);
p.resolve({ type: 'response', command: '', success: false, id: pid, error: 'process exited' });
}
this.pending.clear();
this.onExit(this.id, code);
this.failed ??= 'process exited';
this.close(code);
});
}

private close(code: number | null) {
if (this.closed) return;
this.closed = true;
const error = this.failed ?? 'process exited';
for (const [, pending] of this.pending) {
clearTimeout(pending.timer);
pending.resolve({ type: 'response', command: '', success: false, error });
}
this.pending.clear();
this.onExit(this.id, code);
}

private handleChunk(chunk: string) {
this.buffer += chunk;
let idx: number;
Expand Down Expand Up @@ -96,17 +151,36 @@ class RpcSession {
}

command(cmd: Record<string, unknown>): Promise<RpcResponse> {
if (this.failed || this.closed) {
return Promise.resolve({
type: 'response',
command: String(cmd.type ?? ''),
success: false,
error: this.failed ?? 'process exited',
});
}
const id = `req-${++this.seq}`;
return new Promise(resolve => {
const timer = setTimeout(() => {
this.pending.delete(id);
resolve({ type: 'response', command: String(cmd.type ?? ''), success: false, id, error: 'timeout' });
}, 30000);
this.pending.set(id, { resolve, timer });
this.proc.stdin!.write(JSON.stringify({ id, ...cmd }) + '\n');
try {
this.proc.stdin!.write(JSON.stringify({ id, ...cmd }) + '\n');
} catch (err) {
clearTimeout(timer);
this.pending.delete(id);
const message = err instanceof Error ? err.message : 'could not write to pi';
resolve({ type: 'response', command: String(cmd.type ?? ''), success: false, id, error: message });
}
});
}

launchError(): string | null {
return this.closed ? this.failed : null;
}

getState() {
return this.command({ type: 'get_state' });
}
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ function App() {
const [activeId, setActiveId] = useState<string | null>(null);
const [draft, setDraft] = useState('');
const [opening, setOpening] = useState(false);
const [openError, setOpenError] = useState<string | null>(null);
const [slashIndex, setSlashIndex] = useState(0);
const [fontReady, setFontReady] = useState(false);
const timelineRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -179,12 +180,14 @@ function App() {
return;
}
setOpening(true);
setOpenError(null);
try {
const result = await window.pidex.openSession({
cwd: ws.cwd,
sessionFile: info?.file,
});
if (result.error || !result.sessionId) {
setOpenError(result.error ?? 'Could not open the session.');
return;
}
const model =
Expand Down Expand Up @@ -517,6 +520,7 @@ function App() {
<p className="empty-copy">
Pick a session from the sidebar, or start a new one in the expanded workspace.
</p>
{openError ? <p className="empty-copy empty-copy--error">{openError}</p> : null}
</div>
)}
</main>
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,10 @@
font-size: 0.85rem;
}

.empty-copy--error {
color: var(--error);
}

/* Main pane ---------------------------------------------------------------- */

.main-pane {
Expand Down
51 changes: 51 additions & 0 deletions tests/unit/resolve-pi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import path from 'path';
import { describe, expect, it } from 'vitest';
import { resolvePi } from '../../src/main/rpc';

const home = '/Users/someone';

function existsAmong(files: string[]) {
const set = new Set(files);
return (file: string) => set.has(file);
}

describe('resolvePi', () => {
it('uses a Finder-invisible Bun or Homebrew install instead of the bare name', () => {
const bun = path.join(home, '.bun', 'bin', 'pi');
const brew = '/opt/homebrew/bin/pi';
const resolved = resolvePi({ PATH: '/usr/bin:/bin' }, existsAmong([bun, brew]), home);
expect(resolved).toBe(bun);
});

it('uses Homebrew when the Bun install is absent', () => {
const brew = '/opt/homebrew/bin/pi';
const resolved = resolvePi({ PATH: '/usr/bin:/bin' }, existsAmong([brew]), home);
expect(resolved).toBe(brew);
});

it('honours an absolute PIDEX_PI override', () => {
const custom = '/opt/pi/bin/pi';
const resolved = resolvePi({ PATH: '/usr/bin:/bin', PIDEX_PI: custom }, existsAmong([custom]), home);
expect(resolved).toBe(custom);
});

it('skips a missing PIDEX_PI and keeps searching', () => {
const brew = '/opt/homebrew/bin/pi';
const resolved = resolvePi(
{ PATH: '/usr/bin:/bin', PIDEX_PI: '/missing/pi' },
existsAmong([brew]),
home,
);
expect(resolved).toBe(brew);
});

it('falls back to PATH when no known install exists', () => {
const onPath = '/usr/local/custom/bin/pi';
const resolved = resolvePi({ PATH: '/usr/local/custom/bin:/usr/bin' }, existsAmong([onPath]), home);
expect(resolved).toBe(onPath);
});

it('returns null when pi is not installed', () => {
expect(resolvePi({ PATH: '/usr/bin:/bin' }, () => false, home)).toBeNull();
});
});