diff --git a/apps/tui/package.json b/apps/tui/package.json index 7fca44c..4a5c8da 100644 --- a/apps/tui/package.json +++ b/apps/tui/package.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@types/bun": "latest", + "@types/diff": "^8.0.0", "@types/react": "^19.2.4" }, "peerDependencies": { @@ -27,6 +28,7 @@ "@techatnyu/ralphd": "workspace:*", "@opentui/core": "^0.1.77", "@opentui/react": "^0.1.77", + "diff": "^9.0.0", "react": "^19.2.4" } } diff --git a/apps/tui/src/components/chat.tsx b/apps/tui/src/components/chat.tsx index ab68e94..d49eff5 100644 --- a/apps/tui/src/components/chat.tsx +++ b/apps/tui/src/components/chat.tsx @@ -6,6 +6,7 @@ import { daemon } from "@techatnyu/ralphd"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPromptTask } from "../lib/prompt-task"; import { ralphStore } from "../lib/store"; +import { DiffViewer } from "./diff-viewer"; type Role = "user" | "assistant" | "system"; @@ -99,6 +100,7 @@ export function Chat({ const [errorMessage, setErrorMessage] = useState(null); const [sessionId, setSessionId] = useState(initialSessionId); const [hydrated, setHydrated] = useState(false); + const [tab, setTab] = useState<"chat" | "diffs">("chat"); const [pendingQuestion, setPendingQuestion] = useState(null); const sendLockRef = useRef(false); @@ -309,6 +311,19 @@ export function Chat({ ); useKeyboard((event) => { + if (event.ctrl && event.name === "r") { + if (tab === "chat" && sessionId) { + setTab("diffs"); + } else if (tab === "diffs") { + setTab("chat"); + } + return; + } + + if (tab !== "chat") { + return; + } + if (event.ctrl && event.name === "c") { onQuit(); } @@ -487,6 +502,17 @@ export function Chat({ const activeAnswers = pendingQuestion?.answers[pendingQuestion.currentIndex] ?? []; + if (tab === "diffs" && sessionId) { + return ( + setTab("chat")} + onQuit={onQuit} + /> + ); + } + return ( @@ -494,7 +520,8 @@ export function Chat({ Ralph Chat · {instanceName} {sessionId ? ` · session: ${sessionId.slice(0, 8)}` : ""} {errorMessage ? ` · error: ${errorMessage}` : ""} · PgUp/PgDn or - Ctrl+U/Ctrl+D scroll · esc back · ctrl+c quit + Ctrl+U/Ctrl+D scroll{sessionId ? " · ctrl+r diffs" : ""} · esc back · + ctrl+c quit diff --git a/apps/tui/src/components/diff-viewer.tsx b/apps/tui/src/components/diff-viewer.tsx new file mode 100644 index 0000000..98ad6ab --- /dev/null +++ b/apps/tui/src/components/diff-viewer.tsx @@ -0,0 +1,235 @@ +import type { ScrollBoxRenderable } from "@opentui/core"; +import { TextAttributes } from "@opentui/core"; +import { useKeyboard } from "@opentui/react"; +import type { FileDiff } from "@techatnyu/ralphd"; +import { daemon } from "@techatnyu/ralphd"; +import { useEffect, useRef, useState } from "react"; + +const DIFFS_POLL_INTERVAL_MS = 500; + +interface DiffViewerProps { + instanceId: string; + sessionId: string; + onBack(): void; + onQuit(): void; +} + +function statusGlyph(status: FileDiff["status"]): string { + switch (status) { + case "added": + return "A"; + case "deleted": + return "D"; + case "modified": + return "M"; + default: + return " "; + } +} + +function extensionOf(file: string): string { + const dot = file.lastIndexOf("."); + if (dot < 0 || dot === file.length - 1) { + return ""; + } + return file.slice(dot + 1); +} + +interface FileRowProps { + diff: FileDiff; + focused: boolean; + expanded: boolean; +} + +function FileRow({ diff, focused, expanded }: FileRowProps) { + return ( + + + + {`${focused ? "> " : " "}${statusGlyph(diff.status)} ${diff.file} `} + + {`+${diff.additions}`} + {" "} + {`-${diff.deletions}`} + + {expanded ? ( + + + + ) : null} + + ); +} + +export function DiffViewer({ + instanceId, + sessionId, + onBack, + onQuit, +}: DiffViewerProps) { + const [diffs, setDiffs] = useState([]); + const [error, setError] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(0); + const [expanded, setExpanded] = useState>(() => new Set()); + const scrollRef = useRef(null); + + useEffect(() => { + let cancelled = false; + let inFlight = false; + + const fetchDiffs = async () => { + if (inFlight || cancelled) { + return; + } + inFlight = true; + try { + const result = await daemon.sessionDiffs({ instanceId, sessionId }); + if (!cancelled) { + setDiffs(result.diffs); + setError(null); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)); + } + } finally { + inFlight = false; + } + }; + + void fetchDiffs(); + const handle = setInterval(() => { + void fetchDiffs(); + }, DIFFS_POLL_INTERVAL_MS); + return () => { + cancelled = true; + clearInterval(handle); + }; + }, [instanceId, sessionId]); + + useKeyboard((event) => { + if (event.ctrl && event.name === "c") { + onQuit(); + return; + } + + if (event.name === "escape") { + onBack(); + return; + } + + if (event.name === "pageup") { + scrollRef.current?.scrollBy(-1, "viewport"); + return; + } + + if (event.name === "pagedown") { + scrollRef.current?.scrollBy(1, "viewport"); + return; + } + + if (event.ctrl && event.name === "u") { + scrollRef.current?.scrollBy(-0.5, "viewport"); + return; + } + + if (event.ctrl && event.name === "d") { + scrollRef.current?.scrollBy(0.5, "viewport"); + return; + } + + if (event.name === "down" || event.name === "j") { + setSelectedIndex((prev) => + Math.max(0, Math.min(prev + 1, diffs.length - 1)), + ); + return; + } + + if (event.name === "up" || event.name === "k") { + setSelectedIndex((prev) => + Math.max(0, Math.min(prev - 1, diffs.length - 1)), + ); + return; + } + + if (event.name === "return" || event.name === "space") { + const current = diffs[selectedIndex]; + if (!current) { + return; + } + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(current.file)) { + next.delete(current.file); + } else { + next.add(current.file); + } + return next; + }); + return; + } + + if (event.name === "e") { + setExpanded((prev) => { + if (prev.size < diffs.length) { + return new Set(diffs.map((d) => d.file)); + } + return new Set(); + }); + return; + } + }); + + let additions = 0; + let deletions = 0; + for (const d of diffs) { + additions += d.additions; + deletions += d.deletions; + } + + return ( + + + + {`Diffs · ${diffs.length} file${diffs.length === 1 ? "" : "s"} · +${additions}/-${deletions} · j/k nav · enter expand · e all · esc back · ctrl+c quit`} + + + + + {diffs.length === 0 ? ( + No changes yet. + ) : ( + diffs.map((d, i) => ( + + )) + )} + + + {error ? ( + + {`Error: ${error}`} + + ) : null} + + ); +} diff --git a/bun.lock b/bun.lock index 07d0f03..358ce6c 100644 --- a/bun.lock +++ b/bun.lock @@ -52,10 +52,12 @@ "@opentui/core": "^0.1.77", "@opentui/react": "^0.1.77", "@techatnyu/ralphd": "workspace:*", + "diff": "^9.0.0", "react": "^19.2.4", }, "devDependencies": { "@types/bun": "latest", + "@types/diff": "^8.0.0", "@types/react": "^19.2.4", }, "peerDependencies": { @@ -602,10 +604,12 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + "@types/diff": ["@types/diff@8.0.0", "", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -684,7 +688,7 @@ "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], - "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], @@ -764,7 +768,7 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], @@ -1512,6 +1516,8 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "@opentui/core/diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/packages/daemon/src/__tests__/daemon.test.ts b/packages/daemon/src/__tests__/daemon.test.ts index 8f837b6..e092d00 100644 --- a/packages/daemon/src/__tests__/daemon.test.ts +++ b/packages/daemon/src/__tests__/daemon.test.ts @@ -537,6 +537,101 @@ describe("Daemon", () => { ); await nextDaemon.shutdown(); }); + + test("returns session diffs from the opencode runtime", async () => { + const created = await daemon.handleRequest( + req({ + id: "instance-create", + method: "instance.create", + params: { + name: "One", + directory: "/tmp/project-one", + }, + }), + ); + const instance = expectSuccess(created, "instance.create"); + const patch = + "Index: src/a.ts\n===================================================================\n--- src/a.ts\n+++ src/a.ts\n@@ -1 +1 @@\n-old\n+new\n"; + registry.diffsBySession.set("session-xyz", [ + { + file: "src/a.ts", + patch, + additions: 3, + deletions: 2, + status: "modified", + }, + ]); + + const response = await daemon.handleRequest( + req({ + id: "session-diffs", + method: "session.diffs", + params: { + instanceId: instance.instance.id, + sessionId: "session-xyz", + }, + }), + ); + + const result = expectSuccess(response, "session.diffs"); + expect(result.diffs).toHaveLength(1); + expect(result.diffs[0]).toEqual({ + file: "src/a.ts", + patch, + additions: 3, + deletions: 2, + status: "modified", + }); + expect(registry.diffCalls).toEqual([ + { + instanceId: instance.instance.id, + sessionId: "session-xyz", + directory: "/tmp/project-one", + }, + ]); + }); + + test("returns empty diffs when session has no changes", async () => { + const created = await daemon.handleRequest( + req({ + id: "instance-create", + method: "instance.create", + params: { + name: "One", + directory: "/tmp/project-one", + }, + }), + ); + const instance = expectSuccess(created, "instance.create"); + + const response = await daemon.handleRequest( + req({ + id: "session-diffs", + method: "session.diffs", + params: { + instanceId: instance.instance.id, + sessionId: "session-empty", + }, + }), + ); + + const result = expectSuccess(response, "session.diffs"); + expect(result.diffs).toEqual([]); + }); + + test("rejects session.diffs for nonexistent instance", async () => { + const response = await daemon.handleRequest( + req({ + id: "session-diffs", + method: "session.diffs", + params: { + instanceId: "nonexistent", + sessionId: "session-xyz", + }, + }), + ); + expect(expectFailure(response).error.code).toBe("not_found"); + }); }); describe("Daemon sessions", () => { diff --git a/packages/daemon/src/__tests__/helpers.ts b/packages/daemon/src/__tests__/helpers.ts index 099d1f7..1cc1b7a 100644 --- a/packages/daemon/src/__tests__/helpers.ts +++ b/packages/daemon/src/__tests__/helpers.ts @@ -5,6 +5,7 @@ import type { OpencodeRuntimeEvent, OpencodeRuntimeManager, } from "../opencode"; +import type { FileDiff } from "../protocol"; function fakeSession(overrides: Partial & { id: string }): Session { return { @@ -74,6 +75,12 @@ export class FakeOpencodeRegistry implements OpencodeRuntimeManager { }> = []; readonly directoriesStarted: string[] = []; readonly disposeCalls: Array<{ directory?: string }> = []; + readonly diffCalls: Array<{ + instanceId: string; + sessionId: string; + directory: string | undefined; + }> = []; + readonly diffsBySession = new Map(); globalMaxConcurrent = 0; /** Configurable per-test: an array of text deltas the fake will emit * via the wired onEvent handler before returning the final response @@ -197,6 +204,14 @@ export class FakeOpencodeRegistry implements OpencodeRuntimeManager { this.abortCalls.push({ instanceId, sessionId: sessionID }); return undefined; }, + diff: async ({ sessionID, directory }) => { + this.diffCalls.push({ + instanceId, + sessionId: sessionID, + directory, + }); + return this.diffsBySession.get(sessionID) ?? []; + }, }, question: { reply: async ({ requestID, directory, answers }) => { diff --git a/packages/daemon/src/__tests__/protocol.test.ts b/packages/daemon/src/__tests__/protocol.test.ts index 80e8386..5499ecb 100644 --- a/packages/daemon/src/__tests__/protocol.test.ts +++ b/packages/daemon/src/__tests__/protocol.test.ts @@ -62,4 +62,103 @@ describe("protocol schemas", () => { expect(parsed.success).toBe(false); }); + + test("parses a valid session.diffs request", () => { + const parsed = RequestMessage.safeParse({ + id: "req-1", + method: "session.diffs", + params: { + instanceId: "instance-1", + sessionId: "session-1", + }, + }); + + expect(parsed.success).toBe(true); + }); + + test("parses a valid session.diffs success response", () => { + const parsed = ResponseMessage.safeParse({ + id: "req-1", + method: "session.diffs", + ok: true, + result: { + diffs: [ + { + file: "src/a.ts", + patch: + "Index: src/a.ts\n===================================================================\n--- src/a.ts\n+++ src/a.ts\n@@ -1 +1 @@\n-old\n+new\n", + additions: 1, + deletions: 1, + status: "modified", + }, + ], + }, + }); + + expect(parsed.success).toBe(true); + }); + + test("rejects session.diffs request missing sessionId", () => { + const parsed = RequestMessage.safeParse({ + id: "req-1", + method: "session.diffs", + params: { + instanceId: "instance-1", + }, + }); + + expect(parsed.success).toBe(false); + }); + + test("rejects session.diffs request with empty instanceId", () => { + const parsed = RequestMessage.safeParse({ + id: "req-1", + method: "session.diffs", + params: { + instanceId: "", + sessionId: "session-1", + }, + }); + + expect(parsed.success).toBe(false); + }); + + test("rejects session.diffs response with negative additions", () => { + const parsed = ResponseMessage.safeParse({ + id: "req-1", + method: "session.diffs", + ok: true, + result: { + diffs: [ + { + file: "src/a.ts", + patch: "", + additions: -1, + deletions: 0, + }, + ], + }, + }); + + expect(parsed.success).toBe(false); + }); + + test("rejects session.diffs response missing patch field", () => { + const parsed = ResponseMessage.safeParse({ + id: "req-1", + method: "session.diffs", + ok: true, + result: { + diffs: [ + { + file: "src/a.ts", + additions: 1, + deletions: 0, + }, + ], + }, + }); + + expect(parsed.success).toBe(false); + }); }); diff --git a/packages/daemon/src/client.ts b/packages/daemon/src/client.ts index d718ba3..5abf802 100644 --- a/packages/daemon/src/client.ts +++ b/packages/daemon/src/client.ts @@ -298,6 +298,12 @@ export class DaemonClient { socket.destroy(); } } + + sessionDiffs(params: ParamsByMethod<"session.diffs">) { + return send(this.socketPath, "session.diffs", params) as Promise< + ResultByMethod<"session.diffs"> + >; + } } export const daemon = new DaemonClient(); diff --git a/packages/daemon/src/opencode.ts b/packages/daemon/src/opencode.ts index f935665..0a518e2 100644 --- a/packages/daemon/src/opencode.ts +++ b/packages/daemon/src/opencode.ts @@ -7,6 +7,8 @@ import { type TextPartInput, } from "@opencode-ai/sdk/v2"; +import type { FileDiff } from "./protocol"; + export interface OpencodeSessionClient { create(parameters: { directory?: string; title?: string }): Promise; prompt(parameters: { @@ -25,6 +27,10 @@ export interface OpencodeSessionClient { sessionID: string; directory?: string; }): Promise; + diff(parameters: { + sessionID: string; + directory?: string; + }): Promise; } export interface ProviderModel { @@ -175,6 +181,13 @@ export class OpencodeRegistry implements OpencodeRuntimeManager { client.session.abort(parameters, { throwOnError: true, }), + diff: async (parameters) => { + const res = await client.session.diff(parameters, { + throwOnError: true, + responseStyle: "data", + }); + return res as unknown as FileDiff[]; + }, }, question: { reply: (parameters) => diff --git a/packages/daemon/src/protocol.ts b/packages/daemon/src/protocol.ts index a50afce..94c6ce2 100644 --- a/packages/daemon/src/protocol.ts +++ b/packages/daemon/src/protocol.ts @@ -59,6 +59,16 @@ const JobSession = z.discriminatedUnion("type", [ ]); export type JobSession = z.infer; +/** A file-level diff produced by the OpenCode SDK for a session. */ +const FileDiff = z.strictObject({ + file: z.string().min(1), + patch: z.string(), + additions: z.int().nonnegative(), + deletions: z.int().nonnegative(), + status: z.enum(["added", "deleted", "modified"]).optional(), +}); +export type FileDiff = z.infer; + /** A registered Claude Code instance the daemon manages. */ const ManagedInstance = z.strictObject({ id: z.string().min(1), @@ -188,6 +198,14 @@ const JobStreamParams = z.strictObject({ }); export type JobStreamParams = z.infer; +// Session operations + +const SessionDiffsParams = z.strictObject({ + instanceId: z.string().min(1), + sessionId: z.string().min(1), +}); +export type SessionDiffsParams = z.infer; + const QuestionAnswer = z.array(z.string().min(1)); export type QuestionAnswer = z.infer; @@ -369,6 +387,13 @@ const ProviderListResult = z.strictObject({ }); export type ProviderListResult = z.infer; +// Session results + +const SessionDiffsResult = z.strictObject({ + diffs: z.array(FileDiff), +}); +export type SessionDiffsResult = z.infer; + // --------------------------------------------------------------------------- // Error schema // --------------------------------------------------------------------------- @@ -417,6 +442,7 @@ const RequestMethod = z.enum([ "job.get", "job.cancel", "job.stream", + "session.diffs", "question.reply", ]); export type RequestMethod = z.infer; @@ -527,6 +553,14 @@ const JobStreamRequest = z.strictObject({ params: JobStreamParams, }); +// Session requests + +const SessionDiffsRequest = z.strictObject({ + id: z.string().min(1), + method: z.literal("session.diffs"), + params: SessionDiffsParams, +}); + const QuestionReplyRequest = z.strictObject({ id: z.string().min(1), method: z.literal("question.reply"), @@ -551,6 +585,7 @@ export const RequestMessage = z.discriminatedUnion("method", [ JobGetRequest, JobCancelRequest, JobStreamRequest, + SessionDiffsRequest, QuestionReplyRequest, ]); export type RequestMessage = z.infer; @@ -681,6 +716,15 @@ const JobStreamSuccess = z.strictObject({ result: StreamAckResult, }); +// Session successes + +const SessionDiffsSuccess = z.strictObject({ + id: z.string().min(1), + method: z.literal("session.diffs"), + ok: z.literal(true), + result: SessionDiffsResult, +}); + const QuestionReplySuccess = z.strictObject({ id: z.string().min(1), method: z.literal("question.reply"), @@ -716,6 +760,7 @@ export const ResponseMessage = z.union([ JobGetSuccess, JobCancelSuccess, JobStreamSuccess, + SessionDiffsSuccess, QuestionReplySuccess, ErrorResponse, ]); diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts index d67a7a7..e947dda 100644 --- a/packages/daemon/src/server.ts +++ b/packages/daemon/src/server.ts @@ -33,6 +33,7 @@ import { type ResponseError, type ResponseMessage, type ResultByMethod, + type SessionDiffsResult, type SessionGetResult, type SessionListResult, type ShutdownResult, @@ -245,6 +246,8 @@ export class Daemon { return this.success(raw, await this.handleJobCancel(raw)); case "job.stream": return this.success(raw, this.handleJobStream(raw)); + case "session.diffs": + return this.success(raw, await this.handleSessionDiffs(raw)); case "question.reply": return this.success(raw, await this.handleQuestionReply(raw)); } @@ -582,6 +585,21 @@ export class Daemon { } } + private async handleSessionDiffs( + request: RequestByMethod<"session.diffs">, + ): Promise { + const instance = await this.startInstance(request.params.instanceId); + const runtime = await this.registry.ensureStarted( + instance.id, + instance.directory, + ); + const diffs = await runtime.client.session.diff({ + sessionID: request.params.sessionId, + directory: instance.directory, + }); + return { diffs }; + } + private routeQuestionToJob( instanceId: string, question: {