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
2 changes: 1 addition & 1 deletion apps/desktop/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ NativePi is a Pi-only desktop wrapper, not a separate agent harness. Pi remains
- Pi session files are the durable conversation source of truth. NativePi persists only pinned projects and chats, the last project and chat, text drafts, favorite models, pane state, and its own interface preferences.
- Agent configuration is Pi's. NativePi reads and writes it through Pi's own settings manager at user scope, so a change made here is a change the Pi command line sees; NativePi never writes Pi's configuration format itself, and exposes only the settings that have meaning in a desktop window. Project-scope overrides remain the Pi command line's business.
- Authentication is Pi-backed. Credentials are never stored in NativePi renderer persistence or its state file.
- Git mutation is deliberately narrow: branch checkout and creation require a clean worktree, and worktrees may be added. NativePi does not stage, commit, merge, rebase, discard changes, create checkpoints, roll back work, or rewrite history.
- Git mutation is deliberately narrow: branch checkout and creation require a clean worktree, and worktrees may be added. NativePi can stage individual hunks, create commits, push the current branch, and open a GitHub pull request through `gh`; Pi drafts commit wording when asked. NativePi does not merge, rebase, discard changes, create checkpoints, roll back work, or rewrite history.
- Normal Pi extensions run unchanged. Optional graphical extensions contribute only through controlled NativePi UI slots and are trusted code, not sandboxed code.
- Terminal extension components are drawn by Pi and displayed, not reimplemented: NativePi runs the component in the Pi process and shows what it draws, so it looks as its author wrote it rather than as NativePi would have styled it. Two parts of Pi's terminal UI have no equivalent here and keep Pi's documented no-op: raw terminal input, and replacing the input editor, which in this window is the composer.
- NativePi has no cloud sync, collaboration, remote projects, SSH launching, configurable keybindings, product accounts, paid features, or telemetry.
Expand Down
42 changes: 41 additions & 1 deletion apps/desktop/src/main/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { mkdtemp, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { gitAddWorktree, gitBranches, gitCheckout } from "./git.ts";
import { gitAddWorktree, gitBranches, gitCheckout, gitCommit, gitHunks, gitStageFile, gitStageHunk, gitStatus } from "./git.ts";

/**
* These run against a real repository rather than a mocked `git`.
Expand Down Expand Up @@ -103,3 +103,43 @@ test("a checkout Git refuses returns its reason instead of claiming success", as
expect(res.ok).toBe(false);
expect(res.error).toBeTruthy();
});

test("a selected hunk stages without staging its neighbour", async () => {
const dir = await repo();
await writeFile(path.join(dir, "a.txt"), "one\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\ntwenty\n", "utf8");
execFileSync("git", ["add", "a.txt"], { cwd: dir });
execFileSync("git", ["commit", "-m", "lines"], { cwd: dir });
await writeFile(path.join(dir, "a.txt"), "first\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\nb\n", "utf8");

const hunks = await gitHunks(dir, "a.txt", false);
expect(hunks.length).toBe(2);
expect(await gitStageHunk(dir, "a.txt", false, hunks[0]!.patch)).toEqual({ ok: true });

const status = await gitStatus(dir);
expect(status.files).toEqual([{ path: "a.txt", state: "modified", staged: true, unstaged: true }]);
expect(execFileSync("git", ["diff", "--cached"], { cwd: dir, encoding: "utf8" })).toContain("first");
expect(execFileSync("git", ["diff"], { cwd: dir, encoding: "utf8" })).toContain("b");
});

test("a hunk that changed after display is not staged by a stale selection", async () => {
const dir = await repo();
await writeFile(path.join(dir, "a.txt"), "first\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\nb\n", "utf8");
const [first] = await gitHunks(dir, "a.txt", false);
await writeFile(path.join(dir, "a.txt"), "one\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\nb\n", "utf8");

expect(await gitStageHunk(dir, "a.txt", false, first!.patch)).toEqual({ ok: false, error: "That change is no longer available. Refresh and try again." });
});

test("a file without textual hunks can still be staged", async () => {
const dir = await repo();
await writeFile(path.join(dir, "image.bin"), Buffer.from([0, 1, 2, 3]));

expect(await gitHunks(dir, "image.bin", false)).toEqual([]);
expect(await gitStageFile(dir, "image.bin")).toEqual({ ok: true });
expect((await gitStatus(dir)).files).toEqual([{ path: "image.bin", state: "added", staged: true, unstaged: false }]);
});

test("committing without staged changes is refused", async () => {
const dir = await repo();
expect(await gitCommit(dir, "feat: nothing")).toEqual({ ok: false, error: "Stage at least one change before committing." });
});
80 changes: 78 additions & 2 deletions apps/desktop/src/main/git.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import type { GitBranch, GitDiff, GitStatus } from "../shared/pi-types.ts";
import type { GitBranch, GitDiff, GitHunk, GitStatus } from "../shared/pi-types.ts";


function run(args: string[], cwd: string): Promise<{ stdout: string; stderr: string; code: number }> {
Expand All @@ -18,6 +18,15 @@ function run(args: string[], cwd: string): Promise<{ stdout: string; stderr: str
});
}

function runGh(args: string[], cwd: string): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve) => {
execFile("gh", args, { cwd, maxBuffer: 32 * 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => {
const code = err && typeof (err as { code?: unknown }).code === "number" ? (err as { code: number }).code : err ? 1 : 0;
resolve({ stdout, stderr, code });
});
});
}

function labelFor(x: string, y: string): GitStatus["files"][number]["state"] {
if (x === "?" && y === "?") return "untracked";
if (x === "A" || y === "A") return "added";
Expand Down Expand Up @@ -49,7 +58,7 @@ export async function gitStatus(projectDir: string): Promise<GitStatus> {
// A rename entry is followed by its original path in the next NUL field.
if (x === "R" || y === "R") i++;
if (!path) continue;
files.push({ path, state: labelFor(x, y), staged: x !== " " && x !== "?" });
files.push({ path, state: labelFor(x, y), staged: x !== " " && x !== "?", unstaged: y !== " " || x === "?" });
}

return {
Expand All @@ -70,6 +79,73 @@ export async function gitDiff(projectDir: string, file: string, untracked: boole
return { path: file, patch: res.stdout };
}

export async function gitHunks(projectDir: string, file: string, untracked: boolean): Promise<GitHunk[]> {
const res = await run(
untracked
? ["diff", "--no-color", "--unified=0", "--no-index", "--", "/dev/null", file]
: ["diff", "--no-color", "--unified=0", "--", file],
projectDir,
);
const starts = [...res.stdout.matchAll(/^@@/gm)].map((match) => match.index ?? 0);
if (starts.length === 0) return [];
Comment thread
nonlooped marked this conversation as resolved.
const prefix = res.stdout.slice(0, starts[0]);
return starts.map((start, index) => ({
header: res.stdout.slice(start, res.stdout.indexOf("\n", start)).trim(),
patch: prefix + res.stdout.slice(start, starts[index + 1]),
}));
}

export async function gitStageHunk(
projectDir: string,
file: string,
untracked: boolean,
patch: string,
): Promise<{ ok: boolean; error?: string }> {
const hunks = await gitHunks(projectDir, file, untracked);
const selected = hunks.find((hunk) => hunk.patch === patch);
if (!selected) return { ok: false, error: "That change is no longer available. Refresh and try again." };
const result = await new Promise<{ stdout: string; stderr: string; code: number }>((resolve) => {
const child = execFile("git", ["apply", "--cached", "--unidiff-zero", "-"], {
cwd: projectDir,
maxBuffer: 32 * 1024 * 1024,
windowsHide: true,
}, (err, stdout, stderr) => {
const code = err && typeof (err as { code?: unknown }).code === "number" ? (err as { code: number }).code : err ? 1 : 0;
resolve({ stdout, stderr, code });
});
child.stdin?.end(selected.patch);
});
return result.code === 0 ? { ok: true } : { ok: false, error: failure(result) };
}

export async function gitStageFile(projectDir: string, file: string): Promise<{ ok: boolean; error?: string }> {
const result = await run(["add", "--", file], projectDir);
return result.code === 0 ? { ok: true } : { ok: false, error: failure(result) };
}

export async function gitCommit(projectDir: string, message: string): Promise<{ ok: boolean; error?: string }> {
const staged = await run(["diff", "--cached", "--quiet"], projectDir);
if (staged.code === 0) return { ok: false, error: "Stage at least one change before committing." };
const result = await run(["commit", "-m", message], projectDir);
return result.code === 0 ? { ok: true } : { ok: false, error: failure(result) };
}

export async function gitPushAndCreatePr(
projectDir: string,
title: string,
body: string,
): Promise<{ ok: boolean; url?: string; error?: string }> {
const base = await runGh(["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], projectDir);
if (base.code !== 0 || !base.stdout.trim()) return { ok: false, error: failure(base) };
const current = await run(["branch", "--show-current"], projectDir);
if (current.code !== 0 || !current.stdout.trim()) return { ok: false, error: "Check out a branch before opening a pull request." };
if (current.stdout.trim() === base.stdout.trim()) return { ok: false, error: "Create a branch before opening a pull request." };
const push = await run(["push", "-u", "origin", "HEAD"], projectDir);
if (push.code !== 0) return { ok: false, error: failure(push) };
const pr = await runGh(["pr", "create", "--base", base.stdout.trim(), "--title", title, "--body", body], projectDir);
return pr.code === 0 ? { ok: true, url: pr.stdout.trim() } : { ok: false, error: failure(pr) };
}

/** Git's own message is the useful one; ours would only be vaguer. */
function failure(res: { stdout: string; stderr: string }): string {
return (res.stderr.trim() || res.stdout.trim() || "git failed").split("\n").slice(0, 4).join("\n");
Expand Down
23 changes: 22 additions & 1 deletion apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { PiMessage } from "./pi/protocol.ts";
import { deleteSession, listSessions, readSession, searchSessions, sessionMtime, watchProjectSessions, watchSessionFile } from "./sessions.ts";
import { loadState, saveState } from "./state.ts";
import * as auth from "./auth.ts";
import { gitAddWorktree, gitBranches, gitCheckout, gitDiff, gitStatus } from "./git.ts";
import { gitAddWorktree, gitBranches, gitCheckout, gitCommit, gitDiff, gitHunks, gitPushAndCreatePr, gitStageFile, gitStageHunk, gitStatus } from "./git.ts";
import { installPackage, listPackages, removePackage, updatePackage } from "./packages.ts";
import { listSkills } from "./skills.ts";
import { listProjectFiles } from "./files.ts";
Expand Down Expand Up @@ -294,6 +294,10 @@ const gitMutationParamsSchema = z.object({
branch: z.string().min(1),
create: z.boolean(),
});
const gitHunkParamsSchema = z.object({ projectDir: z.string().min(1), file: z.string().min(1), untracked: z.boolean(), patch: z.string().min(1) });
const gitFileParamsSchema = z.object({ projectDir: z.string().min(1), file: z.string().min(1) });
const gitCommitParamsSchema = z.object({ projectDir: z.string().min(1), message: z.string().trim().min(1).max(10_000) });
const gitPrParamsSchema = z.object({ projectDir: z.string().min(1), title: z.string().trim().min(1).max(256), body: z.string().max(50_000) });
const projectDirParamsSchema = z.object({ projectDir: z.string().min(1) });
/**
* `get_commands` as the composer needs it, checked at the Pi boundary.
Expand Down Expand Up @@ -953,6 +957,23 @@ const handlers: HandlerMap = {

gitStatus: async ({ projectDir }) => ({ status: await gitStatus(projectDir) }),
gitDiff: async ({ projectDir, file, untracked }) => ({ diff: await gitDiff(projectDir, file, untracked) }),
gitHunks: async ({ projectDir, file, untracked }) => ({ hunks: await gitHunks(projectDir, file, untracked) }),
gitStageHunk: async (params) => {
try { const { projectDir, file, untracked, patch } = gitHunkParamsSchema.parse(params); return await gitStageHunk(projectDir, file, untracked, patch); }
catch (err) { return { ok: false, error: errorMessage(err) }; }
},
gitStageFile: async (params) => {
try { const { projectDir, file } = gitFileParamsSchema.parse(params); return await gitStageFile(projectDir, file); }
catch (err) { return { ok: false, error: errorMessage(err) }; }
},
gitCommit: async (params) => {
try { const { projectDir, message } = gitCommitParamsSchema.parse(params); return await gitCommit(projectDir, message); }
catch (err) { return { ok: false, error: errorMessage(err) }; }
},
gitPushAndCreatePr: async (params) => {
try { const { projectDir, title, body } = gitPrParamsSchema.parse(params); return await gitPushAndCreatePr(projectDir, title, body); }
catch (err) { return { ok: false, error: errorMessage(err) }; }
},
gitBranches: async ({ projectDir }) => ({ branches: await gitBranches(projectDir) }),
gitCheckout: async (params) => {
try {
Expand Down
141 changes: 141 additions & 0 deletions apps/desktop/src/renderer/components/CommitDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { useEffect, useRef, useState } from "react";
import { SparkleIcon } from "@phosphor-icons/react/Sparkle";
import type { AssistantMessage, SessionEntry } from "../../shared/pi-types.ts";
import { rpc } from "../lib/rpc.ts";
import { activeConversation, useAppStore } from "../lib/store.ts";
import { Button } from "@/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Textarea } from "@/components/ui/textarea.tsx";

function assistantText(entries: SessionEntry[], since: number): string | null {
for (let index = entries.length - 1; index >= 0; index--) {
const entry = entries[index];
const message = (entry as { message?: unknown }).message;
if (entry.type !== "message" || !message || (message as { role?: string }).role !== "assistant") continue;
const assistant = message as AssistantMessage;
if (assistant.timestamp < since) return null;
const text = assistant.content.filter((part) => part.type === "text").map((part) => part.text).join("\n").trim();
if (text) return text;
}
return null;
}

export default function CommitDialog({ projectDir, onClose }: { projectDir: string | null; onClose: () => void }) {
const conversation = useAppStore(activeConversation);
const refreshGit = useAppStore((s) => s.refreshGit);
const [message, setMessage] = useState("");
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [draftingSince, setDraftingSince] = useState<number | null>(null);
const sawDraftRun = useRef(false);
const [busy, setBusy] = useState<"commit" | "pr" | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!draftingSince) return;
const draft = assistantText(conversation.entries, draftingSince);
if (draft) {
setMessage(draft);
setTitle(draft.split("\n")[0].slice(0, 256));
setDraftingSince(null);
return;
}
if (conversation.running) {
sawDraftRun.current = true;
} else if (sawDraftRun.current) {
setDraftingSince(null);
setError("Pi finished without a usable commit-message draft.");
}
}, [conversation.entries, conversation.running, draftingSince]);

useEffect(() => {
setMessage("");
Comment thread
nonlooped marked this conversation as resolved.
setTitle("");
setBody("");
setError(null);
setDraftingSince(null);
sawDraftRun.current = false;
}, [projectDir]);

async function askPi() {
if (!projectDir || !conversation.sessionFile || conversation.running) return;
setError(null);
const since = Date.now();
sawDraftRun.current = false;
setDraftingSince(since);
const result = await rpc.request.submit({
projectDir,
sessionFile: conversation.sessionFile,
message: "Inspect the currently staged Git changes. Draft only a concise Conventional Commit message, with an optional body only when it adds necessary context. Do not commit or change files.",
});
if (!result.ok) {
setDraftingSince(null);
setError(result.error ?? "Pi could not draft a commit message.");
}
}

async function commit() {
if (!projectDir || !message.trim()) return;
setBusy("commit");
setError(null);
const result = await rpc.request.gitCommit({ projectDir, message });
setBusy(null);
if (!result.ok) return setError(result.error ?? "Git could not create the commit.");
await refreshGit();
}

async function createPr() {
if (!projectDir || !title.trim()) return;
setBusy("pr");
setError(null);
const result = await rpc.request.gitPushAndCreatePr({ projectDir, title, body });
setBusy(null);
if (!result.ok) return setError(result.error ?? "GitHub CLI could not open the pull request.");
if (result.url) window.open(result.url, "_blank", "noopener,noreferrer");
onClose();
}

const canAskPi = Boolean(conversation.sessionFile) && !conversation.running && !draftingSince;
return (
<Dialog open={projectDir !== null} onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="font-heading text-base font-semibold">Commit changes</DialogTitle>
<DialogDescription>
Stage the changes you want first. Pi can draft the message from the staged diff; you can edit it before committing.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<Textarea value={message} onChange={(event) => setMessage(event.target.value)} placeholder="feat: describe the change" aria-label="Commit message" />
<Button variant="outline" onClick={() => void askPi()} disabled={!canAskPi}>
<SparkleIcon data-icon="inline-start" />
{draftingSince ? "Pi is drafting…" : "Draft with Pi"}
</Button>
{!conversation.sessionFile ? <p className="text-xs text-muted-foreground">Open a chat to ask Pi for a draft.</p> : null}
{conversation.running ? <p className="text-xs text-muted-foreground">Wait for Pi’s current turn before asking for a draft.</p> : null}
<div className="flex flex-col gap-2 border-t pt-3">
<p className="text-xs font-medium">Open a pull request</p>
<Input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Pull request title" aria-label="Pull request title" />
<Textarea value={body} onChange={(event) => setBody(event.target.value)} placeholder="What changed and how it was checked" aria-label="Pull request description" />
</div>
{error ? <p className="text-xs whitespace-pre-wrap text-destructive">{error}</p> : null}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy !== null}>Close</Button>
<Button variant="outline" onClick={() => void commit()} disabled={!message.trim() || busy !== null}>Commit</Button>
<Button onClick={() => void createPr()} disabled={!title.trim() || busy !== null}>
{busy === "pr" ? "Opening PR…" : "Push & open PR"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Loading
Loading