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
7 changes: 7 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@electric-sql/client": "^1.5.4",
"@octokit/webhooks": "^14.2.0",
"@opencode-ai/sdk": "^1.2.1",
"@pierre/diffs": "^1.0.11",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/electric-db-collection": "^0.2.33",
"@tanstack/hotkeys": "^0.4.1",
Expand Down
36 changes: 36 additions & 0 deletions packages/desktop-shell/src/desktop-runner.mts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ type RunnerModelProvider = {
name: string;
};

type RunnerDiff = {
additions: number;
after: string;
before: string;
deletions: number;
file: string;
};

type ListRunnerModelsResponse = {
connected: string[];
default: Record<string, string>;
Expand Down Expand Up @@ -75,6 +83,7 @@ type AppRunnerController = {
workspaceDirectory: string;
}>;
deleteRunnerWorkspace: (args: DeleteRunnerWorkspaceArgs) => Promise<void>;
getRunnerDiff: (args: { directory: string; sessionId: string }) => Promise<RunnerDiff[]>;
listRunnerModels: (args: { directory: string }) => Promise<ListRunnerModelsResponse>;
openWorkspaceInEditor: (args: OpenWorkspaceInEditorArgs) => Promise<void>;
promptRunnerTask: (args: PromptRunnerTaskArgs) => Promise<void>;
Expand All @@ -94,6 +103,10 @@ type DeleteWorkspaceResponse = {
ok: boolean;
};

type GetRunnerDiffResponse = {
diffs: RunnerDiff[];
};

export function createDesktopRunnerController({
workspaceRoot,
}: {
Expand Down Expand Up @@ -138,6 +151,13 @@ export function createDesktopRunnerController({
);
}

async function getRunnerDiff(args: {
directory: string;
sessionId: string;
}): Promise<RunnerDiff[]> {
return await requestRunnerDiff(args);
}

async function promptRunnerTask(args: PromptRunnerTaskArgs): Promise<void> {
const runner = await ensureRunner();
const payload = await postRunnerJson<PromptTaskAssistantSessionResponse>(
Expand Down Expand Up @@ -264,9 +284,25 @@ export function createDesktopRunnerController({
return nextRunnerProcess;
}

async function requestRunnerDiff(args: {
directory: string;
sessionId: string;
}): Promise<RunnerDiff[]> {
const runner = await ensureRunner();
const payload = await getRunnerJson<GetRunnerDiffResponse>(
`${runner.baseUrl}/assistant/session/diff?${new URLSearchParams({
directory: args.directory,
sessionId: args.sessionId,
}).toString()}`,
);

return payload.diffs;
}

return {
createRunnerSession,
deleteRunnerWorkspace,
getRunnerDiff,
listRunnerModels,
openWorkspaceInEditor,
promptRunnerTask,
Expand Down
4 changes: 4 additions & 0 deletions packages/desktop-shell/src/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ function registerIpcHandlers(): void {
return await getDesktopRunnerController().deleteRunnerWorkspace(args);
});

ipcMain.handle("desktop-runner:get-diff", async (_event, args) => {
return await getDesktopRunnerController().getRunnerDiff(args);
});

ipcMain.handle("desktop-runner:list-models", async (_event, args) => {
return await getDesktopRunnerController().listRunnerModels(args);
});
Expand Down
3 changes: 3 additions & 0 deletions packages/desktop-shell/src/preload.mts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ contextBridge.exposeInMainWorld("clankiDesktop", {
deleteRunnerWorkspace(workspaceDirectory: string) {
return ipcRenderer.invoke("desktop-runner:delete-workspace", { workspaceDirectory });
},
getRunnerDiff(args: { directory: string; sessionId: string }) {
return ipcRenderer.invoke("desktop-runner:get-diff", args);
},
listRunnerModels(args: { directory: string }) {
return ipcRenderer.invoke("desktop-runner:list-models", args);
},
Expand Down
143 changes: 143 additions & 0 deletions packages/runner/src/assistant-session-diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import type { FileDiff } from "@opencode-ai/sdk";

export async function getAssistantSessionDiff(args: {
directory: string;
messageId?: string;
sessionId: string;
}): Promise<FileDiff[]> {
void args.messageId;
void args.sessionId;

const directory = path.resolve(args.directory);
const defaultBranch = resolveDefaultBranch(directory);
fetchDefaultBranch(directory, defaultBranch);

const mergeBase = runGitCommand(
directory,
["merge-base", `origin/${defaultBranch}`, "HEAD"],
"Failed to resolve merge base for workspace diff",
).trim();

if (mergeBase.length === 0) {
throw new Error("Failed to resolve merge base for workspace diff");
}

const changedFiles = listChangedFiles(directory, mergeBase);

return changedFiles.map((file) => {
const stats = readDiffStats(directory, mergeBase, file);

return {
additions: stats.additions,
after: readWorkingTreeFile(directory, file),
before: readGitFile(directory, mergeBase, file),
deletions: stats.deletions,
file,
};
});
}

function fetchDefaultBranch(directory: string, defaultBranch: string): void {
runGitCommand(
directory,
["fetch", "origin", defaultBranch, "--prune"],
`Failed to fetch origin/${defaultBranch} for workspace diff`,
);
}

function listChangedFiles(directory: string, mergeBase: string): string[] {
const output = runGitCommand(
directory,
["diff", "--name-only", "--no-renames", mergeBase, "--"],
"Failed to list workspace diff files",
);

return output
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
}

function readGitFile(directory: string, revision: string, file: string): string {
const output = spawnSync("git", ["-C", directory, "show", `${revision}:${file}`], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});

if (output.status === 0) {
return output.stdout;
}

const stderr = output.stderr.trim();
if (stderr.includes("exists on disk, but not in") || stderr.includes("does not exist in")) {
return "";
}

throw new Error(stderr || `Failed to read ${file} from ${revision}`);
}

function readDiffStats(
directory: string,
mergeBase: string,
file: string,
): { additions: number; deletions: number } {
const output = runGitCommand(
directory,
["diff", "--numstat", "--no-renames", mergeBase, "--", file],
`Failed to read diff stats for ${file}`,
).trim();

const [additionsRaw = "0", deletionsRaw = "0"] = output.split("\t");

return {
additions: Number.parseInt(additionsRaw, 10) || 0,
deletions: Number.parseInt(deletionsRaw, 10) || 0,
};
}

function readWorkingTreeFile(directory: string, file: string): string {
const absolutePath = path.join(directory, file);
if (!fs.existsSync(absolutePath)) {
return "";
}

return fs.readFileSync(absolutePath, "utf8");
}

function resolveDefaultBranch(directory: string): string {
const branch = runGitCommand(
directory,
["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
"Failed to resolve default branch for workspace diff",
)
.trim()
.replace(/^origin\//u, "");

if (branch.length === 0) {
throw new Error("Failed to resolve default branch for workspace diff");
}

return branch;
}

function runGitCommand(directory: string, args: string[], errorContext: string): string {
const output = spawnSync("git", ["-C", directory, ...args], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});

if (output.error) {
throw new Error(`${errorContext}: ${output.error.message}`);
}

if (output.status === 0) {
return output.stdout;
}

const stderr = output.stderr.trim();
const stdout = output.stdout.trim();
throw new Error(`${errorContext}: ${stderr || stdout || `exit status ${output.status}`}`);
}
1 change: 1 addition & 0 deletions packages/runner/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./assistant-session";
export * from "./assistant-session-diff";
export * from "./local-runner-client";
export * from "./local-runner-protocol";
export * from "./local-runner-server";
Expand Down
18 changes: 18 additions & 0 deletions packages/runner/src/local-runner-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type {
DeleteWorkspaceResponse,
EnsureAssistantSessionRequest,
EnsureAssistantSessionResponse,
GetAssistantSessionDiffRequest,
GetAssistantSessionDiffResponse,
ListAssistantSessionsRequest,
ListAssistantSessionsResponse,
ListOpencodeModelsRequest,
Expand Down Expand Up @@ -32,6 +34,22 @@ export function createLocalRunnerClient(baseUrl: string) {
): Promise<EnsureAssistantSessionResponse> {
return await postJson(`${normalizedBaseUrl}/assistant/session/ensure`, body);
},
async getAssistantSessionDiff(
params: GetAssistantSessionDiffRequest,
): Promise<GetAssistantSessionDiffResponse> {
const searchParams = new URLSearchParams({
directory: params.directory,
sessionId: params.sessionId,
});

if (params.messageId) {
searchParams.set("messageId", params.messageId);
}

return await getJson(
`${normalizedBaseUrl}/assistant/session/diff?${searchParams.toString()}`,
);
},
async health(): Promise<LocalRunnerHealthResponse> {
return await getJson(`${normalizedBaseUrl}/health`);
},
Expand Down
12 changes: 11 additions & 1 deletion packages/runner/src/local-runner-protocol.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ProviderListResponse } from "@opencode-ai/sdk";
import type { FileDiff, ProviderListResponse } from "@opencode-ai/sdk";

export const LOCAL_RUNNER_PROTOCOL_VERSION = "v1alpha1";

Expand Down Expand Up @@ -67,6 +67,16 @@ export type ListAssistantSessionsResponse = {
sessions: AssistantSessionSummary[];
};

export type GetAssistantSessionDiffRequest = {
directory: string;
messageId?: string;
sessionId: string;
};

export type GetAssistantSessionDiffResponse = {
diffs: FileDiff[];
};

export type PromptAssistantSessionRequest = {
directory: string;
model?: string;
Expand Down
Loading
Loading