From 648a6ac372bb32e7f6a35b73eb23e444f3c99ef3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 16 Feb 2026 00:30:49 -0800 Subject: [PATCH] Support script editing and persist project keybindings - add server `upsertKeybinding` flow to write `~/.t3/keybindings.json` and refresh cached config - wire project script create/edit to save optional keybindings via the WS API - improve script action UI with edit controls, icon picker, and preferred-script recall - add coverage for keybinding upsert behavior in server and websocket tests --- apps/server/src/keybindings.test.ts | 82 ++++- apps/server/src/keybindings.ts | 120 ++++--- apps/server/src/wsServer.test.ts | 53 +++ apps/server/src/wsServer.ts | 10 +- apps/web/src/components/ChatView.tsx | 185 +++++++++- .../src/components/ProjectScriptsControl.tsx | 335 ++++++++++++++---- apps/web/src/projectScripts.test.ts | 49 +++ apps/web/src/projectScripts.ts | 32 ++ apps/web/src/wsNativeApi.ts | 1 + packages/contracts/src/ipc.ts | 2 + packages/contracts/src/project.test.ts | 17 + packages/contracts/src/project.ts | 9 +- packages/contracts/src/server.ts | 10 +- packages/contracts/src/ws.ts | 1 + 14 files changed, 779 insertions(+), 127 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index d761588e93872b3fa084d7cefde627f87a3728b5..f525139ddf2bb110c538be272c195dcd2c1847d7 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -1,8 +1,34 @@ -import { assert, describe, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { assert, afterEach, describe, expect, it, vi } from "vitest"; -import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings"; +import { + compileResolvedKeybindingRule, + parseKeybindingShortcut, + upsertKeybindingRule, +} from "./keybindings"; describe("server keybindings", () => { + const tempDirs: string[] = []; + const logger = { + warn: vi.fn(), + }; + + function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; + } + + afterEach(() => { + vi.restoreAllMocks(); + logger.warn.mockReset(); + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("parses shortcuts including plus key", () => { assert.deepEqual(parseKeybindingShortcut("mod+j"), { key: "j", @@ -66,4 +92,56 @@ describe("server keybindings", () => { }), ); }); + + it("upserts custom keybindings to ~/.t3/keybindings.json", () => { + const fakeHome = makeTempDir("t3code-keybindings-upsert-"); + const configDir = path.join(fakeHome, ".t3"); + fs.mkdirSync(configDir, { recursive: true }); + const configPath = path.join(configDir, "keybindings.json"); + fs.writeFileSync( + configPath, + JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), + "utf8", + ); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + const resolved = upsertKeybindingRule(logger, { + key: "mod+shift+r", + command: "script.run-tests.run", + }); + const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{ + key: string; + command: string; + }>; + + expect(persisted).toEqual([ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+shift+r", command: "script.run-tests.run" }, + ]); + expect(resolved.some((entry) => entry.command === "script.run-tests.run")).toBe(true); + }); + + it("replaces existing custom keybinding for the same command", () => { + const fakeHome = makeTempDir("t3code-keybindings-upsert-replace-"); + const configDir = path.join(fakeHome, ".t3"); + fs.mkdirSync(configDir, { recursive: true }); + const configPath = path.join(configDir, "keybindings.json"); + fs.writeFileSync( + configPath, + JSON.stringify([{ key: "mod+r", command: "script.run-tests.run" }]), + "utf8", + ); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + upsertKeybindingRule(logger, { + key: "mod+shift+r", + command: "script.run-tests.run", + }); + + const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{ + key: string; + command: string; + }>; + expect(persisted).toEqual([{ key: "mod+shift+r", command: "script.run-tests.run" }]); + }); }); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 97e2de438ff79666a16cdbf60844a2fbbbaabd97..631e4a2a829c0333ab37a59fd0e24f9936aeb222 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -274,30 +274,15 @@ function compileDefaultKeybindings(): ResolvedKeybindingsConfig { } const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings(); +const KEYBINDINGS_CONFIG_PATH = path.join(".t3", "keybindings.json"); +const MAX_KEYBINDINGS = 256; -function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { - if (custom.length === 0) { - return [...DEFAULT_RESOLVED_KEYBINDINGS]; - } - - const overriddenCommands = new Set(custom.map((binding) => binding.command)); - const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( - (binding) => !overriddenCommands.has(binding.command), - ); - const merged = [...retainedDefaults, ...custom]; - - if (merged.length <= 256) { - return merged; - } - - // Keep the latest rules when the config exceeds max size; later rules have higher precedence. - return merged.slice(-256); +function resolveKeybindingsConfigPath(): string { + return path.join(os.homedir(), KEYBINDINGS_CONFIG_PATH); } -export function loadResolvedKeybindingsConfig( - logger: KeybindingsLogger, -): ResolvedKeybindingsConfig { - const configPath = path.join(os.homedir(), ".t3", "keybindings.json"); +function loadCustomKeybindingsConfig(logger: KeybindingsLogger): KeybindingsConfig { + const configPath = resolveKeybindingsConfigPath(); try { const raw = fs.readFileSync(configPath, "utf8"); const parsed = JSON.parse(raw) as unknown; @@ -305,20 +290,19 @@ export function loadResolvedKeybindingsConfig( logger.warn("ignoring keybindings config with unsupported format; expected array", { path: configPath, }); - return [...DEFAULT_RESOLVED_KEYBINDINGS]; + return []; } - const sanitized: ResolvedKeybindingsConfig = []; + const sanitized: KeybindingsConfig = []; let invalidEntries = 0; for (const entry of parsed) { const result = keybindingRuleSchema.safeParse(entry); if (result.success) { - const compiled = compileResolvedKeybindingRule(result.data); - if (!compiled) { + if (!compileResolvedKeybindingRule(result.data)) { invalidEntries += 1; continue; } - sanitized.push(compiled); + sanitized.push(result.data); continue; } invalidEntries += 1; @@ -330,26 +314,82 @@ export function loadResolvedKeybindingsConfig( totalEntries: parsed.length, }); } - const overriddenCommands = new Set(sanitized.map((entry) => entry.command)); - const mergedBeforeCapLength = - DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) - .length + sanitized.length; - const merged = mergeWithDefaultKeybindings(sanitized); - if (mergedBeforeCapLength > 256) { - logger.warn("truncating merged keybindings config to max entries", { - path: configPath, - maxEntries: 256, - }); - } - return merged; + return sanitized; } catch (error) { if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { - return [...DEFAULT_RESOLVED_KEYBINDINGS]; + return []; } logger.warn("ignoring malformed keybindings config", { path: configPath, error: error instanceof Error ? error.message : String(error), }); + return []; } - return [...DEFAULT_RESOLVED_KEYBINDINGS]; +} + +function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { + if (custom.length === 0) { + return [...DEFAULT_RESOLVED_KEYBINDINGS]; + } + + const overriddenCommands = new Set(custom.map((binding) => binding.command)); + const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( + (binding) => !overriddenCommands.has(binding.command), + ); + const merged = [...retainedDefaults, ...custom]; + + if (merged.length <= MAX_KEYBINDINGS) { + return merged; + } + + // Keep the latest rules when the config exceeds max size; later rules have higher precedence. + return merged.slice(-MAX_KEYBINDINGS); +} + +export function loadResolvedKeybindingsConfig( + logger: KeybindingsLogger, +): ResolvedKeybindingsConfig { + const customConfig = loadCustomKeybindingsConfig(logger); + const compiledCustomConfig = compileResolvedKeybindingsConfig(customConfig); + const overriddenCommands = new Set(compiledCustomConfig.map((entry) => entry.command)); + const mergedBeforeCapLength = + DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) + .length + compiledCustomConfig.length; + const merged = mergeWithDefaultKeybindings(compiledCustomConfig); + if (mergedBeforeCapLength > MAX_KEYBINDINGS) { + logger.warn("truncating merged keybindings config to max entries", { + path: resolveKeybindingsConfigPath(), + maxEntries: MAX_KEYBINDINGS, + }); + } + return merged; +} + +export function upsertKeybindingRule( + logger: KeybindingsLogger, + rawRule: unknown, +): ResolvedKeybindingsConfig { + const rule = keybindingRuleSchema.parse(rawRule); + if (!compileResolvedKeybindingRule(rule)) { + throw new Error(`Invalid keybinding shortcut: "${rule.key}"`); + } + + const configPath = resolveKeybindingsConfigPath(); + const customConfig = loadCustomKeybindingsConfig(logger); + const nextConfig = [...customConfig.filter((entry) => entry.command !== rule.command), rule]; + const cappedConfig = + nextConfig.length > MAX_KEYBINDINGS ? nextConfig.slice(-MAX_KEYBINDINGS) : nextConfig; + + if (nextConfig.length > MAX_KEYBINDINGS) { + logger.warn("truncating keybindings config to max entries", { + path: configPath, + maxEntries: MAX_KEYBINDINGS, + }); + } + + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, `${JSON.stringify(cappedConfig, null, 2)}\n`, "utf8"); + + const compiledCustomConfig = compileResolvedKeybindingsConfig(cappedConfig); + return mergeWithDefaultKeybindings(compiledCustomConfig); } diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 7ccc090697851c5237b703af061cb0795aee7539..bf492e19915fb90178a7f7d63f15f6fd2c01e4bc 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -491,6 +491,59 @@ describe("WebSocket Server", () => { expect(secondResponse.result).toEqual(firstResponse.result); }); + it("upserts keybinding rules and updates cached server config", async () => { + const fakeHome = makeTempDir("t3code-home-upsert-keybinding-"); + const configDir = path.join(fakeHome, ".t3"); + fs.mkdirSync(configDir, { recursive: true }); + const keybindingsPath = path.join(configDir, "keybindings.json"); + fs.writeFileSync( + keybindingsPath, + JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), + "utf8", + ); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + server = createTestServer({ cwd: "/my/workspace" }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const upsertResponse = await sendRequest(ws, WS_METHODS.serverUpsertKeybinding, { + key: "mod+shift+r", + command: "script.run-tests.run", + }); + expect(upsertResponse.error).toBeUndefined(); + expect(upsertResponse.result).toEqual({ + keybindings: mergeWithDefaultsForTest([ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+shift+r", command: "script.run-tests.run" }, + ]), + }); + + const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as Array<{ + key: string; + command: string; + }>; + expect(persistedConfig).toEqual([ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+shift+r", command: "script.run-tests.run" }, + ]); + + const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(configResponse.error).toBeUndefined(); + expect(configResponse.result).toEqual({ + cwd: "/my/workspace", + keybindings: mergeWithDefaultsForTest([ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+shift+r", command: "script.run-tests.run" }, + ]), + }); + }); + it("warns and ignores unsupported keybindings config format", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const fakeHome = makeTempDir("t3code-home-unsupported-format-"); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index bf5f26b5bd9238c16dd893499a7106ec05241382..ce9878cd469bb982e0ba6a706f8a3d98b514fa73 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -31,7 +31,7 @@ import { removeGitWorktree, } from "./git"; import { TerminalManager } from "./terminalManager"; -import { loadResolvedKeybindingsConfig } from "./keybindings"; +import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; import { searchWorkspaceEntries } from "./workspaceEntries"; const MIME_TYPES: Record = { @@ -92,7 +92,7 @@ export function createServer(options: ServerOptions) { const logger = createLogger("ws"); const logWebSocketEvents = explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); - const keybindingsConfig = loadResolvedKeybindingsConfig(logger); + let keybindingsConfig = loadResolvedKeybindingsConfig(logger); function logOutgoingPush(push: WsPush, recipients: number) { if (!logWebSocketEvents) return; @@ -423,6 +423,12 @@ export function createServer(options: ServerOptions) { keybindings: keybindingsConfig, }; + case WS_METHODS.serverUpsertKeybinding: + keybindingsConfig = upsertKeybindingRule(logger, request.params); + return { + keybindings: keybindingsConfig, + }; + default: throw new Error(`Unknown method: ${request.method}`); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5841877fde15fba617d1baf42b3c56bcbd7c7e03..0e0a05ad9089f94fada67d6f34672ab71b6b8a7f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -31,7 +31,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; -import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; +import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; import { buildBootstrapInput } from "../historyBootstrap"; @@ -97,8 +97,10 @@ import { Badge } from "./ui/badge"; import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { + commandForProjectScript, injectEnvIntoShellCommand, nextProjectScriptId, + projectScriptRuntimeEnv, projectScriptIdFromCommand, setupProjectScript, } from "~/projectScripts"; @@ -109,6 +111,7 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { } const LAST_EDITOR_KEY = "t3code:last-editor"; +const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; const IMAGE_ONLY_BOOTSTRAP_PROMPT = @@ -125,6 +128,24 @@ const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({ const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; +function readLastInvokedScriptByProjectFromStorage(): Record { + const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); + if (!stored) return {}; + + try { + const parsed: unknown = JSON.parse(stored); + if (!parsed || typeof parsed !== "object") return {}; + return Object.fromEntries( + Object.entries(parsed).filter( + (entry): entry is [string, string] => + typeof entry[0] === "string" && typeof entry[1] === "string", + ), + ); + } catch { + return {}; + } +} + function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; if (tone === "tool") return "text-muted-foreground/70"; @@ -330,6 +351,9 @@ export default function ChatView() { const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [composerCursor, setComposerCursor] = useState(0); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); + const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< + Record + >(() => readLastInvokedScriptByProjectFromStorage()); const messagesScrollRef = useRef(null); const messagesEndRef = useRef(null); const shouldAutoScrollRef = useRef(true); @@ -635,6 +659,10 @@ export default function ChatView() { }, ) => { if (!api || !activeThreadId || !activeProject || !activeThread) return; + setLastInvokedScriptByProjectId((current) => { + if (current[activeProject.id] === script.id) return current; + return { ...current, [activeProject.id]: script.id }; + }); const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; const baseTerminalId = activeThread.activeTerminalId; const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId); @@ -663,12 +691,20 @@ export default function ChatView() { } setTerminalFocusRequestId((value) => value + 1); - const scriptEnv = - options?.env ?? - (script.runOnWorktreeCreate ? { T3CODE_PROJECT_ROOT: activeProject.cwd } : undefined); - const resolvedCommand = scriptEnv - ? injectEnvIntoShellCommand(script.command, scriptEnv) - : script.command; + const resolvedCommand = injectEnvIntoShellCommand( + script.command, + projectScriptRuntimeEnv({ + project: { + id: activeProject.id, + name: activeProject.name, + cwd: activeProject.cwd, + }, + script, + threadId: activeThreadId, + worktreePath: activeThread.worktreePath ?? null, + extraEnv: options?.env, + }), + ); try { await api.terminal.open({ @@ -723,17 +759,45 @@ export default function ChatView() { }); if (!isElectron || !api) return; + let scriptsPersisted = false; try { const updated = await api.projects.updateScripts({ id: activeProject.id, scripts: nextScripts, }); + scriptsPersisted = true; + + if (input.keybinding) { + const keybindingUpdate = await api.server.upsertKeybinding({ + key: input.keybinding, + command: commandForProjectScript(nextId), + }); + queryClient.setQueryData( + serverQueryKeys.config(), + (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) => + current + ? { ...current, keybindings: keybindingUpdate.keybindings } + : { + cwd: activeProject.cwd, + keybindings: keybindingUpdate.keybindings, + }, + ); + } + dispatch({ type: "SET_PROJECT_SCRIPTS", projectId: updated.project.id, scripts: updated.project.scripts, }); } catch (error) { + if (scriptsPersisted) { + await api.projects + .updateScripts({ + id: activeProject.id, + scripts: activeProject.scripts, + }) + .catch(() => undefined); + } dispatch({ type: "SET_PROJECT_SCRIPTS", projectId: activeProject.id, @@ -742,7 +806,86 @@ export default function ChatView() { throw error; } }, - [activeProject, api, dispatch], + [activeProject, api, dispatch, queryClient], + ); + const updateProjectScript = useCallback( + async (scriptId: string, input: NewProjectScriptInput) => { + if (!activeProject) return; + const existingScript = activeProject.scripts.find((script) => script.id === scriptId); + if (!existingScript) { + throw new Error("Script not found."); + } + + const updatedScript: ProjectScript = { + ...existingScript, + name: input.name, + command: input.command, + icon: input.icon, + runOnWorktreeCreate: input.runOnWorktreeCreate, + }; + const nextScripts = activeProject.scripts.map((script) => + script.id === scriptId + ? updatedScript + : input.runOnWorktreeCreate + ? { ...script, runOnWorktreeCreate: false } + : script, + ); + + dispatch({ + type: "SET_PROJECT_SCRIPTS", + projectId: activeProject.id, + scripts: nextScripts, + }); + + if (!isElectron || !api) return; + let scriptsPersisted = false; + try { + const updated = await api.projects.updateScripts({ + id: activeProject.id, + scripts: nextScripts, + }); + scriptsPersisted = true; + + if (input.keybinding) { + const keybindingUpdate = await api.server.upsertKeybinding({ + key: input.keybinding, + command: commandForProjectScript(scriptId), + }); + queryClient.setQueryData( + serverQueryKeys.config(), + (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) => + current + ? { ...current, keybindings: keybindingUpdate.keybindings } + : { + cwd: activeProject.cwd, + keybindings: keybindingUpdate.keybindings, + }, + ); + } + + dispatch({ + type: "SET_PROJECT_SCRIPTS", + projectId: updated.project.id, + scripts: updated.project.scripts, + }); + } catch (error) { + if (scriptsPersisted) { + await api.projects + .updateScripts({ + id: activeProject.id, + scripts: activeProject.scripts, + }) + .catch(() => undefined); + } + dispatch({ + type: "SET_PROJECT_SCRIPTS", + projectId: activeProject.id, + scripts: activeProject.scripts, + }); + throw error; + } + }, + [activeProject, api, dispatch, queryClient], ); const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { @@ -768,6 +911,21 @@ export default function ChatView() { } }; + useEffect(() => { + try { + if (Object.keys(lastInvokedScriptByProjectId).length === 0) { + localStorage.removeItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); + return; + } + localStorage.setItem( + LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, + JSON.stringify(lastInvokedScriptByProjectId), + ); + } catch { + // Ignore storage write failures (private mode, quota exceeded, etc.) + } + }, [lastInvokedScriptByProjectId]); + // Auto-scroll on new messages const messageCount = activeThread?.messages.length ?? 0; const workLogCount = workLogEntries.length; @@ -1192,7 +1350,6 @@ export default function ChatView() { if (setupScript) { void runProjectScript(setupScript, { cwd: result.worktree.path, - env: { T3CODE_PROJECT_ROOT: activeProject.cwd }, }); } } catch (err) { @@ -1474,6 +1631,9 @@ export default function ChatView() { activeThreadTitle={activeThread.title} activeProjectName={activeProject?.name} activeProjectScripts={activeProject?.scripts} + preferredScriptId={ + activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null + } keybindings={keybindings} api={api} gitCwd={gitCwd} @@ -1482,6 +1642,7 @@ export default function ChatView() { void runProjectScript(script); }} onAddProjectScript={saveProjectScript} + onUpdateProjectScript={updateProjectScript} onToggleDiff={onToggleDiff} /> @@ -1806,12 +1967,14 @@ interface ChatHeaderProps { activeThreadTitle: string; activeProjectName: string | undefined; activeProjectScripts: ProjectScript[] | undefined; + preferredScriptId: string | null; keybindings: ResolvedKeybindingsConfig; api: NativeApi | undefined; gitCwd: string | null; diffOpen: boolean; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; + onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; onToggleDiff: () => void; } @@ -1819,12 +1982,14 @@ const ChatHeader = memo(function ChatHeader({ activeThreadTitle, activeProjectName, activeProjectScripts, + preferredScriptId, keybindings, api, gitCwd, diffOpen, onRunProjectScript, onAddProjectScript, + onUpdateProjectScript, onToggleDiff, }: ChatHeaderProps) { return ( @@ -1840,8 +2005,10 @@ const ChatHeader = memo(function ChatHeader({ )} {activeProjectName && } diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index bc2516a4d987a88e3236d3ec0da026b7a18f665f..dd523fce585648cd9a052640a8cd880be1e263f2 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -4,7 +4,9 @@ import type { ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { + BugIcon, ChevronDownIcon, + CogIcon, FlaskConicalIcon, HammerIcon, ListChecksIcon, @@ -12,14 +14,11 @@ import { PlusIcon, WrenchIcon, } from "lucide-react"; -import { type FormEvent, useMemo, useState } from "react"; +import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react"; -import { - commandForProjectScript, - nextProjectScriptId, - primaryProjectScript, -} from "~/projectScripts"; +import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; import { shortcutLabelForCommand } from "~/keybindings"; +import { isMacPlatform } from "~/lib/utils"; import { Button } from "./ui/button"; import { Dialog, @@ -34,7 +33,7 @@ import { Group, GroupSeparator } from "./ui/group"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; +import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Switch } from "./ui/switch"; import { Textarea } from "./ui/textarea"; @@ -44,6 +43,7 @@ const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ { id: "lint", label: "Lint" }, { id: "configure", label: "Configure" }, { id: "build", label: "Build" }, + { id: "debug", label: "Debug" }, ]; function ScriptIcon({ @@ -57,6 +57,7 @@ function ScriptIcon({ if (icon === "lint") return ; if (icon === "configure") return ; if (icon === "build") return ; + if (icon === "debug") return ; return ; } @@ -65,39 +66,133 @@ export interface NewProjectScriptInput { command: string; icon: ProjectScriptIcon; runOnWorktreeCreate: boolean; + keybinding: string | null; } interface ProjectScriptsControlProps { scripts: ProjectScript[]; keybindings: ResolvedKeybindingsConfig; + preferredScriptId?: string | null; onRunScript: (script: ProjectScript) => void; onAddScript: (input: NewProjectScriptInput) => Promise | void; + onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise | void; +} + +function normalizeShortcutKeyToken(key: string): string | null { + const normalized = key.toLowerCase(); + if ( + normalized === "meta" || + normalized === "control" || + normalized === "ctrl" || + normalized === "shift" || + normalized === "alt" || + normalized === "option" + ) { + return null; + } + if (normalized === " ") return "space"; + if (normalized === "escape") return "esc"; + if (normalized === "arrowup") return "arrowup"; + if (normalized === "arrowdown") return "arrowdown"; + if (normalized === "arrowleft") return "arrowleft"; + if (normalized === "arrowright") return "arrowright"; + if (normalized.length === 1) return normalized; + if (normalized.startsWith("f") && normalized.length <= 3) return normalized; + if (normalized === "enter" || normalized === "tab" || normalized === "backspace") { + return normalized; + } + if (normalized === "delete" || normalized === "home" || normalized === "end") { + return normalized; + } + if (normalized === "pageup" || normalized === "pagedown") return normalized; + return null; +} + +function keybindingFromEvent(event: KeyboardEvent): string | null { + const keyToken = normalizeShortcutKeyToken(event.key); + if (!keyToken) return null; + + const parts: string[] = []; + if (isMacPlatform(navigator.platform)) { + if (event.metaKey) parts.push("mod"); + if (event.ctrlKey) parts.push("ctrl"); + } else { + if (event.ctrlKey) parts.push("mod"); + if (event.metaKey) parts.push("meta"); + } + if (event.altKey) parts.push("alt"); + if (event.shiftKey) parts.push("shift"); + parts.push(keyToken); + return parts.join("+"); +} + +function keybindingValueForCommand( + keybindings: ResolvedKeybindingsConfig, + command: string, +): string | null { + for (let index = keybindings.length - 1; index >= 0; index -= 1) { + const binding = keybindings[index]; + if (!binding || binding.command !== command) continue; + + const parts: string[] = []; + if (binding.shortcut.modKey) parts.push("mod"); + if (binding.shortcut.ctrlKey) parts.push("ctrl"); + if (binding.shortcut.metaKey) parts.push("meta"); + if (binding.shortcut.altKey) parts.push("alt"); + if (binding.shortcut.shiftKey) parts.push("shift"); + const keyToken = + binding.shortcut.key === " " + ? "space" + : binding.shortcut.key === "escape" + ? "esc" + : binding.shortcut.key; + parts.push(keyToken); + return parts.join("+"); + } + return null; } export default function ProjectScriptsControl({ scripts, keybindings, + preferredScriptId = null, onRunScript, onAddScript, + onUpdateScript, }: ProjectScriptsControlProps) { + const addScriptFormId = React.useId(); + const [editingScriptId, setEditingScriptId] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); const [name, setName] = useState(""); const [command, setCommand] = useState(""); const [icon, setIcon] = useState("play"); + const [iconPickerOpen, setIconPickerOpen] = useState(false); const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); + const [keybinding, setKeybinding] = useState(""); const [validationError, setValidationError] = useState(null); - const primaryScript = useMemo(() => primaryProjectScript(scripts), [scripts]); - const keybindingCommandPreview = useMemo( - () => - commandForProjectScript( - nextProjectScriptId( - name.length > 0 ? name : "script", - scripts.map((script) => script.id), - ), - ), - [name, scripts], - ); + const primaryScript = useMemo(() => { + if (preferredScriptId) { + const preferred = scripts.find((script) => script.id === preferredScriptId); + if (preferred) return preferred; + } + return primaryProjectScript(scripts); + }, [preferredScriptId, scripts]); + const isEditing = editingScriptId !== null; + const dropdownItemClassName = + "data-highlighted:bg-transparent data-highlighted:text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-highlighted:hover:bg-accent data-highlighted:hover:text-accent-foreground data-highlighted:focus-visible:bg-accent data-highlighted:focus-visible:text-accent-foreground"; + + const captureKeybinding = (event: KeyboardEvent) => { + if (event.key === "Tab") return; + event.preventDefault(); + if (event.key === "Backspace" || event.key === "Delete") { + setKeybinding(""); + return; + } + const next = keybindingFromEvent(event); + if (!next) return; + setKeybinding(next); + }; const submitAddScript = async (event: FormEvent) => { event.preventDefault(); @@ -114,23 +209,51 @@ export default function ProjectScriptsControl({ setValidationError(null); try { - await onAddScript({ + const payload = { name: trimmedName, command: trimmedCommand, icon, runOnWorktreeCreate, - }); + keybinding: keybinding.trim() || null, + } satisfies NewProjectScriptInput; + if (editingScriptId) { + await onUpdateScript(editingScriptId, payload); + } else { + await onAddScript(payload); + } setDialogOpen(false); + setEditingScriptId(null); setName(""); setCommand(""); setIcon("play"); + setIconPickerOpen(false); setRunOnWorktreeCreate(false); + setKeybinding(""); } catch (error) { setValidationError(error instanceof Error ? error.message : "Failed to save action."); } }; - const openDialog = () => { + const openAddDialog = () => { + setEditingScriptId(null); + setName(""); + setCommand(""); + setIcon("play"); + setIconPickerOpen(false); + setRunOnWorktreeCreate(false); + setKeybinding(""); + setValidationError(null); + setDialogOpen(true); + }; + + const openEditDialog = (script: ProjectScript) => { + setEditingScriptId(script.id); + setName(script.name); + setCommand(script.command); + setIcon(script.icon); + setIconPickerOpen(false); + setRunOnWorktreeCreate(script.runOnWorktreeCreate); + setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); setValidationError(null); setDialogOpen(true); }; @@ -146,10 +269,10 @@ export default function ProjectScriptsControl({ title={`Run ${primaryScript.name}`} > - {primaryScript.name} + {primaryScript.name} - + } > @@ -162,14 +285,44 @@ export default function ProjectScriptsControl({ commandForProjectScript(script.id), ); return ( - onRunScript(script)}> + onRunScript(script)} + > - {script.runOnWorktreeCreate ? `${script.name} (Setup)` : script.name} - {shortcutLabel && {shortcutLabel}} + + {script.runOnWorktreeCreate ? `${script.name}` : script.name} + + + {shortcutLabel && ( + + {shortcutLabel} + + )} + + ); })} - + Add action @@ -177,7 +330,7 @@ export default function ProjectScriptsControl({ ) : ( - @@ -185,24 +338,75 @@ export default function ProjectScriptsControl({ -
- - Add Action - - Actions are project-scoped commands you can run from the top bar or keybindings. - - - + + {isEditing ? "Edit Action" : "Add Action"} + + Actions are project-scoped commands you can run from the top bar or keybindings. + + + +
+
+ + + } + > + + + +
+ {SCRIPT_ICONS.map((entry) => { + const isSelected = entry.id === icon; + return ( + + ); + })} +
+
+
+ setName(event.target.value)} + /> +
+
+
+ setName(event.target.value)} + id="script-keybinding" + placeholder="Press shortcut" + value={keybinding} + readOnly + onKeyDown={captureKeybinding} />

