| author | |
| committer | |
| log | 648a6ac372bb32e7f6a35b73eb23e444f3c99ef3 |
| tree | b6098dfb978a188fe0e4b29f1035ac3994c2f4b6 |
| parent | 3537d477027565cd0d2c1661ff2e41d8f20ea166 |
- 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 tests14 files changed, 779 insertions(+), 127 deletions(-)
apps/server/src/keybindings.test.ts+80-2| ... | ... | @@ -1,8 +1,34 @@ |
| 1 | import { assert, describe, it } from "vitest"; | |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | import { assert, afterEach, describe, expect, it, vi } from "vitest"; | |
| 2 | 5 | |
| 3 | import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings"; | |
| 6 | import { | |
| 7 | compileResolvedKeybindingRule, | |
| 8 | parseKeybindingShortcut, | |
| 9 | upsertKeybindingRule, | |
| 10 | } from "./keybindings"; | |
| 4 | 11 | |
| 5 | 12 | describe("server keybindings", () => { |
| 13 | const tempDirs: string[] = []; | |
| 14 | const logger = { | |
| 15 | warn: vi.fn(), | |
| 16 | }; | |
| 17 | ||
| 18 | function makeTempDir(prefix: string): string { | |
| 19 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 20 | tempDirs.push(dir); | |
| 21 | return dir; | |
| 22 | } | |
| 23 | ||
| 24 | afterEach(() => { | |
| 25 | vi.restoreAllMocks(); | |
| 26 | logger.warn.mockReset(); | |
| 27 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 28 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 29 | } | |
| 30 | }); | |
| 31 | ||
| 6 | 32 | it("parses shortcuts including plus key", () => { |
| 7 | 33 | assert.deepEqual(parseKeybindingShortcut("mod+j"), { |
| 8 | 34 | key: "j", |
| ... | ... | @@ -66,4 +92,56 @@ describe("server keybindings", () => { |
| 66 | 92 | }), |
| 67 | 93 | ); |
| 68 | 94 | }); |
| 95 | ||
| 96 | it("upserts custom keybindings to ~/.t3/keybindings.json", () => { | |
| 97 | const fakeHome = makeTempDir("t3code-keybindings-upsert-"); | |
| 98 | const configDir = path.join(fakeHome, ".t3"); | |
| 99 | fs.mkdirSync(configDir, { recursive: true }); | |
| 100 | const configPath = path.join(configDir, "keybindings.json"); | |
| 101 | fs.writeFileSync( | |
| 102 | configPath, | |
| 103 | JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), | |
| 104 | "utf8", | |
| 105 | ); | |
| 106 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 107 | ||
| 108 | const resolved = upsertKeybindingRule(logger, { | |
| 109 | key: "mod+shift+r", | |
| 110 | command: "script.run-tests.run", | |
| 111 | }); | |
| 112 | const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{ | |
| 113 | key: string; | |
| 114 | command: string; | |
| 115 | }>; | |
| 116 | ||
| 117 | expect(persisted).toEqual([ | |
| 118 | { key: "mod+j", command: "terminal.toggle" }, | |
| 119 | { key: "mod+shift+r", command: "script.run-tests.run" }, | |
| 120 | ]); | |
| 121 | expect(resolved.some((entry) => entry.command === "script.run-tests.run")).toBe(true); | |
| 122 | }); | |
| 123 | ||
| 124 | it("replaces existing custom keybinding for the same command", () => { | |
| 125 | const fakeHome = makeTempDir("t3code-keybindings-upsert-replace-"); | |
| 126 | const configDir = path.join(fakeHome, ".t3"); | |
| 127 | fs.mkdirSync(configDir, { recursive: true }); | |
| 128 | const configPath = path.join(configDir, "keybindings.json"); | |
| 129 | fs.writeFileSync( | |
| 130 | configPath, | |
| 131 | JSON.stringify([{ key: "mod+r", command: "script.run-tests.run" }]), | |
| 132 | "utf8", | |
| 133 | ); | |
| 134 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 135 | ||
| 136 | upsertKeybindingRule(logger, { | |
| 137 | key: "mod+shift+r", | |
| 138 | command: "script.run-tests.run", | |
| 139 | }); | |
| 140 | ||
| 141 | const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{ | |
| 142 | key: string; | |
| 143 | command: string; | |
| 144 | }>; | |
| 145 | expect(persisted).toEqual([{ key: "mod+shift+r", command: "script.run-tests.run" }]); | |
| 146 | }); | |
| 69 | 147 | }); |
apps/server/src/keybindings.ts+80-40| ... | ... | @@ -274,30 +274,15 @@ function compileDefaultKeybindings(): ResolvedKeybindingsConfig { |
| 274 | 274 | } |
| 275 | 275 | |
| 276 | 276 | const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings(); |
| 277 | const KEYBINDINGS_CONFIG_PATH = path.join(".t3", "keybindings.json"); | |
| 278 | const MAX_KEYBINDINGS = 256; | |
| 277 | 279 | |
| 278 | function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { | |
| 279 | if (custom.length === 0) { | |
| 280 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 281 | } | |
| 282 | ||
| 283 | const overriddenCommands = new Set(custom.map((binding) => binding.command)); | |
| 284 | const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( | |
| 285 | (binding) => !overriddenCommands.has(binding.command), | |
| 286 | ); | |
| 287 | const merged = [...retainedDefaults, ...custom]; | |
| 288 | ||
| 289 | if (merged.length <= 256) { | |
| 290 | return merged; | |
| 291 | } | |
| 292 | ||
| 293 | // Keep the latest rules when the config exceeds max size; later rules have higher precedence. | |
| 294 | return merged.slice(-256); | |
| 280 | function resolveKeybindingsConfigPath(): string { | |
| 281 | return path.join(os.homedir(), KEYBINDINGS_CONFIG_PATH); | |
| 295 | 282 | } |
| 296 | 283 | |
| 297 | export function loadResolvedKeybindingsConfig( | |
| 298 | logger: KeybindingsLogger, | |
| 299 | ): ResolvedKeybindingsConfig { | |
| 300 | const configPath = path.join(os.homedir(), ".t3", "keybindings.json"); | |
| 284 | function loadCustomKeybindingsConfig(logger: KeybindingsLogger): KeybindingsConfig { | |
| 285 | const configPath = resolveKeybindingsConfigPath(); | |
| 301 | 286 | try { |
| 302 | 287 | const raw = fs.readFileSync(configPath, "utf8"); |
| 303 | 288 | const parsed = JSON.parse(raw) as unknown; |
| ... | ... | @@ -305,20 +290,19 @@ export function loadResolvedKeybindingsConfig( |
| 305 | 290 | logger.warn("ignoring keybindings config with unsupported format; expected array", { |
| 306 | 291 | path: configPath, |
| 307 | 292 | }); |
| 308 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 293 | return []; | |
| 309 | 294 | } |
| 310 | 295 | |
| 311 | const sanitized: ResolvedKeybindingsConfig = []; | |
| 296 | const sanitized: KeybindingsConfig = []; | |
| 312 | 297 | let invalidEntries = 0; |
| 313 | 298 | for (const entry of parsed) { |
| 314 | 299 | const result = keybindingRuleSchema.safeParse(entry); |
| 315 | 300 | if (result.success) { |
| 316 | const compiled = compileResolvedKeybindingRule(result.data); | |
| 317 | if (!compiled) { | |
| 301 | if (!compileResolvedKeybindingRule(result.data)) { | |
| 318 | 302 | invalidEntries += 1; |
| 319 | 303 | continue; |
| 320 | 304 | } |
| 321 | sanitized.push(compiled); | |
| 305 | sanitized.push(result.data); | |
| 322 | 306 | continue; |
| 323 | 307 | } |
| 324 | 308 | invalidEntries += 1; |
| ... | ... | @@ -330,26 +314,82 @@ export function loadResolvedKeybindingsConfig( |
| 330 | 314 | totalEntries: parsed.length, |
| 331 | 315 | }); |
| 332 | 316 | } |
| 333 | const overriddenCommands = new Set(sanitized.map((entry) => entry.command)); | |
| 334 | const mergedBeforeCapLength = | |
| 335 | DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) | |
| 336 | .length + sanitized.length; | |
| 337 | const merged = mergeWithDefaultKeybindings(sanitized); | |
| 338 | if (mergedBeforeCapLength > 256) { | |
| 339 | logger.warn("truncating merged keybindings config to max entries", { | |
| 340 | path: configPath, | |
| 341 | maxEntries: 256, | |
| 342 | }); | |
| 343 | } | |
| 344 | return merged; | |
| 317 | return sanitized; | |
| 345 | 318 | } catch (error) { |
| 346 | 319 | if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { |
| 347 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 320 | return []; | |
| 348 | 321 | } |
| 349 | 322 | logger.warn("ignoring malformed keybindings config", { |
| 350 | 323 | path: configPath, |
| 351 | 324 | error: error instanceof Error ? error.message : String(error), |
| 352 | 325 | }); |
| 326 | return []; | |
| 327 | } | |
| 328 | } | |
| 329 | ||
| 330 | function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { | |
| 331 | if (custom.length === 0) { | |
| 332 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 333 | } | |
| 334 | ||
| 335 | const overriddenCommands = new Set(custom.map((binding) => binding.command)); | |
| 336 | const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( | |
| 337 | (binding) => !overriddenCommands.has(binding.command), | |
| 338 | ); | |
| 339 | const merged = [...retainedDefaults, ...custom]; | |
| 340 | ||
| 341 | if (merged.length <= MAX_KEYBINDINGS) { | |
| 342 | return merged; | |
| 343 | } | |
| 344 | ||
| 345 | // Keep the latest rules when the config exceeds max size; later rules have higher precedence. | |
| 346 | return merged.slice(-MAX_KEYBINDINGS); | |
| 347 | } | |
| 348 | ||
| 349 | export function loadResolvedKeybindingsConfig( | |
| 350 | logger: KeybindingsLogger, | |
| 351 | ): ResolvedKeybindingsConfig { | |
| 352 | const customConfig = loadCustomKeybindingsConfig(logger); | |
| 353 | const compiledCustomConfig = compileResolvedKeybindingsConfig(customConfig); | |
| 354 | const overriddenCommands = new Set(compiledCustomConfig.map((entry) => entry.command)); | |
| 355 | const mergedBeforeCapLength = | |
| 356 | DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) | |
| 357 | .length + compiledCustomConfig.length; | |
| 358 | const merged = mergeWithDefaultKeybindings(compiledCustomConfig); | |
| 359 | if (mergedBeforeCapLength > MAX_KEYBINDINGS) { | |
| 360 | logger.warn("truncating merged keybindings config to max entries", { | |
| 361 | path: resolveKeybindingsConfigPath(), | |
| 362 | maxEntries: MAX_KEYBINDINGS, | |
| 363 | }); | |
| 364 | } | |
| 365 | return merged; | |
| 366 | } | |
| 367 | ||
| 368 | export function upsertKeybindingRule( | |
| 369 | logger: KeybindingsLogger, | |
| 370 | rawRule: unknown, | |
| 371 | ): ResolvedKeybindingsConfig { | |
| 372 | const rule = keybindingRuleSchema.parse(rawRule); | |
| 373 | if (!compileResolvedKeybindingRule(rule)) { | |
| 374 | throw new Error(`Invalid keybinding shortcut: "${rule.key}"`); | |
| 375 | } | |
| 376 | ||
| 377 | const configPath = resolveKeybindingsConfigPath(); | |
| 378 | const customConfig = loadCustomKeybindingsConfig(logger); | |
| 379 | const nextConfig = [...customConfig.filter((entry) => entry.command !== rule.command), rule]; | |
| 380 | const cappedConfig = | |
| 381 | nextConfig.length > MAX_KEYBINDINGS ? nextConfig.slice(-MAX_KEYBINDINGS) : nextConfig; | |
| 382 | ||
| 383 | if (nextConfig.length > MAX_KEYBINDINGS) { | |
| 384 | logger.warn("truncating keybindings config to max entries", { | |
| 385 | path: configPath, | |
| 386 | maxEntries: MAX_KEYBINDINGS, | |
| 387 | }); | |
| 353 | 388 | } |
| 354 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 389 | ||
| 390 | fs.mkdirSync(path.dirname(configPath), { recursive: true }); | |
| 391 | fs.writeFileSync(configPath, `${JSON.stringify(cappedConfig, null, 2)}\n`, "utf8"); | |
| 392 | ||
| 393 | const compiledCustomConfig = compileResolvedKeybindingsConfig(cappedConfig); | |
| 394 | return mergeWithDefaultKeybindings(compiledCustomConfig); | |
| 355 | 395 | } |
apps/server/src/wsServer.test.ts+53| ... | ... | @@ -491,6 +491,59 @@ describe("WebSocket Server", () => { |
| 491 | 491 | expect(secondResponse.result).toEqual(firstResponse.result); |
| 492 | 492 | }); |
| 493 | 493 | |
| 494 | it("upserts keybinding rules and updates cached server config", async () => { | |
| 495 | const fakeHome = makeTempDir("t3code-home-upsert-keybinding-"); | |
| 496 | const configDir = path.join(fakeHome, ".t3"); | |
| 497 | fs.mkdirSync(configDir, { recursive: true }); | |
| 498 | const keybindingsPath = path.join(configDir, "keybindings.json"); | |
| 499 | fs.writeFileSync( | |
| 500 | keybindingsPath, | |
| 501 | JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), | |
| 502 | "utf8", | |
| 503 | ); | |
| 504 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 505 | ||
| 506 | server = createTestServer({ cwd: "/my/workspace" }); | |
| 507 | await server.start(); | |
| 508 | const addr = server.httpServer.address(); | |
| 509 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 510 | ||
| 511 | const ws = await connectWs(port); | |
| 512 | connections.push(ws); | |
| 513 | await waitForMessage(ws); | |
| 514 | ||
| 515 | const upsertResponse = await sendRequest(ws, WS_METHODS.serverUpsertKeybinding, { | |
| 516 | key: "mod+shift+r", | |
| 517 | command: "script.run-tests.run", | |
| 518 | }); | |
| 519 | expect(upsertResponse.error).toBeUndefined(); | |
| 520 | expect(upsertResponse.result).toEqual({ | |
| 521 | keybindings: mergeWithDefaultsForTest([ | |
| 522 | { key: "mod+j", command: "terminal.toggle" }, | |
| 523 | { key: "mod+shift+r", command: "script.run-tests.run" }, | |
| 524 | ]), | |
| 525 | }); | |
| 526 | ||
| 527 | const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as Array<{ | |
| 528 | key: string; | |
| 529 | command: string; | |
| 530 | }>; | |
| 531 | expect(persistedConfig).toEqual([ | |
| 532 | { key: "mod+j", command: "terminal.toggle" }, | |
| 533 | { key: "mod+shift+r", command: "script.run-tests.run" }, | |
| 534 | ]); | |
| 535 | ||
| 536 | const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 537 | expect(configResponse.error).toBeUndefined(); | |
| 538 | expect(configResponse.result).toEqual({ | |
| 539 | cwd: "/my/workspace", | |
| 540 | keybindings: mergeWithDefaultsForTest([ | |
| 541 | { key: "mod+j", command: "terminal.toggle" }, | |
| 542 | { key: "mod+shift+r", command: "script.run-tests.run" }, | |
| 543 | ]), | |
| 544 | }); | |
| 545 | }); | |
| 546 | ||
| 494 | 547 | it("warns and ignores unsupported keybindings config format", async () => { |
| 495 | 548 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); |
| 496 | 549 | const fakeHome = makeTempDir("t3code-home-unsupported-format-"); |
apps/server/src/wsServer.ts+8-2| ... | ... | @@ -31,7 +31,7 @@ import { |
| 31 | 31 | removeGitWorktree, |
| 32 | 32 | } from "./git"; |
| 33 | 33 | import { TerminalManager } from "./terminalManager"; |
| 34 | import { loadResolvedKeybindingsConfig } from "./keybindings"; | |
| 34 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; | |
| 35 | 35 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 36 | 36 | |
| 37 | 37 | const MIME_TYPES: Record<string, string> = { |
| ... | ... | @@ -92,7 +92,7 @@ export function createServer(options: ServerOptions) { |
| 92 | 92 | const logger = createLogger("ws"); |
| 93 | 93 | const logWebSocketEvents = |
| 94 | 94 | explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); |
| 95 | const keybindingsConfig = loadResolvedKeybindingsConfig(logger); | |
| 95 | let keybindingsConfig = loadResolvedKeybindingsConfig(logger); | |
| 96 | 96 | |
| 97 | 97 | function logOutgoingPush(push: WsPush, recipients: number) { |
| 98 | 98 | if (!logWebSocketEvents) return; |
| ... | ... | @@ -423,6 +423,12 @@ export function createServer(options: ServerOptions) { |
| 423 | 423 | keybindings: keybindingsConfig, |
| 424 | 424 | }; |
| 425 | 425 | |
| 426 | case WS_METHODS.serverUpsertKeybinding: | |
| 427 | keybindingsConfig = upsertKeybindingRule(logger, request.params); | |
| 428 | return { | |
| 429 | keybindings: keybindingsConfig, | |
| 430 | }; | |
| 431 | ||
| 426 | 432 | default: |
| 427 | 433 | throw new Error(`Unknown method: ${request.method}`); |
| 428 | 434 | } |
apps/web/src/components/ChatView.tsx+176-9| ... | ... | @@ -31,7 +31,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 31 | 31 | import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; |
| 32 | 32 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; |
| 33 | 33 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 34 | import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; | |
| 34 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; | |
| 35 | 35 | |
| 36 | 36 | import { isElectron } from "../env"; |
| 37 | 37 | import { buildBootstrapInput } from "../historyBootstrap"; |
| ... | ... | @@ -97,8 +97,10 @@ import { Badge } from "./ui/badge"; |
| 97 | 97 | import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; |
| 98 | 98 | import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| 99 | 99 | import { |
| 100 | commandForProjectScript, | |
| 100 | 101 | injectEnvIntoShellCommand, |
| 101 | 102 | nextProjectScriptId, |
| 103 | projectScriptRuntimeEnv, | |
| 102 | 104 | projectScriptIdFromCommand, |
| 103 | 105 | setupProjectScript, |
| 104 | 106 | } from "~/projectScripts"; |
| ... | ... | @@ -109,6 +111,7 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 109 | 111 | } |
| 110 | 112 | |
| 111 | 113 | const LAST_EDITOR_KEY = "t3code:last-editor"; |
| 114 | const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; | |
| 112 | 115 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| 113 | 116 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; |
| 114 | 117 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| ... | ... | @@ -125,6 +128,24 @@ const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({ |
| 125 | 128 | const SCRIPT_TERMINAL_COLS = 120; |
| 126 | 129 | const SCRIPT_TERMINAL_ROWS = 30; |
| 127 | 130 | |
| 131 | function readLastInvokedScriptByProjectFromStorage(): Record<string, string> { | |
| 132 | const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); | |
| 133 | if (!stored) return {}; | |
| 134 | ||
| 135 | try { | |
| 136 | const parsed: unknown = JSON.parse(stored); | |
| 137 | if (!parsed || typeof parsed !== "object") return {}; | |
| 138 | return Object.fromEntries( | |
| 139 | Object.entries(parsed).filter( | |
| 140 | (entry): entry is [string, string] => | |
| 141 | typeof entry[0] === "string" && typeof entry[1] === "string", | |
| 142 | ), | |
| 143 | ); | |
| 144 | } catch { | |
| 145 | return {}; | |
| 146 | } | |
| 147 | } | |
| 148 | ||
| 128 | 149 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 129 | 150 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; |
| 130 | 151 | if (tone === "tool") return "text-muted-foreground/70"; |
| ... | ... | @@ -330,6 +351,9 @@ export default function ChatView() { |
| 330 | 351 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 331 | 352 | const [composerCursor, setComposerCursor] = useState(0); |
| 332 | 353 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); |
| 354 | const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< | |
| 355 | Record<string, string> | |
| 356 | >(() => readLastInvokedScriptByProjectFromStorage()); | |
| 333 | 357 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| 334 | 358 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 335 | 359 | const shouldAutoScrollRef = useRef(true); |
| ... | ... | @@ -635,6 +659,10 @@ export default function ChatView() { |
| 635 | 659 | }, |
| 636 | 660 | ) => { |
| 637 | 661 | if (!api || !activeThreadId || !activeProject || !activeThread) return; |
| 662 | setLastInvokedScriptByProjectId((current) => { | |
| 663 | if (current[activeProject.id] === script.id) return current; | |
| 664 | return { ...current, [activeProject.id]: script.id }; | |
| 665 | }); | |
| 638 | 666 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; |
| 639 | 667 | const baseTerminalId = activeThread.activeTerminalId; |
| 640 | 668 | const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId); |
| ... | ... | @@ -663,12 +691,20 @@ export default function ChatView() { |
| 663 | 691 | } |
| 664 | 692 | setTerminalFocusRequestId((value) => value + 1); |
| 665 | 693 | |
| 666 | const scriptEnv = | |
| 667 | options?.env ?? | |
| 668 | (script.runOnWorktreeCreate ? { T3CODE_PROJECT_ROOT: activeProject.cwd } : undefined); | |
| 669 | const resolvedCommand = scriptEnv | |
| 670 | ? injectEnvIntoShellCommand(script.command, scriptEnv) | |
| 671 | : script.command; | |
| 694 | const resolvedCommand = injectEnvIntoShellCommand( | |
| 695 | script.command, | |
| 696 | projectScriptRuntimeEnv({ | |
| 697 | project: { | |
| 698 | id: activeProject.id, | |
| 699 | name: activeProject.name, | |
| 700 | cwd: activeProject.cwd, | |
| 701 | }, | |
| 702 | script, | |
| 703 | threadId: activeThreadId, | |
| 704 | worktreePath: activeThread.worktreePath ?? null, | |
| 705 | extraEnv: options?.env, | |
| 706 | }), | |
| 707 | ); | |
| 672 | 708 | |
| 673 | 709 | try { |
| 674 | 710 | await api.terminal.open({ |
| ... | ... | @@ -723,17 +759,124 @@ export default function ChatView() { |
| 723 | 759 | }); |
| 724 | 760 | |
| 725 | 761 | if (!isElectron || !api) return; |
| 762 | let scriptsPersisted = false; | |
| 763 | try { | |
| 764 | const updated = await api.projects.updateScripts({ | |
| 765 | id: activeProject.id, | |
| 766 | scripts: nextScripts, | |
| 767 | }); | |
| 768 | scriptsPersisted = true; | |
| 769 | ||
| 770 | if (input.keybinding) { | |
| 771 | const keybindingUpdate = await api.server.upsertKeybinding({ | |
| 772 | key: input.keybinding, | |
| 773 | command: commandForProjectScript(nextId), | |
| 774 | }); | |
| 775 | queryClient.setQueryData( | |
| 776 | serverQueryKeys.config(), | |
| 777 | (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) => | |
| 778 | current | |
| 779 | ? { ...current, keybindings: keybindingUpdate.keybindings } | |
| 780 | : { | |
| 781 | cwd: activeProject.cwd, | |
| 782 | keybindings: keybindingUpdate.keybindings, | |
| 783 | }, | |
| 784 | ); | |
| 785 | } | |
| 786 | ||
| 787 | dispatch({ | |
| 788 | type: "SET_PROJECT_SCRIPTS", | |
| 789 | projectId: updated.project.id, | |
| 790 | scripts: updated.project.scripts, | |
| 791 | }); | |
| 792 | } catch (error) { | |
| 793 | if (scriptsPersisted) { | |
| 794 | await api.projects | |
| 795 | .updateScripts({ | |
| 796 | id: activeProject.id, | |
| 797 | scripts: activeProject.scripts, | |
| 798 | }) | |
| 799 | .catch(() => undefined); | |
| 800 | } | |
| 801 | dispatch({ | |
| 802 | type: "SET_PROJECT_SCRIPTS", | |
| 803 | projectId: activeProject.id, | |
| 804 | scripts: activeProject.scripts, | |
| 805 | }); | |
| 806 | throw error; | |
| 807 | } | |
| 808 | }, | |
| 809 | [activeProject, api, dispatch, queryClient], | |
| 810 | ); | |
| 811 | const updateProjectScript = useCallback( | |
| 812 | async (scriptId: string, input: NewProjectScriptInput) => { | |
| 813 | if (!activeProject) return; | |
| 814 | const existingScript = activeProject.scripts.find((script) => script.id === scriptId); | |
| 815 | if (!existingScript) { | |
| 816 | throw new Error("Script not found."); | |
| 817 | } | |
| 818 | ||
| 819 | const updatedScript: ProjectScript = { | |
| 820 | ...existingScript, | |
| 821 | name: input.name, | |
| 822 | command: input.command, | |
| 823 | icon: input.icon, | |
| 824 | runOnWorktreeCreate: input.runOnWorktreeCreate, | |
| 825 | }; | |
| 826 | const nextScripts = activeProject.scripts.map((script) => | |
| 827 | script.id === scriptId | |
| 828 | ? updatedScript | |
| 829 | : input.runOnWorktreeCreate | |
| 830 | ? { ...script, runOnWorktreeCreate: false } | |
| 831 | : script, | |
| 832 | ); | |
| 833 | ||
| 834 | dispatch({ | |
| 835 | type: "SET_PROJECT_SCRIPTS", | |
| 836 | projectId: activeProject.id, | |
| 837 | scripts: nextScripts, | |
| 838 | }); | |
| 839 | ||
| 840 | if (!isElectron || !api) return; | |
| 841 | let scriptsPersisted = false; | |
| 726 | 842 | try { |
| 727 | 843 | const updated = await api.projects.updateScripts({ |
| 728 | 844 | id: activeProject.id, |
| 729 | 845 | scripts: nextScripts, |
| 730 | 846 | }); |
| 847 | scriptsPersisted = true; | |
| 848 | ||
| 849 | if (input.keybinding) { | |
| 850 | const keybindingUpdate = await api.server.upsertKeybinding({ | |
| 851 | key: input.keybinding, | |
| 852 | command: commandForProjectScript(scriptId), | |
| 853 | }); | |
| 854 | queryClient.setQueryData( | |
| 855 | serverQueryKeys.config(), | |
| 856 | (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) => | |
| 857 | current | |
| 858 | ? { ...current, keybindings: keybindingUpdate.keybindings } | |
| 859 | : { | |
| 860 | cwd: activeProject.cwd, | |
| 861 | keybindings: keybindingUpdate.keybindings, | |
| 862 | }, | |
| 863 | ); | |
| 864 | } | |
| 865 | ||
| 731 | 866 | dispatch({ |
| 732 | 867 | type: "SET_PROJECT_SCRIPTS", |
| 733 | 868 | projectId: updated.project.id, |
| 734 | 869 | scripts: updated.project.scripts, |
| 735 | 870 | }); |
| 736 | 871 | } catch (error) { |
| 872 | if (scriptsPersisted) { | |
| 873 | await api.projects | |
| 874 | .updateScripts({ | |
| 875 | id: activeProject.id, | |
| 876 | scripts: activeProject.scripts, | |
| 877 | }) | |
| 878 | .catch(() => undefined); | |
| 879 | } | |
| 737 | 880 | dispatch({ |
| 738 | 881 | type: "SET_PROJECT_SCRIPTS", |
| 739 | 882 | projectId: activeProject.id, |
| ... | ... | @@ -742,7 +885,7 @@ export default function ChatView() { |
| 742 | 885 | throw error; |
| 743 | 886 | } |
| 744 | 887 | }, |
| 745 | [activeProject, api, dispatch], | |
| 888 | [activeProject, api, dispatch, queryClient], | |
| 746 | 889 | ); |
| 747 | 890 | |
| 748 | 891 | const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { |
| ... | ... | @@ -768,6 +911,21 @@ export default function ChatView() { |
| 768 | 911 | } |
| 769 | 912 | }; |
| 770 | 913 | |
| 914 | useEffect(() => { | |
| 915 | try { | |
| 916 | if (Object.keys(lastInvokedScriptByProjectId).length === 0) { | |
| 917 | localStorage.removeItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); | |
| 918 | return; | |
| 919 | } | |
| 920 | localStorage.setItem( | |
| 921 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, | |
| 922 | JSON.stringify(lastInvokedScriptByProjectId), | |
| 923 | ); | |
| 924 | } catch { | |
| 925 | // Ignore storage write failures (private mode, quota exceeded, etc.) | |
| 926 | } | |
| 927 | }, [lastInvokedScriptByProjectId]); | |
| 928 | ||
| 771 | 929 | // Auto-scroll on new messages |
| 772 | 930 | const messageCount = activeThread?.messages.length ?? 0; |
| 773 | 931 | const workLogCount = workLogEntries.length; |
| ... | ... | @@ -1192,7 +1350,6 @@ export default function ChatView() { |
| 1192 | 1350 | if (setupScript) { |
| 1193 | 1351 | void runProjectScript(setupScript, { |
| 1194 | 1352 | cwd: result.worktree.path, |
| 1195 | env: { T3CODE_PROJECT_ROOT: activeProject.cwd }, | |
| 1196 | 1353 | }); |
| 1197 | 1354 | } |
| 1198 | 1355 | } catch (err) { |
| ... | ... | @@ -1474,6 +1631,9 @@ export default function ChatView() { |
| 1474 | 1631 | activeThreadTitle={activeThread.title} |
| 1475 | 1632 | activeProjectName={activeProject?.name} |
| 1476 | 1633 | activeProjectScripts={activeProject?.scripts} |
| 1634 | preferredScriptId={ | |
| 1635 | activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null | |
| 1636 | } | |
| 1477 | 1637 | keybindings={keybindings} |
| 1478 | 1638 | api={api} |
| 1479 | 1639 | gitCwd={gitCwd} |
| ... | ... | @@ -1482,6 +1642,7 @@ export default function ChatView() { |
| 1482 | 1642 | void runProjectScript(script); |
| 1483 | 1643 | }} |
| 1484 | 1644 | onAddProjectScript={saveProjectScript} |
| 1645 | onUpdateProjectScript={updateProjectScript} | |
| 1485 | 1646 | onToggleDiff={onToggleDiff} |
| 1486 | 1647 | /> |
| 1487 | 1648 | </header> |
| ... | ... | @@ -1806,12 +1967,14 @@ interface ChatHeaderProps { |
| 1806 | 1967 | activeThreadTitle: string; |
| 1807 | 1968 | activeProjectName: string | undefined; |
| 1808 | 1969 | activeProjectScripts: ProjectScript[] | undefined; |
| 1970 | preferredScriptId: string | null; | |
| 1809 | 1971 | keybindings: ResolvedKeybindingsConfig; |
| 1810 | 1972 | api: NativeApi | undefined; |
| 1811 | 1973 | gitCwd: string | null; |
| 1812 | 1974 | diffOpen: boolean; |
| 1813 | 1975 | onRunProjectScript: (script: ProjectScript) => void; |
| 1814 | 1976 | onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>; |
| 1977 | onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>; | |
| 1815 | 1978 | onToggleDiff: () => void; |
| 1816 | 1979 | } |
| 1817 | 1980 | |
| ... | ... | @@ -1819,12 +1982,14 @@ const ChatHeader = memo(function ChatHeader({ |
| 1819 | 1982 | activeThreadTitle, |
| 1820 | 1983 | activeProjectName, |
| 1821 | 1984 | activeProjectScripts, |
| 1985 | preferredScriptId, | |
| 1822 | 1986 | keybindings, |
| 1823 | 1987 | api, |
| 1824 | 1988 | gitCwd, |
| 1825 | 1989 | diffOpen, |
| 1826 | 1990 | onRunProjectScript, |
| 1827 | 1991 | onAddProjectScript, |
| 1992 | onUpdateProjectScript, | |
| 1828 | 1993 | onToggleDiff, |
| 1829 | 1994 | }: ChatHeaderProps) { |
| 1830 | 1995 | return ( |
| ... | ... | @@ -1840,8 +2005,10 @@ const ChatHeader = memo(function ChatHeader({ |
| 1840 | 2005 | <ProjectScriptsControl |
| 1841 | 2006 | scripts={activeProjectScripts} |
| 1842 | 2007 | keybindings={keybindings} |
| 2008 | preferredScriptId={preferredScriptId} | |
| 1843 | 2009 | onRunScript={onRunProjectScript} |
| 1844 | 2010 | onAddScript={onAddProjectScript} |
| 2011 | onUpdateScript={onUpdateProjectScript} | |
| 1845 | 2012 | /> |
| 1846 | 2013 | )} |
| 1847 | 2014 | {activeProjectName && <OpenInPicker keybindings={keybindings} />} |
apps/web/src/components/ProjectScriptsControl.tsx+263-72| ... | ... | @@ -4,7 +4,9 @@ import type { |
| 4 | 4 | ResolvedKeybindingsConfig, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | import { |
| 7 | BugIcon, | |
| 7 | 8 | ChevronDownIcon, |
| 9 | CogIcon, | |
| 8 | 10 | FlaskConicalIcon, |
| 9 | 11 | HammerIcon, |
| 10 | 12 | ListChecksIcon, |
| ... | ... | @@ -12,14 +14,11 @@ import { |
| 12 | 14 | PlusIcon, |
| 13 | 15 | WrenchIcon, |
| 14 | 16 | } from "lucide-react"; |
| 15 | import { type FormEvent, useMemo, useState } from "react"; | |
| 17 | import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react"; | |
| 16 | 18 | |
| 17 | import { | |
| 18 | commandForProjectScript, | |
| 19 | nextProjectScriptId, | |
| 20 | primaryProjectScript, | |
| 21 | } from "~/projectScripts"; | |
| 19 | import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; | |
| 22 | 20 | import { shortcutLabelForCommand } from "~/keybindings"; |
| 21 | import { isMacPlatform } from "~/lib/utils"; | |
| 23 | 22 | import { Button } from "./ui/button"; |
| 24 | 23 | import { |
| 25 | 24 | Dialog, |
| ... | ... | @@ -34,7 +33,7 @@ import { Group, GroupSeparator } from "./ui/group"; |
| 34 | 33 | import { Input } from "./ui/input"; |
| 35 | 34 | import { Label } from "./ui/label"; |
| 36 | 35 | import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"; |
| 37 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; | |
| 36 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; | |
| 38 | 37 | import { Switch } from "./ui/switch"; |
| 39 | 38 | import { Textarea } from "./ui/textarea"; |
| 40 | 39 | |
| ... | ... | @@ -44,6 +43,7 @@ const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ |
| 44 | 43 | { id: "lint", label: "Lint" }, |
| 45 | 44 | { id: "configure", label: "Configure" }, |
| 46 | 45 | { id: "build", label: "Build" }, |
| 46 | { id: "debug", label: "Debug" }, | |
| 47 | 47 | ]; |
| 48 | 48 | |
| 49 | 49 | function ScriptIcon({ |
| ... | ... | @@ -57,6 +57,7 @@ function ScriptIcon({ |
| 57 | 57 | if (icon === "lint") return <ListChecksIcon className={className} />; |
| 58 | 58 | if (icon === "configure") return <WrenchIcon className={className} />; |
| 59 | 59 | if (icon === "build") return <HammerIcon className={className} />; |
| 60 | if (icon === "debug") return <BugIcon className={className} />; | |
| 60 | 61 | return <PlayIcon className={className} />; |
| 61 | 62 | } |
| 62 | 63 | |
| ... | ... | @@ -65,39 +66,133 @@ export interface NewProjectScriptInput { |
| 65 | 66 | command: string; |
| 66 | 67 | icon: ProjectScriptIcon; |
| 67 | 68 | runOnWorktreeCreate: boolean; |
| 69 | keybinding: string | null; | |
| 68 | 70 | } |
| 69 | 71 | |
| 70 | 72 | interface ProjectScriptsControlProps { |
| 71 | 73 | scripts: ProjectScript[]; |
| 72 | 74 | keybindings: ResolvedKeybindingsConfig; |
| 75 | preferredScriptId?: string | null; | |
| 73 | 76 | onRunScript: (script: ProjectScript) => void; |
| 74 | 77 | onAddScript: (input: NewProjectScriptInput) => Promise<void> | void; |
| 78 | onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void> | void; | |
| 79 | } | |
| 80 | ||
| 81 | function normalizeShortcutKeyToken(key: string): string | null { | |
| 82 | const normalized = key.toLowerCase(); | |
| 83 | if ( | |
| 84 | normalized === "meta" || | |
| 85 | normalized === "control" || | |
| 86 | normalized === "ctrl" || | |
| 87 | normalized === "shift" || | |
| 88 | normalized === "alt" || | |
| 89 | normalized === "option" | |
| 90 | ) { | |
| 91 | return null; | |
| 92 | } | |
| 93 | if (normalized === " ") return "space"; | |
| 94 | if (normalized === "escape") return "esc"; | |
| 95 | if (normalized === "arrowup") return "arrowup"; | |
| 96 | if (normalized === "arrowdown") return "arrowdown"; | |
| 97 | if (normalized === "arrowleft") return "arrowleft"; | |
| 98 | if (normalized === "arrowright") return "arrowright"; | |
| 99 | if (normalized.length === 1) return normalized; | |
| 100 | if (normalized.startsWith("f") && normalized.length <= 3) return normalized; | |
| 101 | if (normalized === "enter" || normalized === "tab" || normalized === "backspace") { | |
| 102 | return normalized; | |
| 103 | } | |
| 104 | if (normalized === "delete" || normalized === "home" || normalized === "end") { | |
| 105 | return normalized; | |
| 106 | } | |
| 107 | if (normalized === "pageup" || normalized === "pagedown") return normalized; | |
| 108 | return null; | |
| 109 | } | |
| 110 | ||
| 111 | function keybindingFromEvent(event: KeyboardEvent<HTMLInputElement>): string | null { | |
| 112 | const keyToken = normalizeShortcutKeyToken(event.key); | |
| 113 | if (!keyToken) return null; | |
| 114 | ||
| 115 | const parts: string[] = []; | |
| 116 | if (isMacPlatform(navigator.platform)) { | |
| 117 | if (event.metaKey) parts.push("mod"); | |
| 118 | if (event.ctrlKey) parts.push("ctrl"); | |
| 119 | } else { | |
| 120 | if (event.ctrlKey) parts.push("mod"); | |
| 121 | if (event.metaKey) parts.push("meta"); | |
| 122 | } | |
| 123 | if (event.altKey) parts.push("alt"); | |
| 124 | if (event.shiftKey) parts.push("shift"); | |
| 125 | parts.push(keyToken); | |
| 126 | return parts.join("+"); | |
| 127 | } | |
| 128 | ||
| 129 | function keybindingValueForCommand( | |
| 130 | keybindings: ResolvedKeybindingsConfig, | |
| 131 | command: string, | |
| 132 | ): string | null { | |
| 133 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | |
| 134 | const binding = keybindings[index]; | |
| 135 | if (!binding || binding.command !== command) continue; | |
| 136 | ||
| 137 | const parts: string[] = []; | |
| 138 | if (binding.shortcut.modKey) parts.push("mod"); | |
| 139 | if (binding.shortcut.ctrlKey) parts.push("ctrl"); | |
| 140 | if (binding.shortcut.metaKey) parts.push("meta"); | |
| 141 | if (binding.shortcut.altKey) parts.push("alt"); | |
| 142 | if (binding.shortcut.shiftKey) parts.push("shift"); | |
| 143 | const keyToken = | |
| 144 | binding.shortcut.key === " " | |
| 145 | ? "space" | |
| 146 | : binding.shortcut.key === "escape" | |
| 147 | ? "esc" | |
| 148 | : binding.shortcut.key; | |
| 149 | parts.push(keyToken); | |
| 150 | return parts.join("+"); | |
| 151 | } | |
| 152 | return null; | |
| 75 | 153 | } |
| 76 | 154 | |
| 77 | 155 | export default function ProjectScriptsControl({ |
| 78 | 156 | scripts, |
| 79 | 157 | keybindings, |
| 158 | preferredScriptId = null, | |
| 80 | 159 | onRunScript, |
| 81 | 160 | onAddScript, |
| 161 | onUpdateScript, | |
| 82 | 162 | }: ProjectScriptsControlProps) { |
| 163 | const addScriptFormId = React.useId(); | |
| 164 | const [editingScriptId, setEditingScriptId] = useState<string | null>(null); | |
| 83 | 165 | const [dialogOpen, setDialogOpen] = useState(false); |
| 84 | 166 | const [name, setName] = useState(""); |
| 85 | 167 | const [command, setCommand] = useState(""); |
| 86 | 168 | const [icon, setIcon] = useState<ProjectScriptIcon>("play"); |
| 169 | const [iconPickerOpen, setIconPickerOpen] = useState(false); | |
| 87 | 170 | const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); |
| 171 | const [keybinding, setKeybinding] = useState(""); | |
| 88 | 172 | const [validationError, setValidationError] = useState<string | null>(null); |
| 89 | 173 | |
| 90 | const primaryScript = useMemo(() => primaryProjectScript(scripts), [scripts]); | |
| 91 | const keybindingCommandPreview = useMemo( | |
| 92 | () => | |
| 93 | commandForProjectScript( | |
| 94 | nextProjectScriptId( | |
| 95 | name.length > 0 ? name : "script", | |
| 96 | scripts.map((script) => script.id), | |
| 97 | ), | |
| 98 | ), | |
| 99 | [name, scripts], | |
| 100 | ); | |
| 174 | const primaryScript = useMemo(() => { | |
| 175 | if (preferredScriptId) { | |
| 176 | const preferred = scripts.find((script) => script.id === preferredScriptId); | |
| 177 | if (preferred) return preferred; | |
| 178 | } | |
| 179 | return primaryProjectScript(scripts); | |
| 180 | }, [preferredScriptId, scripts]); | |
| 181 | const isEditing = editingScriptId !== null; | |
| 182 | const dropdownItemClassName = | |
| 183 | "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"; | |
| 184 | ||
| 185 | const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => { | |
| 186 | if (event.key === "Tab") return; | |
| 187 | event.preventDefault(); | |
| 188 | if (event.key === "Backspace" || event.key === "Delete") { | |
| 189 | setKeybinding(""); | |
| 190 | return; | |
| 191 | } | |
| 192 | const next = keybindingFromEvent(event); | |
| 193 | if (!next) return; | |
| 194 | setKeybinding(next); | |
| 195 | }; | |
| 101 | 196 | |
| 102 | 197 | const submitAddScript = async (event: FormEvent) => { |
| 103 | 198 | event.preventDefault(); |
| ... | ... | @@ -114,23 +209,51 @@ export default function ProjectScriptsControl({ |
| 114 | 209 | |
| 115 | 210 | setValidationError(null); |
| 116 | 211 | try { |
| 117 | await onAddScript({ | |
| 212 | const payload = { | |
| 118 | 213 | name: trimmedName, |
| 119 | 214 | command: trimmedCommand, |
| 120 | 215 | icon, |
| 121 | 216 | runOnWorktreeCreate, |
| 122 | }); | |
| 217 | keybinding: keybinding.trim() || null, | |
| 218 | } satisfies NewProjectScriptInput; | |
| 219 | if (editingScriptId) { | |
| 220 | await onUpdateScript(editingScriptId, payload); | |
| 221 | } else { | |
| 222 | await onAddScript(payload); | |
| 223 | } | |
| 123 | 224 | setDialogOpen(false); |
| 225 | setEditingScriptId(null); | |
| 124 | 226 | setName(""); |
| 125 | 227 | setCommand(""); |
| 126 | 228 | setIcon("play"); |
| 229 | setIconPickerOpen(false); | |
| 127 | 230 | setRunOnWorktreeCreate(false); |
| 231 | setKeybinding(""); | |
| 128 | 232 | } catch (error) { |
| 129 | 233 | setValidationError(error instanceof Error ? error.message : "Failed to save action."); |
| 130 | 234 | } |
| 131 | 235 | }; |
| 132 | 236 | |
| 133 | const openDialog = () => { | |
| 237 | const openAddDialog = () => { | |
| 238 | setEditingScriptId(null); | |
| 239 | setName(""); | |
| 240 | setCommand(""); | |
| 241 | setIcon("play"); | |
| 242 | setIconPickerOpen(false); | |
| 243 | setRunOnWorktreeCreate(false); | |
| 244 | setKeybinding(""); | |
| 245 | setValidationError(null); | |
| 246 | setDialogOpen(true); | |
| 247 | }; | |
| 248 | ||
| 249 | const openEditDialog = (script: ProjectScript) => { | |
| 250 | setEditingScriptId(script.id); | |
| 251 | setName(script.name); | |
| 252 | setCommand(script.command); | |
| 253 | setIcon(script.icon); | |
| 254 | setIconPickerOpen(false); | |
| 255 | setRunOnWorktreeCreate(script.runOnWorktreeCreate); | |
| 256 | setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); | |
| 134 | 257 | setValidationError(null); |
| 135 | 258 | setDialogOpen(true); |
| 136 | 259 | }; |
| ... | ... | @@ -146,10 +269,10 @@ export default function ProjectScriptsControl({ |
| 146 | 269 | title={`Run ${primaryScript.name}`} |
| 147 | 270 | > |
| 148 | 271 | <ScriptIcon icon={primaryScript.icon} /> |
| 149 | <span>{primaryScript.name}</span> | |
| 272 | <span className="ml-0.5">{primaryScript.name}</span> | |
| 150 | 273 | </Button> |
| 151 | 274 | <GroupSeparator /> |
| 152 | <Menu> | |
| 275 | <Menu highlightItemOnHover={false}> | |
| 153 | 276 | <MenuTrigger |
| 154 | 277 | render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />} |
| 155 | 278 | > |
| ... | ... | @@ -162,14 +285,44 @@ export default function ProjectScriptsControl({ |
| 162 | 285 | commandForProjectScript(script.id), |
| 163 | 286 | ); |
| 164 | 287 | return ( |
| 165 | <MenuItem key={script.id} onClick={() => onRunScript(script)}> | |
| 288 | <MenuItem | |
| 289 | key={script.id} | |
| 290 | className={`group ${dropdownItemClassName}`} | |
| 291 | onClick={() => onRunScript(script)} | |
| 292 | > | |
| 166 | 293 | <ScriptIcon icon={script.icon} className="size-4" /> |
| 167 | {script.runOnWorktreeCreate ? `${script.name} (Setup)` : script.name} | |
| 168 | {shortcutLabel && <MenuShortcut>{shortcutLabel}</MenuShortcut>} | |
| 294 | <span className="truncate"> | |
| 295 | {script.runOnWorktreeCreate ? `${script.name}` : script.name} | |
| 296 | </span> | |
| 297 | <span className="relative ms-auto flex h-6 min-w-6 items-center justify-end"> | |
| 298 | {shortcutLabel && ( | |
| 299 | <MenuShortcut className="ms-0 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0"> | |
| 300 | {shortcutLabel} | |
| 301 | </MenuShortcut> | |
| 302 | )} | |
| 303 | <Button | |
| 304 | type="button" | |
| 305 | variant="ghost" | |
| 306 | size="icon-xs" | |
| 307 | className="absolute right-0 top-1/2 size-6 -translate-y-1/2 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto group-focus-visible:opacity-100 group-focus-visible:pointer-events-auto" | |
| 308 | aria-label={`Edit ${script.name}`} | |
| 309 | onPointerDown={(event) => { | |
| 310 | event.preventDefault(); | |
| 311 | event.stopPropagation(); | |
| 312 | }} | |
| 313 | onClick={(event) => { | |
| 314 | event.preventDefault(); | |
| 315 | event.stopPropagation(); | |
| 316 | openEditDialog(script); | |
| 317 | }} | |
| 318 | > | |
| 319 | <CogIcon className="size-3.5" /> | |
| 320 | </Button> | |
| 321 | </span> | |
| 169 | 322 | </MenuItem> |
| 170 | 323 | ); |
| 171 | 324 | })} |
| 172 | <MenuItem onClick={openDialog}> | |
| 325 | <MenuItem className={dropdownItemClassName} onClick={openAddDialog}> | |
| 173 | 326 | <PlusIcon className="size-4" /> |
| 174 | 327 | Add action |
| 175 | 328 | </MenuItem> |
| ... | ... | @@ -177,7 +330,7 @@ export default function ProjectScriptsControl({ |
| 177 | 330 | </Menu> |
| 178 | 331 | </Group> |
| 179 | 332 | ) : ( |
| 180 | <Button size="xs" variant="outline" onClick={openDialog}> | |
| 333 | <Button size="xs" variant="outline" onClick={openAddDialog}> | |
| 181 | 334 | <PlusIcon className="size-3.5" /> |
| 182 | 335 | Add action |
| 183 | 336 | </Button> |
| ... | ... | @@ -185,24 +338,75 @@ export default function ProjectScriptsControl({ |
| 185 | 338 | |
| 186 | 339 | <Dialog onOpenChange={setDialogOpen} open={dialogOpen}> |
| 187 | 340 | <DialogPopup> |
| 188 | <form onSubmit={submitAddScript}> | |
| 189 | <DialogHeader> | |
| 190 | <DialogTitle>Add Action</DialogTitle> | |
| 191 | <DialogDescription> | |
| 192 | Actions are project-scoped commands you can run from the top bar or keybindings. | |
| 193 | </DialogDescription> | |
| 194 | </DialogHeader> | |
| 195 | <DialogPanel className="space-y-4"> | |
| 341 | <DialogHeader> | |
| 342 | <DialogTitle>{isEditing ? "Edit Action" : "Add Action"}</DialogTitle> | |
| 343 | <DialogDescription> | |
| 344 | Actions are project-scoped commands you can run from the top bar or keybindings. | |
| 345 | </DialogDescription> | |
| 346 | </DialogHeader> | |
| 347 | <DialogPanel> | |
| 348 | <form id={addScriptFormId} className="space-y-4" onSubmit={submitAddScript}> | |
| 196 | 349 | <div className="space-y-1.5"> |
| 197 | 350 | <Label htmlFor="script-name">Name</Label> |
| 351 | <div className="flex items-center gap-2"> | |
| 352 | <Popover onOpenChange={setIconPickerOpen} open={iconPickerOpen}> | |
| 353 | <PopoverTrigger | |
| 354 | render={ | |
| 355 | <Button | |
| 356 | type="button" | |
| 357 | variant="outline" | |
| 358 | className="size-9 shrink-0 hover:bg-popover active:bg-popover data-pressed:bg-popover data-pressed:shadow-xs/5 data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)]" | |
| 359 | aria-label="Choose icon" | |
| 360 | /> | |
| 361 | } | |
| 362 | > | |
| 363 | <ScriptIcon icon={icon} className="size-4.5" /> | |
| 364 | </PopoverTrigger> | |
| 365 | <PopoverPopup align="start"> | |
| 366 | <div className="grid grid-cols-3 gap-2"> | |
| 367 | {SCRIPT_ICONS.map((entry) => { | |
| 368 | const isSelected = entry.id === icon; | |
| 369 | return ( | |
| 370 | <button | |
| 371 | key={entry.id} | |
| 372 | type="button" | |
| 373 | className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs ${ | |
| 374 | isSelected | |
| 375 | ? "border-primary/70 bg-primary/10" | |
| 376 | : "border-border/70 hover:bg-accent/60" | |
| 377 | }`} | |
| 378 | onClick={() => { | |
| 379 | setIcon(entry.id); | |
| 380 | setIconPickerOpen(false); | |
| 381 | }} | |
| 382 | > | |
| 383 | <ScriptIcon icon={entry.id} className="size-4" /> | |
| 384 | <span>{entry.label}</span> | |
| 385 | </button> | |
| 386 | ); | |
| 387 | })} | |
| 388 | </div> | |
| 389 | </PopoverPopup> | |
| 390 | </Popover> | |
| 391 | <Input | |
| 392 | id="script-name" | |
| 393 | placeholder="Test" | |
| 394 | value={name} | |
| 395 | onChange={(event) => setName(event.target.value)} | |
| 396 | /> | |
| 397 | </div> | |
| 398 | </div> | |
| 399 | <div className="space-y-1.5"> | |
| 400 | <Label htmlFor="script-keybinding">Keybinding</Label> | |
| 198 | 401 | <Input |
| 199 | id="script-name" | |
| 200 | placeholder="Test" | |
| 201 | value={name} | |
| 202 | onChange={(event) => setName(event.target.value)} | |
| 402 | id="script-keybinding" | |
| 403 | placeholder="Press shortcut" | |
| 404 | value={keybinding} | |
| 405 | readOnly | |
| 406 | onKeyDown={captureKeybinding} | |
| 203 | 407 | /> |
| 204 | 408 | <p className="text-xs text-muted-foreground"> |
| 205 | Keybinding command: <code>{keybindingCommandPreview}</code> | |
| 409 | Press a shortcut. Use <code>Backspace</code> to clear. | |
| 206 | 410 | </p> |
| 207 | 411 | </div> |
| 208 | 412 | <div className="space-y-1.5"> |
| ... | ... | @@ -214,44 +418,31 @@ export default function ProjectScriptsControl({ |
| 214 | 418 | onChange={(event) => setCommand(event.target.value)} |
| 215 | 419 | /> |
| 216 | 420 | </div> |
| 217 | <div className="space-y-1.5"> | |
| 218 | <Label>Icon</Label> | |
| 219 | <Select | |
| 220 | items={SCRIPT_ICONS.map((entry) => ({ label: entry.label, value: entry.id }))} | |
| 221 | value={icon} | |
| 222 | onValueChange={(value) => { | |
| 223 | if (!value) return; | |
| 224 | setIcon(value as ProjectScriptIcon); | |
| 225 | }} | |
| 226 | > | |
| 227 | <SelectTrigger> | |
| 228 | <SelectValue /> | |
| 229 | </SelectTrigger> | |
| 230 | <SelectPopup> | |
| 231 | {SCRIPT_ICONS.map((entry) => ( | |
| 232 | <SelectItem key={entry.id} value={entry.id}> | |
| 233 | {entry.label} | |
| 234 | </SelectItem> | |
| 235 | ))} | |
| 236 | </SelectPopup> | |
| 237 | </Select> | |
| 238 | </div> | |
| 239 | 421 | <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm"> |
| 240 | <span>Run automatically on worktree creation (Setup)</span> | |
| 422 | <span>Run automatically on worktree creation</span> | |
| 241 | 423 | <Switch |
| 242 | 424 | checked={runOnWorktreeCreate} |
| 243 | 425 | onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} |
| 244 | 426 | /> |
| 245 | 427 | </label> |
| 246 | 428 | {validationError && <p className="text-sm text-destructive">{validationError}</p>} |
| 247 | </DialogPanel> | |
| 248 | <DialogFooter> | |
| 249 | <Button type="button" variant="outline" onClick={() => setDialogOpen(false)}> | |
| 250 | Cancel | |
| 251 | </Button> | |
| 252 | <Button type="submit">Save action</Button> | |
| 253 | </DialogFooter> | |
| 254 | </form> | |
| 429 | </form> | |
| 430 | </DialogPanel> | |
| 431 | <DialogFooter> | |
| 432 | <Button | |
| 433 | type="button" | |
| 434 | variant="outline" | |
| 435 | onClick={() => { | |
| 436 | setDialogOpen(false); | |
| 437 | setEditingScriptId(null); | |
| 438 | }} | |
| 439 | > | |
| 440 | Cancel | |
| 441 | </Button> | |
| 442 | <Button form={addScriptFormId} type="submit"> | |
| 443 | {isEditing ? "Save changes" : "Save action"} | |
| 444 | </Button> | |
| 445 | </DialogFooter> | |
| 255 | 446 | </DialogPopup> |
| 256 | 447 | </Dialog> |
| 257 | 448 | </> |
apps/web/src/projectScripts.test.ts+49| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | injectEnvIntoShellCommand, |
| 6 | 6 | nextProjectScriptId, |
| 7 | 7 | primaryProjectScript, |
| 8 | projectScriptRuntimeEnv, | |
| 8 | 9 | projectScriptIdFromCommand, |
| 9 | 10 | setupProjectScript, |
| 10 | 11 | } from "./projectScripts"; |
| ... | ... | @@ -54,4 +55,52 @@ describe("projectScripts helpers", () => { |
| 54 | 55 | expect(primaryProjectScript(scripts)?.id).toBe("test"); |
| 55 | 56 | expect(setupProjectScript(scripts)?.id).toBe("setup"); |
| 56 | 57 | }); |
| 58 | ||
| 59 | it("builds default runtime env for scripts", () => { | |
| 60 | const env = projectScriptRuntimeEnv({ | |
| 61 | project: { id: "project-1", name: "acme-web", cwd: "/repo" }, | |
| 62 | script: { | |
| 63 | id: "test", | |
| 64 | name: "Test", | |
| 65 | icon: "test", | |
| 66 | runOnWorktreeCreate: false, | |
| 67 | }, | |
| 68 | threadId: "thread-1", | |
| 69 | worktreePath: "/repo/worktree-a", | |
| 70 | }); | |
| 71 | ||
| 72 | expect(env).toMatchObject({ | |
| 73 | T3CODE_PROJECT_ROOT: "/repo", | |
| 74 | T3CODE_PROJECT_ID: "project-1", | |
| 75 | T3CODE_PROJECT_NAME: "acme-web", | |
| 76 | T3CODE_THREAD_ID: "thread-1", | |
| 77 | T3CODE_SCRIPT_ID: "test", | |
| 78 | T3CODE_SCRIPT_NAME: "Test", | |
| 79 | T3CODE_SCRIPT_ICON: "test", | |
| 80 | T3CODE_SCRIPT_IS_SETUP: "0", | |
| 81 | T3CODE_WORKTREE_PATH: "/repo/worktree-a", | |
| 82 | }); | |
| 83 | }); | |
| 84 | ||
| 85 | it("allows overriding runtime env values", () => { | |
| 86 | const env = projectScriptRuntimeEnv({ | |
| 87 | project: { id: "project-1", name: "acme-web", cwd: "/repo" }, | |
| 88 | script: { | |
| 89 | id: "setup", | |
| 90 | name: "Setup", | |
| 91 | icon: "configure", | |
| 92 | runOnWorktreeCreate: true, | |
| 93 | }, | |
| 94 | threadId: "thread-1", | |
| 95 | extraEnv: { | |
| 96 | T3CODE_PROJECT_ROOT: "/custom-root", | |
| 97 | CUSTOM_FLAG: "1", | |
| 98 | }, | |
| 99 | }); | |
| 100 | ||
| 101 | expect(env.T3CODE_PROJECT_ROOT).toBe("/custom-root"); | |
| 102 | expect(env.T3CODE_SCRIPT_IS_SETUP).toBe("1"); | |
| 103 | expect(env.CUSTOM_FLAG).toBe("1"); | |
| 104 | expect(env.T3CODE_WORKTREE_PATH).toBeUndefined(); | |
| 105 | }); | |
| 57 | 106 | }); |
apps/web/src/projectScripts.ts+32| ... | ... | @@ -82,6 +82,38 @@ export function injectEnvIntoShellCommand( |
| 82 | 82 | return `env ${assignments} ${command}`; |
| 83 | 83 | } |
| 84 | 84 | |
| 85 | interface ProjectScriptRuntimeEnvInput { | |
| 86 | project: { | |
| 87 | id: string; | |
| 88 | name: string; | |
| 89 | cwd: string; | |
| 90 | }; | |
| 91 | script: Pick<ProjectScript, "id" | "name" | "icon" | "runOnWorktreeCreate">; | |
| 92 | threadId: string; | |
| 93 | worktreePath?: string | null; | |
| 94 | extraEnv?: Record<string, string>; | |
| 95 | } | |
| 96 | ||
| 97 | export function projectScriptRuntimeEnv(input: ProjectScriptRuntimeEnvInput): Record<string, string> { | |
| 98 | const env: Record<string, string> = { | |
| 99 | T3CODE_PROJECT_ROOT: input.project.cwd, | |
| 100 | T3CODE_PROJECT_ID: input.project.id, | |
| 101 | T3CODE_PROJECT_NAME: input.project.name, | |
| 102 | T3CODE_THREAD_ID: input.threadId, | |
| 103 | T3CODE_SCRIPT_ID: input.script.id, | |
| 104 | T3CODE_SCRIPT_NAME: input.script.name, | |
| 105 | T3CODE_SCRIPT_ICON: input.script.icon, | |
| 106 | T3CODE_SCRIPT_IS_SETUP: input.script.runOnWorktreeCreate ? "1" : "0", | |
| 107 | }; | |
| 108 | if (input.worktreePath) { | |
| 109 | env.T3CODE_WORKTREE_PATH = input.worktreePath; | |
| 110 | } | |
| 111 | if (input.extraEnv) { | |
| 112 | return { ...env, ...input.extraEnv }; | |
| 113 | } | |
| 114 | return env; | |
| 115 | } | |
| 116 | ||
| 85 | 117 | export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null { |
| 86 | 118 | const regular = scripts.find((script) => !script.runOnWorktreeCreate); |
| 87 | 119 | return regular ?? scripts[0] ?? null; |
apps/web/src/wsNativeApi.ts+1| ... | ... | @@ -143,6 +143,7 @@ export function createWsNativeApi(): NativeApi { |
| 143 | 143 | }, |
| 144 | 144 | server: { |
| 145 | 145 | getConfig: () => transport.request(WS_METHODS.serverGetConfig), |
| 146 | upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), | |
| 146 | 147 | }, |
| 147 | 148 | }; |
| 148 | 149 |
packages/contracts/src/ipc.ts+2| ... | ... | @@ -46,6 +46,7 @@ import type { |
| 46 | 46 | TerminalWriteInput, |
| 47 | 47 | } from "./terminal"; |
| 48 | 48 | import type { NewTodoInput, Todo } from "./todo"; |
| 49 | import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; | |
| 49 | 50 | |
| 50 | 51 | export const EDITORS = [ |
| 51 | 52 | { id: "cursor", label: "Cursor", command: "cursor" }, |
| ... | ... | @@ -122,5 +123,6 @@ export interface NativeApi { |
| 122 | 123 | }; |
| 123 | 124 | server: { |
| 124 | 125 | getConfig: () => Promise<ServerConfig>; |
| 126 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; | |
| 125 | 127 | }; |
| 126 | 128 | } |
packages/contracts/src/project.test.ts+17| ... | ... | @@ -105,6 +105,23 @@ describe("project contracts", () => { |
| 105 | 105 | expect(parsed.scripts[0]?.id).toBe("test"); |
| 106 | 106 | }); |
| 107 | 107 | |
| 108 | it("parses debug icon in scripts", () => { | |
| 109 | const parsed = projectUpdateScriptsInputSchema.parse({ | |
| 110 | id: "project-1", | |
| 111 | scripts: [ | |
| 112 | { | |
| 113 | id: "debug", | |
| 114 | name: "Debug", | |
| 115 | command: "bun --inspect test", | |
| 116 | icon: "debug", | |
| 117 | runOnWorktreeCreate: false, | |
| 118 | }, | |
| 119 | ], | |
| 120 | }); | |
| 121 | ||
| 122 | expect(parsed.scripts[0]?.icon).toBe("debug"); | |
| 123 | }); | |
| 124 | ||
| 108 | 125 | it("parses update scripts result", () => { |
| 109 | 126 | const parsed = projectUpdateScriptsResultSchema.parse({ |
| 110 | 127 | project: { |
packages/contracts/src/project.ts+8-1| ... | ... | @@ -1,6 +1,13 @@ |
| 1 | 1 | import { z } from "zod"; |
| 2 | 2 | |
| 3 | export const PROJECT_SCRIPT_ICON_VALUES = ["play", "test", "lint", "configure", "build"] as const; | |
| 3 | export const PROJECT_SCRIPT_ICON_VALUES = [ | |
| 4 | "play", | |
| 5 | "test", | |
| 6 | "lint", | |
| 7 | "configure", | |
| 8 | "build", | |
| 9 | "debug", | |
| 10 | ] as const; | |
| 4 | 11 | |
| 5 | 12 | export const projectScriptIconSchema = z.enum(PROJECT_SCRIPT_ICON_VALUES); |
| 6 | 13 |
packages/contracts/src/server.ts+9-1| ... | ... | @@ -1,9 +1,17 @@ |
| 1 | 1 | import { z } from "zod"; |
| 2 | import { resolvedKeybindingsConfigSchema } from "./keybindings"; | |
| 2 | import { keybindingRuleSchema, resolvedKeybindingsConfigSchema } from "./keybindings"; | |
| 3 | 3 | |
| 4 | 4 | export const serverConfigSchema = z.object({ |
| 5 | 5 | cwd: z.string().min(1), |
| 6 | 6 | keybindings: resolvedKeybindingsConfigSchema.default([]), |
| 7 | 7 | }); |
| 8 | 8 | |
| 9 | export const serverUpsertKeybindingInputSchema = keybindingRuleSchema; | |
| 10 | ||
| 11 | export const serverUpsertKeybindingResultSchema = z.object({ | |
| 12 | keybindings: resolvedKeybindingsConfigSchema.default([]), | |
| 13 | }); | |
| 14 | ||
| 9 | 15 | export type ServerConfig = z.infer<typeof serverConfigSchema>; |
| 16 | export type ServerUpsertKeybindingInput = z.infer<typeof serverUpsertKeybindingInputSchema>; | |
| 17 | export type ServerUpsertKeybindingResult = z.infer<typeof serverUpsertKeybindingResultSchema>; |
packages/contracts/src/ws.ts+1| ... | ... | @@ -42,6 +42,7 @@ export const WS_METHODS = { |
| 42 | 42 | |
| 43 | 43 | // Server meta |
| 44 | 44 | serverGetConfig: "server.getConfig", |
| 45 | serverUpsertKeybinding: "server.upsertKeybinding", | |
| 45 | 46 | } as const; |
| 46 | 47 | |
| 47 | 48 | // ── Push Event Channels ────────────────────────────────────────────── |