diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 83976e3d4..89d2af83e 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -50,6 +50,7 @@ const testLayer = Layer.mergeAll( Layer.succeed(Open, { openBrowser: (_target: string) => Effect.void, openInEditor: () => Effect.void, + openWorkspace: () => Effect.void, } satisfies OpenShape), AnalyticsService.layerTest, FetchHttpClient.layer, diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 0f864554e..5732f3182 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -8,7 +8,9 @@ import { isCommandAvailable, launchDetached, resolveAvailableEditors, + resolveAvailableOpenTargets, resolveEditorLaunch, + resolveWorkspaceLaunch, } from "./open"; import { Effect } from "effect"; import { assertSuccess } from "@effect/vitest/utils"; @@ -117,6 +119,78 @@ describe("resolveEditorLaunch", () => { ); }); +describe("resolveWorkspaceLaunch", () => { + it.effect("returns editor-backed workspace launches for existing workspace targets", () => + Effect.gen(function* () { + const cursorLaunch = yield* resolveWorkspaceLaunch( + { cwd: "/tmp/workspace", target: "cursor" }, + "darwin", + ); + assert.deepEqual(cursorLaunch, { + command: "cursor", + args: ["/tmp/workspace"], + }); + + const fileManagerLaunch = yield* resolveWorkspaceLaunch( + { cwd: "/tmp/workspace", target: "file-manager" }, + "linux", + ); + assert.deepEqual(fileManagerLaunch, { + command: "xdg-open", + args: ["/tmp/workspace"], + }); + }), + ); + + it.effect("uses AppleScript for Ghostty on macOS", () => + Effect.gen(function* () { + const launch = yield* resolveWorkspaceLaunch( + { cwd: "/tmp/workspace", target: "ghostty" }, + "darwin", + ); + + assert.deepEqual(launch, { + command: "osascript", + args: [ + "-e", + [ + 'tell application "Ghostty"', + " activate", + " set cfg to new surface configuration", + ' set initial working directory of cfg to "/tmp/workspace"', + " new window with configuration cfg", + "end tell", + ].join("\n"), + ], + }); + }), + ); + + it.effect("uses Ghostty CLI for Linux", () => + Effect.gen(function* () { + const launch = yield* resolveWorkspaceLaunch( + { cwd: "/tmp/workspace", target: "ghostty" }, + "linux", + ); + + assert.deepEqual(launch, { + command: "ghostty", + args: ["+new-window", "--working-directory", "/tmp/workspace"], + }); + }), + ); + + it.effect("rejects Ghostty on unsupported platforms", () => + Effect.gen(function* () { + const result = yield* resolveWorkspaceLaunch( + { cwd: "C:\\workspace", target: "ghostty" }, + "win32", + ).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + }), + ); +}); + describe("launchDetached", () => { it.effect("resolves when command can be spawned", () => Effect.gen(function* () { @@ -220,3 +294,80 @@ describe("resolveAvailableEditors", () => { } }); }); + +describe("resolveAvailableOpenTargets", () => { + it("returns Ghostty on macOS when the app is installed", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-open-targets-darwin-")); + try { + fs.writeFileSync(path.join(dir, "cursor"), "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync(path.join(dir, "open"), "#!/bin/sh\n", { mode: 0o755 }); + + const targets = resolveAvailableOpenTargets( + "darwin", + { + PATH: dir, + }, + { + isMacApplicationAvailable: (appName) => appName === "Ghostty", + }, + ); + assert.deepEqual(targets, ["cursor", "ghostty", "file-manager"]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("omits Ghostty on macOS when the app is not installed", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-open-targets-darwin-missing-")); + try { + fs.writeFileSync(path.join(dir, "cursor"), "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync(path.join(dir, "open"), "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync(path.join(dir, "ghostty"), "#!/bin/sh\n", { mode: 0o755 }); + + const targets = resolveAvailableOpenTargets( + "darwin", + { + PATH: dir, + }, + { + isMacApplicationAvailable: () => false, + }, + ); + assert.deepEqual(targets, ["cursor", "file-manager"]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns Ghostty on Linux only when the CLI is available", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-open-targets-")); + try { + fs.writeFileSync(path.join(dir, "cursor"), "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync(path.join(dir, "ghostty"), "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync(path.join(dir, "xdg-open"), "#!/bin/sh\n", { mode: 0o755 }); + + const targets = resolveAvailableOpenTargets("linux", { + PATH: dir, + }); + assert.deepEqual(targets, ["cursor", "ghostty", "file-manager"]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("omits Ghostty on unsupported platforms", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-open-targets-win-")); + try { + fs.writeFileSync(path.join(dir, "cursor.CMD"), "@echo off\r\n", "utf8"); + fs.writeFileSync(path.join(dir, "explorer.EXE"), "MZ", "utf8"); + + const targets = resolveAvailableOpenTargets("win32", { + PATH: dir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }); + assert.deepEqual(targets, ["cursor", "file-manager"]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index e7238c04b..742d4230a 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -6,11 +6,16 @@ * * @module Open */ -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { accessSync, constants, statSync } from "node:fs"; import { extname, join } from "node:path"; -import { EDITORS, type EditorId } from "@t3tools/contracts"; +import { + EDITORS, + type EditorId, + WORKSPACE_OPEN_TARGETS, + type WorkspaceOpenTargetId, +} from "@t3tools/contracts"; import { ServiceMap, Schema, Effect, Layer } from "effect"; // ============================== @@ -27,6 +32,11 @@ export interface OpenInEditorInput { readonly editor: EditorId; } +export interface OpenWorkspaceInput { + readonly cwd: string; + readonly target: WorkspaceOpenTargetId; +} + interface EditorLaunch { readonly command: string; readonly args: ReadonlyArray; @@ -37,6 +47,10 @@ interface CommandAvailabilityOptions { readonly env?: NodeJS.ProcessEnv; } +interface OpenTargetAvailabilityOptions extends CommandAvailabilityOptions { + readonly isMacApplicationAvailable?: (appName: string) => boolean; +} + const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; function shouldUseGotoFlag(editorId: EditorId, target: string): boolean { @@ -56,6 +70,16 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { } } +function ghosttyCommandForPlatform(platform: NodeJS.Platform): string | null { + switch (platform) { + case "darwin": + case "linux": + return "ghostty"; + default: + return null; + } +} + function stripWrappingQuotes(value: string): string { return value.replace(/^"+|"+$/g, ""); } @@ -177,6 +201,90 @@ export function resolveAvailableEditors( return available; } +function isWorkspaceEditorTarget(target: WorkspaceOpenTargetId): target is EditorId { + return target !== "ghostty"; +} + +function resolveWorkspaceTargetCommand( + target: WorkspaceOpenTargetId, + platform: NodeJS.Platform, +): string | null { + if (target === "ghostty") { + return ghosttyCommandForPlatform(platform); + } + + const editorDef = EDITORS.find((editor) => editor.id === target); + if (!editorDef) return null; + return editorDef.command ?? fileManagerCommandForPlatform(platform); +} + +function isMacApplicationAvailable(appName: string): boolean { + const result = spawnSync("open", ["-Ra", appName], { + stdio: "ignore", + }); + return result.status === 0; +} + +function isWorkspaceTargetAvailable( + target: WorkspaceOpenTargetId, + options: OpenTargetAvailabilityOptions, +): boolean { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + + if (target === "ghostty") { + switch (platform) { + case "darwin": + return (options.isMacApplicationAvailable ?? isMacApplicationAvailable)("Ghostty"); + case "linux": + return isCommandAvailable("ghostty", { platform, env }); + default: + return false; + } + } + + const command = resolveWorkspaceTargetCommand(target, platform); + if (!command) return false; + return isCommandAvailable(command, { platform, env }); +} + +export function resolveAvailableOpenTargets( + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, + options: Omit = {}, +): ReadonlyArray { + const available: WorkspaceOpenTargetId[] = []; + + for (const target of WORKSPACE_OPEN_TARGETS) { + if ( + isWorkspaceTargetAvailable(target.id, { + platform, + env, + ...options, + }) + ) { + available.push(target.id); + } + } + + return available; +} + +function escapeAppleScriptString(value: string): string { + return JSON.stringify(value); +} + +function ghosttyAppleScript(cwd: string): string { + return [ + 'tell application "Ghostty"', + " activate", + " set cfg to new surface configuration", + ` set initial working directory of cfg to ${escapeAppleScriptString(cwd)}`, + " new window with configuration cfg", + "end tell", + ].join("\n"); +} + /** * OpenShape - Service API for browser and editor launch actions. */ @@ -192,6 +300,11 @@ export interface OpenShape { * Launches the editor as a detached process so server startup is not blocked. */ readonly openInEditor: (input: OpenInEditorInput) => Effect.Effect; + + /** + * Open a workspace in a selected workspace launcher target. + */ + readonly openWorkspace: (input: OpenWorkspaceInput) => Effect.Effect; } /** @@ -225,6 +338,32 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* ( return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] }; }); +export const resolveWorkspaceLaunch = Effect.fnUntraced(function* ( + input: OpenWorkspaceInput, + platform: NodeJS.Platform = process.platform, +): Effect.fn.Return { + if (isWorkspaceEditorTarget(input.target)) { + return yield* resolveEditorLaunch({ cwd: input.cwd, editor: input.target }, platform); + } + + switch (platform) { + case "darwin": + return { + command: "osascript", + args: ["-e", ghosttyAppleScript(input.cwd)], + }; + case "linux": + return { + command: "ghostty", + args: ["+new-window", "--working-directory", input.cwd], + }; + default: + return yield* new OpenError({ + message: `Unsupported workspace target ${input.target} on platform ${platform}`, + }); + } +}); + export const launchDetached = (launch: EditorLaunch) => Effect.gen(function* () { if (!isCommandAvailable(launch.command)) { @@ -270,6 +409,7 @@ const make = Effect.gen(function* () { catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }), }), openInEditor: (input) => Effect.flatMap(resolveEditorLaunch(input), launchDetached), + openWorkspace: (input) => Effect.flatMap(resolveWorkspaceLaunch(input), launchDetached), } satisfies OpenShape; }); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index f12792a31..57cdd8ba4 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -14,6 +14,7 @@ import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serve import { DEFAULT_TERMINAL_ID, EDITORS, + WORKSPACE_OPEN_TARGETS, EventId, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, @@ -62,6 +63,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); const defaultOpenService: OpenShape = { openBrowser: () => Effect.void, openInEditor: () => Effect.void, + openWorkspace: () => Effect.void, }; const defaultProviderStatuses: ReadonlyArray = [ @@ -442,6 +444,7 @@ function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsCon const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]); const VALID_EDITOR_IDS = new Set(EDITORS.map((editor) => editor.id)); +const VALID_OPEN_TARGET_IDS = new Set(WORKSPACE_OPEN_TARGETS.map((target) => target.id)); function expectAvailableEditors(value: unknown): void { expect(Array.isArray(value)).toBe(true); @@ -451,6 +454,16 @@ function expectAvailableEditors(value: unknown): void { } } +function expectAvailableOpenTargets(value: unknown): void { + expect(Array.isArray(value)).toBe(true); + for (const targetId of value as unknown[]) { + expect(typeof targetId).toBe("string"); + expect( + VALID_OPEN_TARGET_IDS.has(targetId as (typeof WORKSPACE_OPEN_TARGETS)[number]["id"]), + ).toBe(true); + } +} + describe("WebSocket Server", () => { let server: Http.Server | null = null; let serverScope: Scope.Closeable | null = null; @@ -831,8 +844,12 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + availableOpenTargets: expect.any(Array), }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); + expectAvailableOpenTargets( + (response.result as { availableOpenTargets: unknown }).availableOpenTargets, + ); }); it("bootstraps default keybindings file when missing", async () => { @@ -856,8 +873,12 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + availableOpenTargets: expect.any(Array), }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); + expectAvailableOpenTargets( + (response.result as { availableOpenTargets: unknown }).availableOpenTargets, + ); const persistedConfig = JSON.parse( fs.readFileSync(keybindingsPath, "utf8"), @@ -891,8 +912,12 @@ describe("WebSocket Server", () => { ], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + availableOpenTargets: expect.any(Array), }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); + expectAvailableOpenTargets( + (response.result as { availableOpenTargets: unknown }).availableOpenTargets, + ); expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json"); }); @@ -925,6 +950,7 @@ describe("WebSocket Server", () => { issues: Array<{ kind: string; index?: number; message: string }>; providers: ReadonlyArray; availableEditors: unknown; + availableOpenTargets: unknown; }; expect(result.cwd).toBe("/my/workspace"); expect(result.keybindingsConfigPath).toBe(keybindingsPath); @@ -945,6 +971,7 @@ describe("WebSocket Server", () => { expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true); expect(result.providers).toEqual(defaultProviderStatuses); expectAvailableEditors(result.availableEditors); + expectAvailableOpenTargets(result.availableOpenTargets); }); it("pushes server.configUpdated issues when keybindings file changes", async () => { @@ -990,6 +1017,7 @@ describe("WebSocket Server", () => { openCalls.push({ cwd: input.cwd, editor: input.editor }); return Effect.void; }, + openWorkspace: () => Effect.void, }; server = await createTestServer({ cwd: "/my/workspace", open: openService }); @@ -1007,6 +1035,32 @@ describe("WebSocket Server", () => { expect(openCalls).toEqual([{ cwd: "/my/workspace", editor: "cursor" }]); }); + it("routes shell.openWorkspace through the injected open service", async () => { + const openCalls: Array<{ cwd: string; target: string }> = []; + const openService: OpenShape = { + openBrowser: () => Effect.void, + openInEditor: () => Effect.void, + openWorkspace: (input) => { + openCalls.push({ cwd: input.cwd, target: input.target }); + return Effect.void; + }, + }; + + server = await createTestServer({ cwd: "/my/workspace", open: openService }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.shellOpenWorkspace, { + cwd: "/my/workspace", + target: "ghostty", + }); + expect(response.error).toBeUndefined(); + expect(openCalls).toEqual([{ cwd: "/my/workspace", target: "ghostty" }]); + }); + it("reads keybindings from the configured state directory", async () => { const stateDir = makeTempDir("t3code-state-keybindings-"); const keybindingsPath = path.join(stateDir, "keybindings.json"); @@ -1038,8 +1092,12 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + availableOpenTargets: expect.any(Array), }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); + expectAvailableOpenTargets( + (response.result as { availableOpenTargets: unknown }).availableOpenTargets, + ); }); it("upserts keybinding rules and updates cached server config", async () => { @@ -1085,10 +1143,14 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + availableOpenTargets: expect.any(Array), }); expectAvailableEditors( (configResponse.result as { availableEditors: unknown }).availableEditors, ); + expectAvailableOpenTargets( + (configResponse.result as { availableOpenTargets: unknown }).availableOpenTargets, + ); }); it("returns error for unknown methods", async () => { @@ -1467,6 +1529,7 @@ describe("WebSocket Server", () => { openBrowser: () => Effect.void, openInEditor: () => Effect.sync(() => BigInt(1)).pipe(Effect.map((result) => result as unknown as void)), + openWorkspace: () => Effect.void, }; try { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7..fc5b93805 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -57,7 +57,7 @@ import { ProviderService } from "./provider/Services/ProviderService"; import { ProviderHealth } from "./provider/Services/ProviderHealth"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; import { clamp } from "effect/Number"; -import { Open, resolveAvailableEditors } from "./open"; +import { Open, resolveAvailableEditors, resolveAvailableOpenTargets } from "./open"; import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore.ts"; import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; @@ -249,6 +249,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< autoBootstrapProjectFromCwd, } = serverConfig; const availableEditors = resolveAvailableEditors(); + const availableOpenTargets = resolveAvailableOpenTargets(); const gitManager = yield* GitManager; const terminalManager = yield* TerminalManager; @@ -602,7 +603,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const projectionReadModelQuery = yield* ProjectionSnapshotQuery; const checkpointDiffQuery = yield* CheckpointDiffQuery; const orchestrationReactor = yield* OrchestrationReactor; - const { openInEditor } = yield* Open; + const { openInEditor, openWorkspace } = yield* Open; const subscriptionsScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); @@ -781,6 +782,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* openInEditor(body); } + case WS_METHODS.shellOpenWorkspace: { + const body = stripRequestTag(request.body); + return yield* openWorkspace(body); + } + case WS_METHODS.gitStatus: { const body = stripRequestTag(request.body); return yield* gitManager.status(body); @@ -875,6 +881,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< issues: keybindingsConfig.issues, providers: providerStatuses, availableEditors, + availableOpenTargets, }; case WS_METHODS.serverUpsertKeybinding: { diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index d8b74c8cc..6f8266b56 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -109,6 +109,7 @@ function createBaseServerConfig(): ServerConfig { }, ], availableEditors: [], + availableOpenTargets: [], }; } @@ -897,6 +898,7 @@ describe("ChatView timeline estimator parity (full app)", () => { nextFixture.serverConfig = { ...nextFixture.serverConfig, availableEditors: ["vscode"], + availableOpenTargets: ["vscode"], }; }, }); @@ -929,6 +931,80 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("opens Ghostty from the workspace Open menu without changing the file editor preference", async () => { + useComposerDraftStore.setState({ + draftThreadsByThreadId: { + [THREAD_ID]: { + projectId: PROJECT_ID, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + envMode: "local", + }, + }, + projectDraftThreadIdByProjectId: { + [PROJECT_ID]: THREAD_ID, + }, + }); + window.localStorage.setItem("t3code:last-editor", "vscode"); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + availableEditors: ["vscode"], + availableOpenTargets: ["ghostty", "vscode"], + }; + }, + }); + + try { + const menuTrigger = await waitForElement(() => { + const openActions = Array.from( + document.querySelectorAll("[aria-label='Subscription actions']"), + ).find((group) => + Array.from(group.querySelectorAll("button")).some( + (button) => button.textContent?.trim() === "Open", + ), + ); + if (!openActions) return null; + return (openActions.querySelectorAll("button")[1] ?? null) as HTMLButtonElement | null; + }, "Unable to find Open menu trigger."); + menuTrigger.click(); + + const ghosttyOption = await waitForElement( + () => + Array.from(document.querySelectorAll("[role='menuitem']")).find((item) => + item.textContent?.includes("Ghostty"), + ) as HTMLElement | null, + "Unable to find Ghostty option.", + ); + ghosttyOption.click(); + + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.shellOpenWorkspace, + ); + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.shellOpenWorkspace, + cwd: "/repo/project", + target: "ghostty", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + + expect(window.localStorage.getItem("t3code:last-editor")).toBe("vscode"); + } finally { + await mounted.cleanup(); + } + }); + it("toggles plan mode with Shift+Tab only while the composer is focused", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d81e024f3..09eb1bf25 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2,6 +2,7 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, type EditorId, + type WorkspaceOpenTargetId, type KeybindingCommand, type CodexReasoningEffort, type MessageId, @@ -164,6 +165,7 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; +const EMPTY_AVAILABLE_OPEN_TARGETS: WorkspaceOpenTargetId[] = []; const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; @@ -958,6 +960,8 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; + const availableOpenTargets = + serverConfigQuery.data?.availableOpenTargets ?? EMPTY_AVAILABLE_OPEN_TARGETS; const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; const activeProvider = activeThread?.session?.provider ?? "codex"; const activeProviderStatus = useMemo( @@ -3171,6 +3175,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } keybindings={keybindings} availableEditors={availableEditors} + availableOpenTargets={availableOpenTargets} diffToggleShortcutLabel={diffPanelShortcutLabel} gitCwd={gitCwd} diffOpen={diffOpen} diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index ba4c8f432..baf207426 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -14,6 +14,7 @@ import { import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { ws, http, HttpResponse } from "msw"; import { setupWorker } from "msw/browser"; +import type { ReactNode } from "react"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -21,6 +22,10 @@ import { useComposerDraftStore } from "../composerDraftStore"; import { getRouter } from "../router"; import { useStore } from "../store"; +vi.mock("./DiffWorkerPoolProvider", () => ({ + DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children ?? null, +})); + const THREAD_ID = "thread-kb-toast-test" as ThreadId; const PROJECT_ID = "project-1" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; @@ -53,6 +58,7 @@ function createBaseServerConfig(): ServerConfig { }, ], availableEditors: [], + availableOpenTargets: [], }; } diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index ea7f911be..fdc1bfb0d 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -3,6 +3,7 @@ import { type ProjectScript, type ResolvedKeybindingsConfig, type ThreadId, + type WorkspaceOpenTargetId, } from "@t3tools/contracts"; import { memo } from "react"; import GitActionsControl from "../GitActionsControl"; @@ -24,6 +25,7 @@ interface ChatHeaderProps { preferredScriptId: string | null; keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; + availableOpenTargets: ReadonlyArray; diffToggleShortcutLabel: string | null; gitCwd: string | null; diffOpen: boolean; @@ -44,6 +46,7 @@ export const ChatHeader = memo(function ChatHeader({ preferredScriptId, keybindings, availableEditors, + availableOpenTargets, diffToggleShortcutLabel, gitCwd, diffOpen, @@ -90,6 +93,7 @@ export const ChatHeader = memo(function ChatHeader({ )} diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 701204c1b..207950d3b 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,7 +1,12 @@ -import { EDITORS, type EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { + EDITORS, + type EditorId, + type ResolvedKeybindingsConfig, + type WorkspaceOpenTargetId, +} from "@t3tools/contracts"; import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; -import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; +import { ChevronDownIcon, FolderClosedIcon, TerminalIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; @@ -14,18 +19,20 @@ const LAST_EDITOR_KEY = "t3code:last-editor"; export const OpenInPicker = memo(function OpenInPicker({ keybindings, availableEditors, + availableOpenTargets, openInCwd, }: { keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; + availableOpenTargets: ReadonlyArray; openInCwd: string | null; }) { const [lastEditor, setLastEditor] = useState(() => { const stored = localStorage.getItem(LAST_EDITOR_KEY); - return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; + return EDITORS.some((editor) => editor.id === stored) ? (stored as EditorId) : EDITORS[0].id; }); - const allOptions = useMemo>( + const allOptions = useMemo>( () => [ { label: "Cursor", @@ -42,6 +49,11 @@ export const OpenInPicker = memo(function OpenInPicker({ Icon: Zed, value: "zed", }, + { + label: "Ghostty", + Icon: TerminalIcon, + value: "ghostty", + }, { label: isMacPlatform(navigator.platform) ? "Finder" @@ -54,15 +66,23 @@ export const OpenInPicker = memo(function OpenInPicker({ ], [], ); - const options = useMemo( - () => allOptions.filter((option) => availableEditors.includes(option.value)), + const openTargetOptions = useMemo( + () => allOptions.filter((option) => availableOpenTargets.includes(option.value)), + [allOptions, availableOpenTargets], + ); + const editorOptions = useMemo( + () => + allOptions.filter( + (option) => + option.value !== "ghostty" && availableEditors.includes(option.value as EditorId), + ), [allOptions, availableEditors], ); - const effectiveEditor = options.some((option) => option.value === lastEditor) + const effectiveEditor = editorOptions.some((option) => option.value === lastEditor) ? lastEditor - : (options[0]?.value ?? null); - const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; + : ((editorOptions[0]?.value ?? null) as EditorId | null); + const primaryOption = editorOptions.find(({ value }) => value === effectiveEditor) ?? null; const openInEditor = useCallback( (editorId: EditorId | null) => { @@ -74,7 +94,21 @@ export const OpenInPicker = memo(function OpenInPicker({ localStorage.setItem(LAST_EDITOR_KEY, editor); setLastEditor(editor); }, - [effectiveEditor, openInCwd, setLastEditor], + [effectiveEditor, openInCwd], + ); + + const openTarget = useCallback( + (target: WorkspaceOpenTargetId) => { + const api = readNativeApi(); + if (!api || !openInCwd) return; + if (target === "ghostty") { + void api.shell.openWorkspace(openInCwd, target); + return; + } + + openInEditor(target); + }, + [openInCwd, openInEditor], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -83,18 +117,16 @@ export const OpenInPicker = memo(function OpenInPicker({ ); useEffect(() => { - const handler = (e: globalThis.KeyboardEvent) => { - const api = readNativeApi(); - if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; - if (!api || !openInCwd) return; - if (!effectiveEditor) return; + const handler = (event: globalThis.KeyboardEvent) => { + if (!isOpenFavoriteEditorShortcut(event, keybindings)) return; + if (!openInCwd || !effectiveEditor) return; - e.preventDefault(); - void api.shell.openInEditor(openInCwd, effectiveEditor); + event.preventDefault(); + openInEditor(effectiveEditor); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [effectiveEditor, keybindings, openInCwd]); + }, [effectiveEditor, keybindings, openInCwd, openInEditor]); return ( @@ -111,13 +143,22 @@ export const OpenInPicker = memo(function OpenInPicker({ - }> + + } + > - {options.length === 0 && No installed editors found} - {options.map(({ label, Icon, value }) => ( - openInEditor(value)}> + {openTargetOptions.length === 0 && No open targets found} + {openTargetOptions.map(({ label, Icon, value }) => ( + openTarget(value)}>