- Keybinding command: {keybindingCommandPreview} + Press a shortcut. Use Backspace to clear.

@@ -214,44 +418,31 @@ export default function ProjectScriptsControl({ onChange={(event) => setCommand(event.target.value)} />
-
- - -
{validationError &&

{validationError}

} -
- - - - - + +
+ + + +
diff --git a/apps/web/src/projectScripts.test.ts b/apps/web/src/projectScripts.test.ts index 1ee4e5be214eab6623d464a708e72293f584db3e..cba0f418d26ee20cf1663e3483529082f7f8cf22 100644 --- a/apps/web/src/projectScripts.test.ts +++ b/apps/web/src/projectScripts.test.ts @@ -5,6 +5,7 @@ import { injectEnvIntoShellCommand, nextProjectScriptId, primaryProjectScript, + projectScriptRuntimeEnv, projectScriptIdFromCommand, setupProjectScript, } from "./projectScripts"; @@ -54,4 +55,52 @@ describe("projectScripts helpers", () => { expect(primaryProjectScript(scripts)?.id).toBe("test"); expect(setupProjectScript(scripts)?.id).toBe("setup"); }); + + it("builds default runtime env for scripts", () => { + const env = projectScriptRuntimeEnv({ + project: { id: "project-1", name: "acme-web", cwd: "/repo" }, + script: { + id: "test", + name: "Test", + icon: "test", + runOnWorktreeCreate: false, + }, + threadId: "thread-1", + worktreePath: "/repo/worktree-a", + }); + + expect(env).toMatchObject({ + T3CODE_PROJECT_ROOT: "/repo", + T3CODE_PROJECT_ID: "project-1", + T3CODE_PROJECT_NAME: "acme-web", + T3CODE_THREAD_ID: "thread-1", + T3CODE_SCRIPT_ID: "test", + T3CODE_SCRIPT_NAME: "Test", + T3CODE_SCRIPT_ICON: "test", + T3CODE_SCRIPT_IS_SETUP: "0", + T3CODE_WORKTREE_PATH: "/repo/worktree-a", + }); + }); + + it("allows overriding runtime env values", () => { + const env = projectScriptRuntimeEnv({ + project: { id: "project-1", name: "acme-web", cwd: "/repo" }, + script: { + id: "setup", + name: "Setup", + icon: "configure", + runOnWorktreeCreate: true, + }, + threadId: "thread-1", + extraEnv: { + T3CODE_PROJECT_ROOT: "/custom-root", + CUSTOM_FLAG: "1", + }, + }); + + expect(env.T3CODE_PROJECT_ROOT).toBe("/custom-root"); + expect(env.T3CODE_SCRIPT_IS_SETUP).toBe("1"); + expect(env.CUSTOM_FLAG).toBe("1"); + expect(env.T3CODE_WORKTREE_PATH).toBeUndefined(); + }); }); diff --git a/apps/web/src/projectScripts.ts b/apps/web/src/projectScripts.ts index 501f1b34ad67422282b1a454fe1fa58b70237d0b..a08f73bcac428e4fa3d3fd04b169fd52320ab6f9 100644 --- a/apps/web/src/projectScripts.ts +++ b/apps/web/src/projectScripts.ts @@ -82,6 +82,38 @@ export function injectEnvIntoShellCommand( return `env ${assignments} ${command}`; } +interface ProjectScriptRuntimeEnvInput { + project: { + id: string; + name: string; + cwd: string; + }; + script: Pick; + threadId: string; + worktreePath?: string | null; + extraEnv?: Record; +} + +export function projectScriptRuntimeEnv(input: ProjectScriptRuntimeEnvInput): Record { + const env: Record = { + T3CODE_PROJECT_ROOT: input.project.cwd, + T3CODE_PROJECT_ID: input.project.id, + T3CODE_PROJECT_NAME: input.project.name, + T3CODE_THREAD_ID: input.threadId, + T3CODE_SCRIPT_ID: input.script.id, + T3CODE_SCRIPT_NAME: input.script.name, + T3CODE_SCRIPT_ICON: input.script.icon, + T3CODE_SCRIPT_IS_SETUP: input.script.runOnWorktreeCreate ? "1" : "0", + }; + if (input.worktreePath) { + env.T3CODE_WORKTREE_PATH = input.worktreePath; + } + if (input.extraEnv) { + return { ...env, ...input.extraEnv }; + } + return env; +} + export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null { const regular = scripts.find((script) => !script.runOnWorktreeCreate); return regular ?? scripts[0] ?? null; diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index d5c232eeee383333feeaebf4a8ac385bb5fe0e64..bf5dc8b300f515b38e8cf46d1b460d47d1a160ca 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -143,6 +143,7 @@ export function createWsNativeApi(): NativeApi { }, server: { getConfig: () => transport.request(WS_METHODS.serverGetConfig), + upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), }, }; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 42b377b87b12c923ec2ad90492d8b165710f40cb..3153037a9e540ca6cdb68413f0c1fb512d14cec2 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -46,6 +46,7 @@ import type { TerminalWriteInput, } from "./terminal"; import type { NewTodoInput, Todo } from "./todo"; +import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; export const EDITORS = [ { id: "cursor", label: "Cursor", command: "cursor" }, @@ -122,5 +123,6 @@ export interface NativeApi { }; server: { getConfig: () => Promise; + upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; }; } diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index 693e4ff7173350e2c9586d52df49397d65f31f27..b5b16d93bc51d0b791b5d44bf14ec07f286bbd1b 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -105,6 +105,23 @@ describe("project contracts", () => { expect(parsed.scripts[0]?.id).toBe("test"); }); + it("parses debug icon in scripts", () => { + const parsed = projectUpdateScriptsInputSchema.parse({ + id: "project-1", + scripts: [ + { + id: "debug", + name: "Debug", + command: "bun --inspect test", + icon: "debug", + runOnWorktreeCreate: false, + }, + ], + }); + + expect(parsed.scripts[0]?.icon).toBe("debug"); + }); + it("parses update scripts result", () => { const parsed = projectUpdateScriptsResultSchema.parse({ project: { diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 8ed18e5fd9cdc3a5fd29bfb081ac45c56e442301..0466a6887ed836a2a0de3c4cf88e8d3ad504ee9e 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,6 +1,13 @@ import { z } from "zod"; -export const PROJECT_SCRIPT_ICON_VALUES = ["play", "test", "lint", "configure", "build"] as const; +export const PROJECT_SCRIPT_ICON_VALUES = [ + "play", + "test", + "lint", + "configure", + "build", + "debug", +] as const; export const projectScriptIconSchema = z.enum(PROJECT_SCRIPT_ICON_VALUES); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index ebf3f1334e41441a8f8dc5eb7b8cc5ad5f1ba8bf..6303d9cfca971f40db5d4d682f4127eb3748fada 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,9 +1,17 @@ import { z } from "zod"; -import { resolvedKeybindingsConfigSchema } from "./keybindings"; +import { keybindingRuleSchema, resolvedKeybindingsConfigSchema } from "./keybindings"; export const serverConfigSchema = z.object({ cwd: z.string().min(1), keybindings: resolvedKeybindingsConfigSchema.default([]), }); +export const serverUpsertKeybindingInputSchema = keybindingRuleSchema; + +export const serverUpsertKeybindingResultSchema = z.object({ + keybindings: resolvedKeybindingsConfigSchema.default([]), +}); + export type ServerConfig = z.infer; +export type ServerUpsertKeybindingInput = z.infer; +export type ServerUpsertKeybindingResult = z.infer; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 790a8f8ec1dbbc0a927d71138f940937786adf69..7d4abb60fc710e3bc9d35472fd6440244c320497 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -42,6 +42,7 @@ export const WS_METHODS = { // Server meta serverGetConfig: "server.getConfig", + serverUpsertKeybinding: "server.upsertKeybinding", } as const; // ── Push Event Channels ────────────────────────────────────────────── -- 2.54.0