diff --git a/.codex/prompts/restart-t3code.md b/.codex/prompts/restart-t3code.md new file mode 100644 index 0000000000..aa1058a6b5 --- /dev/null +++ b/.codex/prompts/restart-t3code.md @@ -0,0 +1,23 @@ +--- +description: Rebuild and restart the PM2-managed t3code app for this repo. +argument-hint: optional target: web or dev +--- + +# Restart T3 Code + +Rebuild and restart the PM2-managed `t3code` processes for this repository. + +When you run this command: + +1. Work from the repo root. +2. Inspect the optional argument. +3. If no argument is provided, or the argument is `web`, run `bun run rebuild:restart -- web`. +4. If the argument is `dev`, run `bun run rebuild:restart -- dev`. +5. Report which target was restarted and include the relevant PM2 process names. + +Target meanings: + +- `web`: rebuilds `apps/web` and restarts the Cloudflare/tunnel-backed PM2 app `t3code-web` +- `dev`: restarts the local PM2 dev apps `t3code-dev-server` and `t3code-dev-web` + +Do not make unrelated code changes as part of this command. diff --git a/apps/server/package.json b/apps/server/package.json index edcb004ded..ba32b5d661 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -35,6 +35,7 @@ "@effect/language-service": "catalog:", "@effect/vitest": "catalog:", "@t3tools/contracts": "workspace:*", + "@t3tools/plugin-sdk": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/web": "workspace:*", "@types/bun": "catalog:", diff --git a/apps/server/src/difitManager.test.ts b/apps/server/src/difitManager.test.ts new file mode 100644 index 0000000000..e5ddc73271 --- /dev/null +++ b/apps/server/src/difitManager.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { ProjectId, ThreadId, type OrchestrationReadModel } from "@t3tools/contracts"; +import { resolveDifitThreadCwd } from "./difitManager"; + +function makeSnapshot(): OrchestrationReadModel { + return { + snapshotSequence: 1, + updatedAt: "2026-03-23T00:00:00.000Z", + projects: [ + { + id: ProjectId.makeUnsafe("project-1"), + title: "Project", + workspaceRoot: "/repo/root", + defaultModel: null, + scripts: [], + createdAt: "2026-03-23T00:00:00.000Z", + updatedAt: "2026-03-23T00:00:00.000Z", + deletedAt: null, + }, + ], + threads: [ + { + id: ThreadId.makeUnsafe("thread-1"), + projectId: ProjectId.makeUnsafe("project-1"), + title: "Thread", + model: "gpt-5-codex", + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: "/repo/worktrees/feature-a", + latestTurn: null, + createdAt: "2026-03-23T00:00:00.000Z", + updatedAt: "2026-03-23T00:00:00.000Z", + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + { + id: ThreadId.makeUnsafe("thread-2"), + projectId: ProjectId.makeUnsafe("project-1"), + title: "Thread 2", + model: "gpt-5-codex", + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-03-23T00:00:00.000Z", + updatedAt: "2026-03-23T00:00:00.000Z", + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + }; +} + +describe("resolveDifitThreadCwd", () => { + it("prefers thread worktree paths", () => { + expect(resolveDifitThreadCwd(makeSnapshot(), "thread-1")).toEqual({ + cwd: "/repo/worktrees/feature-a", + source: "worktree", + }); + }); + + it("falls back to project workspace root", () => { + expect(resolveDifitThreadCwd(makeSnapshot(), "thread-2")).toEqual({ + cwd: "/repo/root", + source: "project", + }); + }); + + it("returns thread_not_found for unknown threads", () => { + expect(resolveDifitThreadCwd(makeSnapshot(), "thread-404")).toEqual({ + cwd: null, + source: "none", + reason: "thread_not_found", + }); + }); +}); diff --git a/apps/server/src/difitManager.ts b/apps/server/src/difitManager.ts new file mode 100644 index 0000000000..e16e2cf740 --- /dev/null +++ b/apps/server/src/difitManager.ts @@ -0,0 +1,459 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { IncomingMessage, ServerResponse } from "node:http"; + +import type { + DifitCloseResult, + DifitProcessDiagnostics, + DifitOpenFailureReason, + DifitOpenInput, + DifitOpenResult, + DifitState, + DifitStatusResult, + OrchestrationReadModel, +} from "@t3tools/contracts"; +import { buildDifitProxyBasePath, matchDifitProxyPath, proxyDifitRequest } from "./difitProxy"; + +const DIFIT_VERSION = "3.1.17"; +const DIFIT_READY_TIMEOUT_MS = 15_000; +const DIFIT_READY_POLL_INTERVAL_MS = 250; +const LOOPBACK_HOST = "127.0.0.1"; +const UNTRACKED_FILES_PROMPT = + "Would you like to include these untracked files in the diff review?"; + +type Logger = (message: string, context?: Record) => void; + +interface ResolvedDifitCwd { + readonly cwd: string | null; + readonly source: "worktree" | "project" | "none"; + readonly reason?: Extract; +} + +export interface DifitManagerDependencies { + readonly getSnapshot: () => Promise; + readonly reserveLoopbackPort: () => Promise; + readonly fetchImpl?: typeof fetch; + readonly spawnImpl?: typeof spawn; + readonly logger?: Logger; +} + +interface DifitRuntimeState { + readonly state: DifitState; + readonly cwd: string | null; + readonly proxyPath: string | null; + readonly sessionRevision: string | null; + readonly reason?: DifitOpenFailureReason; + readonly diagnostics?: DifitProcessDiagnostics; +} + +interface ActiveProcessState { + readonly child: ChildProcessWithoutNullStreams; + readonly port: number; + readonly cwd: string; + readonly sessionRevision: string; + readonly spawnToken: string; + readonly diagnostics: { + stdoutTail: string; + stderrTail: string; + exitCode: number | null; + signal: NodeJS.Signals | null; + }; +} + +interface MutableDifitProcessDiagnostics { + stdoutTail: string; + stderrTail: string; + exitCode: number | null; + signal: NodeJS.Signals | null; +} + +export interface DifitManager { + readonly open: (input: DifitOpenInput) => Promise; + readonly close: () => Promise; + readonly status: () => Promise; + readonly handleProxyRequest: (input: { + request: IncomingMessage; + response: ServerResponse; + url: URL; + }) => Promise; +} + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function appendTail(existing: string, nextChunk: string): string { + return `${existing}${nextChunk}`.slice(-4_096); +} + +function trimTail(value: string): string | undefined { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function toDifitDiagnostics(input: { + stdoutTail: string; + stderrTail: string; + exitCode: number | null; + signal: NodeJS.Signals | null; +}): DifitProcessDiagnostics | undefined { + const diagnostics = { + ...(input.exitCode !== null ? { exitCode: input.exitCode } : {}), + ...(input.signal ? { signal: input.signal } : {}), + ...(trimTail(input.stdoutTail) ? { stdoutTail: trimTail(input.stdoutTail)! } : {}), + ...(trimTail(input.stderrTail) ? { stderrTail: trimTail(input.stderrTail)! } : {}), + } satisfies Partial; + + return Object.keys(diagnostics).length > 0 ? (diagnostics as DifitProcessDiagnostics) : undefined; +} + +function normalizeDifitLauncherCommand(): string { + return process.platform === "win32" ? "bunx.cmd" : "bunx"; +} + +function buildDifitLauncherArgs(port: number): string[] { + return [ + "--bun", + `difit@${DIFIT_VERSION}`, + ".", + "--host", + LOOPBACK_HOST, + "--port", + String(port), + "--no-open", + "--keep-alive", + ]; +} + +export function resolveDifitThreadCwd( + snapshot: OrchestrationReadModel, + threadId: string, +): ResolvedDifitCwd { + const thread = snapshot.threads.find( + (entry) => entry.id === threadId && entry.deletedAt === null, + ); + if (!thread) { + return { cwd: null, source: "none", reason: "thread_not_found" }; + } + if (thread.worktreePath) { + return { cwd: thread.worktreePath, source: "worktree" }; + } + const project = snapshot.projects.find( + (entry) => entry.id === thread.projectId && entry.deletedAt === null, + ); + if (!project) { + return { cwd: null, source: "none", reason: "thread_not_found" }; + } + if (project.workspaceRoot) { + return { cwd: project.workspaceRoot, source: "project" }; + } + return { cwd: null, source: "none", reason: "no_active_worktree" }; +} + +export function createDifitManager(dependencies: DifitManagerDependencies): DifitManager { + const fetchImpl = dependencies.fetchImpl ?? fetch; + const spawnImpl = dependencies.spawnImpl ?? spawn; + const logger = dependencies.logger ?? (() => undefined); + + let activeProcess: ActiveProcessState | null = null; + let runtimeState: DifitRuntimeState = { + state: "idle", + cwd: null, + proxyPath: null, + sessionRevision: null, + }; + let queue = Promise.resolve(); + + const setRuntimeState = (next: DifitRuntimeState) => { + runtimeState = next; + }; + + const terminateActiveProcess = async (): Promise => { + const processState = activeProcess; + if (!processState) { + setRuntimeState({ + state: "idle", + cwd: null, + proxyPath: null, + sessionRevision: null, + }); + return; + } + activeProcess = null; + if (processState.child.exitCode !== null || processState.child.signalCode !== null) { + setRuntimeState({ + state: "idle", + cwd: null, + proxyPath: null, + sessionRevision: null, + }); + return; + } + await new Promise((resolve) => { + processState.child.once("exit", () => resolve()); + processState.child.kill("SIGTERM"); + const killTimer = setTimeout(() => { + if (processState.child.exitCode === null && processState.child.signalCode === null) { + processState.child.kill("SIGKILL"); + } + resolve(); + }, 1_000); + killTimer.unref(); + }); + setRuntimeState({ + state: "idle", + cwd: null, + proxyPath: null, + sessionRevision: null, + }); + }; + + const waitUntilReady = async ( + processState: ActiveProcessState, + ): Promise => { + const startedAt = Date.now(); + while (Date.now() - startedAt < DIFIT_READY_TIMEOUT_MS) { + if (activeProcess?.spawnToken !== processState.spawnToken) { + return "process_exited"; + } + try { + const response = await fetchImpl(`http://${LOOPBACK_HOST}:${processState.port}/api/diff`); + if (response.ok) { + return null; + } + } catch { + // Continue polling until timeout or process exit. + } + await delay(DIFIT_READY_POLL_INTERVAL_MS); + } + return "startup_timeout"; + }; + + const spawnDifit = async (cwd: string): Promise => { + const port = await dependencies.reserveLoopbackPort(); + const sessionRevision = crypto.randomUUID(); + const proxyPath = `${buildDifitProxyBasePath(sessionRevision)}/`; + const spawnToken = crypto.randomUUID(); + const command = normalizeDifitLauncherCommand(); + const args = buildDifitLauncherArgs(port); + + logger("starting difit process", { cwd, port, version: DIFIT_VERSION }); + + let child: ChildProcessWithoutNullStreams; + try { + child = spawnImpl(command, args, { + cwd, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch (error) { + logger("failed to spawn difit process", { cwd, error: String(error) }); + setRuntimeState({ + state: "error", + cwd, + proxyPath: null, + sessionRevision: null, + reason: "spawn_failed", + }); + return { ok: false, reason: "spawn_failed" }; + } + + let stdoutBuffer = ""; + let answeredUntrackedPrompt = false; + const processDiagnostics: MutableDifitProcessDiagnostics = { + stdoutTail: "", + stderrTail: "", + exitCode: null, + signal: null, + }; + child.stdout.on("data", (chunk) => { + const data = chunk.toString(); + stdoutBuffer = appendTail(stdoutBuffer, data); + processDiagnostics.stdoutTail = appendTail(processDiagnostics.stdoutTail, data); + logger("difit stdout", { cwd, data: data.trim() }); + if (!answeredUntrackedPrompt && stdoutBuffer.includes(UNTRACKED_FILES_PROMPT)) { + answeredUntrackedPrompt = true; + child.stdin.write("n\n"); + logger("difit prompt answered", { cwd, answer: "n" }); + } + }); + child.stderr.on("data", (chunk) => { + const data = chunk.toString(); + processDiagnostics.stderrTail = appendTail(processDiagnostics.stderrTail, data); + logger("difit stderr", { cwd, data: data.trim() }); + }); + + const processState: ActiveProcessState = { + child, + port, + cwd, + sessionRevision, + spawnToken, + diagnostics: processDiagnostics, + }; + activeProcess = processState; + setRuntimeState({ + state: "starting", + cwd, + proxyPath, + sessionRevision, + }); + + child.once("exit", (code, signal) => { + processDiagnostics.exitCode = code; + processDiagnostics.signal = signal; + const diagnostics = toDifitDiagnostics(processDiagnostics); + if (activeProcess?.spawnToken !== spawnToken) { + return; + } + activeProcess = null; + setRuntimeState({ + state: "error", + cwd, + proxyPath, + sessionRevision, + reason: "process_exited", + ...(diagnostics ? { diagnostics } : {}), + }); + logger("difit process exited", { cwd, code, signal, diagnostics }); + }); + child.once("error", (error) => { + const diagnostics = toDifitDiagnostics(processDiagnostics); + if (activeProcess?.spawnToken !== spawnToken) { + return; + } + activeProcess = null; + setRuntimeState({ + state: "error", + cwd, + proxyPath, + sessionRevision, + reason: "spawn_failed", + ...(diagnostics ? { diagnostics } : {}), + }); + logger("difit process error", { cwd, error: String(error), diagnostics }); + }); + + const readinessFailure = await waitUntilReady(processState); + if (readinessFailure) { + const diagnostics = toDifitDiagnostics(processDiagnostics); + await terminateActiveProcess(); + setRuntimeState({ + state: "error", + cwd, + proxyPath, + sessionRevision, + reason: readinessFailure, + ...(diagnostics ? { diagnostics } : {}), + }); + logger("difit readiness failed", { cwd, reason: readinessFailure, diagnostics }); + return { + ok: false, + reason: readinessFailure, + ...(diagnostics ? { diagnostics } : {}), + }; + } + + if (activeProcess?.spawnToken !== spawnToken) { + const diagnostics = toDifitDiagnostics(processDiagnostics); + setRuntimeState({ + state: "error", + cwd, + proxyPath, + sessionRevision, + reason: "process_exited", + ...(diagnostics ? { diagnostics } : {}), + }); + return { + ok: false, + reason: "process_exited", + ...(diagnostics ? { diagnostics } : {}), + }; + } + + setRuntimeState({ + state: "ready", + cwd, + proxyPath, + sessionRevision, + }); + logger("difit process ready", { cwd, port }); + return { + ok: true, + cwd, + proxyPath, + sessionRevision, + }; + }; + + const runExclusive = async (operation: () => Promise): Promise => { + const next = queue.catch(() => undefined).then(operation); + queue = next.then( + () => undefined, + () => undefined, + ); + return next; + }; + + return { + open: (input) => + runExclusive(async () => { + const snapshot = await dependencies.getSnapshot(); + const resolved = resolveDifitThreadCwd(snapshot, input.threadId); + if (!resolved.cwd) { + return { + ok: false, + reason: resolved.reason ?? "no_active_worktree", + ...(runtimeState.diagnostics ? { diagnostics: runtimeState.diagnostics } : {}), + }; + } + + if (activeProcess && runtimeState.state === "ready" && activeProcess.cwd === resolved.cwd) { + return { + ok: true, + cwd: activeProcess.cwd, + proxyPath: `${buildDifitProxyBasePath(activeProcess.sessionRevision)}/`, + sessionRevision: activeProcess.sessionRevision, + }; + } + + if (activeProcess) { + await terminateActiveProcess(); + } + + return spawnDifit(resolved.cwd); + }), + close: () => + runExclusive(async () => { + await terminateActiveProcess(); + return { ok: true }; + }), + status: () => + Promise.resolve({ + state: runtimeState.state, + ...(runtimeState.cwd ? { cwd: runtimeState.cwd } : {}), + ...(runtimeState.proxyPath ? { proxyPath: runtimeState.proxyPath } : {}), + ...(runtimeState.reason ? { reason: runtimeState.reason } : {}), + ...(runtimeState.diagnostics ? { diagnostics: runtimeState.diagnostics } : {}), + }), + handleProxyRequest: async ({ request, response, url }) => { + const match = matchDifitProxyPath(url.pathname); + if (!match) { + return false; + } + if ( + runtimeState.state !== "ready" || + !activeProcess || + activeProcess.sessionRevision !== match.sessionRevision + ) { + response.writeHead(503, { "Content-Type": "text/plain" }); + response.end("Difit session unavailable"); + return true; + } + await proxyDifitRequest({ + request, + response, + url, + targetPort: activeProcess.port, + proxyBasePath: buildDifitProxyBasePath(activeProcess.sessionRevision), + }); + return true; + }, + }; +} diff --git a/apps/server/src/difitProxy.test.ts b/apps/server/src/difitProxy.test.ts new file mode 100644 index 0000000000..54afded28a --- /dev/null +++ b/apps/server/src/difitProxy.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { + buildDifitProxyBasePath, + matchDifitProxyPath, + rewriteDifitDocumentHtml, + rewriteDifitTextAsset, +} from "./difitProxy"; + +describe("difitProxy", () => { + it("matches proxied difit paths", () => { + expect(matchDifitProxyPath("/__difit/rev-1/api/diff")).toEqual({ + sessionRevision: "rev-1", + targetPath: "/api/diff", + }); + expect(matchDifitProxyPath("/assets/index.js")).toBeNull(); + }); + + it("rewrites html document roots and injects proxy shim", () => { + const proxyBasePath = buildDifitProxyBasePath("rev-1"); + const html = ``; + const rewritten = rewriteDifitDocumentHtml(html, proxyBasePath); + + expect(rewritten).toContain(`${proxyBasePath}/favicon.svg`); + expect(rewritten).toContain(`${proxyBasePath}/assets/app.js`); + expect(rewritten).toContain("XMLHttpRequest.prototype.open"); + }); + + it("rewrites text assets with root-relative api paths", () => { + const rewritten = rewriteDifitTextAsset( + `fetch("/api/diff"); import("/assets/app.js");`, + buildDifitProxyBasePath("rev-1"), + "text/javascript", + ); + + expect(rewritten).toContain(`fetch("/__difit/rev-1/api/diff")`); + expect(rewritten).toContain(`import("/__difit/rev-1/assets/app.js")`); + }); +}); diff --git a/apps/server/src/difitProxy.ts b/apps/server/src/difitProxy.ts new file mode 100644 index 0000000000..dfc3252d9f --- /dev/null +++ b/apps/server/src/difitProxy.ts @@ -0,0 +1,197 @@ +import http from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; + +export const DIFIT_PROXY_PATH_PREFIX = "/__difit"; + +export interface DifitProxyMatch { + readonly sessionRevision: string; + readonly targetPath: string; +} + +export interface ProxyDifitRequestOptions { + readonly request: IncomingMessage; + readonly response: ServerResponse; + readonly url: URL; + readonly targetPort: number; + readonly proxyBasePath: string; +} + +function rewriteQuotedAbsolutePaths(source: string, proxyBasePath: string): string { + return source.replace( + /(["'`])\/(assets\/|api\/|favicon(?:-white)?\.svg)/g, + (_match, quote, path) => `${quote}${proxyBasePath}/${path}`, + ); +} + +function buildDifitProxyShim(proxyBasePath: string): string { + return ``; +} + +export function buildDifitProxyBasePath(sessionRevision: string): string { + return `${DIFIT_PROXY_PATH_PREFIX}/${encodeURIComponent(sessionRevision)}`; +} + +export function matchDifitProxyPath(pathname: string): DifitProxyMatch | null { + if (!pathname.startsWith(`${DIFIT_PROXY_PATH_PREFIX}/`)) { + return null; + } + const suffix = pathname.slice(DIFIT_PROXY_PATH_PREFIX.length + 1); + const slashIndex = suffix.indexOf("/"); + if (slashIndex < 0) { + return { + sessionRevision: decodeURIComponent(suffix), + targetPath: "/", + }; + } + const sessionRevision = decodeURIComponent(suffix.slice(0, slashIndex)); + const remainder = suffix.slice(slashIndex); + return { + sessionRevision, + targetPath: remainder.length > 0 ? remainder : "/", + }; +} + +export function rewriteDifitDocumentHtml(input: string, proxyBasePath: string): string { + const rewritten = rewriteQuotedAbsolutePaths(input, proxyBasePath); + const shim = buildDifitProxyShim(proxyBasePath); + if (rewritten.includes("")) { + return rewritten.replace("", `${shim}`); + } + return `${shim}${rewritten}`; +} + +export function rewriteDifitTextAsset( + input: string, + proxyBasePath: string, + contentType: string | null, +): string { + if (contentType?.includes("text/html")) { + return rewriteDifitDocumentHtml(input, proxyBasePath); + } + return rewriteQuotedAbsolutePaths(input, proxyBasePath); +} + +function shouldRewriteResponse(headers: http.IncomingHttpHeaders): boolean { + const contentEncoding = headers["content-encoding"]; + if (typeof contentEncoding === "string" && contentEncoding.length > 0) { + return false; + } + const contentType = headers["content-type"]; + if (typeof contentType !== "string") { + return false; + } + return ( + contentType.includes("text/html") || + contentType.includes("javascript") || + contentType.includes("text/css") + ); +} + +function copyProxyHeaders( + headers: http.IncomingHttpHeaders, + rewrittenBody?: Buffer, +): http.OutgoingHttpHeaders { + const nextHeaders: http.OutgoingHttpHeaders = { ...headers }; + delete nextHeaders["content-length"]; + delete nextHeaders["transfer-encoding"]; + delete nextHeaders.connection; + if (rewrittenBody) { + nextHeaders["content-length"] = Buffer.byteLength(rewrittenBody); + } + return nextHeaders; +} + +function filterRequestHeaders(headers: IncomingMessage["headers"], targetPort: number) { + const nextHeaders: http.OutgoingHttpHeaders = { ...headers }; + nextHeaders.host = `127.0.0.1:${targetPort}`; + delete nextHeaders.connection; + return nextHeaders; +} + +export async function proxyDifitRequest(options: ProxyDifitRequestOptions): Promise { + const { request, response, url, targetPort, proxyBasePath } = options; + const match = matchDifitProxyPath(url.pathname); + if (!match) { + response.writeHead(404, { "Content-Type": "text/plain" }); + response.end("Not Found"); + return; + } + + await new Promise((resolve) => { + const proxyRequest = http.request( + { + hostname: "127.0.0.1", + port: targetPort, + method: request.method, + path: `${match.targetPath}${url.search}`, + headers: filterRequestHeaders(request.headers, targetPort), + }, + (proxyResponse) => { + const shouldRewrite = shouldRewriteResponse(proxyResponse.headers); + if (!shouldRewrite || request.method === "HEAD") { + response.writeHead( + proxyResponse.statusCode ?? 502, + copyProxyHeaders(proxyResponse.headers), + ); + proxyResponse.pipe(response); + proxyResponse.on("end", () => resolve()); + return; + } + + const chunks: Buffer[] = []; + proxyResponse.on("data", (chunk: Buffer) => { + chunks.push(chunk); + }); + proxyResponse.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + const rewrittenBody = Buffer.from( + rewriteDifitTextAsset( + text, + proxyBasePath, + typeof proxyResponse.headers["content-type"] === "string" + ? proxyResponse.headers["content-type"] + : null, + ), + ); + response.writeHead( + proxyResponse.statusCode ?? 502, + copyProxyHeaders(proxyResponse.headers, rewrittenBody), + ); + response.end(rewrittenBody); + resolve(); + }); + proxyResponse.on("error", () => { + if (!response.headersSent) { + response.writeHead(502, { "Content-Type": "text/plain" }); + } + response.end("Bad Gateway"); + resolve(); + }); + }, + ); + + const closeProxyRequest = () => { + proxyRequest.destroy(); + }; + + request.on("aborted", closeProxyRequest); + response.on("close", closeProxyRequest); + + proxyRequest.on("error", () => { + if (!response.headersSent) { + response.writeHead(502, { "Content-Type": "text/plain" }); + } + response.end("Bad Gateway"); + resolve(); + }); + + if (request.readableEnded || request.method === "GET" || request.method === "HEAD") { + proxyRequest.end(); + return; + } + + request.pipe(proxyRequest); + }); +} diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index bf58467825..96fd82cc20 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -70,6 +70,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, + { key: "alt+g", command: "difit.toggle", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, diff --git a/apps/server/src/plugins/discovery.ts b/apps/server/src/plugins/discovery.ts new file mode 100644 index 0000000000..020f6d5f2a --- /dev/null +++ b/apps/server/src/plugins/discovery.ts @@ -0,0 +1,148 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { DiscoveredPluginManifest, DiscoveredPluginRoot } from "./types"; + +const PLUGINS_ENV_VAR = "T3CODE_PLUGIN_DIRS"; +const DEFAULT_LOCAL_PLUGINS_DIR = "plugins"; +const PLUGIN_MANIFEST_FILE = "t3-plugin.json"; + +interface RawPluginManifest { + readonly id?: unknown; + readonly name?: unknown; + readonly version?: unknown; + readonly hostApiVersion?: unknown; + readonly enabled?: unknown; + readonly serverEntry?: unknown; + readonly webEntry?: unknown; +} + +function trimNonEmpty(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +async function pathExists(candidatePath: string): Promise { + try { + await fs.access(candidatePath); + return true; + } catch { + return false; + } +} + +async function isDirectory(candidatePath: string): Promise { + try { + return (await fs.stat(candidatePath)).isDirectory(); + } catch { + return false; + } +} + +function normalizePluginRoots(cwd: string): string[] { + const configuredRoots = (process.env[PLUGINS_ENV_VAR] ?? "") + .split(path.delimiter) + .map((value) => value.trim()) + .filter((value) => value.length > 0) + .map((value) => path.resolve(value)); + const localRoot = path.resolve(cwd, DEFAULT_LOCAL_PLUGINS_DIR); + return Array.from(new Set([localRoot, ...configuredRoots])); +} + +async function discoverRootCandidates(rootPath: string): Promise { + if (!(await isDirectory(rootPath))) { + return []; + } + + const directManifestPath = path.join(rootPath, PLUGIN_MANIFEST_FILE); + if (await pathExists(directManifestPath)) { + return [{ rootDir: rootPath, manifestPath: directManifestPath }]; + } + + const entries = await fs.readdir(rootPath, { withFileTypes: true }).catch(() => []); + const childCandidates = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + rootDir: path.join(rootPath, entry.name), + manifestPath: path.join(rootPath, entry.name, PLUGIN_MANIFEST_FILE), + })); + + const existingCandidates = await Promise.all( + childCandidates.map(async (candidate) => + (await pathExists(candidate.manifestPath)) ? candidate : null, + ), + ); + + return existingCandidates.filter( + (candidate): candidate is DiscoveredPluginRoot => candidate !== null, + ); +} + +export async function discoverPluginRoots(cwd: string): Promise { + const rootCandidates = await Promise.all( + normalizePluginRoots(cwd).map((rootPath) => discoverRootCandidates(rootPath)), + ); + + const flatCandidates = rootCandidates.flat(); + const existingCandidates = await Promise.all( + flatCandidates.map(async (candidate) => + (await pathExists(candidate.manifestPath)) ? candidate : null, + ), + ); + + return existingCandidates.filter( + (candidate): candidate is DiscoveredPluginRoot => candidate !== null, + ); +} + +export async function loadPluginManifest( + root: DiscoveredPluginRoot, +): Promise { + const rawManifest = await fs + .readFile(root.manifestPath, "utf8") + .then((contents) => JSON.parse(contents) as RawPluginManifest) + .catch(() => ({}) as RawPluginManifest); + + const fallbackId = path.basename(root.rootDir); + const id = trimNonEmpty(rawManifest.id) ?? fallbackId; + const name = trimNonEmpty(rawManifest.name) ?? id; + const version = trimNonEmpty(rawManifest.version) ?? "0.0.0"; + const hostApiVersion = trimNonEmpty(rawManifest.hostApiVersion) ?? "unknown"; + const enabled = rawManifest.enabled !== false; + const serverEntry = trimNonEmpty(rawManifest.serverEntry) ?? "dist/server.js"; + const webEntry = trimNonEmpty(rawManifest.webEntry) ?? "dist/web.js"; + const serverEntryPath = (await pathExists(path.resolve(root.rootDir, serverEntry))) + ? path.resolve(root.rootDir, serverEntry) + : null; + const webEntryPath = (await pathExists(path.resolve(root.rootDir, webEntry))) + ? path.resolve(root.rootDir, webEntry) + : null; + + let error: string | null = null; + if (!trimNonEmpty(rawManifest.id)) { + error = "Plugin manifest is missing a valid 'id'."; + } else if (!trimNonEmpty(rawManifest.name)) { + error = "Plugin manifest is missing a valid 'name'."; + } else if (!trimNonEmpty(rawManifest.version)) { + error = "Plugin manifest is missing a valid 'version'."; + } else if (!trimNonEmpty(rawManifest.hostApiVersion)) { + error = "Plugin manifest is missing a valid 'hostApiVersion'."; + } + + return { + id, + name, + version, + hostApiVersion, + enabled, + compatible: hostApiVersion === "1" && error === null, + rootDir: root.rootDir, + manifestPath: root.manifestPath, + serverEntryPath, + webEntryPath, + error, + }; +} diff --git a/apps/server/src/plugins/manager.ts b/apps/server/src/plugins/manager.ts new file mode 100644 index 0000000000..6d71f3e18d --- /dev/null +++ b/apps/server/src/plugins/manager.ts @@ -0,0 +1,420 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { Exit, Schema } from "effect"; + +import type { PluginBootstrap, PluginListItem } from "@t3tools/contracts"; +import type { + ServerPluginContext, + ServerPluginFactory, + ServerPluginProcedure, +} from "@t3tools/plugin-sdk"; +import { formatSchemaError } from "@t3tools/shared/schemaJson"; + +import { createLogger } from "../logger"; +import { listAvailableSkills } from "../skills"; +import { searchWorkspaceEntries } from "../workspaceEntries"; +import { discoverPluginRoots, loadPluginManifest } from "./discovery"; +import type { DiscoveredPluginManifest, LoadedPluginProcedure, LoadedPluginState } from "./types"; + +interface PluginModuleShape { + readonly default?: ServerPluginFactory | undefined; + readonly activateServer?: ServerPluginFactory | undefined; +} + +function resolveServerActivator(module: PluginModuleShape): ServerPluginFactory | null { + if (typeof module.default === "function") { + return module.default; + } + if (typeof module.activateServer === "function") { + return module.activateServer; + } + return null; +} + +export interface PluginManager { + readonly getBootstrap: () => PluginBootstrap; + readonly callProcedure: ( + pluginId: string, + procedureName: string, + payload: unknown, + ) => Promise; + readonly getWebEntry: (pluginId: string) => { filePath: string; version: string } | null; + readonly subscribeToRegistryUpdates: (listener: (ids: string[]) => void) => () => void; + readonly close: () => Promise; +} + +function comparePluginListItems(left: PluginListItem, right: PluginListItem): number { + return left.id.localeCompare(right.id); +} + +function safeMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + return String(error); +} + +async function maybeCallCleanup(cleanup: (() => void | Promise) | undefined): Promise { + if (cleanup) { + await cleanup(); + } +} + +function decodeProcedureInput( + pluginId: string, + procedureName: string, + schema: ServerPluginProcedure["input"], + payload: unknown, +): unknown { + const result = Schema.decodeUnknownExit(schema as never)(payload); + if (Exit.isFailure(result)) { + throw new Error( + `Invalid plugin procedure input for '${pluginId}.${procedureName}': ${formatSchemaError(result.cause)}`, + ); + } + return result.value; +} + +function encodeProcedureOutput( + pluginId: string, + procedureName: string, + schema: ServerPluginProcedure["output"], + output: unknown, +): unknown { + const result = Schema.encodeUnknownExit(schema as never)(output); + if (Exit.isFailure(result)) { + throw new Error( + `Invalid plugin procedure output for '${pluginId}.${procedureName}': ${formatSchemaError(result.cause)}`, + ); + } + return result.value; +} + +function pluginListItemFromManifest( + manifest: DiscoveredPluginManifest, + input: { + webVersion: string; + error?: string | null; + }, +): PluginListItem { + const effectiveError = input.error ?? manifest.error; + return { + id: manifest.id, + name: manifest.name, + version: manifest.version, + hostApiVersion: manifest.hostApiVersion, + enabled: manifest.enabled, + compatible: manifest.compatible, + hasServer: manifest.serverEntryPath !== null, + hasWeb: manifest.webEntryPath !== null, + ...(manifest.webEntryPath !== null && + manifest.enabled && + manifest.compatible && + effectiveError === null + ? { + webUrl: `/__plugins/${encodeURIComponent(manifest.id)}/web.js?v=${encodeURIComponent(input.webVersion)}`, + } + : {}), + ...(effectiveError ? { error: effectiveError } : {}), + }; +} + +export async function createPluginManager(input: { cwd: string }): Promise { + const logger = createLogger("plugins"); + const pluginStates = new Map(); + const updateListeners = new Set<(ids: string[]) => void>(); + const watchers: fs.FSWatcher[] = []; + const reloadTimers = new Map>(); + const rootWatchTimers = new Map>(); + const pluginStorageRoot = path.resolve(input.cwd, ".t3", "plugins"); + + const notifyUpdated = (ids: string[]) => { + if (ids.length === 0) { + return; + } + for (const listener of updateListeners) { + try { + listener(ids); + } catch { + // Swallow listener errors. + } + } + }; + + const clearWatchers = () => { + for (const watcher of watchers) { + watcher.close(); + } + watchers.length = 0; + }; + + const unloadPlugin = async (state: LoadedPluginState) => { + const cleanupTasks = [...state.cleanup]; + state.cleanup.length = 0; + state.procedures.clear(); + await Promise.all(cleanupTasks.map((cleanup) => maybeCallCleanup(cleanup))); + }; + + const activatePlugin = async (manifest: DiscoveredPluginManifest): Promise => { + const webVersion = `${Date.now()}`; + const state: LoadedPluginState = { + manifest, + listItem: pluginListItemFromManifest(manifest, { webVersion, error: manifest.error }), + procedures: new Map(), + cleanup: [], + webVersion, + }; + + if (!manifest.enabled) { + return state; + } + + if (!manifest.compatible) { + const error = + manifest.error ?? + `Unsupported hostApiVersion '${manifest.hostApiVersion}' for plugin '${manifest.id}'.`; + logger.warn(`[${manifest.id}] ${error}`); + state.listItem = pluginListItemFromManifest(manifest, { webVersion, error }); + return state; + } + + if (!manifest.serverEntryPath) { + return state; + } + + const pluginLog = { + info: (...args: unknown[]) => + logger.info(`[${manifest.id}] ${args.map(safeMessage).join(" ")}`), + warn: (...args: unknown[]) => + logger.warn(`[${manifest.id}] ${args.map(safeMessage).join(" ")}`), + error: (...args: unknown[]) => + logger.error(`[${manifest.id}] ${args.map(safeMessage).join(" ")}`), + }; + + await fs.promises.mkdir(path.join(pluginStorageRoot, manifest.id), { recursive: true }); + + const context: ServerPluginContext = { + pluginId: manifest.id, + registerProcedure: (procedure) => { + state.procedures.set(procedure.name, { + pluginId: manifest.id, + procedure, + } satisfies LoadedPluginProcedure); + return () => { + state.procedures.delete(procedure.name); + }; + }, + onDispose: (cleanup) => { + state.cleanup.push(cleanup); + }, + host: { + log: pluginLog, + pluginStorageDir: path.join(pluginStorageRoot, manifest.id), + skills: { + list: ({ cwd }) => listAvailableSkills(cwd ? { cwd } : {}), + }, + projects: { + searchEntries: ({ cwd, query, limit }) => + searchWorkspaceEntries({ + cwd, + query: query ?? "", + limit: typeof limit === "number" ? limit : 100, + }), + }, + }, + }; + + try { + const module = (await import( + `${pathToFileURL(manifest.serverEntryPath).href}?v=${Date.now()}` + )) as PluginModuleShape; + const activate = resolveServerActivator(module); + if (activate) { + pluginLog.info("activating"); + const maybeCleanup = await activate(context); + if (typeof maybeCleanup === "function") { + state.cleanup.push(maybeCleanup); + } + } + pluginLog.info("activated"); + } catch (error) { + const message = safeMessage(error); + pluginLog.error("activation failed", message); + await unloadPlugin(state); + state.listItem = pluginListItemFromManifest(manifest, { + webVersion, + error: `Plugin activation failed: ${message}`, + }); + } + + return state; + }; + + const scheduleFullReload = (reasonKey: string) => { + const existing = rootWatchTimers.get(reasonKey); + if (existing) { + clearTimeout(existing); + } + rootWatchTimers.set( + reasonKey, + setTimeout(() => { + rootWatchTimers.delete(reasonKey); + void reloadAllPlugins(); + }, 120), + ); + }; + + const watchTarget = (watchPath: string, onChange: () => void) => { + try { + const watcher = fs.watch(watchPath, () => { + onChange(); + }); + watchers.push(watcher); + } catch (error) { + logger.warn(`failed to watch plugin path '${watchPath}': ${safeMessage(error)}`); + } + }; + + const reloadPlugin = async (pluginId: string) => { + logger.info(`[${pluginId}] reloading`); + await reloadAllPlugins([pluginId]); + }; + + const installWatches = async () => { + clearWatchers(); + + const localAndEnvRoots = Array.from( + new Set([ + path.resolve(input.cwd, "plugins"), + ...(process.env.T3CODE_PLUGIN_DIRS ?? "") + .split(path.delimiter) + .map((value) => value.trim()) + .filter((value) => value.length > 0) + .map((value) => path.resolve(value)), + ]), + ); + + for (const rootPath of localAndEnvRoots) { + watchTarget(rootPath, () => scheduleFullReload(rootPath)); + } + + for (const [pluginId, state] of pluginStates) { + const watchPaths = new Set( + [ + state.manifest.rootDir, + state.manifest.manifestPath, + state.manifest.serverEntryPath, + state.manifest.webEntryPath, + ].flatMap((value) => (value ? [value] : [])), + ); + for (const watchPath of watchPaths) { + watchTarget(watchPath, () => { + const existing = reloadTimers.get(pluginId); + if (existing) { + clearTimeout(existing); + } + reloadTimers.set( + pluginId, + setTimeout(() => { + reloadTimers.delete(pluginId); + void reloadPlugin(pluginId); + }, 120), + ); + }); + } + } + }; + + const reloadAllPlugins = async (preferredIds?: string[]) => { + const previousIds = [...pluginStates.keys()]; + const previousStates = [...pluginStates.values()]; + for (const state of previousStates) { + await unloadPlugin(state); + } + pluginStates.clear(); + + const discoveredRoots = await discoverPluginRoots(input.cwd); + const manifests = await Promise.all(discoveredRoots.map((root) => loadPluginManifest(root))); + for (const manifest of manifests.toSorted((left, right) => left.id.localeCompare(right.id))) { + const state = await activatePlugin(manifest); + pluginStates.set(manifest.id, state); + } + + await installWatches(); + + const nextIds = [...pluginStates.keys()]; + const changedIds = + preferredIds && preferredIds.length > 0 + ? preferredIds + : Array.from(new Set([...previousIds, ...nextIds])).toSorted((left, right) => + left.localeCompare(right), + ); + notifyUpdated(changedIds); + }; + + await reloadAllPlugins(); + + return { + getBootstrap: () => ({ + plugins: [...pluginStates.values()] + .map((state) => state.listItem) + .toSorted(comparePluginListItems), + }), + callProcedure: async (pluginId, procedureName, payload) => { + const pluginState = pluginStates.get(pluginId); + if (!pluginState) { + throw new Error(`Unknown plugin '${pluginId}'.`); + } + const loadedProcedure = pluginState.procedures.get(procedureName); + if (!loadedProcedure) { + throw new Error(`Unknown plugin procedure '${procedureName}' for plugin '${pluginId}'.`); + } + + const decodedInput = decodeProcedureInput( + pluginId, + procedureName, + loadedProcedure.procedure.input, + payload, + ); + const output = await loadedProcedure.procedure.handler(decodedInput); + return encodeProcedureOutput( + pluginId, + procedureName, + loadedProcedure.procedure.output, + output, + ); + }, + getWebEntry: (pluginId) => { + const state = pluginStates.get(pluginId); + if (!state?.manifest.webEntryPath || state.listItem.error) { + return null; + } + return { + filePath: state.manifest.webEntryPath, + version: state.webVersion, + }; + }, + subscribeToRegistryUpdates: (listener) => { + updateListeners.add(listener); + return () => { + updateListeners.delete(listener); + }; + }, + close: async () => { + clearWatchers(); + for (const timer of reloadTimers.values()) { + clearTimeout(timer); + } + reloadTimers.clear(); + for (const timer of rootWatchTimers.values()) { + clearTimeout(timer); + } + rootWatchTimers.clear(); + for (const state of pluginStates.values()) { + await unloadPlugin(state); + } + pluginStates.clear(); + }, + }; +} diff --git a/apps/server/src/plugins/types.ts b/apps/server/src/plugins/types.ts new file mode 100644 index 0000000000..083b7cc60b --- /dev/null +++ b/apps/server/src/plugins/types.ts @@ -0,0 +1,34 @@ +import type { PluginListItem } from "@t3tools/contracts"; +import type { ServerPluginProcedure } from "@t3tools/plugin-sdk"; + +export interface DiscoveredPluginRoot { + readonly rootDir: string; + readonly manifestPath: string; +} + +export interface DiscoveredPluginManifest { + readonly id: string; + readonly name: string; + readonly version: string; + readonly hostApiVersion: string; + readonly enabled: boolean; + readonly compatible: boolean; + readonly rootDir: string; + readonly manifestPath: string; + readonly serverEntryPath: string | null; + readonly webEntryPath: string | null; + readonly error: string | null; +} + +export interface LoadedPluginProcedure { + readonly pluginId: string; + readonly procedure: ServerPluginProcedure; +} + +export interface LoadedPluginState { + readonly manifest: DiscoveredPluginManifest; + listItem: PluginListItem; + readonly procedures: Map; + readonly cleanup: Array<() => void | Promise>; + readonly webVersion: string; +} diff --git a/apps/server/src/prompts.ts b/apps/server/src/prompts.ts new file mode 100644 index 0000000000..f0bf6f2690 --- /dev/null +++ b/apps/server/src/prompts.ts @@ -0,0 +1,143 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import type { PromptSourceKind, PromptSummary, PromptsListInput } from "@t3tools/contracts"; + +interface PromptRoot { + readonly rootPath: string; + readonly sourceKind: PromptSourceKind; +} + +function sourceOrder(sourceKind: PromptSourceKind): number { + return sourceKind === "project" ? 0 : 1; +} + +function dedupeBy(values: readonly T[], keyOf: (value: T) => string): T[] { + const seen = new Set(); + const next: T[] = []; + for (const value of values) { + const key = keyOf(value); + if (seen.has(key)) continue; + seen.add(key); + next.push(value); + } + return next; +} + +function extractFrontmatter(markdown: string): string { + return /^---\n([\s\S]*?)\n---\n?/.exec(markdown)?.[1] ?? ""; +} + +function extractFrontmatterValue(frontmatter: string, key: string): string | undefined { + const match = new RegExp(`^${key}:\\s*(.+)$`, "m").exec(frontmatter); + const rawValue = match?.[1]?.trim(); + if (!rawValue) return undefined; + return rawValue.replace(/^['"]|['"]$/g, "").trim() || undefined; +} + +function extractHeading(markdown: string): string | undefined { + const heading = /^#\s+(.+)$/m.exec(markdown)?.[1]?.trim(); + return heading && heading.length > 0 ? heading : undefined; +} + +function parsePromptMarkdown(input: { markdown: string; fallbackName: string }) { + const frontmatter = extractFrontmatter(input.markdown); + const heading = extractHeading(input.markdown); + const displayName = heading ?? input.fallbackName; + const description = + extractFrontmatterValue(frontmatter, "description") ?? + (heading ? `Use the ${heading} prompt.` : `Use the /${input.fallbackName} prompt.`); + const argumentHint = extractFrontmatterValue(frontmatter, "argument-hint"); + + return { + displayName, + description, + argumentHint, + }; +} + +async function listPromptFiles(rootPath: string): Promise { + const entries = await fs.readdir(rootPath, { withFileTypes: true }).catch(() => []); + return entries + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md")) + .map((entry) => path.join(rootPath, entry.name)) + .toSorted((left, right) => left.localeCompare(right)); +} + +async function readPromptSummary(input: { + readonly promptFilePath: string; + readonly sourceKind: PromptSourceKind; +}): Promise { + const markdown = await fs.readFile(input.promptFilePath, "utf8").catch(() => null); + if (!markdown) { + return null; + } + + const fallbackName = path + .basename(input.promptFilePath, path.extname(input.promptFilePath)) + .trim(); + if (!fallbackName) { + return null; + } + + const parsed = parsePromptMarkdown({ + markdown, + fallbackName, + }); + + return { + id: `${input.sourceKind}:${input.promptFilePath}`, + name: fallbackName, + displayName: parsed.displayName, + description: parsed.description, + ...(parsed.argumentHint ? { argumentHint: parsed.argumentHint } : {}), + sourceKind: input.sourceKind, + sourcePath: input.promptFilePath, + defaultPrompt: `/${fallbackName} `, + } satisfies PromptSummary; +} + +function promptRootsForInput(input: PromptsListInput): PromptRoot[] { + const homeDir = os.homedir(); + const projectRoots = + input.cwd?.trim().length && input.cwd + ? [{ rootPath: path.join(input.cwd, ".codex", "prompts"), sourceKind: "project" as const }] + : []; + + const userRoots = [ + { rootPath: path.join(homeDir, ".codex", "prompts"), sourceKind: "user" as const }, + ]; + + return dedupeBy([...projectRoots, ...userRoots], (root) => root.rootPath); +} + +function comparePrompts(left: PromptSummary, right: PromptSummary): number { + const sourceDelta = sourceOrder(left.sourceKind) - sourceOrder(right.sourceKind); + if (sourceDelta !== 0) return sourceDelta; + + const nameDelta = left.name.localeCompare(right.name); + if (nameDelta !== 0) return nameDelta; + + return left.sourcePath.localeCompare(right.sourcePath); +} + +export async function listAvailablePrompts(input: PromptsListInput): Promise { + const roots = promptRootsForInput(input); + const promptSummaries = await Promise.all( + roots.map(async ({ rootPath, sourceKind }) => { + const promptFiles = await listPromptFiles(rootPath); + const prompts = await Promise.all( + promptFiles.map((promptFilePath) => + readPromptSummary({ + promptFilePath, + sourceKind, + }), + ), + ); + return prompts.filter((prompt): prompt is PromptSummary => prompt !== null); + }), + ); + + return dedupeBy(promptSummaries.flat().toSorted(comparePrompts), (prompt) => prompt.name); +} diff --git a/apps/server/src/skills.ts b/apps/server/src/skills.ts new file mode 100644 index 0000000000..3e81e1adfb --- /dev/null +++ b/apps/server/src/skills.ts @@ -0,0 +1,156 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import type { SkillSummary, SkillsListInput, SkillSourceKind } from "@t3tools/contracts"; + +interface SkillRoot { + readonly rootPath: string; + readonly sourceKind: SkillSourceKind; +} + +function sourceOrder(sourceKind: SkillSourceKind): number { + return sourceKind === "project" ? 0 : sourceKind === "user" ? 1 : 2; +} + +function dedupeBy(values: readonly T[], keyOf: (value: T) => string): T[] { + const seen = new Set(); + const next: T[] = []; + for (const value of values) { + const key = keyOf(value); + if (seen.has(key)) continue; + seen.add(key); + next.push(value); + } + return next; +} + +function titleCaseSkillName(name: string): string { + return name + .split(/[-_]+/g) + .filter((segment) => segment.length > 0) + .map((segment) => segment[0]?.toUpperCase() + segment.slice(1)) + .join(" "); +} + +function extractFrontmatterValue(frontmatter: string, key: string): string | undefined { + const match = new RegExp(`^${key}:\\s*(.+)$`, "m").exec(frontmatter); + const rawValue = match?.[1]?.trim(); + if (!rawValue) return undefined; + return rawValue.replace(/^['"]|['"]$/g, "").trim() || undefined; +} + +function extractHeading(markdown: string): string | undefined { + const match = /^#\s+(.+)$/m.exec(markdown); + const heading = match?.[1]?.trim(); + return heading && heading.length > 0 ? heading : undefined; +} + +function parseSkillMarkdown(input: { markdown: string; fallbackName: string }): { + readonly name: string; + readonly displayName: string; + readonly description: string; +} { + const { markdown, fallbackName } = input; + const frontmatterMatch = /^---\n([\s\S]*?)\n---\n?/.exec(markdown); + const frontmatter = frontmatterMatch?.[1] ?? ""; + + const name = extractFrontmatterValue(frontmatter, "name") ?? fallbackName; + const displayName = extractHeading(markdown) ?? titleCaseSkillName(name); + const description = + extractFrontmatterValue(frontmatter, "description") ?? `Use the ${displayName} skill.`; + + return { + name, + displayName, + description, + }; +} + +async function listSkillDirs(rootPath: string): Promise { + const entries = await fs.readdir(rootPath, { withFileTypes: true }).catch(() => []); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(rootPath, entry.name)) + .toSorted((left, right) => left.localeCompare(right)); +} + +async function readSkillSummary(input: { + readonly skillDir: string; + readonly sourceKind: SkillSourceKind; +}): Promise { + const skillFilePath = path.join(input.skillDir, "SKILL.md"); + const markdown = await fs.readFile(skillFilePath, "utf8").catch(() => null); + if (!markdown) { + return null; + } + + const fallbackName = path.basename(input.skillDir).trim(); + if (!fallbackName) { + return null; + } + + const parsed = parseSkillMarkdown({ + markdown, + fallbackName, + }); + + return { + id: `${input.sourceKind}:${input.skillDir}`, + name: parsed.name, + displayName: parsed.displayName, + description: parsed.description, + sourceKind: input.sourceKind, + sourcePath: input.skillDir, + allowImplicitInvocation: true, + defaultPrompt: `$${parsed.name} `, + } satisfies SkillSummary; +} + +function skillRootsForInput(input: SkillsListInput): SkillRoot[] { + const homeDir = os.homedir(); + const projectRoots = + input.cwd?.trim().length && input.cwd + ? [ + { rootPath: path.join(input.cwd, ".codex", "skills"), sourceKind: "project" as const }, + { rootPath: path.join(input.cwd, ".agents", "skills"), sourceKind: "project" as const }, + ] + : []; + + const userRoots = [ + { rootPath: path.join(homeDir, ".codex", "skills"), sourceKind: "user" as const }, + { rootPath: path.join(homeDir, ".agents", "skills"), sourceKind: "user" as const }, + ]; + + return dedupeBy([...projectRoots, ...userRoots], (root) => root.rootPath); +} + +function compareSkills(left: SkillSummary, right: SkillSummary): number { + const sourceDelta = sourceOrder(left.sourceKind) - sourceOrder(right.sourceKind); + if (sourceDelta !== 0) return sourceDelta; + + const nameDelta = left.name.localeCompare(right.name); + if (nameDelta !== 0) return nameDelta; + + return left.sourcePath.localeCompare(right.sourcePath); +} + +export async function listAvailableSkills(input: SkillsListInput): Promise { + const roots = skillRootsForInput(input); + const skillSummaries = await Promise.all( + roots.map(async ({ rootPath, sourceKind }) => { + const skillDirs = await listSkillDirs(rootPath); + const skills = await Promise.all( + skillDirs.map((skillDir) => + readSkillSummary({ + skillDir, + sourceKind, + }), + ), + ); + return skills.filter((skill): skill is SkillSummary => skill !== null); + }), + ); + + return skillSummaries.flat().toSorted(compareSkills); +} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 9c6adfeba9..2ec24d0457 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -10,6 +10,7 @@ import { createServer } from "./wsServer"; import WebSocket from "ws"; import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config"; import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; +import { NetService } from "@t3tools/shared/Net"; import { DEFAULT_TERMINAL_ID, @@ -547,6 +548,7 @@ describe("WebSocket Server", () => { Layer.provideMerge(openLayer), Layer.provideMerge(serverConfigLayer), Layer.provideMerge(AnalyticsService.layerTest), + Layer.provideMerge(NetService.layer), Layer.provideMerge(NodeServices.layer), ); const runtimeServices = await Effect.runPromise( diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index e22c23988b..f5abc2eeb5 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -13,6 +13,7 @@ import Mime from "@effect/platform-node/Mime"; import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, + type DifitOpenResult, type ClientOrchestrationCommand, type OrchestrationCommand, ORCHESTRATION_WS_CHANNELS, @@ -27,6 +28,7 @@ import { WsResponse, type WsPushEnvelopeBase, } from "@t3tools/contracts"; +import { NetService } from "@t3tools/shared/Net"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import { Cause, @@ -78,6 +80,10 @@ import { expandHomePath } from "./os-jank.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; +import { listAvailableSkills } from "./skills.ts"; +import { listAvailablePrompts } from "./prompts.ts"; +import { createPluginManager } from "./plugins/manager.ts"; +import { createDifitManager } from "./difitManager.ts"; /** * ServerShape - Service API for server lifecycle control. @@ -217,7 +223,8 @@ export type ServerRuntimeServices = | TerminalManager | Keybindings | Open - | AnalyticsService; + | AnalyticsService + | NetService; export class ServerLifecycleError extends Schema.TaggedErrorClass()( "ServerLifecycleError", @@ -257,6 +264,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const git = yield* GitCore; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const pluginManager = yield* Effect.tryPromise({ + try: () => createPluginManager({ cwd }), + catch: (cause) => new ServerLifecycleError({ operation: "createPluginManager", cause }), + }); + yield* Effect.addFinalizer(() => + Effect.tryPromise({ + try: () => pluginManager.close(), + catch: (cause) => new ServerLifecycleError({ operation: "closePluginManager", cause }), + }).pipe(Effect.ignoreCause({ log: false }), Effect.asVoid), + ); yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe( Effect.catch((error) => @@ -272,7 +289,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const clients = yield* Ref.make(new Set()); const logger = createLogger("ws"); + const difitLogger = createLogger("difit"); const readiness = yield* makeServerReadiness; + const { reserveLoopbackPort } = yield* NetService; function logOutgoingPush(push: WsPushEnvelopeBase, recipients: number) { if (!logWebSocketEvents) return; @@ -289,6 +308,14 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< logOutgoingPush, }); yield* readiness.markPushBusReady; + const unsubscribePluginUpdates = pluginManager.subscribeToRegistryUpdates((ids) => { + void Effect.runPromise( + pushBus.publishAll(WS_CHANNELS.pluginsRegistryUpdated, { + ids, + }), + ); + }); + yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribePluginUpdates())); yield* keybindingsManager.start.pipe( Effect.mapError( (cause) => new ServerLifecycleError({ operation: "keybindingsRuntimeStart", cause }), @@ -427,6 +454,13 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< if (tryHandleProjectFaviconRequest(url, res)) { return; } + if ( + yield* Effect.tryPromise(() => + difitManager.handleProxyRequest({ request: req, response: res, url }), + ) + ) { + return; + } if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); @@ -488,6 +522,36 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return; } + if (url.pathname.startsWith("/__plugins/")) { + const match = /^\/__plugins\/([^/]+)\/web\.js$/.exec(url.pathname); + const pluginId = match?.[1] ? decodeURIComponent(match[1]) : null; + if (!pluginId) { + respond(404, { "Content-Type": "text/plain" }, "Not Found"); + return; + } + const webEntry = pluginManager.getWebEntry(pluginId); + if (!webEntry) { + respond(404, { "Content-Type": "text/plain" }, "Not Found"); + return; + } + const data = yield* fileSystem + .readFile(webEntry.filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!data) { + respond(404, { "Content-Type": "text/plain" }, "Not Found"); + return; + } + respond( + 200, + { + "Content-Type": "text/javascript; charset=utf-8", + "Cache-Control": "no-store", + }, + data, + ); + return; + } + // In dev mode, redirect to Vite dev server if (devUrl) { respond(302, { Location: devUrl.href }); @@ -688,6 +752,19 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path >(); const runPromise = Effect.runPromiseWith(runtimeServices); + const difitManager = createDifitManager({ + getSnapshot: () => runPromise(projectionReadModelQuery.getSnapshot()), + reserveLoopbackPort: () => runPromise(reserveLoopbackPort()), + logger: (message, context) => { + difitLogger.event(message, context ?? {}); + }, + }); + yield* Effect.addFinalizer(() => + Effect.tryPromise({ + try: () => difitManager.close(), + catch: (cause) => new ServerLifecycleError({ operation: "closeDifitManager", cause }), + }).pipe(Effect.ignoreCause({ log: false }), Effect.asVoid), + ); const unsubscribeTerminalEvents = yield* terminalManager.subscribe( (event) => void Effect.runPromise(pushBus.publishAll(WS_CHANNELS.terminalEvent, event)), @@ -776,6 +853,47 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { relativePath: target.relativePath }; } + case WS_METHODS.pluginsGetBootstrap: { + return pluginManager.getBootstrap(); + } + + case WS_METHODS.pluginsCallProcedure: { + const body = stripRequestTag(request.body); + return yield* Effect.tryPromise({ + try: () => pluginManager.callProcedure(body.pluginId, body.procedure, body.payload), + catch: (cause) => + new RouteRequestError({ + message: `Failed to call plugin procedure: ${String(cause)}`, + }), + }); + } + + case WS_METHODS.skillsList: { + const body = stripRequestTag(request.body); + return { + skills: yield* Effect.tryPromise({ + try: () => listAvailableSkills(body), + catch: (cause) => + new RouteRequestError({ + message: `Failed to list skills: ${String(cause)}`, + }), + }), + }; + } + + case WS_METHODS.promptsList: { + const body = stripRequestTag(request.body); + return { + prompts: yield* Effect.tryPromise({ + try: () => listAvailablePrompts(body), + catch: (cause) => + new RouteRequestError({ + message: `Failed to list prompts: ${String(cause)}`, + }), + }), + }; + } + case WS_METHODS.shellOpenInEditor: { const body = stripRequestTag(request.body); return yield* openInEditor(body); @@ -883,6 +1001,17 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { keybindings: keybindingsConfig, issues: [] }; } + case WS_METHODS.difitOpen: { + const body = stripRequestTag(request.body); + return (yield* Effect.tryPromise({ + try: () => difitManager.open(body), + catch: (cause) => + new RouteRequestError({ + message: `Failed to open difit: ${String(cause)}`, + }), + })) satisfies DifitOpenResult; + } + default: { const _exhaustiveCheck: never = request.body; return yield* new RouteRequestError({ diff --git a/apps/web/package.json b/apps/web/package.json index f98697fc25..46a1d4a5c5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", "@t3tools/contracts": "workspace:*", + "@t3tools/plugin-sdk": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 48c627747d..c2d2300d00 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -3,15 +3,16 @@ import "../index.css"; import { ORCHESTRATION_WS_METHODS, + OrchestrationSessionStatus, type MessageId, type OrchestrationReadModel, type ProjectId, + type ResolvedKeybindingRule, type ServerConfig, type ThreadId, type WsWelcomePayload, WS_CHANNELS, WS_METHODS, - OrchestrationSessionStatus, } from "@t3tools/contracts"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; @@ -118,6 +119,10 @@ function createBaseServerConfig(): ServerConfig { }; } +function resolvedBinding(binding: ResolvedKeybindingRule): ResolvedKeybindingRule { + return binding; +} + function createUserMessage(options: { id: MessageId; text: string; @@ -389,6 +394,11 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } + if (tag === WS_METHODS.pluginsGetBootstrap) { + return { + plugins: [], + }; + } if (tag === WS_METHODS.gitListBranches) { return { isRepo: true, @@ -437,6 +447,14 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { updatedAt: NOW_ISO, }; } + if (tag === WS_METHODS.difitOpen) { + return { + ok: true, + cwd: "/repo/project", + proxyPath: "/__difit/browser-revision/", + sessionRevision: "browser-revision", + }; + } return {}; } @@ -478,6 +496,9 @@ const worker = setupWorker( }), ), http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), + http.get(/.*\/__difit\/.*/, () => + HttpResponse.html("difit test"), + ), ); async function nextFrame(): Promise { @@ -1045,6 +1066,82 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("opens fullscreen difit with Alt+G using the browser keyboard chord", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-target-difit-hotkey" as MessageId, + targetText: "difit hotkey target", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + resolvedBinding({ + command: "difit.toggle", + shortcut: { + key: "g", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: true, + modKey: false, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }), + ], + }; + }, + }); + + try { + const capturedEvents: Array<{ key: string; code: string; altKey: boolean }> = []; + const capture = (event: KeyboardEvent) => { + capturedEvents.push({ + key: event.key, + code: event.code, + altKey: event.altKey, + }); + }; + window.addEventListener("keydown", capture); + + await page.getByTestId("composer-editor").click(); + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "©", + code: "KeyG", + altKey: true, + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor( + () => { + const difitRequest = wsRequests.find((request) => request._tag === WS_METHODS.difitOpen); + expect(difitRequest).toMatchObject({ + _tag: WS_METHODS.difitOpen, + threadId: THREAD_ID, + }); + }, + { timeout: 8_000, interval: 16 }, + ); + await waitForURL( + mounted.router, + (pathname) => pathname === `/difit/${THREAD_ID}`, + "Expected Alt+G to navigate to the fullscreen difit route.", + ); + expect(capturedEvents.some((event) => event.code === "KeyG" && event.altKey)).toBe(true); + + window.removeEventListener("keydown", capture); + } finally { + await mounted.cleanup(); + } + }); + it("keeps backspaced terminal context pills removed when a new one is added", async () => { const removedLabel = "Terminal 1 lines 1-2"; const addedLabel = "Terminal 2 lines 9-10"; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e628f6ea6a..c3c148bbd2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3,6 +3,7 @@ import { DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, type MessageId, + type PromptSummary, type ProjectScript, type ModelSlug, type ProviderKind, @@ -13,6 +14,7 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ResolvedKeybindingsConfig, type ServerProviderStatus, + type SkillSummary, type ThreadId, type TurnId, type EditorId, @@ -32,8 +34,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; +import { promptsListQueryOptions } from "~/lib/promptReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; +import { skillsListQueryOptions } from "~/lib/skillReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { @@ -83,7 +87,6 @@ import { type ChatMessage, type TurnDiffSummary, } from "../types"; -import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; @@ -178,6 +181,12 @@ import { SendPhase, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { PluginSlot, usePluginComposerItems } from "../plugins/host"; +import { + buildComposerMenuItems, + resolveSecondaryComposerMenuState, + type SecondaryComposerMenuState, +} from "../plugins/composerBridge"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -188,6 +197,8 @@ const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; +const EMPTY_AVAILABLE_PROMPTS: PromptSummary[] = []; +const EMPTY_AVAILABLE_SKILLS: SkillSummary[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; function formatOutgoingPrompt(params: { @@ -232,6 +243,23 @@ const terminalContextIdListsEqual = ( ): boolean => contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); +function difitFailureToast(reason: string): string { + switch (reason) { + case "no_active_worktree": + return "No active worktree is available for this thread."; + case "thread_not_found": + return "Fullscreen diff is only available for saved threads."; + case "spawn_failed": + return "Failed to start difit."; + case "startup_timeout": + return "Timed out while starting difit."; + case "process_exited": + return "Difit exited before it was ready."; + default: + return "Failed to open fullscreen diff."; + } +} + interface ChatViewProps { threadId: ThreadId; } @@ -344,6 +372,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); + const [secondaryComposerMenu, setSecondaryComposerMenu] = + useState(null); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< @@ -1023,71 +1053,51 @@ export default function ChatView({ threadId }: ChatViewProps) { limit: 80, }), ); + const promptsQuery = useQuery(promptsListQueryOptions({ cwd: gitCwd })); + const skillsQuery = useQuery( + skillsListQueryOptions({ + cwd: gitCwd, + enabled: composerTriggerKind === "skill-mention" || composerTriggerKind === "slash-skills", + }), + ); const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; + const availablePrompts = promptsQuery.data?.prompts ?? EMPTY_AVAILABLE_PROMPTS; + const availableSkills = skillsQuery.data?.skills ?? EMPTY_AVAILABLE_SKILLS; + const pluginComposerItems = usePluginComposerItems({ + triggerKind: + composerTriggerKind === "slash-command" || + composerTriggerKind === "slash-workspace" || + composerTriggerKind === "slash-skills" || + composerTriggerKind === "skill-mention" + ? composerTriggerKind + : null, + query: + composerTriggerKind === "path" || composerTriggerKind === "slash-model" + ? "" + : (composerTrigger?.query ?? ""), + threadId, + cwd: gitCwd, + }); const composerMenuItems = useMemo(() => { - if (!composerTrigger) return []; - if (composerTrigger.kind === "path") { - return workspaceEntries.map((entry) => ({ - id: `path:${entry.kind}:${entry.path}`, - type: "path", - path: entry.path, - pathKind: entry.kind, - label: basenameOfPath(entry.path), - description: entry.parentPath ?? "", - })); - } - - if (composerTrigger.kind === "slash-command") { - const slashCommandItems = [ - { - id: "slash:model", - type: "slash-command", - command: "model", - label: "/model", - description: "Switch response model for this thread", - }, - { - id: "slash:plan", - type: "slash-command", - command: "plan", - label: "/plan", - description: "Switch this thread into plan mode", - }, - { - id: "slash:default", - type: "slash-command", - command: "default", - label: "/default", - description: "Switch this thread back to normal chat mode", - }, - ] satisfies ReadonlyArray>; - const query = composerTrigger.query.trim().toLowerCase(); - if (!query) { - return [...slashCommandItems]; - } - return slashCommandItems.filter( - (item) => item.command.includes(query) || item.label.slice(1).includes(query), - ); - } - - return searchableModelOptions - .filter(({ searchSlug, searchName, searchProvider }) => { - const query = composerTrigger.query.trim().toLowerCase(); - if (!query) return true; - return ( - searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query) - ); - }) - .map(({ provider, providerLabel, slug, name }) => ({ - id: `model:${provider}:${slug}`, - type: "model", - provider, - model: slug, - label: name, - description: `${providerLabel} · ${slug}`, - })); - }, [composerTrigger, searchableModelOptions, workspaceEntries]); - const composerMenuOpen = Boolean(composerTrigger); + return buildComposerMenuItems({ + composerTrigger, + secondaryComposerMenu, + workspaceEntries, + availablePrompts, + pluginComposerItems: pluginComposerItems.items, + availableSkills, + searchableModelOptions, + }); + }, [ + composerTrigger, + availableSkills, + availablePrompts, + pluginComposerItems.items, + searchableModelOptions, + secondaryComposerMenu, + workspaceEntries, + ]); + const composerMenuOpen = Boolean(composerTrigger || secondaryComposerMenu); const activeComposerMenuItem = useMemo( () => composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? @@ -1149,6 +1159,43 @@ export default function ChatView({ threadId }: ChatViewProps) { }, }); }, [diffOpen, navigate, threadId]); + const onToggleDifit = useCallback(() => { + const api = readNativeApi(); + if (!api) { + return; + } + if (!isServerThread) { + toastManager.add({ + type: "error", + title: "Fullscreen diff is only available for saved threads.", + }); + return; + } + void api.difit + .open({ threadId }) + .then((result) => { + if (!result.ok) { + toastManager.add({ + type: "error", + title: difitFailureToast(result.reason), + }); + return; + } + void navigate({ + to: "/difit/$threadId", + params: { threadId }, + search: { + sessionRevision: result.sessionRevision, + }, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: error instanceof Error ? error.message : "Failed to open fullscreen diff.", + }); + }); + }, [isServerThread, navigate, threadId]); const envLocked = Boolean( activeThread && @@ -1833,6 +1880,7 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { setExpandedWorkGroups({}); setPullRequestDialogState(null); + setSecondaryComposerMenu(null); if (planSidebarOpenOnNextThreadRef.current) { planSidebarOpenOnNextThreadRef.current = false; setPlanSidebarOpen(true); @@ -2165,6 +2213,13 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } + if (command === "difit.toggle") { + event.preventDefault(); + event.stopPropagation(); + onToggleDifit(); + return; + } + const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; const script = activeProject.scripts.find((entry) => entry.id === scriptId); @@ -2187,6 +2242,7 @@ export default function ChatView({ threadId }: ChatViewProps) { splitTerminal, keybindings, onToggleDiff, + onToggleDifit, toggleTerminalVisibility, ]); @@ -3235,6 +3291,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const { snapshot, trigger } = resolveActiveComposerTrigger(); if (!trigger) return; if (item.type === "path") { + setSecondaryComposerMenu(null); const replacement = `@${item.path} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, @@ -3252,9 +3309,66 @@ export default function ChatView({ threadId }: ChatViewProps) { } return; } + if (item.type === "skill") { + setSecondaryComposerMenu(null); + const replacement = item.replacementText; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } if (item.type === "slash-command") { - if (item.command === "model") { - const replacement = "/model "; + if (item.onSelect) { + setComposerHighlightedItemId(null); + void Promise.resolve(item.onSelect()) + .then(async (result) => { + if (!result || result.type === "none") { + return; + } + if (result.type === "open-secondary") { + void resolveSecondaryComposerMenuState({ + title: result.title, + items: result.items, + }).then(setSecondaryComposerMenu); + return; + } + setSecondaryComposerMenu(null); + if (result.type === "replace-trigger") { + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + result.text, + ); + applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, result.text, { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), + }); + return; + } + applyPromptReplacement(snapshot.expandedCursor, snapshot.expandedCursor, result.text); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Plugin command failed", + description: error instanceof Error ? error.message : "Unknown error", + }); + }); + return; + } + if (item.action === "insert") { + setSecondaryComposerMenu(null); + const replacement = `/${item.command} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, trigger.rangeEnd, @@ -3271,7 +3385,9 @@ export default function ChatView({ threadId }: ChatViewProps) { } return; } - void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); + setSecondaryComposerMenu(null); + const nextInteractionMode = item.command === "plan" ? "plan" : "default"; + void handleInteractionModeChange(nextInteractionMode); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), }); @@ -3280,6 +3396,8 @@ export default function ChatView({ threadId }: ChatViewProps) { } return; } + + setSecondaryComposerMenu(null); onProviderModelSelect(item.provider, item.model); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), @@ -3293,6 +3411,8 @@ export default function ChatView({ threadId }: ChatViewProps) { handleInteractionModeChange, onProviderModelSelect, resolveActiveComposerTrigger, + setComposerHighlightedItemId, + setSecondaryComposerMenu, ], ); const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { @@ -3316,12 +3436,18 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [composerHighlightedItemId, composerMenuItems], ); + const isComposerPromptLoading = + composerTriggerKind === "slash-command" && (promptsQuery.isLoading || promptsQuery.isFetching); const isComposerMenuLoading = - composerTriggerKind === "path" && - ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || - workspaceEntriesQuery.isLoading || - workspaceEntriesQuery.isFetching); - + (composerTriggerKind === "path" && + pathTriggerQuery.length > 0 && + composerPathQueryDebouncer.state.isPending) || + (composerTriggerKind === "path" && + (workspaceEntriesQuery.isLoading || workspaceEntriesQuery.isFetching)) || + pluginComposerItems.isLoading || + ((composerTriggerKind === "skill-mention" || composerTriggerKind === "slash-skills") && + (skillsQuery.isLoading || skillsQuery.isFetching)) || + isComposerPromptLoading; const onPromptChange = useCallback( ( nextPrompt: string, @@ -3342,6 +3468,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } promptRef.current = nextPrompt; setPrompt(nextPrompt); + setSecondaryComposerMenu(null); if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { setComposerDraftTerminalContexts( threadId, @@ -3731,7 +3858,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ? "Add feedback to refine the plan, or leave this blank to implement it" : phase === "disconnected" ? "Ask for follow-up changes or attach images" - : "Ask anything, @tag files/folders, or use / to show available commands" + : "Ask anything, @tag files/folders, use / for commands, or $ for skills" } disabled={isConnecting || isComposerApprovalState} /> @@ -4103,6 +4230,14 @@ export default function ChatView({ threadId }: ChatViewProps) { }} /> ) : null} + {/* end horizontal flex container */} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9ff741897c..c824d37e8b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -91,6 +91,7 @@ import { shouldClearThreadSelectionOnMouseDown, } from "./Sidebar.logic"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { PluginSlot } from "../plugins/host"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -1678,6 +1679,7 @@ export default function Sidebar() { + {isOnSettings ? ( diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index ea7f911bec..3423743403 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -5,6 +5,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import { memo } from "react"; +import { PluginSlot } from "../../plugins/host"; import GitActionsControl from "../GitActionsControl"; import { DiffIcon } from "lucide-react"; import { Badge } from "../ui/badge"; @@ -94,6 +95,14 @@ export const ChatHeader = memo(function ChatHeader({ /> )} {activeProjectName && } + PluginComposerSelectResult | Promise | undefined; } | { id: string; @@ -30,6 +41,14 @@ export type ComposerCommandItem = model: ModelSlug; label: string; description: string; + } + | { + id: string; + type: "skill"; + label: string; + description: string; + sourceLabel: string; + replacementText: string; }; export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { @@ -65,12 +84,23 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { {props.items.length === 0 && (

