From f670a9d040973d9dd145cce0f076e026f75ea16e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 7 Oct 2025 15:33:48 +0200 Subject: [PATCH] feat: add command palette and keybinds integration Integrate command registry provider and palette overlay to manage core actions, expose keybind for Cmd+Shift+P, and persist recents for recall Add cmdk dependency and document palette shortcuts throughout docs. Harden workspace removal by pruning stale git worktrees when missing. --- README.md | 1 + bun.lock | 53 +++ docs/AGENTS.md | 8 + docs/keybinds.md | 8 +- package.json | 1 + src/App.tsx | 169 ++++++++- src/components/ChatInput.tsx | 22 ++ src/components/CommandPalette.tsx | 449 ++++++++++++++++++++++++ src/contexts/CommandRegistryContext.tsx | 134 +++++++ src/git.ts | 10 + src/services/ipcMain.ts | 44 ++- src/utils/commands/sources.test.ts | 49 +++ src/utils/commands/sources.ts | 439 +++++++++++++++++++++++ src/utils/ui/keybinds.ts | 20 +- 14 files changed, 1386 insertions(+), 21 deletions(-) create mode 100644 src/components/CommandPalette.tsx create mode 100644 src/contexts/CommandRegistryContext.tsx create mode 100644 src/utils/commands/sources.test.ts create mode 100644 src/utils/commands/sources.ts diff --git a/README.md b/README.md index 71c0b1b53..5b8536155 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,4 @@ See [AGENTS.md](./AGENTS.md) for development setup and guidelines. - šŸ“¦ Multi-project management - šŸ’¬ Persistent session history - āŒØļø Keyboard-first interface + - Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`) diff --git a/bun.lock b/bun.lock index b3d75d0c0..8f01e835a 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "ai": "^5.0.56", + "cmdk": "^1.0.0", "crc-32": "^1.2.2", "diff": "^8.0.2", "jsonc-parser": "^3.3.1", @@ -364,6 +365,40 @@ "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], "@rollup/plugin-virtual": ["@rollup/plugin-virtual@3.0.2", "", { "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A=="], @@ -652,6 +687,8 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], @@ -790,6 +827,8 @@ "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="], + "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], + "co": ["co@4.6.0", "", {}, "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ=="], "collect-v8-coverage": ["collect-v8-coverage@1.0.2", "", {}, "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q=="], @@ -944,6 +983,8 @@ "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], @@ -1122,6 +1163,8 @@ "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "get-package-type": ["get-package-type@0.1.0", "", {}, "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], @@ -1746,6 +1789,12 @@ "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "react-syntax-highlighter": ["react-syntax-highlighter@15.6.6", "", { "dependencies": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^3.6.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw=="], "read-config-file": ["read-config-file@6.3.2", "", { "dependencies": { "config-file-ts": "^0.2.4", "dotenv": "^9.0.2", "dotenv-expand": "^5.1.0", "js-yaml": "^4.1.0", "json5": "^2.2.0", "lazy-val": "^1.0.4" } }, "sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q=="], @@ -2010,6 +2059,10 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 01bc380ee..5804828a1 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -154,6 +154,14 @@ in `./docs/vercel/**.mdx`. If IPC is hard to test, fix the test infrastructure or IPC layer, don't work around it by bypassing IPC. +## Command Palette (Cmd+Shift+P) + +- Open with `Cmd+Shift+P` on macOS or `Ctrl+Shift+P` on Windows/Linux. +- Quick toggle sidebar is `Cmd+P` / `Ctrl+P`. +- Palette includes workspace switching/creation, navigation, chat utils, mode/model, projects, and slash-command prefixes: + - `/` shows slash command suggestions (select to insert into Chat input). + - `>` filters to actions only. + ## Styling - Colors are centralized as CSS variables in `src/styles/colors.tsx` diff --git a/docs/keybinds.md b/docs/keybinds.md index ff7185680..1dc9f0830 100644 --- a/docs/keybinds.md +++ b/docs/keybinds.md @@ -47,12 +47,14 @@ When documentation shows `Ctrl`, it means: ## Interface -| Action | Shortcut | -| -------------- | -------------- | -| Toggle sidebar | `Ctrl+Shift+P` | +| Action | Shortcut | +| -------------------- | -------------- | +| Open command palette | `Ctrl+Shift+P` | +| Toggle sidebar | `Ctrl+P` | ## Tips - **Vim-inspired navigation**: We use `J`/`K` for next/previous navigation, similar to Vim +- **VS Code conventions**: Command palette is `Ctrl+Shift+P` and quick toggle is `Ctrl+P` (use `⌘` on macOS) - **Consistent modifiers**: Most workspace/project operations use `Ctrl` as the modifier - **Natural expectations**: We try to use shortcuts users would naturally expect (e.g., `Ctrl+N` for new) diff --git a/package.json b/package.json index 58ec00efa..5f8e4fdfe 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "ai": "^5.0.56", + "cmdk": "^1.0.0", "crc-32": "^1.2.2", "diff": "^8.0.2", "jsonc-parser": "^3.3.1", diff --git a/src/App.tsx b/src/App.tsx index f01c8415c..9a8798464 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import styled from "@emotion/styled"; import { Global, css } from "@emotion/react"; import { GlobalColors } from "./styles/colors"; @@ -15,6 +15,10 @@ import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds"; import { useProjectManagement } from "./hooks/useProjectManagement"; import { useWorkspaceManagement } from "./hooks/useWorkspaceManagement"; import { useWorkspaceAggregators } from "./hooks/useWorkspaceAggregators"; +import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandRegistryContext"; +import type { CommandAction } from "./contexts/CommandRegistryContext"; +import { CommandPalette } from "./components/CommandPalette"; +import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources"; import { useGitStatus } from "./hooks/useGitStatus"; // Global Styles with nice fonts @@ -142,7 +146,7 @@ const WelcomeView = styled.div` } `; -function App() { +function AppInner() { const [selectedWorkspace, setSelectedWorkspace] = usePersistedState( "selectedWorkspace", null @@ -173,21 +177,35 @@ function App() { // Use workspace aggregators hook for message state const { getWorkspaceState } = useWorkspaceAggregators(workspaceMetadata); + const streamingModels = new Map(); + for (const metadata of workspaceMetadata.values()) { + const state = getWorkspaceState(metadata.id); + if (state.canInterrupt) { + streamingModels.set(metadata.id, state.currentModel); + } + } + // Enrich workspace metadata with git status const displayedWorkspaceMetadata = useGitStatus(workspaceMetadata); - const handleRemoveProject = async (path: string) => { - // Clear selected workspace if it belongs to the removed project - if (selectedWorkspace?.projectPath === path) { - setSelectedWorkspace(null); - } - await removeProject(path); - }; + const openWorkspaceInTerminal = useCallback((workspacePath: string) => { + void window.api.workspace.openTerminal(workspacePath); + }, []); - const handleAddWorkspace = (projectPath: string) => { + const handleRemoveProject = useCallback( + async (path: string) => { + if (selectedWorkspace?.projectPath === path) { + setSelectedWorkspace(null); + } + await removeProject(path); + }, + [removeProject, selectedWorkspace, setSelectedWorkspace] + ); + + const handleAddWorkspace = useCallback((projectPath: string) => { setWorkspaceModalProject(projectPath); setWorkspaceModalOpen(true); - }; + }, []); const handleCreateWorkspace = async (branchName: string) => { if (!workspaceModalProject) return; @@ -235,6 +253,106 @@ function App() { [selectedWorkspace, projects, workspaceMetadata, setSelectedWorkspace] ); + // Register command sources with registry + const { + registerSource, + isOpen: isCommandPaletteOpen, + open: openCommandPalette, + close: closeCommandPalette, + } = useCommandRegistry(); + + const registerParamsRef = useRef(null); + + const openNewWorkspaceFromPalette = useCallback( + (projectPath: string) => { + handleAddWorkspace(projectPath); + }, + [handleAddWorkspace] + ); + + const createWorkspaceFromPalette = useCallback( + async (projectPath: string, branchName: string) => { + const newWs = await createWorkspace(projectPath, branchName); + if (newWs) setSelectedWorkspace(newWs); + }, + [createWorkspace, setSelectedWorkspace] + ); + + const selectWorkspaceFromPalette = useCallback( + (selection: { + projectPath: string; + projectName: string; + workspacePath: string; + workspaceId: string; + }) => { + setSelectedWorkspace(selection); + }, + [setSelectedWorkspace] + ); + + const removeWorkspaceFromPalette = useCallback( + async (workspaceId: string) => removeWorkspace(workspaceId), + [removeWorkspace] + ); + + const renameWorkspaceFromPalette = useCallback( + async (workspaceId: string, newName: string) => renameWorkspace(workspaceId, newName), + [renameWorkspace] + ); + + const addProjectFromPalette = useCallback(() => { + void addProject(); + }, [addProject]); + + const removeProjectFromPalette = useCallback( + (path: string) => { + void handleRemoveProject(path); + }, + [handleRemoveProject] + ); + + const toggleSidebarFromPalette = useCallback(() => { + setSidebarCollapsed((prev) => !prev); + }, [setSidebarCollapsed]); + + const navigateWorkspaceFromPalette = useCallback( + (dir: "next" | "prev") => { + handleNavigateWorkspace(dir); + }, + [handleNavigateWorkspace] + ); + + registerParamsRef.current = { + projects, + workspaceMetadata, + selectedWorkspace, + streamingModels, + onOpenNewWorkspaceModal: openNewWorkspaceFromPalette, + onCreateWorkspace: createWorkspaceFromPalette, + onSelectWorkspace: selectWorkspaceFromPalette, + onRemoveWorkspace: removeWorkspaceFromPalette, + onRenameWorkspace: renameWorkspaceFromPalette, + onAddProject: addProjectFromPalette, + onRemoveProject: removeProjectFromPalette, + onToggleSidebar: toggleSidebarFromPalette, + onNavigateWorkspace: navigateWorkspaceFromPalette, + onOpenWorkspaceInTerminal: openWorkspaceInTerminal, + }; + + useEffect(() => { + const unregister = registerSource(() => { + const params = registerParamsRef.current; + if (!params) return []; + const factories = buildCoreSources(params); + const actions: CommandAction[] = []; + for (const factory of factories) { + actions.push(...factory()); + } + return actions; + }); + return unregister; + }, [registerSource]); + // Handle keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -244,6 +362,13 @@ function App() { } else if (matchesKeybind(e, KEYBINDS.PREV_WORKSPACE)) { e.preventDefault(); handleNavigateWorkspace("prev"); + } else if (matchesKeybind(e, KEYBINDS.OPEN_COMMAND_PALETTE)) { + e.preventDefault(); + if (isCommandPaletteOpen) { + closeCommandPalette(); + } else { + openCommandPalette(); + } } else if (matchesKeybind(e, KEYBINDS.TOGGLE_SIDEBAR)) { e.preventDefault(); setSidebarCollapsed((prev) => !prev); @@ -252,7 +377,13 @@ function App() { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [handleNavigateWorkspace, setSidebarCollapsed]); + }, [ + handleNavigateWorkspace, + setSidebarCollapsed, + isCommandPaletteOpen, + closeCommandPalette, + openCommandPalette, + ]); return ( <> @@ -304,6 +435,12 @@ function App() { )} + ({ + providerNames: [], + workspaceId: selectedWorkspace?.workspaceId, + })} + /> {workspaceModalOpen && workspaceModalProject && ( + + + ); +} + export default App; diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 7865790c4..6e6ec217e 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -420,6 +420,28 @@ export const ChatInput: React.FC = ({ }; }, []); + // Allow external components (e.g., CommandPalette) to insert text + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail as { text?: string } | undefined; + if (!detail?.text) return; + setInput(detail.text); + setTimeout(() => inputRef.current?.focus(), 0); + }; + window.addEventListener("cmux:insertToChatInput", handler as EventListener); + return () => window.removeEventListener("cmux:insertToChatInput", handler as EventListener); + }, [setInput]); + + // Allow external components to open the Model Selector + useEffect(() => { + const handler = () => { + // Open the inline ModelSelector and let it take focus itself + modelSelectorRef.current?.open(); + }; + window.addEventListener("cmux:openModelSelector", handler as EventListener); + return () => window.removeEventListener("cmux:openModelSelector", handler as EventListener); + }, []); + // Handle command selection const handleCommandSelect = useCallback( (suggestion: SlashSuggestion) => { diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx new file mode 100644 index 000000000..68420e96d --- /dev/null +++ b/src/components/CommandPalette.tsx @@ -0,0 +1,449 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import styled from "@emotion/styled"; +import { Command } from "cmdk"; +import { useCommandRegistry } from "@/contexts/CommandRegistryContext"; +import type { CommandAction } from "@/contexts/CommandRegistryContext"; +import { formatKeybind, KEYBINDS, isEditableElement, matchesKeybind } from "@/utils/ui/keybinds"; +import { getSlashCommandSuggestions } from "@/utils/slashCommands/suggestions"; + +const Overlay = styled.div` + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 2000; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 10vh; +`; + +const PaletteContainer = styled(Command)` + width: min(720px, 92vw); + background: #1f1f1f; + border: 1px solid #333; + border-radius: 8px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4); + color: #e5e5e5; + font-family: var(--font-primary); + overflow: hidden; +` as unknown as typeof Command; + +const PaletteInput = styled(Command.Input)` + width: 100%; + padding: 12px 14px; + background: #161616; + color: #e5e5e5; + border: none; + outline: none; + font-size: 14px; + border-bottom: 1px solid #2a2a2a; +` as unknown as typeof Command.Input; + +const Empty = styled.div` + padding: 16px; + color: #7a7a7a; + font-size: 13px; +`; + +const List = styled(Command.List)` + max-height: 420px; + overflow: auto; +` as unknown as typeof Command.List; + +const Group = styled(Command.Group)` + &[cmdk-group] { + padding: 8px 6px; + } + &[cmdk-group-heading] { + padding: 4px 10px; + color: #9a9a9a; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + } +` as unknown as typeof Command.Group; + +const Item = styled(Command.Item)` + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: 13px; + cursor: pointer; + border-radius: 6px; + margin: 2px 4px; + &:hover { + background: #2a2a2a; + } + &[aria-selected="true"] { + background: #2f2f2f; + } +` as unknown as typeof Command.Item; + +const Subtitle = styled.span` + color: #9a9a9a; + font-size: 12px; +`; + +const ShortcutHint = styled.span` + color: #9a9a9a; + font-size: 11px; + font-family: var(--font-monospace); +`; + +interface CommandPaletteProps { + getSlashContext?: () => { providerNames: string[]; workspaceId?: string }; +} + +type PromptDef = NonNullable>; +type PromptField = PromptDef["fields"][number]; + +interface PromptPaletteItem { + id: string; + title: string; + section: string; + keywords?: string[]; + subtitle?: string; + shortcutHint?: string; + run: () => void; +} + +type PaletteItem = CommandAction | PromptPaletteItem; + +interface PaletteGroup { + name: string; + items: PaletteItem[]; +} + +export const CommandPalette: React.FC = ({ getSlashContext }) => { + const { isOpen, close, getActions, addRecent, recent } = useCommandRegistry(); + const [query, setQuery] = useState(""); + const [activePrompt, setActivePrompt] = useState; + }>(null); + const [promptError, setPromptError] = useState(null); + + // Close palette with Escape + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (matchesKeybind(e, KEYBINDS.CANCEL) && isOpen) { + e.preventDefault(); + setActivePrompt(null); + setPromptError(null); + setQuery(""); + close(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isOpen, close]); + + // Reset state whenever palette visibility changes + useEffect(() => { + if (!isOpen) { + setActivePrompt(null); + setPromptError(null); + setQuery(""); + } else { + setPromptError(null); + setQuery(""); + } + }, [isOpen]); + + const rawActions = getActions(); + + const recentIndex = useMemo(() => { + const idx = new Map(); + recent.forEach((id, i) => idx.set(id, i)); + return idx; + }, [recent]); + + const startPrompt = useCallback((action: CommandAction) => { + if (!action.prompt) return; + setPromptError(null); + setQuery(""); + setActivePrompt({ + title: action.prompt.title ?? action.title, + fields: action.prompt.fields, + onSubmit: action.prompt.onSubmit, + idx: 0, + values: {}, + }); + }, []); + + const handlePromptValue = useCallback( + (value: string) => { + let nextInitial: string | null = null; + setPromptError(null); + setActivePrompt((current) => { + if (!current) return current; + const field = current.fields[current.idx]; + if (!field) return current; + const nextValues = { ...current.values, [field.name]: value }; + const nextIdx = current.idx + 1; + if (nextIdx < current.fields.length) { + const nextField = current.fields[nextIdx]; + if (nextField.type === "text") { + nextInitial = nextField.getInitialValue?.(nextValues) ?? nextField.initialValue ?? ""; + } else { + nextInitial = ""; + } + return { + ...current, + idx: nextIdx, + values: nextValues, + }; + } + const submit = current.onSubmit; + setTimeout(() => void submit(nextValues), 0); + close(); + setQuery(""); + return null; + }); + if (nextInitial !== null) { + const valueToSet = nextInitial; + setTimeout(() => setQuery(valueToSet), 0); + } + }, + [close] + ); + + const handlePromptTextSubmit = useCallback(() => { + if (!activePrompt) return; + const field = activePrompt.fields[activePrompt.idx]; + if (!field || field.type !== "text") return; + const trimmed = query.trim(); + const err = field.validate?.(trimmed) ?? null; + if (err) { + setPromptError(err); + return; + } + handlePromptValue(trimmed); + }, [activePrompt, query, handlePromptValue]); + + const handleQueryChange = useCallback( + (value: string) => { + setQuery(value); + if (activePrompt) { + setPromptError(null); + } + }, + [activePrompt] + ); + + const generalResults = useMemo(() => { + const q = query.trim(); + + if (q.startsWith("/")) { + const ctx = getSlashContext?.() ?? { providerNames: [] }; + const suggestions = getSlashCommandSuggestions(q, { providerNames: ctx.providerNames }); + const section = "Slash Commands"; + const groups: PaletteGroup[] = [ + { + name: section, + items: suggestions.map((s) => ({ + id: `slash:${s.id}`, + title: s.display, + subtitle: s.description, + section, + shortcutHint: `${formatKeybind(KEYBINDS.SEND_MESSAGE)} to insert`, + run: () => { + const text = s.replacement; + window.dispatchEvent(new CustomEvent("cmux:insertToChatInput", { detail: { text } })); + }, + })), + }, + ]; + return { + groups, + emptyText: suggestions.length ? undefined : "No command suggestions", + } satisfies { groups: PaletteGroup[]; emptyText: string | undefined }; + } + + const filtered = [...rawActions].sort((a, b) => { + const ai = recentIndex.has(a.id) ? recentIndex.get(a.id)! : 9999; + const bi = recentIndex.has(b.id) ? recentIndex.get(b.id)! : 9999; + if (ai !== bi) return ai - bi; + return a.title.localeCompare(b.title); + }); + + const bySection = new Map(); + for (const action of filtered) { + const sec = action.section || "Other"; + const list = bySection.get(sec) ?? []; + list.push(action); + bySection.set(sec, list); + } + + const groups: PaletteGroup[] = Array.from(bySection.entries()).map(([name, items]) => ({ + name, + items, + })); + + return { + groups, + emptyText: filtered.length ? undefined : "No results", + } satisfies { groups: PaletteGroup[]; emptyText: string | undefined }; + }, [query, rawActions, recentIndex, getSlashContext]); + + useEffect(() => { + if (!activePrompt) return; + const field = activePrompt.fields[activePrompt.idx]; + if (!field) return; + if (field.type === "text") { + const initial = field.getInitialValue?.(activePrompt.values) ?? field.initialValue ?? ""; + setQuery(initial); + } else { + setQuery(""); + } + }, [activePrompt]); + + const currentField: PromptField | null = activePrompt + ? (activePrompt.fields[activePrompt.idx] ?? null) + : null; + const isSlashQuery = !currentField && query.trim().startsWith("/"); + const shouldUseCmdkFilter = currentField ? currentField.type === "select" : !isSlashQuery; + + let groups: PaletteGroup[] = generalResults.groups; + let emptyText: string | undefined = generalResults.emptyText; + + if (currentField) { + const promptTitle = activePrompt?.title ?? currentField.label ?? "Provide details"; + if (currentField.type === "select") { + const options = currentField.getOptions(activePrompt?.values ?? {}); + groups = [ + { + name: promptTitle, + items: options.map((opt) => ({ + id: `prompt-select:${currentField.name}:${opt.id}`, + title: opt.label, + section: promptTitle, + keywords: opt.keywords, + run: () => handlePromptValue(opt.id), + })), + }, + ]; + emptyText = options.length ? undefined : "No options"; + } else { + const typed = query.trim(); + const fallbackHint = currentField.placeholder ?? "Type value and press Enter"; + const hint = + promptError ?? (typed.length > 0 ? `Press Enter to use ā€œ${typed}ā€` : fallbackHint); + groups = [ + { + name: promptTitle, + items: [ + { + id: `prompt-text:${currentField.name}`, + title: hint, + section: promptTitle, + run: handlePromptTextSubmit, + }, + ], + }, + ]; + emptyText = undefined; + } + } + + if (!isOpen) return null; + + const groupsWithItems = groups.filter((group) => group.items.length > 0); + const hasAnyItems = groupsWithItems.length > 0; + + return ( + { + setActivePrompt(null); + setPromptError(null); + setQuery(""); + close(); + }} + > + e.stopPropagation()} + shouldFilter={shouldUseCmdkFilter} + > + { + if (!currentField && isEditableElement(e.target)) return; + + if (currentField) { + if (e.key === "Enter" && currentField.type === "text") { + e.preventDefault(); + e.stopPropagation(); + handlePromptTextSubmit(); + } else if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + setActivePrompt(null); + setPromptError(null); + setQuery(""); + close(); + } + return; + } + }} + /> + + {groupsWithItems.map((group) => ( + + {group.items.map((item) => ( + { + if ("prompt" in item && item.prompt) { + addRecent(item.id); + startPrompt(item); + return; + } + + if (currentField) { + void item.run(); + return; + } + + addRecent(item.id); + close(); + setTimeout(() => { + void item.run(); + }, 0); + }} + > +
+ {item.title} + {"subtitle" in item && item.subtitle && ( + <> +
+ {item.subtitle} + + )} +
+ {"shortcutHint" in item && item.shortcutHint && ( + {item.shortcutHint} + )} +
+ ))} +
+ ))} + {!hasAnyItems && {emptyText ?? "No results"}} +
+
+
+ ); +}; diff --git a/src/contexts/CommandRegistryContext.tsx b/src/contexts/CommandRegistryContext.tsx new file mode 100644 index 000000000..a2c2a8844 --- /dev/null +++ b/src/contexts/CommandRegistryContext.tsx @@ -0,0 +1,134 @@ +import React, { createContext, useCallback, useContext, useMemo, useState } from "react"; + +export interface CommandAction { + id: string; + title: string; + subtitle?: string; + section: string; // grouping label + keywords?: string[]; + shortcutHint?: string; // display-only hint (e.g., ⌘P) + icon?: React.ReactNode; + visible?: () => boolean; + enabled?: () => boolean; + run: () => void | Promise; + prompt?: { + title?: string; + fields: Array< + | { + type: "text"; + name: string; + label?: string; + placeholder?: string; + initialValue?: string; + getInitialValue?: (values: Record) => string; + validate?: (v: string) => string | null; + } + | { + type: "select"; + name: string; + label?: string; + placeholder?: string; + getOptions: (values: Record) => Array<{ + id: string; + label: string; + keywords?: string[]; + }>; + } + >; + onSubmit: (values: Record) => void | Promise; + }; +} + +export type CommandSource = () => CommandAction[]; + +interface CommandRegistryContextValue { + isOpen: boolean; + open: () => void; + close: () => void; + toggle: () => void; + registerSource: (source: CommandSource) => () => void; + getActions: () => CommandAction[]; + addRecent: (actionId: string) => void; + recent: string[]; +} + +const CommandRegistryContext = createContext(null); + +export function useCommandRegistry(): CommandRegistryContextValue { + const ctx = useContext(CommandRegistryContext); + if (!ctx) throw new Error("useCommandRegistry must be used within CommandRegistryProvider"); + return ctx; +} + +const RECENT_STORAGE_KEY = "commandPalette:recent"; + +export const CommandRegistryProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [isOpen, setIsOpen] = useState(false); + const [sources, setSources] = useState>(new Set()); + const [recent, setRecent] = useState(() => { + try { + const raw = localStorage.getItem(RECENT_STORAGE_KEY); + const parsed = raw ? (JSON.parse(raw) as string[]) : []; + return Array.isArray(parsed) ? parsed.slice(0, 20) : []; + } catch { + return []; + } + }); + + const persistRecent = useCallback((next: string[]) => { + setRecent(next); + try { + localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(next.slice(0, 20))); + } catch { + /* ignore persistence errors */ + } + }, []); + + const addRecent = useCallback( + (actionId: string) => { + // Move to front, dedupe + const next = [actionId, ...recent.filter((id) => id !== actionId)].slice(0, 20); + persistRecent(next); + }, + [recent, persistRecent] + ); + + const open = useCallback(() => setIsOpen(true), []); + const close = useCallback(() => setIsOpen(false), []); + const toggle = useCallback(() => setIsOpen((v) => !v), []); + + const registerSource = useCallback((source: CommandSource) => { + setSources((prev) => new Set(prev).add(source)); + return () => + setSources((prev) => { + const copy = new Set(prev); + copy.delete(source); + return copy; + }); + }, []); + + const getActions = useCallback(() => { + const all: CommandAction[] = []; + for (const s of sources) { + try { + const actions = s(); + for (const a of actions) { + if (a.visible && !a.visible()) continue; + all.push(a); + } + } catch (e) { + console.error("Command source error:", e); + } + } + return all; + }, [sources]); + + const value = useMemo( + () => ({ isOpen, open, close, toggle, registerSource, getActions, addRecent, recent }), + [isOpen, open, close, toggle, registerSource, getActions, addRecent, recent] + ); + + return ( + {children} + ); +}; diff --git a/src/git.ts b/src/git.ts index 00a1ed6e3..f57269ef9 100644 --- a/src/git.ts +++ b/src/git.ts @@ -73,6 +73,16 @@ export async function removeWorktree( } } +export async function pruneWorktrees(projectPath: string): Promise { + try { + await execAsync(`git -C "${projectPath}" worktree prune`); + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; + } +} + export async function moveWorktree( projectPath: string, oldPath: string, diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 96da1e09c..3a6c2340c 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -3,7 +3,7 @@ import { spawn } from "child_process"; import * as path from "path"; import * as fsPromises from "fs/promises"; import type { Config, ProjectConfig } from "@/config"; -import { createWorktree, removeWorktree, moveWorktree } from "@/git"; +import { createWorktree, removeWorktree, moveWorktree, pruneWorktrees } from "@/git"; import { AIService } from "@/services/aiService"; import { HistoryService } from "@/services/historyService"; import { PartialService } from "@/services/partialService"; @@ -181,9 +181,45 @@ export class IpcMain { // Remove git worktree if we found the path if (workspacePath) { - const gitResult = await removeWorktree(workspacePath, { force: false }); - if (!gitResult.success) { - return gitResult; + const worktreeExists = await fsPromises + .access(workspacePath) + .then(() => true) + .catch(() => false); + + if (worktreeExists) { + const gitResult = await removeWorktree(workspacePath, { force: false }); + if (!gitResult.success) { + const errorMessage = gitResult.error ?? "Unknown error"; + const normalizedError = errorMessage.toLowerCase(); + const looksLikeMissingWorktree = + normalizedError.includes("not a working tree") || + normalizedError.includes("does not exist") || + normalizedError.includes("no such file"); + + if (looksLikeMissingWorktree) { + if (foundProjectPath) { + const pruneResult = await pruneWorktrees(foundProjectPath); + if (!pruneResult.success) { + log.info( + `Failed to prune stale worktrees for ${foundProjectPath} after removeWorktree error: ${ + pruneResult.error ?? "unknown error" + }` + ); + } + } + } else { + return gitResult; + } + } + } else if (foundProjectPath) { + const pruneResult = await pruneWorktrees(foundProjectPath); + if (!pruneResult.success) { + log.info( + `Failed to prune stale worktrees for ${foundProjectPath} after detecting missing workspace at ${workspacePath}: ${ + pruneResult.error ?? "unknown error" + }` + ); + } } } diff --git a/src/utils/commands/sources.test.ts b/src/utils/commands/sources.test.ts new file mode 100644 index 000000000..b601a0b35 --- /dev/null +++ b/src/utils/commands/sources.test.ts @@ -0,0 +1,49 @@ +import { buildCoreSources } from "./sources"; +import type { ProjectConfig } from "@/config"; +import type { WorkspaceMetadata } from "@/types/workspace"; + +const mk = (over: Partial[0]> = {}) => { + const projects = new Map(); + projects.set("/repo/a", { path: "/repo/a", workspaces: [{ path: "/repo/a/feat-x" }] }); + const workspaceMetadata = new Map(); + workspaceMetadata.set("/repo/a/feat-x", { + id: "w1", + projectName: "a", + workspacePath: "/repo/a/feat-x", + } as WorkspaceMetadata); + const params: Parameters[0] = { + projects, + workspaceMetadata, + selectedWorkspace: { + projectPath: "/repo/a", + projectName: "a", + workspacePath: "/repo/a/feat-x", + workspaceId: "w1", + }, + streamingModels: new Map(), + onCreateWorkspace: async () => { + await Promise.resolve(); + }, + onOpenNewWorkspaceModal: () => undefined, + onSelectWorkspace: () => undefined, + onRemoveWorkspace: () => Promise.resolve({ success: true }), + onRenameWorkspace: () => Promise.resolve({ success: true }), + onAddProject: () => undefined, + onRemoveProject: () => undefined, + onToggleSidebar: () => undefined, + onNavigateWorkspace: () => undefined, + onOpenWorkspaceInTerminal: () => undefined, + ...over, + }; + return buildCoreSources(params); +}; + +test("buildCoreSources includes create/switch workspace actions", () => { + const sources = mk(); + const actions = sources.flatMap((s) => s()); + const titles = actions.map((a) => a.title); + expect(titles.some((t) => t.startsWith("Create New Workspace"))).toBe(true); + expect(titles.some((t) => t.includes("Switch to "))).toBe(true); + expect(titles.includes("Open Current Workspace in Terminal")).toBe(true); + expect(titles.includes("Open Workspace in Terminal…")).toBe(true); +}); diff --git a/src/utils/commands/sources.ts b/src/utils/commands/sources.ts new file mode 100644 index 000000000..1d0e8ceef --- /dev/null +++ b/src/utils/commands/sources.ts @@ -0,0 +1,439 @@ +import type { CommandAction } from "@/contexts/CommandRegistryContext"; +import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds"; +import type { ProjectConfig } from "@/config"; +import type { WorkspaceMetadata } from "@/types/workspace"; + +export interface BuildSourcesParams { + projects: Map; + workspaceMetadata: Map; + selectedWorkspace: { + projectPath: string; + projectName: string; + workspacePath: string; + workspaceId: string; + } | null; + streamingModels: Map; + // UI actions + onOpenNewWorkspaceModal: (projectPath: string) => void; + onCreateWorkspace: (projectPath: string, branchName: string) => Promise; + onSelectWorkspace: (sel: { + projectPath: string; + projectName: string; + workspacePath: string; + workspaceId: string; + }) => void; + onRemoveWorkspace: (workspaceId: string) => Promise<{ success: boolean; error?: string }>; + onRenameWorkspace: ( + workspaceId: string, + newName: string + ) => Promise<{ success: boolean; error?: string }>; + onAddProject: () => void; + onRemoveProject: (path: string) => void; + onToggleSidebar: () => void; + onNavigateWorkspace: (dir: "next" | "prev") => void; + onOpenWorkspaceInTerminal: (workspacePath: string) => void; +} + +const section = { + workspaces: "Workspaces", + navigation: "Navigation", + chat: "Chat", + mode: "Modes & Model", + help: "Help", + projects: "Projects", +}; + +export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandAction[]> { + const actions: Array<() => CommandAction[]> = []; + + // Workspaces + actions.push(() => { + const list: CommandAction[] = []; + + const selected = p.selectedWorkspace; + if (selected) { + list.push({ + id: "ws:new", + title: "Create New Workspace…", + subtitle: `for ${selected.projectName}`, + section: section.workspaces, + shortcutHint: formatKeybind(KEYBINDS.NEW_WORKSPACE), + run: () => undefined, + prompt: { + title: "New Workspace", + fields: [ + { + type: "text", + name: "branchName", + label: "Branch name", + placeholder: "Enter branch name", + validate: (v) => (!v.trim() ? "Branch name is required" : null), + }, + ], + onSubmit: async (vals) => { + await p.onCreateWorkspace(selected.projectPath, vals.branchName.trim()); + }, + }, + }); + } + + // Switch to workspace + for (const [projectPath, config] of p.projects.entries()) { + const projectName = projectPath.split("/").pop() ?? projectPath; + for (const ws of config.workspaces) { + const meta = p.workspaceMetadata.get(ws.path); + if (!meta) continue; + const isCurrent = selected?.workspaceId === meta.id; + const isStreaming = p.streamingModels.has(meta.id); + list.push({ + id: `ws:switch:${meta.id}`, + title: `${isCurrent ? "• " : ""}Switch to ${ws.path.split("/").pop() ?? ws.path}`, + subtitle: `${projectName}${isStreaming ? " • streaming" : ""}`, + section: section.workspaces, + keywords: [projectName, ws.path], + run: () => + p.onSelectWorkspace({ + projectPath, + projectName, + workspacePath: ws.path, + workspaceId: meta.id, + }), + }); + } + } + + // Remove current workspace (rename action intentionally omitted until we add a proper modal) + if (selected) { + const workspaceDisplayName = `${selected.projectName}/${selected.workspacePath.split("/").pop() ?? selected.workspacePath}`; + list.push({ + id: "ws:open-terminal-current", + title: "Open Current Workspace in Terminal", + subtitle: workspaceDisplayName, + section: section.workspaces, + shortcutHint: formatKeybind(KEYBINDS.OPEN_TERMINAL), + run: () => { + p.onOpenWorkspaceInTerminal(selected.workspacePath); + }, + }); + list.push({ + id: "ws:remove", + title: "Remove Current Workspace…", + subtitle: workspaceDisplayName, + section: section.workspaces, + run: async () => { + const ok = confirm("Remove current workspace? This cannot be undone."); + if (ok) await p.onRemoveWorkspace(selected.workspaceId); + }, + }); + list.push({ + id: "ws:rename", + title: "Rename Current Workspace…", + subtitle: workspaceDisplayName, + section: section.workspaces, + run: () => undefined, + prompt: { + title: "Rename Workspace", + fields: [ + { + type: "text", + name: "newName", + label: "New name", + placeholder: "Enter new workspace name", + initialValue: selected.workspacePath.split("/").pop() ?? "", + getInitialValue: () => selected.workspacePath.split("/").pop() ?? "", + validate: (v) => (!v.trim() ? "Name is required" : null), + }, + ], + onSubmit: async (vals) => { + await p.onRenameWorkspace(selected.workspaceId, vals.newName.trim()); + }, + }, + }); + } + + if (p.workspaceMetadata.size > 0) { + list.push({ + id: "ws:open-terminal", + title: "Open Workspace in Terminal…", + section: section.workspaces, + run: () => undefined, + prompt: { + title: "Open Workspace in Terminal", + fields: [ + { + type: "select", + name: "workspacePath", + label: "Workspace", + placeholder: "Search workspaces…", + getOptions: () => + Array.from(p.workspaceMetadata.values()).map((meta) => { + const workspaceName = meta.workspacePath.split("/").pop() ?? meta.workspacePath; + const label = `${meta.projectName} / ${workspaceName}`; + return { + id: meta.workspacePath, + label, + keywords: [workspaceName, meta.projectName, meta.workspacePath, meta.id], + }; + }), + }, + ], + onSubmit: (vals) => { + p.onOpenWorkspaceInTerminal(vals.workspacePath); + }, + }, + }); + list.push({ + id: "ws:rename-any", + title: "Rename Workspace…", + section: section.workspaces, + run: () => undefined, + prompt: { + title: "Rename Workspace", + fields: [ + { + type: "select", + name: "workspaceId", + label: "Select workspace", + placeholder: "Search workspaces…", + getOptions: () => + Array.from(p.workspaceMetadata.values()).map((meta) => { + const workspaceName = meta.workspacePath.split("/").pop() ?? meta.workspacePath; + const label = `${meta.projectName} / ${workspaceName}`; + return { + id: meta.id, + label, + keywords: [workspaceName, meta.projectName, meta.workspacePath, meta.id], + }; + }), + }, + { + type: "text", + name: "newName", + label: "New name", + placeholder: "Enter new workspace name", + getInitialValue: (values) => { + const meta = Array.from(p.workspaceMetadata.values()).find( + (m) => m.id === values.workspaceId + ); + return meta ? (meta.workspacePath.split("/").pop() ?? "") : ""; + }, + validate: (v) => (!v.trim() ? "Name is required" : null), + }, + ], + onSubmit: async (vals) => { + await p.onRenameWorkspace(vals.workspaceId, vals.newName.trim()); + }, + }, + }); + list.push({ + id: "ws:remove-any", + title: "Remove Workspace…", + section: section.workspaces, + run: () => undefined, + prompt: { + title: "Remove Workspace", + fields: [ + { + type: "select", + name: "workspaceId", + label: "Select workspace", + placeholder: "Search workspaces…", + getOptions: () => + Array.from(p.workspaceMetadata.values()).map((meta) => { + const workspaceName = meta.workspacePath.split("/").pop() ?? meta.workspacePath; + const label = `${meta.projectName}/${workspaceName}`; + return { + id: meta.id, + label, + keywords: [workspaceName, meta.projectName, meta.workspacePath, meta.id], + }; + }), + }, + ], + onSubmit: async (vals) => { + const meta = Array.from(p.workspaceMetadata.values()).find( + (m) => m.id === vals.workspaceId + ); + const workspaceName = meta + ? `${meta.projectName}/${meta.workspacePath.split("/").pop() ?? meta.workspacePath}` + : vals.workspaceId; + const ok = confirm(`Remove workspace ${workspaceName}? This cannot be undone.`); + if (ok) { + await p.onRemoveWorkspace(vals.workspaceId); + } + }, + }, + }); + } + + return list; + }); + + // Navigation / Interface + actions.push(() => [ + { + id: "nav:next", + title: "Next Workspace", + section: section.navigation, + shortcutHint: formatKeybind(KEYBINDS.NEXT_WORKSPACE), + run: () => p.onNavigateWorkspace("next"), + }, + { + id: "nav:prev", + title: "Previous Workspace", + section: section.navigation, + shortcutHint: formatKeybind(KEYBINDS.PREV_WORKSPACE), + run: () => p.onNavigateWorkspace("prev"), + }, + { + id: "nav:toggleSidebar", + title: "Toggle Sidebar", + section: section.navigation, + shortcutHint: formatKeybind(KEYBINDS.TOGGLE_SIDEBAR), + run: () => p.onToggleSidebar(), + }, + ]); + + // Chat utilities + actions.push(() => { + const list: CommandAction[] = []; + if (p.selectedWorkspace) { + const id = p.selectedWorkspace.workspaceId; + list.push({ + id: "chat:clear", + title: "Clear History", + section: section.chat, + run: async () => { + await window.api.workspace.truncateHistory(id, 1.0); + }, + }); + for (const pct of [0.75, 0.5, 0.25]) { + list.push({ + id: `chat:truncate:${pct}`, + title: `Truncate History to ${Math.round((1 - pct) * 100)}%`, + section: section.chat, + run: async () => { + await window.api.workspace.truncateHistory(id, pct); + }, + }); + } + list.push({ + id: "chat:interrupt", + title: "Interrupt Streaming", + section: section.chat, + run: async () => { + await window.api.workspace.sendMessage(id, ""); + }, + }); + list.push({ + id: "chat:jumpBottom", + title: "Jump to Bottom", + section: section.chat, + shortcutHint: formatKeybind(KEYBINDS.JUMP_TO_BOTTOM), + run: () => { + // Dispatch the keybind; AIView listens for it + const ev = new KeyboardEvent("keydown", { key: "G", shiftKey: true }); + window.dispatchEvent(ev); + }, + }); + } + return list; + }); + + // Modes & Model + actions.push(() => [ + { + id: "mode:toggle", + title: "Toggle Plan/Exec Mode", + section: section.mode, + shortcutHint: formatKeybind(KEYBINDS.TOGGLE_MODE), + run: () => { + const ev = new KeyboardEvent("keydown", { key: "M", ctrlKey: true, shiftKey: true }); + window.dispatchEvent(ev); + }, + }, + { + id: "model:change", + title: "Change Model…", + section: section.mode, + shortcutHint: formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR), + run: () => { + window.dispatchEvent(new CustomEvent("cmux:openModelSelector")); + }, + }, + ]); + + // Help / Docs + actions.push(() => [ + { + id: "help:keybinds", + title: "Show Keyboard Shortcuts", + section: section.help, + run: () => { + try { + window.open("https://cmux.io/keybinds.html", "_blank"); + } catch { + /* ignore */ + } + }, + }, + ]); + + // Projects + actions.push(() => { + const list: CommandAction[] = [ + { + id: "project:add", + title: "Add Project…", + section: section.projects, + run: () => p.onAddProject(), + }, + { + id: "ws:new-in-project", + title: "Create New Workspace in Project…", + section: section.projects, + run: () => undefined, + prompt: { + title: "New Workspace in Project", + fields: [ + { + type: "select", + name: "projectPath", + label: "Select project", + placeholder: "Search projects…", + getOptions: (_values) => + Array.from(p.projects.keys()).map((projectPath) => ({ + id: projectPath, + label: projectPath.split("/").pop() ?? projectPath, + keywords: [projectPath], + })), + }, + { + type: "text", + name: "branchName", + label: "Branch name", + placeholder: "Enter branch name", + validate: (v) => (!v.trim() ? "Branch name is required" : null), + }, + ], + onSubmit: async (vals) => { + await p.onCreateWorkspace(vals.projectPath, vals.branchName.trim()); + }, + }, + }, + ]; + + for (const [projectPath] of p.projects.entries()) { + const projectName = projectPath.split("/").pop() ?? projectPath; + list.push({ + id: `project:remove:${projectPath}`, + title: `Remove Project ${projectName}…`, + section: section.projects, + run: () => p.onRemoveProject(projectPath), + }); + } + return list; + }); + + return actions; +} diff --git a/src/utils/ui/keybinds.ts b/src/utils/ui/keybinds.ts index f2099d8a3..550cfdb2d 100644 --- a/src/utils/ui/keybinds.ts +++ b/src/utils/ui/keybinds.ts @@ -21,7 +21,16 @@ export interface Keybind { * Detect if running on macOS */ export function isMac(): boolean { - return window.api.platform === "darwin"; + try { + if (typeof window === "undefined") return false; + interface MinimalAPI { + platform: string; + } + const api = (window as unknown as { api?: MinimalAPI }).api; + return api?.platform === "darwin"; + } catch { + return false; + } } /** @@ -146,11 +155,18 @@ export const KEYBINDS = { PREV_WORKSPACE: { key: "k", ctrl: true }, /** Toggle sidebar visibility */ - TOGGLE_SIDEBAR: { key: "P", ctrl: true, shift: true }, + // VS Code-style quick toggle + // macOS: Cmd+P, Win/Linux: Ctrl+P + TOGGLE_SIDEBAR: { key: "P", ctrl: true }, /** Open model selector */ OPEN_MODEL_SELECTOR: { key: "/", ctrl: true }, /** Open workspace in terminal */ OPEN_TERMINAL: { key: "t", ctrl: true }, + + /** Open Command Palette */ + // VS Code-style palette + // macOS: Cmd+Shift+P, Win/Linux: Ctrl+Shift+P + OPEN_COMMAND_PALETTE: { key: "P", ctrl: true, shift: true }, } as const;