{props.isLoading - ? "Searching workspace files..." - : props.triggerKind === "path" + ? props.triggerKind === "skill-mention" || props.triggerKind === "slash-skills" + ? "Loading skills..." + : "Searching workspace files..." + : props.triggerKind === "path" || props.triggerKind === "slash-workspace" ? "No matching files or folders." - : "No matching command."} + : props.triggerKind === "skill-mention" || props.triggerKind === "slash-skills" + ? "No matching skill." + : "No matching command."}

)} + {props.items.length > 0 && ( + item.id === props.activeItemId) ?? props.items[0] ?? null + } + /> + )} ); @@ -104,17 +134,84 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { /> ) : null} {props.item.type === "slash-command" ? ( - + ) : null} {props.item.type === "model" ? ( model ) : null} + {props.item.type === "skill" ? ( + + ) : null} {props.item.label} + {props.item.type === "slash-command" ? ( + + {props.item.badge ?? props.item.action} + + ) : null} + {props.item.type === "skill" ? ( + + skill + + ) : null} + + + {props.item.type === "skill" + ? `${props.item.sourceLabel} · ${props.item.description}` + : props.item.description} - {props.item.description} ); }); + +function SlashCommandIcon(props: { command: string; icon?: PluginComposerIcon }) { + if (props.icon === "file-search" || props.command === "workspace") { + return ; + } + if (props.icon === "sparkles" || props.command === "skills" || props.command === "list-skills") { + return ; + } + if (props.icon === "list" || props.command === "plan") { + return ; + } + if (props.icon === "terminal" || props.command === "default") { + return ; + } + return ; +} + +const ComposerCommandPreview = memo(function ComposerCommandPreview(props: { + item: ComposerCommandItem | null; +}) { + if (!props.item) { + return null; + } + + const detail = + props.item.type === "skill" + ? `${props.item.sourceLabel} skill` + : props.item.type === "slash-command" + ? props.item.badge === "prompt" + ? "Inserts this custom prompt" + : props.item.action === "pick" + ? "Opens a second picker" + : props.item.action === "run" + ? "Runs immediately" + : "Inserts text into the prompt" + : props.item.type === "model" + ? "Switches this thread model" + : "Inserts a workspace path mention"; + + return ( +
+
{props.item.label}
+
{props.item.description}
+
{detail}
+
+ ); +}); diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 44f32bef9a..c3720adc7c 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -60,6 +60,48 @@ describe("detectComposerTrigger", () => { }); }); + it("detects workspace picker query after /workspace", () => { + const text = "/workspace src/com"; + const trigger = detectComposerTrigger(text, text.length); + + expect(trigger).toEqual({ + kind: "slash-workspace", + query: "src/com", + rangeStart: 0, + rangeEnd: text.length, + }); + }); + + it("detects skills picker query after /skills", () => { + const text = "/skills rea"; + const trigger = detectComposerTrigger(text, text.length); + + expect(trigger).toEqual({ + kind: "slash-skills", + query: "rea", + rangeStart: 0, + rangeEnd: text.length, + }); + }); + + it("detects $skill trigger at token start", () => { + const text = "Use $rea"; + const trigger = detectComposerTrigger(text, text.length); + + expect(trigger).toEqual({ + kind: "skill-mention", + query: "rea", + rangeStart: "Use ".length, + rangeEnd: text.length, + }); + }); + + it("does not detect $skill trigger in the middle of a word", () => { + const text = "price$rea"; + + expect(detectComposerTrigger(text, text.length)).toBeNull(); + }); + it("detects @path trigger in the middle of existing text", () => { // User typed @ between "inspect " and "in this sentence" const text = "Please inspect @in this sentence"; diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index c8e62ebdcc..ed9386eded 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,8 +1,20 @@ import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; -export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; -export type ComposerSlashCommand = "model" | "plan" | "default"; +export type ComposerTriggerKind = + | "path" + | "slash-command" + | "slash-model" + | "slash-workspace" + | "slash-skills" + | "skill-mention"; +export type ComposerSlashCommand = + | "model" + | "plan" + | "default" + | "workspace" + | "skills" + | "list-skills"; export interface ComposerTrigger { kind: ComposerTriggerKind; @@ -11,7 +23,6 @@ export interface ComposerTrigger { rangeEnd: number; } -const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"]; const isInlineTokenSegment = ( segment: { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" }, ): boolean => segment.type !== "text"; @@ -190,6 +201,26 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos const linePrefix = text.slice(lineStart, cursor); if (linePrefix.startsWith("/")) { + const workspaceMatch = /^\/workspace(?:\s+(.*))?$/.exec(linePrefix); + if (workspaceMatch) { + return { + kind: "slash-workspace", + query: (workspaceMatch[1] ?? "").trim(), + rangeStart: lineStart, + rangeEnd: cursor, + }; + } + + const skillsMatch = /^\/skills(?:\s+(.*))?$/.exec(linePrefix); + if (skillsMatch) { + return { + kind: "slash-skills", + query: (skillsMatch[1] ?? "").trim(), + rangeStart: lineStart, + rangeEnd: cursor, + }; + } + const commandMatch = /^\/(\S*)$/.exec(linePrefix); if (commandMatch) { const commandQuery = commandMatch[1] ?? ""; @@ -201,15 +232,12 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos rangeEnd: cursor, }; } - if (SLASH_COMMANDS.some((command) => command.startsWith(commandQuery.toLowerCase()))) { - return { - kind: "slash-command", - query: commandQuery, - rangeStart: lineStart, - rangeEnd: cursor, - }; - } - return null; + return { + kind: "slash-command", + query: commandQuery, + rangeStart: lineStart, + rangeEnd: cursor, + }; } const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); @@ -225,6 +253,14 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos const tokenStart = tokenStartForCursor(text, cursor); const token = text.slice(tokenStart, cursor); + if (token.startsWith("$")) { + return { + kind: "skill-mention", + query: token.slice(1), + rangeStart: tokenStart, + rangeEnd: cursor, + }; + } if (!token.startsWith("@")) { return null; } @@ -237,9 +273,7 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos }; } -export function parseStandaloneComposerSlashCommand( - text: string, -): Exclude | null { +export function parseStandaloneComposerSlashCommand(text: string): "plan" | "default" | null { const match = /^\/(plan|default)\s*$/i.exec(text.trim()); if (!match) { return null; diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 0ecccf43f8..45419c14cb 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -26,6 +26,7 @@ import { function event(overrides: Partial = {}): ShortcutEventLike { return { key: "j", + code: "KeyJ", metaKey: false, ctrlKey: false, shiftKey: false, @@ -97,6 +98,18 @@ const DEFAULT_BINDINGS = compile([ command: "diff.toggle", whenAst: whenNot(whenIdentifier("terminalFocus")), }, + { + shortcut: { + key: "g", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: true, + modKey: false, + }, + command: "difit.toggle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, @@ -237,6 +250,7 @@ describe("shortcutLabelForCommand", () => { it("returns labels for non-terminal commands", () => { assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); + assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "difit.toggle", "Linux"), "Alt+G"); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), "Ctrl+O", @@ -373,6 +387,16 @@ describe("resolveShortcutCommand", () => { "script.setup.run", ); }); + + it("returns difit.toggle for Alt+G outside terminal focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "©", code: "KeyG", altKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: false }, + }), + "difit.toggle", + ); + }); }); describe("formatShortcutLabel", () => { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 09d9308aad..f5ed3d2d01 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -9,6 +9,7 @@ import { isMacPlatform } from "./lib/utils"; export interface ShortcutEventLike { type?: string; key: string; + code?: string; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; @@ -37,12 +38,59 @@ function normalizeEventKey(key: string): string { return normalized; } +function normalizeEventCode(code: string | undefined): string | null { + if (!code) return null; + if (code.startsWith("Key") && code.length === 4) { + return code.slice(3).toLowerCase(); + } + if (code.startsWith("Digit") && code.length === 6) { + return code.slice(5); + } + switch (code) { + case "Backquote": + return "`"; + case "Minus": + return "-"; + case "Equal": + return "="; + case "Backslash": + return "\\"; + case "BracketLeft": + return "["; + case "BracketRight": + return "]"; + case "Semicolon": + return ";"; + case "Quote": + return "'"; + case "Comma": + return ","; + case "Period": + return "."; + case "Slash": + return "/"; + case "Space": + return " "; + default: + return null; + } +} + +function resolveEventShortcutKey(event: ShortcutEventLike): string { + const normalizedKey = normalizeEventKey(event.key); + if (!event.altKey) { + return normalizedKey; + } + const normalizedCode = normalizeEventCode(event.code); + return normalizedCode ?? normalizedKey; +} + function matchesShortcut( event: ShortcutEventLike, shortcut: KeybindingShortcut, platform = navigator.platform, ): boolean { - const key = normalizeEventKey(event.key); + const key = resolveEventShortcutKey(event); if (key !== shortcut.key) return false; const useMetaForMod = isMacPlatform(platform); diff --git a/apps/web/src/lib/projectReactQuery.ts b/apps/web/src/lib/projectReactQuery.ts index 20aa265b87..4f59056579 100644 --- a/apps/web/src/lib/projectReactQuery.ts +++ b/apps/web/src/lib/projectReactQuery.ts @@ -21,6 +21,7 @@ export function projectSearchEntriesQueryOptions(input: { enabled?: boolean; limit?: number; staleTime?: number; + allowEmptyQuery?: boolean; }) { const limit = input.limit ?? DEFAULT_SEARCH_ENTRIES_LIMIT; return queryOptions({ @@ -36,7 +37,10 @@ export function projectSearchEntriesQueryOptions(input: { limit, }); }, - enabled: (input.enabled ?? true) && input.cwd !== null && input.query.length > 0, + enabled: + (input.enabled ?? true) && + input.cwd !== null && + (input.allowEmptyQuery === true || input.query.length > 0), staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME, placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT, }); diff --git a/apps/web/src/lib/promptReactQuery.ts b/apps/web/src/lib/promptReactQuery.ts new file mode 100644 index 0000000000..5c4e800e0f --- /dev/null +++ b/apps/web/src/lib/promptReactQuery.ts @@ -0,0 +1,26 @@ +import { queryOptions } from "@tanstack/react-query"; +import type { PromptsListResult } from "@t3tools/contracts"; + +import { ensureNativeApi } from "../nativeApi"; + +export const promptQueryKeys = { + all: ["prompts"] as const, + list: (cwd: string | null) => ["prompts", "list", cwd] as const, +}; + +const EMPTY_PROMPTS_RESULT: PromptsListResult = { + prompts: [], +}; + +export function promptsListQueryOptions(input: { cwd: string | null; enabled?: boolean }) { + return queryOptions({ + queryKey: promptQueryKeys.list(input.cwd), + queryFn: async () => { + const api = ensureNativeApi(); + return api.prompts.list(input.cwd ? { cwd: input.cwd } : {}); + }, + enabled: input.enabled ?? true, + staleTime: 30_000, + placeholderData: (previous) => previous ?? EMPTY_PROMPTS_RESULT, + }); +} diff --git a/apps/web/src/lib/skillReactQuery.ts b/apps/web/src/lib/skillReactQuery.ts new file mode 100644 index 0000000000..d3837b6a3d --- /dev/null +++ b/apps/web/src/lib/skillReactQuery.ts @@ -0,0 +1,26 @@ +import { queryOptions } from "@tanstack/react-query"; +import type { SkillsListResult } from "@t3tools/contracts"; + +import { ensureNativeApi } from "~/nativeApi"; + +export const skillQueryKeys = { + all: ["skills"] as const, + list: (cwd: string | null) => ["skills", "list", cwd] as const, +}; + +const EMPTY_SKILLS_RESULT: SkillsListResult = { + skills: [], +}; + +export function skillsListQueryOptions(input: { cwd: string | null; enabled?: boolean }) { + return queryOptions({ + queryKey: skillQueryKeys.list(input.cwd), + queryFn: async () => { + const api = ensureNativeApi(); + return api.skills.list(input.cwd ? { cwd: input.cwd } : {}); + }, + enabled: input.enabled ?? true, + staleTime: 30_000, + placeholderData: (previous) => previous ?? EMPTY_SKILLS_RESULT, + }); +} diff --git a/apps/web/src/plugins/composer.ts b/apps/web/src/plugins/composer.ts new file mode 100644 index 0000000000..b545ca147e --- /dev/null +++ b/apps/web/src/plugins/composer.ts @@ -0,0 +1,13 @@ +import type { PluginComposerItem } from "@t3tools/contracts"; + +export function comparePluginComposerItems( + left: PluginComposerItem, + right: PluginComposerItem, +): number { + const leftPriority = left.priority ?? 0; + const rightPriority = right.priority ?? 0; + if (leftPriority !== rightPriority) { + return rightPriority - leftPriority; + } + return left.label.localeCompare(right.label); +} diff --git a/apps/web/src/plugins/composerBridge.test.ts b/apps/web/src/plugins/composerBridge.test.ts new file mode 100644 index 0000000000..e8bd09bd9b --- /dev/null +++ b/apps/web/src/plugins/composerBridge.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import type { SkillSummary } from "@t3tools/contracts"; + +import { buildComposerMenuItems, buildSkillComposerItems } from "./composerBridge"; + +const skills: SkillSummary[] = [ + { + id: "user:/skills/react-doctor", + name: "react-doctor", + displayName: "React Doctor", + description: "Diagnose React code health issues.", + sourceKind: "user", + sourcePath: "/skills/react-doctor", + allowImplicitInvocation: true, + defaultPrompt: "$react-doctor ", + }, + { + id: "project:/skills/megaplan", + name: "megaplan", + displayName: "Megaplan", + description: "Build robust plans.", + sourceKind: "project", + sourcePath: "/skills/megaplan", + allowImplicitInvocation: true, + defaultPrompt: "$megaplan ", + }, +]; + +describe("buildSkillComposerItems", () => { + it("sorts project skills before user skills when query is empty", () => { + expect(buildSkillComposerItems({ skills, query: "" })).toMatchObject([ + { label: "Megaplan", replacementText: "$megaplan ", sourceLabel: "Project" }, + { label: "React Doctor", replacementText: "$react-doctor ", sourceLabel: "User" }, + ]); + }); + + it("filters to matching skills for the current query", () => { + expect(buildSkillComposerItems({ skills, query: "react" })).toMatchObject([ + { label: "React Doctor", replacementText: "$react-doctor " }, + ]); + }); +}); + +describe("buildComposerMenuItems", () => { + it("falls back to core skills when no plugin skill provider is available", () => { + const items = buildComposerMenuItems({ + composerTrigger: { + kind: "skill-mention", + query: "mega", + }, + secondaryComposerMenu: null, + workspaceEntries: [], + availablePrompts: [], + pluginComposerItems: [], + availableSkills: skills, + searchableModelOptions: [], + }); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + type: "skill", + label: "Megaplan", + replacementText: "$megaplan ", + }); + }); +}); diff --git a/apps/web/src/plugins/composerBridge.ts b/apps/web/src/plugins/composerBridge.ts new file mode 100644 index 0000000000..43032394aa --- /dev/null +++ b/apps/web/src/plugins/composerBridge.ts @@ -0,0 +1,316 @@ +import type { + PromptSummary, + ProjectEntry, + ProviderKind, + ModelSlug, + PluginComposerItem, + SkillSummary, +} from "@t3tools/contracts"; + +import type { ComposerTriggerKind } from "~/composer-logic"; +import { basenameOfPath } from "~/vscode-icons"; +import type { ComposerCommandItem } from "~/components/chat/ComposerCommandMenu"; + +export interface SecondaryComposerMenuState { + readonly title: string; + readonly items: readonly ComposerCommandItem[]; +} + +interface SearchableModelOption { + readonly provider: ProviderKind; + readonly providerLabel: string; + readonly slug: ModelSlug; + readonly name: string; + readonly searchSlug: string; + readonly searchName: string; + readonly searchProvider: string; +} + +function normalizeComposerSearchValue(value: string): string { + return value.trim().toLowerCase(); +} + +function scoreSlashCommandMatch( + item: Extract, + rawQuery: string, +): number | null { + const query = normalizeComposerSearchValue(rawQuery); + if (!query) { + return item.action === "pick" ? 0 : item.action === "run" ? 1 : 2; + } + + const command = item.command.toLowerCase(); + const label = item.label.toLowerCase(); + const description = item.description.toLowerCase(); + const keywords = item.keywords?.map((keyword) => keyword.toLowerCase()) ?? []; + + if (command === query) return 0; + if (command.startsWith(query)) return 1; + if (label.startsWith(query)) return 2; + if (keywords.some((keyword) => keyword.startsWith(query))) return 3; + if (label.includes(query)) return 4; + if (keywords.some((keyword) => keyword.includes(query))) return 5; + if (description.includes(query)) return 6; + return null; +} + +export function mapPluginComposerItem(item: PluginComposerItem): ComposerCommandItem { + if (item.type === "path") { + return { + id: item.id, + type: "path", + path: item.path, + pathKind: item.pathKind, + label: item.label, + description: item.description, + }; + } + + if (item.type === "skill") { + return { + id: item.id, + type: "skill", + label: item.label, + description: item.description, + sourceLabel: item.sourceLabel, + replacementText: item.replacementText, + }; + } + + return { + id: item.id, + type: "slash-command", + command: item.id, + action: item.action, + label: item.label, + description: item.description, + keywords: item.keywords ? [...item.keywords] : undefined, + icon: item.icon, + badge: item.badge, + onSelect: item.onSelect, + }; +} + +function normalizeSkillSearchValue(value: string): string { + return value.trim().toLowerCase(); +} + +function scoreSkillMatch( + skill: Pick, + rawQuery: string, +): number | null { + const query = normalizeSkillSearchValue(rawQuery); + if (!query) { + return 0; + } + + const name = skill.name.toLowerCase(); + const displayName = skill.displayName.toLowerCase(); + const description = skill.description.toLowerCase(); + + if (name === query) return 0; + if (displayName === query) return 1; + if (name.startsWith(query)) return 2; + if (displayName.startsWith(query)) return 3; + if (name.includes(query)) return 4; + if (displayName.includes(query)) return 5; + if (description.includes(query)) return 6; + return null; +} + +function sourceRank(sourceKind: SkillSummary["sourceKind"]): number { + return sourceKind === "project" ? 0 : sourceKind === "user" ? 1 : 2; +} + +function buildSkillSourceLabel(sourceKind: SkillSummary["sourceKind"]): string { + return sourceKind === "project" ? "Project" : sourceKind === "user" ? "User" : "System"; +} + +export function buildSkillComposerItems(input: { + skills: readonly SkillSummary[]; + query: string; +}): ComposerCommandItem[] { + return input.skills + .filter((skill) => scoreSkillMatch(skill, input.query) !== null) + .toSorted( + (left, right) => + (scoreSkillMatch(left, input.query) ?? Number.MAX_SAFE_INTEGER) - + (scoreSkillMatch(right, input.query) ?? Number.MAX_SAFE_INTEGER) || + sourceRank(left.sourceKind) - sourceRank(right.sourceKind) || + left.name.localeCompare(right.name), + ) + .map((skill) => ({ + id: `builtin-skill:${skill.id}`, + type: "skill", + label: skill.displayName, + description: skill.description, + sourceLabel: buildSkillSourceLabel(skill.sourceKind), + replacementText: skill.defaultPrompt, + })); +} + +export function buildComposerMenuItems(input: { + composerTrigger: { + kind: ComposerTriggerKind; + query: string; + } | null; + secondaryComposerMenu: SecondaryComposerMenuState | null; + workspaceEntries: readonly ProjectEntry[]; + availablePrompts: readonly PromptSummary[]; + pluginComposerItems: readonly PluginComposerItem[]; + availableSkills: readonly SkillSummary[]; + searchableModelOptions: readonly SearchableModelOption[]; +}): ComposerCommandItem[] { + if (input.secondaryComposerMenu) { + return [...input.secondaryComposerMenu.items]; + } + if (!input.composerTrigger) { + return []; + } + + if (input.composerTrigger.kind === "path") { + return input.workspaceEntries.map((entry) => ({ + id: `path:${entry.kind}:${entry.path}`, + type: "path", + path: entry.path, + pathKind: entry.kind, + label: basenameOfPath(entry.path), + description: entry.parentPath ?? "", + })); + } + + if ( + input.composerTrigger.kind === "skill-mention" || + input.composerTrigger.kind === "slash-skills" || + input.composerTrigger.kind === "slash-workspace" + ) { + const pluginItems = input.pluginComposerItems.map(mapPluginComposerItem); + if ( + input.composerTrigger.kind === "skill-mention" || + input.composerTrigger.kind === "slash-skills" + ) { + const builtinSkillItems = buildSkillComposerItems({ + skills: input.availableSkills, + query: input.composerTrigger.query, + }); + const pluginSkillKeys = new Set( + pluginItems + .filter( + (item): item is Extract => + item.type === "skill", + ) + .map((item) => `${item.replacementText}\n${item.label}\n${item.sourceLabel}`), + ); + return [ + ...pluginItems, + ...builtinSkillItems.filter( + (item) => + item.type !== "skill" || + !pluginSkillKeys.has(`${item.replacementText}\n${item.label}\n${item.sourceLabel}`), + ), + ]; + } + return pluginItems; + } + + if (input.composerTrigger.kind === "slash-command") { + const slashQuery = input.composerTrigger.query; + const slashCommandItems = [ + { + id: "slash:model", + type: "slash-command", + command: "model", + action: "insert", + label: "Switch model", + description: "Insert /model to choose a different thread model", + keywords: ["model", "switch", "provider"], + }, + { + id: "slash:plan", + type: "slash-command", + command: "plan", + action: "run", + label: "Insert plan request", + description: "Switch this thread into plan mode", + keywords: ["plan", "mode"], + }, + { + id: "slash:default", + type: "slash-command", + command: "default", + action: "run", + label: "Return to default mode", + description: "Switch this thread back to normal chat mode", + keywords: ["default", "chat", "mode"], + }, + ] satisfies ReadonlyArray>; + + const builtInItems = [...slashCommandItems] + .filter((item) => scoreSlashCommandMatch(item, slashQuery) !== null) + .toSorted( + (left, right) => + (scoreSlashCommandMatch(left, slashQuery) ?? Number.MAX_SAFE_INTEGER) - + (scoreSlashCommandMatch(right, slashQuery) ?? Number.MAX_SAFE_INTEGER) || + left.label.localeCompare(right.label), + ); + + const promptItems = input.availablePrompts + .map( + (prompt) => + ({ + id: `prompt:${prompt.sourceKind}:${prompt.name}`, + type: "slash-command", + command: prompt.name, + action: "insert", + label: `/${prompt.name}`, + description: prompt.description, + keywords: [ + prompt.name, + prompt.displayName, + ...(prompt.argumentHint ? [prompt.argumentHint] : []), + ], + badge: "prompt", + }) satisfies Extract, + ) + .filter((item) => scoreSlashCommandMatch(item, slashQuery) !== null) + .toSorted( + (left, right) => + (scoreSlashCommandMatch(left, slashQuery) ?? Number.MAX_SAFE_INTEGER) - + (scoreSlashCommandMatch(right, slashQuery) ?? Number.MAX_SAFE_INTEGER) || + left.label.localeCompare(right.label), + ); + + const pluginItems = input.pluginComposerItems.flatMap((item) => + item.type === "slash-command" ? [mapPluginComposerItem(item)] : [], + ); + + return [...builtInItems, ...promptItems, ...pluginItems]; + } + + return input.searchableModelOptions + .filter(({ searchSlug, searchName, searchProvider }) => { + const query = input.composerTrigger?.query.trim().toLowerCase() ?? ""; + if (!query) return true; + return ( + searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query) + ); + }) + .map(({ provider, providerLabel, slug, name }) => ({ + id: `model:${provider}:${slug}`, + type: "model", + provider, + model: slug, + label: name, + description: `${providerLabel} · ${slug}`, + })); +} + +export async function resolveSecondaryComposerMenuState(input: { + title: string; + items: readonly PluginComposerItem[] | Promise; +}): Promise { + return { + title: input.title, + items: (await Promise.resolve(input.items)).map(mapPluginComposerItem), + }; +} diff --git a/apps/web/src/plugins/host.tsx b/apps/web/src/plugins/host.tsx new file mode 100644 index 0000000000..929af5b111 --- /dev/null +++ b/apps/web/src/plugins/host.tsx @@ -0,0 +1,338 @@ +import * as React from "react"; +import { + type PropsWithChildren, + type ReactNode, + Component, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import type { + PluginComposerItem, + PluginComposerQueryContext, + PluginSlotId, +} from "@t3tools/contracts"; + +import { ensureNativeApi } from "~/nativeApi"; +import { comparePluginComposerItems } from "./composer"; +import { resolveWebPluginActivator } from "./runtime"; + +interface LoadedWebPluginHandle { + readonly cleanup: (() => void | Promise) | null; +} + +type SlotRenderer = (props: Record) => ReactNode; + +interface RegisteredSlotRenderer { + readonly id: string; + readonly pluginId: string; + readonly renderer: SlotRenderer; +} + +interface PluginComposerProvider { + readonly id: string; + readonly pluginId: string; + readonly triggers: readonly PluginComposerQueryContext["triggerKind"][]; + readonly getItems: ( + input: PluginComposerQueryContext, + ) => readonly PluginComposerItem[] | Promise; +} + +interface PluginHostContextValue { + readonly revision: number; + readonly getComposerProviders: ( + triggerKind: PluginComposerQueryContext["triggerKind"], + ) => readonly PluginComposerProvider[]; + readonly getSlotRenderers: (slotId: PluginSlotId) => readonly RegisteredSlotRenderer[]; +} + +const PluginHostContext = createContext(null); + +class PluginRenderErrorBoundary extends Component< + PropsWithChildren<{ pluginId: string }>, + { hasError: boolean } +> { + override state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + override componentDidCatch(error: unknown) { + console.warn(`Plugin render failed '${this.props.pluginId}'`, error); + } + + override render() { + if (this.state.hasError) { + return null; + } + return this.props.children; + } +} + +export function PluginHostProvider(props: PropsWithChildren) { + const nativeApi = useMemo(() => ensureNativeApi(), []); + const composerProvidersRef = useRef([]); + const slotRenderersRef = useRef>(new Map()); + const loadedPluginsRef = useRef>(new Map()); + const [revision, setRevision] = useState(0); + + const bumpRevision = useCallback(() => { + setRevision((current) => current + 1); + }, []); + + const unregisterPlugin = useCallback( + async (pluginId: string) => { + composerProvidersRef.current = composerProvidersRef.current.filter( + (provider) => provider.pluginId !== pluginId, + ); + for (const [slotId, renderers] of slotRenderersRef.current) { + const nextRenderers = renderers.filter((renderer) => renderer.pluginId !== pluginId); + if (nextRenderers.length === 0) { + slotRenderersRef.current.delete(slotId); + } else { + slotRenderersRef.current.set(slotId, nextRenderers); + } + } + + const handle = loadedPluginsRef.current.get(pluginId); + loadedPluginsRef.current.delete(pluginId); + if (handle?.cleanup) { + await handle.cleanup(); + } + bumpRevision(); + }, + [bumpRevision], + ); + + useEffect(() => { + let cancelled = false; + const loadedPluginsRefValue = loadedPluginsRef; + + const loadPlugins = async () => { + const bootstrap = await nativeApi.plugins.getBootstrap(); + if (cancelled) { + return; + } + + await Promise.all( + [...loadedPluginsRef.current.keys()].map((pluginId) => unregisterPlugin(pluginId)), + ); + + for (const plugin of bootstrap.plugins) { + if (!plugin.webUrl || !plugin.enabled || !plugin.compatible) { + continue; + } + + try { + const module = await import(/* @vite-ignore */ plugin.webUrl); + if (cancelled) { + return; + } + + const activate = resolveWebPluginActivator(module); + if (!activate) { + continue; + } + + const pluginId = plugin.id; + let slotRegistrationCount = 0; + const cleanupHandles: Array<() => void> = []; + const cleanupRegistrations = () => { + for (const cleanup of cleanupHandles.toReversed()) { + cleanup(); + } + }; + + const cleanup = await activate({ + pluginId, + callProcedure: (input: { pluginId?: string; procedure: string; payload?: unknown }) => + nativeApi.plugins.callProcedure({ + pluginId: input.pluginId ?? pluginId, + procedure: input.procedure, + ...(input.payload !== undefined ? { payload: input.payload } : {}), + }), + registerComposerProvider: (provider: Omit) => { + const registeredProvider: PluginComposerProvider = { + ...provider, + pluginId, + id: `${pluginId}:${provider.id}`, + }; + composerProvidersRef.current = [...composerProvidersRef.current, registeredProvider]; + bumpRevision(); + const unregister = () => { + composerProvidersRef.current = composerProvidersRef.current.filter( + (candidate) => candidate.id !== registeredProvider.id, + ); + bumpRevision(); + }; + cleanupHandles.push(unregister); + return unregister; + }, + registerSlot: (slotId: PluginSlotId, renderer: SlotRenderer) => { + slotRegistrationCount += 1; + const rendererId = `${pluginId}:${slotId}:${slotRegistrationCount}`; + const current = slotRenderersRef.current.get(slotId) ?? []; + slotRenderersRef.current.set(slotId, [ + ...current, + { id: rendererId, pluginId, renderer }, + ]); + bumpRevision(); + const unregister = () => { + const existing = slotRenderersRef.current.get(slotId) ?? []; + const next = existing.filter((candidate) => candidate.id !== rendererId); + if (next.length === 0) { + slotRenderersRef.current.delete(slotId); + } else { + slotRenderersRef.current.set(slotId, next); + } + bumpRevision(); + }; + cleanupHandles.push(unregister); + return unregister; + }, + onDispose: (cleanup: () => void | Promise) => { + cleanupHandles.push(() => { + void cleanup(); + }); + }, + }); + + loadedPluginsRef.current.set(pluginId, { + cleanup: async () => { + cleanupRegistrations(); + if (typeof cleanup === "function") { + await cleanup(); + } + }, + }); + bumpRevision(); + } catch (error) { + console.warn(`Failed to load web plugin '${plugin.id}'`, error); + } + } + }; + + void loadPlugins(); + const unsubscribe = nativeApi.plugins.onRegistryUpdated(() => { + void loadPlugins(); + }); + + return () => { + cancelled = true; + unsubscribe(); + void Promise.all( + [...loadedPluginsRefValue.current.keys()].map((pluginId) => unregisterPlugin(pluginId)), + ); + }; + }, [bumpRevision, nativeApi, unregisterPlugin]); + + const contextValue = useMemo( + () => ({ + revision, + getComposerProviders: (triggerKind) => + composerProvidersRef.current + .filter((provider) => provider.triggers.includes(triggerKind)) + .toSorted((left, right) => left.pluginId.localeCompare(right.pluginId)), + getSlotRenderers: (slotId) => + (slotRenderersRef.current.get(slotId) ?? []).toSorted((left, right) => + left.pluginId.localeCompare(right.pluginId), + ), + }), + [revision], + ); + + return ( + {props.children} + ); +} + +function usePluginHostContext(): PluginHostContextValue { + const value = useContext(PluginHostContext); + if (!value) { + throw new Error("PluginHostProvider is missing"); + } + return value; +} + +export function usePluginComposerItems(input: { + triggerKind: PluginComposerQueryContext["triggerKind"] | null; + query: string; + threadId?: string; + cwd?: string | null; +}) { + const host = usePluginHostContext(); + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (!input.triggerKind) { + setItems([]); + setIsLoading(false); + return; + } + + const matchingProviders = host.getComposerProviders(input.triggerKind); + if (matchingProviders.length === 0) { + setItems([]); + setIsLoading(false); + return; + } + + let cancelled = false; + setIsLoading(true); + void Promise.all( + matchingProviders.map(async (provider) => { + try { + return await provider.getItems({ + triggerKind: input.triggerKind!, + query: input.query, + ...(input.threadId ? { threadId: input.threadId } : {}), + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + }); + } catch (error) { + console.warn(`Failed to resolve plugin composer provider '${provider.id}'`, error); + return []; + } + }), + ).then((results) => { + if (cancelled) { + return; + } + setItems(results.flat().toSorted(comparePluginComposerItems)); + setIsLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [host, host.revision, input.cwd, input.query, input.threadId, input.triggerKind]); + + return { + items, + isLoading, + }; +} + +export function PluginSlot(props: { slotId: PluginSlotId; renderProps?: Record }) { + const host = usePluginHostContext(); + const renderers = host.getSlotRenderers(props.slotId); + if (renderers.length === 0) { + return null; + } + + return ( + <> + {renderers.map((renderer) => ( + + {renderer.renderer(props.renderProps ?? {})} + + ))} + + ); +} diff --git a/apps/web/src/plugins/runtime.ts b/apps/web/src/plugins/runtime.ts new file mode 100644 index 0000000000..36cc387caa --- /dev/null +++ b/apps/web/src/plugins/runtime.ts @@ -0,0 +1,16 @@ +export interface WebPluginModuleShape { + readonly default?: ((ctx: unknown) => unknown) | undefined; + readonly activateWeb?: ((ctx: unknown) => unknown) | undefined; +} + +export function resolveWebPluginActivator( + module: WebPluginModuleShape, +): ((ctx: unknown) => unknown) | null { + if (typeof module.default === "function") { + return module.default; + } + if (typeof module.activateWeb === "function") { + return module.activateWeb; + } + return null; +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 880d5ef64b..8ff731c734 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' +import { Route as DifitThreadIdRouteImport } from './routes/difit.$threadId' import { Route as ChatSettingsRouteImport } from './routes/_chat.settings' import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId' @@ -23,6 +24,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ path: '/', getParentRoute: () => ChatRoute, } as any) +const DifitThreadIdRoute = DifitThreadIdRouteImport.update({ + id: '/difit/$threadId', + path: '/difit/$threadId', + getParentRoute: () => rootRouteImport, +} as any) const ChatSettingsRoute = ChatSettingsRouteImport.update({ id: '/settings', path: '/settings', @@ -38,10 +44,12 @@ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/$threadId': typeof ChatThreadIdRoute '/settings': typeof ChatSettingsRoute + '/difit/$threadId': typeof DifitThreadIdRoute } export interface FileRoutesByTo { '/$threadId': typeof ChatThreadIdRoute '/settings': typeof ChatSettingsRoute + '/difit/$threadId': typeof DifitThreadIdRoute '/': typeof ChatIndexRoute } export interface FileRoutesById { @@ -49,18 +57,26 @@ export interface FileRoutesById { '/_chat': typeof ChatRouteWithChildren '/_chat/$threadId': typeof ChatThreadIdRoute '/_chat/settings': typeof ChatSettingsRoute + '/difit/$threadId': typeof DifitThreadIdRoute '/_chat/': typeof ChatIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/$threadId' | '/settings' + fullPaths: '/' | '/$threadId' | '/settings' | '/difit/$threadId' fileRoutesByTo: FileRoutesByTo - to: '/$threadId' | '/settings' | '/' - id: '__root__' | '/_chat' | '/_chat/$threadId' | '/_chat/settings' | '/_chat/' + to: '/$threadId' | '/settings' | '/difit/$threadId' | '/' + id: + | '__root__' + | '/_chat' + | '/_chat/$threadId' + | '/_chat/settings' + | '/difit/$threadId' + | '/_chat/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { ChatRoute: typeof ChatRouteWithChildren + DifitThreadIdRoute: typeof DifitThreadIdRoute } declare module '@tanstack/react-router' { @@ -79,6 +95,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatIndexRouteImport parentRoute: typeof ChatRoute } + '/difit/$threadId': { + id: '/difit/$threadId' + path: '/difit/$threadId' + fullPath: '/difit/$threadId' + preLoaderRoute: typeof DifitThreadIdRouteImport + parentRoute: typeof rootRouteImport + } '/_chat/settings': { id: '/_chat/settings' path: '/settings' @@ -112,6 +135,7 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, + DifitThreadIdRoute: DifitThreadIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 0192ee0c6c..141f6c37ba 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -2,6 +2,7 @@ import { createElement } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRouter } from "@tanstack/react-router"; +import { PluginHostProvider } from "./plugins/host"; import { routeTree } from "./routeTree.gen"; import { StoreProvider } from "./store"; @@ -20,7 +21,7 @@ export function getRouter(history: RouterHistory) { createElement( QueryClientProvider, { client: queryClient }, - createElement(StoreProvider, null, children), + createElement(PluginHostProvider, null, createElement(StoreProvider, null, children)), ), }); } diff --git a/apps/web/src/routes/difit.$threadId.tsx b/apps/web/src/routes/difit.$threadId.tsx new file mode 100644 index 0000000000..5942293258 --- /dev/null +++ b/apps/web/src/routes/difit.$threadId.tsx @@ -0,0 +1,223 @@ +import { + type DifitOpenFailureReason, + ThreadId, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { ArrowLeftIcon, LoaderCircleIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { Button } from "../components/ui/button"; +import { toastManager } from "../components/ui/toast"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { readNativeApi } from "../nativeApi"; +import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { useStore } from "../store"; + +const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; +const DIFIT_IFRAME_SANDBOX = + "allow-downloads allow-forms allow-modals allow-popups allow-same-origin allow-scripts"; + +function buildDifitProxyPath(sessionRevision: string): string { + return `/__difit/${encodeURIComponent(sessionRevision)}/`; +} + +function difitFailureMessage(reason: DifitOpenFailureReason): string { + switch (reason) { + case "no_active_worktree": + return "No active worktree is available for this thread."; + case "thread_not_found": + return "This thread is not available for fullscreen diff."; + case "spawn_failed": + return "Failed to start difit."; + case "startup_timeout": + return "Timed out while starting difit."; + case "process_exited": + return "Difit exited before it was ready."; + } +} + +function DifitRouteView() { + const navigate = useNavigate(); + const threadId = Route.useParams({ + select: (params) => ThreadId.makeUnsafe(params.threadId), + }); + const search = Route.useSearch(); + const threadsHydrated = useStore((store) => store.threadsHydrated); + const thread = useStore((store) => store.threads.find((entry) => entry.id === threadId) ?? null); + const api = readNativeApi(); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; + const difitShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "difit.toggle"), + [keybindings], + ); + const [iframeSrc, setIframeSrc] = useState( + search.sessionRevision ? buildDifitProxyPath(search.sessionRevision) : null, + ); + const [isOpening, setIsOpening] = useState(search.sessionRevision === undefined); + const [isFrameLoading, setIsFrameLoading] = useState(search.sessionRevision !== undefined); + const [errorMessage, setErrorMessage] = useState(null); + + const closeDifit = useCallback(() => { + void navigate({ + to: "/$threadId", + params: { threadId }, + replace: true, + }); + }, [navigate, threadId]); + + useEffect(() => { + if (!threadsHydrated) { + return; + } + if (!thread) { + void navigate({ to: "/", replace: true }); + } + }, [navigate, thread, threadsHydrated]); + + useEffect(() => { + const onWindowKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + closeDifit(); + return; + } + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: false, + terminalOpen: false, + }, + }); + if (command !== "difit.toggle") { + return; + } + event.preventDefault(); + event.stopPropagation(); + closeDifit(); + }; + window.addEventListener("keydown", onWindowKeyDown); + return () => window.removeEventListener("keydown", onWindowKeyDown); + }, [closeDifit, keybindings]); + + useEffect(() => { + if (!api || !thread) { + return; + } + + let cancelled = false; + setIsOpening(true); + setErrorMessage(null); + + void api.difit + .open({ threadId }) + .then(async (result) => { + if (cancelled) { + return; + } + if (!result.ok) { + const message = difitFailureMessage(result.reason); + setErrorMessage(message); + setIsOpening(false); + toastManager.add({ + type: "error", + title: message, + }); + return; + } + + setIsOpening(false); + if (iframeSrc !== result.proxyPath) { + setIframeSrc(result.proxyPath); + setIsFrameLoading(true); + } else { + setIsFrameLoading(false); + } + if (search.sessionRevision !== result.sessionRevision) { + await navigate({ + to: "/difit/$threadId", + params: { threadId }, + replace: true, + search: { sessionRevision: result.sessionRevision }, + }); + } + }) + .catch((error) => { + if (cancelled) { + return; + } + setIsOpening(false); + setErrorMessage(error instanceof Error ? error.message : "Failed to open difit."); + }); + + return () => { + cancelled = true; + }; + }, [api, iframeSrc, navigate, search.sessionRevision, thread, thread?.worktreePath, threadId]); + + if (!threadsHydrated || !thread) { + return null; + } + + return ( +
+
+ +
+
+ {iframeSrc ? ( +