From de138ee69b5a5cc7e7d72ba1cd6fb9e50082d98a Mon Sep 17 00:00:00 2001
From: divyeshio <79130336+divyeshio@users.noreply.github.com>
Date: Fri, 10 Apr 2026 22:25:00 +0530
Subject: [PATCH 1/6] refactor: remove unused command creation functions from
flat.ts
---
registry/commandly/utils/flat.ts | 20 --------------------
1 file changed, 20 deletions(-)
diff --git a/registry/commandly/utils/flat.ts b/registry/commandly/utils/flat.ts
index f2b24c0..2e4ed6a 100644
--- a/registry/commandly/utils/flat.ts
+++ b/registry/commandly/utils/flat.ts
@@ -93,16 +93,6 @@ export const exportToStructuredJSON = (tool: Tool) => {
};
};
-export const createNewCommand = (parentKey?: string): Command => {
- const name = randomCommandName();
- return {
- key: slugify(name),
- parentCommandKey: parentKey,
- name,
- isDefault: false,
- sortOrder: 1,
- };
-};
export const createNewParameter = (isGlobal: boolean, commandKey?: string): Parameter => {
return {
@@ -116,16 +106,6 @@ export const createNewParameter = (isGlobal: boolean, commandKey?: string): Para
};
};
-export const randomCommandName = () => {
- const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
- let result = "";
- const charactersLength = characters.length;
- for (let i = 0; i < 7; i++) {
- result += characters.charAt(Math.floor(Math.random() * charactersLength));
- }
- return result;
-};
-
const isEmpty = (value: object | null | undefined): boolean => {
if (value == null) return true;
if (Array.isArray(value)) return value.length === 0;
From d51155d829d602e1eb9442e121eb29be36c37718 Mon Sep 17 00:00:00 2001
From: divyeshio <79130336+divyeshio@users.noreply.github.com>
Date: Wed, 15 Apr 2026 22:34:59 +0530
Subject: [PATCH 2/6] feat: enhance parameter list with context selection and
pending changes tracking
- Added support for context selection in the ParameterList component, allowing multi-select functionality with Ctrl/Cmd and Shift keys.
- Integrated pending changes tracking to highlight added, updated, and removed parameters.
- Updated UI to reflect changes with visual indicators for parameter states.
- Refactored related components to accommodate new features and improve maintainability.
feat: update preview tabs to handle original tool state
- Modified PreviewTabs to include originalTool for better comparison and state management.
- Adjusted JsonOutput component to accept originalTool for enhanced functionality.
refactor: improve prompt generation logic for better context handling
- Refactored generatePrompt function to support multiple selected commands and parameters.
- Enhanced context block generation to focus on relevant commands and parameters.
feat: implement tool editing and application utilities
- Created utility functions for applying JSON merge patches and managing tool edits.
- Added functions for reading tool definitions and finalizing edits with user approval.
feat: add progress and spinner components for better loading indicators
- Introduced Progress and Spinner components to enhance user experience during loading states.
fix: update CSS for dark mode compatibility
- Adjusted CSS to ensure proper styling for dark mode, improving readability.
refactor: enhance AI key management with encryption
- Implemented IndexedDB for secure storage of AI keys, utilizing encryption for sensitive data.
- Updated useAIKeys hook to handle encrypted key storage and retrieval.
---
bun.lock | 197 +-
package.json | 29 +-
public/specification/flat.json | 70 +-
public/specification/nested.json | 65 +-
public/tools.json | 91 +-
.../__tests__/tool-renderer.test.tsx | 30 +
registry/commandly/json-output.tsx | 122 +-
registry/commandly/tool-renderer.tsx | 6 +-
registry/commandly/types/flat.ts | 60 +
registry/commandly/types/nested.ts | 43 +
scripts/generate-tools-json.ts | 2 +-
.../ai-elements/chain-of-thought.tsx | 222 +++
src/components/ai-elements/context.tsx | 409 +++++
src/components/ai-elements/prompt-input.tsx | 1463 +++++++++++++++
src/components/ai-elements/reasoning.tsx | 221 +++
src/components/tool-card.tsx | 9 +-
src/components/tool-editor/ai-chat.tsx | 1586 +++++++++++------
.../tool-editor/api-key-settings.tsx | 144 ++
src/components/tool-editor/command-tree.tsx | 67 +-
.../tool-editor/dialogs/command-dialog.tsx | 2 +-
src/components/tool-editor/model-picker.tsx | 334 ++++
src/components/tool-editor/parameter-list.tsx | 135 +-
src/components/tool-editor/preview-tabs.tsx | 8 +-
src/components/tool-editor/prompt.ts | 75 +-
.../tool-editor/tool-editor.context.tsx | 39 +-
src/components/tool-editor/tool-editor.tsx | 35 +-
src/components/tool-editor/tools.ts | 141 ++
src/components/ui/progress.tsx | 31 +
src/components/ui/spinner.tsx | 10 +
src/index.css | 8 +-
src/lib/ai-keys.ts | 98 +-
31 files changed, 5018 insertions(+), 734 deletions(-)
create mode 100644 src/components/ai-elements/chain-of-thought.tsx
create mode 100644 src/components/ai-elements/context.tsx
create mode 100644 src/components/ai-elements/prompt-input.tsx
create mode 100644 src/components/ai-elements/reasoning.tsx
create mode 100644 src/components/tool-editor/api-key-settings.tsx
create mode 100644 src/components/tool-editor/model-picker.tsx
create mode 100644 src/components/tool-editor/tools.ts
create mode 100644 src/components/ui/progress.tsx
create mode 100644 src/components/ui/spinner.tsx
diff --git a/bun.lock b/bun.lock
index 1e0e21c..7034417 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,8 +5,8 @@
"": {
"name": "commandly",
"dependencies": {
- "@ai-sdk/anthropic": "^3.0.68",
- "@ai-sdk/google": "^3.0.60",
+ "@ai-sdk/anthropic": "^3.0.69",
+ "@ai-sdk/google": "^3.0.62",
"@ai-sdk/groq": "^3.0.35",
"@ai-sdk/mistral": "^3.0.30",
"@ai-sdk/openai": "^3.0.52",
@@ -24,11 +24,11 @@
"@tailwindcss/postcss": "^4.2.2",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-pacer": "^0.21.1",
- "@tanstack/react-query": "^5.96.2",
- "@tanstack/react-router": "1.168.10",
+ "@tanstack/react-query": "^5.99.0",
+ "@tanstack/react-router": "1.168.18",
"@tanstack/react-router-with-query": "1.130.17",
- "@tanstack/react-start": "^1.167.16",
- "ai": "^6.0.154",
+ "@tanstack/react-start": "^1.167.32",
+ "ai": "^6.0.158",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -46,29 +46,30 @@
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tailwindcss-animate": "^1.0.7",
+ "tokenlens": "^1.3.1",
"tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.3",
"zod": "^4.3.6",
},
"devDependencies": {
"@mdx-js/rollup": "^3.1.1",
- "@tanstack/react-query-devtools": "^5.96.2",
- "@tanstack/react-router-devtools": "1.166.11",
- "@tanstack/router-plugin": "1.167.12",
+ "@tanstack/react-query-devtools": "^5.99.0",
+ "@tanstack/react-router-devtools": "1.166.13",
+ "@tanstack/router-plugin": "1.167.18",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
- "@types/bun": "^1.3.11",
+ "@types/bun": "^1.3.12",
"@types/jest": "^30.0.0",
"@types/json-schema": "^7.0.15",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
- "@typescript/native-preview": "^7.0.0-dev.20260408.1",
+ "@typescript/native-preview": "^7.0.0-dev.20260412.1",
"@vitejs/plugin-react": "^6.0.1",
- "@vitest/coverage-v8": "4.1.2",
+ "@vitest/coverage-v8": "4.1.4",
"ajv": "^8.18.0",
"jsdom": "^29.0.2",
- "oxfmt": "^0.43.0",
+ "oxfmt": "^0.44.0",
"oxlint": "^1.59.0",
"oxlint-tsgolint": "^0.20.0",
"rehype-pretty-code": "^0.14.3",
@@ -78,7 +79,7 @@
"typescript": "^6.0.2",
"typescript-json-schema": "^0.67.1",
"vite": "^8.0.8",
- "vitest": "^4.1.3",
+ "vitest": "^4.1.4",
"vitest-browser-react": "^2.2.0",
},
},
@@ -86,11 +87,11 @@
"packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
- "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.68", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-BAd+fmgYoJMmGw0/uV+jRlXX60PyGxelA6Clp4cK/NI0dsyv9jOOwzQmKNaz2nwb+Jz7HqI7I70KK4XtU5EcXQ=="],
+ "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
- "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.94", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uDDwLZhCkvC89crVS3S90D5L7AcVN8WriGuYVNYgVAaVcvy3Mthy3R9ICfzG75BObhz6pm2FWnhxDfNRK+t69Q=="],
+ "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="],
- "@ai-sdk/google": ["@ai-sdk/google@3.0.60", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ye/hG0LeO24VmjLbfgkFZV8V8k/l4nVBODutpJQkFPyUiGOCbFtFUTgxSeC7+njrk5+HhgyHrzJay4zmhwMH+w=="],
+ "@ai-sdk/google": ["@ai-sdk/google@3.0.62", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cC9HAjR5WZxjqGyEJrJqFTlVqyPE9UOFmmGdf5dINaimgfPmzqXYN1qTYEJ+1knbyTVsNMub0KAF5SOqqtO8IQ=="],
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LXoPwSKaqXst9LyLN2J7gK8n7RldQLbP2zsnBYxXcOsXKrtceksqtbsmGXujvab2TM9FisquAw/ZG2hTbD5vnQ=="],
@@ -110,9 +111,9 @@
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
- "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.8", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-OISPR9c2uPo23rUdvfEQiLPjoMLOpEeLNnP5iGkxr6tDDxJd3NjD+6fxY0mdaMbIPUjFGL4HFOJqLvow5q4aqQ=="],
+ "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.10", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-02OhhkKtgNRuicQ/nF3TRnGsxL9wp0r3Y7VlKWyOHHGmGyvXv03y+PnymU8FKFJMTjIr1Bk8U2g1HWSLrpAHww=="],
- "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.0.8", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-erMO6FgtM02dC24NGm0xufMzWz5OF0wXKR7BpvGD973bq/GbmR8/DbxNZbj0YevQ5hlToJaWSVK/G9/NDgGEVw=="],
+ "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.0.9", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg=="],
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
@@ -198,13 +199,13 @@
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
- "@csstools/css-calc": ["@csstools/css-calc@3.1.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ=="],
+ "@csstools/css-calc": ["@csstools/css-calc@3.2.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w=="],
- "@csstools/css-color-parser": ["@csstools/css-color-parser@4.0.2", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.1.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw=="],
+ "@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.0", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.2.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ=="],
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
- "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.2", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA=="],
+ "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.3", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg=="],
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
@@ -372,43 +373,43 @@
"@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="],
- "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.43.0", "", { "os": "android", "cpu": "arm" }, "sha512-CgU2s+/9hHZgo0IxVxrbMPrMj+tJ6VM3mD7Mr/4oiz4FNTISLoCvRmB5nk4wAAle045RtRjd86m673jwPyb1OQ=="],
+ "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.44.0", "", { "os": "android", "cpu": "arm" }, "sha512-5UvghMd9SA/yvKTWCAxMAPXS1d2i054UeOf4iFjZjfayTwCINcC3oaSXjtbZfCaEpxgJod7XiOjTtby5yEv/BQ=="],
- "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.43.0", "", { "os": "android", "cpu": "arm64" }, "sha512-T9OfRwjA/EdYxAqbvR7TtqLv5nIrwPXuCtTwOHtS7aR9uXyn74ZYgzgTo6/ZwvTq9DY4W+DsV09hB2EXgn9EbA=="],
+ "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.44.0", "", { "os": "android", "cpu": "arm64" }, "sha512-IVudM1BWfvrYO++Khtzr8q9n5Rxu7msUvoFMqzGJVdX7HfUXUDHwaH2zHZNB58svx2J56pmCUzophyaPFkcG/A=="],
- "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.43.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-o3i49ZUSJWANzXMAAVY1wnqb65hn4JVzwlRQ5qfcwhRzIA8lGVaud31Q3by5ALHPrksp5QEaKCQF9aAS3TXpZA=="],
+ "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.44.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eWCLAIKAHfx88EqEP1Ga2yz7qVcqDU5lemn4xck+07bH182hDdprOHjbogyk0In1Djys3T0/pO2JepFnRJ41Mg=="],
- "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.43.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vWECzzCFkb0kK6jaHjbtC5sC3adiNWtqawFCxhpvsWlzVeKmv5bNvkB4nux+o4JKWTpHCM57NDK/MeXt44txmA=="],
+ "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.44.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-eHTBznHLM49++dwz07MblQ2cOXyIgeedmE3Wgy4ptUESj38/qYZyRi1MPwC9olQJWssMeY6WI3UZ7YmU5ggvyQ=="],
- "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.43.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-rgz8JpkKiI/umOf7fl9gwKyQasC8bs5SYHy6g7e4SunfLBY3+8ATcD5caIg8KLGEtKFm5ujKaH8EfjcmnhzTLg=="],
+ "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.44.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jLMmbj0u0Ft43QpkUVr/0v1ZfQCGWAvU+WznEHcN3wZC/q6ox7XeSJtk9P36CCpiDSUf3sGnzbIuG1KdEMEDJQ=="],
- "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.43.0", "", { "os": "linux", "cpu": "arm" }, "sha512-nWYnF3vIFzT4OM1qL/HSf1Yuj96aBuKWSaObXHSWliwAk2rcj7AWd6Lf7jowEBQMo4wCZVnueIGw/7C4u0KTBQ=="],
+ "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.44.0", "", { "os": "linux", "cpu": "arm" }, "sha512-n+A/u/ByK1qV8FVGOwyaSpw5NPNl0qlZfgTBqHeGIqr8Qzq1tyWZ4lAaxPoe5mZqE3w88vn3+jZtMxriHPE7tg=="],
- "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.43.0", "", { "os": "linux", "cpu": "arm" }, "sha512-sFg+NWJbLfupYTF4WELHAPSnLPOn1jiDZ33Z1jfDnTaA+cC3iB35x0FMMZTFdFOz3icRIArncwCcemJFGXu6TQ=="],
+ "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.44.0", "", { "os": "linux", "cpu": "arm" }, "sha512-5eax+FkxyCqAi3Rw0mrZFr7+KTt/XweFsbALR+B5ljWBLBl8nHe4ADrUnb1gLEfQCJLl+Ca5FIVD4xEt95AwIw=="],
- "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.43.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-MelWqv68tX6wZEILDrTc9yewiGXe7im62+5x0bNXlCYFOZdA+VnYiJfAihbROsZ5fm90p9C3haFrqjj43XnlAA=="],
+ "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-58l8JaHxSGOmOMOG2CIrNsnkRJAj0YcHQCmvNACniOa/vd1iRHhlPajczegzS5jwMENlqgreyiTR9iNlke8qCw=="],
- "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.43.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ROaWfYh+6BSJ1Arwy5ujijTlwnZetxDxzBpDc1oBR4d7rfrPBqzeyjd5WOudowzQUgyavl2wEpzn1hw3jWcqLA=="],
+ "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AlObQIXyVRZ96LbtVljtFq0JqH5B92NU+BQeDFrXWBUWlCKAM0wF5GLfIhCLT5kQ3Sl+U0YjRJ7Alqj5hGQaCg=="],
- "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.43.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-PJRs/uNxmFipJJ8+SyKHh7Y7VZIKQicqrrBzvfyM5CtKi8D7yZKTwUOZV3ffxmiC2e7l1SDJpkBEOyue5NAFsg=="],
+ "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.44.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-YcFE8/q/BbrCiIiM5piwbkA6GwJc5QqhMQp2yDrqQ2fuVkZ7CInb1aIijZ/k8EXc72qXMSwKpVlBv1w/MsGO/A=="],
- "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.43.0", "", { "os": "linux", "cpu": "none" }, "sha512-j6biGAgzIhj+EtHXlbNumvwG7XqOIdiU4KgIWRXAEj/iUbHKukKW8eXa4MIwpQwW1YkxovduKtzEAPnjlnAhVQ=="],
+ "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.44.0", "", { "os": "linux", "cpu": "none" }, "sha512-eOdzs6RqkRzuqNHUX5C8ISN5xfGh4xDww8OEd9YAmc3OWN8oAe5bmlIqQ+rrHLpv58/0BuU48bxkhnIGjA/ATQ=="],
- "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.43.0", "", { "os": "linux", "cpu": "none" }, "sha512-RYWxAcslKxvy7yri24Xm9cmD0RiANaiEPs007EFG6l9h1ChM69Q5SOzACaCoz4Z9dEplnhhneeBaTWMEdpgIbA=="],
+ "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.44.0", "", { "os": "linux", "cpu": "none" }, "sha512-YBgNTxntD/QvlFUfgvh8bEdwOhXiquX8gaofZJAwYa/Xp1S1DQrFVZEeck7GFktr24DztsSp8N8WtWCBwxs0Hw=="],
- "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.43.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-DT6Q8zfQQy3jxpezAsBACEHNUUixKSYTwdXeXojNHe4DQOoxjPdjr3Szu6BRNjxLykZM/xMNmp9ElOIyDppwtw=="],
+ "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.44.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-GLIh1R6WHWshl/i4QQDNgj0WtT25aRO4HNUWEoitxiywyRdhTFmFEYT2rXlcl9U6/26vhmOqG5cRlMLG3ocaIA=="],
- "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-R8Yk7iYcuZORXmCfFZClqbDxRZgZ9/HEidUuBNdoX8Ptx07cMePnMVJ/woB84lFIDjh2ROHVaOP40Ds3rBXFqg=="],
+ "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-gZOpgTlOsLcLfAF9qgpTr7FIIFSKnQN3hDf/0JvQ4CIwMY7h+eilNjxq/CorqvYcEOu+LRt1W4ZS7KccEHLOdA=="],
- "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-F2YYqyvnQNvi320RWZNAvsaWEHwmW3k4OwNJ1hZxRKXupY63expbBaNp6jAgvYs7y/g546vuQnGHQuCBhslhLQ=="],
+ "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1CyS9JTB+pCUFYFI6pkQGGZaT/AY5gnhHVrQQLhFba6idP9AzVYm1xbdWfywoldTYvjxQJV6x4SuduCIfP3W+A=="],
- "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.43.0", "", { "os": "none", "cpu": "arm64" }, "sha512-OE6TdietLXV3F6c7pNIhx/9YC1/2YFwjU9DPc/fbjxIX19hNIaP1rS0cFjCGJlGX+cVJwIKWe8Mos+LdQ1yAJw=="],
+ "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.44.0", "", { "os": "none", "cpu": "arm64" }, "sha512-bmEv70Ak6jLr1xotCbF5TxIKjsmQaiX+jFRtnGtfA03tJPf6VG3cKh96S21boAt3JZc+Vjx8PYcDuLj39vM2Pw=="],
- "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.43.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-0nWK6a7pGkbdoypfVicmV9k/N1FwjPZENoqhlTU+5HhZnAhpIO3za30nEE33u6l6tuy9OVfpdXUqxUgZ+4lbZw=="],
+ "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.44.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-yWzB+oCpSnP/dmw85eFLAT5o35Ve5pkGS2uF/UCISpIwDqf1xa7OpmtomiqY/Vzg8VyvMbuf6vroF2khF/+1Vg=="],
- "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.43.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-9aokTR4Ft+tRdvgN/pKzSkVy2ksc4/dCpDm9L/xFrbIw0yhLtASLbvoG/5WOTUh/BRPPnfGTsWznEqv0dlOmhA=="],
+ "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.44.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-TcWpo18xEIE3AmIG2kpr3kz5IEhQgnx0lazl2+8L+3eTopOAUevQcmlr4nhguImNWz0OMeOZrYZOhJNCf16nlQ=="],
- "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.43.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4bPgdQux2ZLWn3bf2TTXXMHcJB4lenmuxrLqygPmvCJ104Yqzj1UctxSRzR31TiJ4MLaG22RK8dUsVpJtrCz5g=="],
+ "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.44.0", "", { "os": "win32", "cpu": "x64" }, "sha512-oj8aLkPJZppIM4CMQNsyir9ybM1Xw/CfGPTSsTnzpVGyljgfbdP0EVUlURiGM0BDrmw5psQ6ArmGCcUY/yABaQ=="],
"@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.20.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-KKQcIHZHMxqpHUA1VXIbOG6chNCFkUWbQy6M+AFVtPKkA/3xAeJkJ3njoV66bfzwPHRcWQO+kcj5XqtbkjakoA=="],
@@ -728,49 +729,51 @@
"@tanstack/pacer": ["@tanstack/pacer@0.20.1", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.3", "@tanstack/store": "^0.9.3" } }, "sha512-ZNQ1bIL6eUXVKdic0tiImvBVkWrg/IoSK6VIacTrO3d3HAGnd70qFJNJagR/YOJIOw4EKGWnodwpYZkN1pWuVQ=="],
- "@tanstack/query-core": ["@tanstack/query-core@5.96.2", "", {}, "sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA=="],
+ "@tanstack/query-core": ["@tanstack/query-core@5.99.0", "", {}, "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ=="],
- "@tanstack/query-devtools": ["@tanstack/query-devtools@5.96.2", "", {}, "sha512-vBTB1Qhbm3nHSbEUtQwks/EdcAtFfEapr1WyBW4w2ExYKuXVi3jIxUIHf5MlSltiHuL7zNyUuanqT/7sI2sb6g=="],
+ "@tanstack/query-devtools": ["@tanstack/query-devtools@5.99.0", "", {}, "sha512-m4ufXaJ8FjWXw7xDtyzE/6fkZAyQFg9WrbMrUpt8ZecRJx58jiFOZ2lxZMphZdIpAnIeto/S8stbwLKLusyckQ=="],
"@tanstack/react-pacer": ["@tanstack/react-pacer@0.21.1", "", { "dependencies": { "@tanstack/pacer": "0.20.1", "@tanstack/react-store": "^0.9.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-BIBWmxSi+RHuB4Sh8SPyh3i3PVK8GR3yAyxOqXjERyww1cfvKdu2veUfJuTTiR4os1lfdctcS4HAibt8KTmMiw=="],
- "@tanstack/react-query": ["@tanstack/react-query@5.96.2", "", { "dependencies": { "@tanstack/query-core": "5.96.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA=="],
+ "@tanstack/react-query": ["@tanstack/react-query@5.99.0", "", { "dependencies": { "@tanstack/query-core": "5.99.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw=="],
- "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.96.2", "", { "dependencies": { "@tanstack/query-devtools": "5.96.2" }, "peerDependencies": { "@tanstack/react-query": "^5.96.2", "react": "^18 || ^19" } }, "sha512-nTFKLGuTOFvmFRvcyZ3ArWC/DnMNPoBh6h/2yD6rsf7TCTJCQt+oUWOp2uKPTIuEPtF/vN9Kw5tl5mD1Kbposw=="],
+ "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.99.0", "", { "dependencies": { "@tanstack/query-devtools": "5.99.0" }, "peerDependencies": { "@tanstack/react-query": "^5.99.0", "react": "^18 || ^19" } }, "sha512-CqqX7LCU9yOfCY/vBURSx2YSD83ryfX+QkfkaKionTfg1s2Hdm572Ro99gW3QPoJjzvsj1HM4pnN4nbDy3MXKA=="],
- "@tanstack/react-router": ["@tanstack/react-router@1.168.10", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.168.9", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-/RmDlOwDkCug609KdPB3U+U1zmrtadJpvsmRg2zEn8TRCKRNri7dYZIjQZbNg8PgUiRL4T6njrZBV1ChzblNaA=="],
+ "@tanstack/react-router": ["@tanstack/react-router@1.168.18", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.168.14", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-RmBptS3/qtkGhvG/u41JWOgxz1FIWybBz7iBTgLUIoFkqOj6NE4XlhUOsP2fabxACtbZdJnpvCWcJFWpWGIngw=="],
- "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.166.11", "", { "dependencies": { "@tanstack/router-devtools-core": "1.167.1" }, "peerDependencies": { "@tanstack/react-router": "^1.168.2", "@tanstack/router-core": "^1.168.2", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-WYR3q4Xui5yPT/5PXtQh8i03iUA7q8dONBjWpV3nsGdM8Cs1FxpfhLstW0wZO1dOvSyElscwTRCJ6nO5N8r3Lg=="],
+ "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.166.13", "", { "dependencies": { "@tanstack/router-devtools-core": "1.167.3" }, "peerDependencies": { "@tanstack/react-router": "^1.168.15", "@tanstack/router-core": "^1.168.11", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-6yKRFFJrEEOiGp5RAAuGCYsl81M4XAhJmLcu9PKj+HZle4A3dsP60lwHoqQYWHMK9nKKFkdXR+D8qxzxqtQbEA=="],
"@tanstack/react-router-with-query": ["@tanstack/react-router-with-query@1.130.17", "", { "peerDependencies": { "@tanstack/react-query": ">=5.49.2", "@tanstack/react-router": ">=1.43.2", "@tanstack/router-core": ">=1.114.7", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-TNaSocW20KuPwUojEm130DLWTr9M5hsSzxiu4QqS2jNCnrGLuDrwMHyP+6fq13lG3YuU4u9O1qajxfJIGomZCg=="],
- "@tanstack/react-start": ["@tanstack/react-start@1.167.16", "", { "dependencies": { "@tanstack/react-router": "1.168.10", "@tanstack/react-start-client": "1.166.25", "@tanstack/react-start-server": "1.166.25", "@tanstack/router-utils": "^1.161.6", "@tanstack/start-client-core": "1.167.9", "@tanstack/start-plugin-core": "1.167.17", "@tanstack/start-server-core": "1.167.9", "pathe": "^2.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "bin": { "intent": "bin/intent.js" } }, "sha512-vHIhn+FTWfAVhRus1BZEaBZPhnYL+StDuMlShslIBPEGGTCRt11BxNUfV/iDpr7zbxw36Snj7zGfI7DwfjjlDQ=="],
+ "@tanstack/react-start": ["@tanstack/react-start@1.167.32", "", { "dependencies": { "@tanstack/react-router": "1.168.18", "@tanstack/react-start-client": "1.166.35", "@tanstack/react-start-rsc": "0.0.12", "@tanstack/react-start-server": "1.166.36", "@tanstack/router-utils": "^1.161.6", "@tanstack/start-client-core": "1.167.16", "@tanstack/start-plugin-core": "1.167.29", "@tanstack/start-server-core": "1.167.18", "pathe": "^2.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "bin": { "intent": "bin/intent.js" } }, "sha512-y/f6ALbDKslMwb6O/Epzt8cl2C+oBWEcjiWtfNIziIaAX6vyCnRSgwRtZ6lNGTmEOzRZK3dSnAHkZQdYBtEiJg=="],
- "@tanstack/react-start-client": ["@tanstack/react-start-client@1.166.25", "", { "dependencies": { "@tanstack/react-router": "1.168.10", "@tanstack/router-core": "1.168.9", "@tanstack/start-client-core": "1.167.9" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-FvD279zzneUtsfhaTv2c29qhE1Z3wHy3dt3cCjn9LzWZehOgn5Ij78s0YpmQaQ8lSF3YL7CySE3pDk9XHE6YeA=="],
+ "@tanstack/react-start-client": ["@tanstack/react-start-client@1.166.35", "", { "dependencies": { "@tanstack/react-router": "1.168.18", "@tanstack/router-core": "1.168.14", "@tanstack/start-client-core": "1.167.16" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-FFlYKUMLeFK53eh258RtmK/sT3ZJW4/LAHhjElGYZEhl6PeUSheZ8J+xw1Fn5t02nYixuj4CyCuLpzq/2XUVgQ=="],
- "@tanstack/react-start-server": ["@tanstack/react-start-server@1.166.25", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-router": "1.168.10", "@tanstack/router-core": "1.168.9", "@tanstack/start-client-core": "1.167.9", "@tanstack/start-server-core": "1.167.9" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-bPLADxlplvcnAcnZvBjJl2MzgUnB85d7Mu5aEkYoOFxhz0WiG6mZp7BDadIJuCd33NYMirsd3XrjfCHNzrMTyg=="],
+ "@tanstack/react-start-rsc": ["@tanstack/react-start-rsc@0.0.12", "", { "dependencies": { "@tanstack/react-router": "1.168.18", "@tanstack/react-start-server": "1.166.36", "@tanstack/router-core": "1.168.14", "@tanstack/router-utils": "1.161.6", "@tanstack/start-client-core": "1.167.16", "@tanstack/start-fn-stubs": "1.161.6", "@tanstack/start-plugin-core": "1.167.29", "@tanstack/start-server-core": "1.167.18", "@tanstack/start-storage-context": "1.166.28", "pathe": "^2.0.3" }, "peerDependencies": { "@vitejs/plugin-rsc": ">=0.5.20", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@vitejs/plugin-rsc"] }, "sha512-P2fmYmDBeXyO+9rxUdgvWfa5YD+3RFm+G4z6msEUM0F9QL0RdK8OJW70pkTO4Q1BGf5R9PgGoeQ+yRWMXyCQ/A=="],
+
+ "@tanstack/react-start-server": ["@tanstack/react-start-server@1.166.36", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-router": "1.168.18", "@tanstack/router-core": "1.168.14", "@tanstack/start-client-core": "1.167.16", "@tanstack/start-server-core": "1.167.18" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-auebdZYZE7Hnmg0HEUZfgBbhTyJ8cYJq0nUGVVrCpArtvR07V16QDEB0AAeH80TKnBY1Kfb4S9uk7nC5Do8Cmw=="],
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
- "@tanstack/router-core": ["@tanstack/router-core@1.168.9", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-18oeEwEDyXOIuO1VBP9ACaK7tYHZUjynGDCoUh/5c/BNhia9vCJCp9O0LfhZXOorDc/PmLSgvmweFhVmIxF10g=="],
+ "@tanstack/router-core": ["@tanstack/router-core@1.168.14", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.0", "seroval-plugins": "^1.5.0" }, "bin": { "intent": "bin/intent.js" } }, "sha512-UhCJtjNrd5wcTmhgB2HyUP0+Rj1M7BD4dS11YsF9x6VC2KH/eqxzs/vK+nN5f+cOhPOLZdmLkWMW+WGmacZ8HA=="],
- "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.167.1", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/router-core": "^1.168.2", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-ECMM47J4KmifUvJguGituSiBpfN8SyCUEoxQks5RY09hpIBfR2eswCv2e6cJimjkKwBQXOVTPkTUk/yRvER+9w=="],
+ "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.167.3", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/router-core": "^1.168.11", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-fJ1VMhyQgnoashTrP763c2HRc9kofgF61L7Jb3F6eTHAmCKtGVx8BRtiFt37sr3U0P0jmaaiiSPGP6nT5JtVNg=="],
- "@tanstack/router-generator": ["@tanstack/router-generator@1.166.24", "", { "dependencies": { "@tanstack/router-core": "1.168.9", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-vdaGKwuH+r+DPe6R1mjk+TDDmDH6NTG7QqwxHqGEvOH4aGf9sPjhmRKNJZqQr8cPIbfp6u5lXyZ1TeDcSNMVEA=="],
+ "@tanstack/router-generator": ["@tanstack/router-generator@1.166.29", "", { "dependencies": { "@tanstack/router-core": "1.168.14", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-X/9/4z4tcPyiQfm1kGm9vzEpJboNbfpg/p+QoI5KyaWtqZgF00nyq5dUQKXwacwZBEgHCzUaWCM9etRFCNnXrg=="],
- "@tanstack/router-plugin": ["@tanstack/router-plugin@1.167.12", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.168.9", "@tanstack/router-generator": "1.166.24", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.168.10", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"], "bin": { "intent": "bin/intent.js" } }, "sha512-StEHcctCuFI5taSjO+lhR/yQ+EK63BdyYa+ne6FoNQPB3MMrOUrz2ZVnbqILRLkh2b+p2EfBKt65sgAKdKygPQ=="],
+ "@tanstack/router-plugin": ["@tanstack/router-plugin@1.167.18", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.168.14", "@tanstack/router-generator": "1.166.29", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.168.18", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"], "bin": { "intent": "bin/intent.js" } }, "sha512-LkQYEv9rXWSXJ9BKVmaZz27lZij5UDBJscGY3HHK+IenFlakqqiozKBZKlSMl8/WUGZ2JTAecBzAAOCRE9Vm9Q=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.161.6", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw=="],
- "@tanstack/start-client-core": ["@tanstack/start-client-core@1.167.9", "", { "dependencies": { "@tanstack/router-core": "1.168.9", "@tanstack/start-fn-stubs": "1.161.6", "@tanstack/start-storage-context": "1.166.23", "seroval": "^1.4.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-2ETQO/bxiZGsoTdPxZb7xR8YqCy5l4kv/QPkwIXuvx/A4BjufngXfgISjXUicXsFRIBZeiFnBzp9A38UMsS2iA=="],
+ "@tanstack/start-client-core": ["@tanstack/start-client-core@1.167.16", "", { "dependencies": { "@tanstack/router-core": "1.168.14", "@tanstack/start-fn-stubs": "1.161.6", "@tanstack/start-storage-context": "1.166.28", "seroval": "^1.5.0" }, "bin": { "intent": "bin/intent.js" } }, "sha512-ftQ+f5SB2gLqU24QeofmbkfXiSSPS/VlLRdNC6DmshUIrDp8SQFb8gP/7hbkORs7jx4YULGA4z2xQrz/RmC+Kg=="],
"@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.161.6", "", {}, "sha512-Y6QSlGiLga8cHfvxGGaonXIlt2bIUTVdH6AMjmpMp7+ANNCp+N96GQbjjhLye3JkaxDfP68x5iZA8NK4imgRig=="],
- "@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.167.17", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@rolldown/pluginutils": "1.0.0-beta.40", "@tanstack/router-core": "1.168.9", "@tanstack/router-generator": "1.166.24", "@tanstack/router-plugin": "1.167.12", "@tanstack/router-utils": "1.161.6", "@tanstack/start-client-core": "1.167.9", "@tanstack/start-server-core": "1.167.9", "cheerio": "^1.0.0", "exsolve": "^1.0.7", "pathe": "^2.0.3", "picomatch": "^4.0.3", "source-map": "^0.7.6", "srvx": "^0.11.9", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^3.24.2" }, "peerDependencies": { "vite": ">=7.0.0" } }, "sha512-OkorpOobGOEDVr72QUmkzKjbawKC05CSz+1B3OObB/AxBIIw+lLLhTXbV45QkX2LZA7dcRvPJYZGOH1pkFqA1g=="],
+ "@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.167.29", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@rolldown/pluginutils": "1.0.0-beta.40", "@tanstack/router-core": "1.168.14", "@tanstack/router-generator": "1.166.29", "@tanstack/router-plugin": "1.167.18", "@tanstack/router-utils": "1.161.6", "@tanstack/start-client-core": "1.167.16", "@tanstack/start-server-core": "1.167.18", "cheerio": "^1.0.0", "exsolve": "^1.0.7", "pathe": "^2.0.3", "picomatch": "^4.0.3", "seroval": "^1.5.0", "source-map": "^0.7.6", "srvx": "^0.11.9", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^3.24.2" }, "peerDependencies": { "vite": ">=7.0.0" } }, "sha512-thH2Gg3N3oFxUo9K0FBDLUj93Z50SGZtjn78PIHgx9RV5fYjqeMrwTgNRG4x1M92RiCWT5SXWJccHJrxLTA+RQ=="],
- "@tanstack/start-server-core": ["@tanstack/start-server-core@1.167.9", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/router-core": "1.168.9", "@tanstack/start-client-core": "1.167.9", "@tanstack/start-storage-context": "1.166.23", "h3-v2": "npm:h3@2.0.1-rc.16", "seroval": "^1.4.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-vKkslQIihoDDVumF73VXT7PVFmN7Nea0nKhZx7gMbc0m09yPQYYR1dn86/dz14k6/7cDkJ+qKXa09rlVlN/i9Q=="],
+ "@tanstack/start-server-core": ["@tanstack/start-server-core@1.167.18", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/router-core": "1.168.14", "@tanstack/start-client-core": "1.167.16", "@tanstack/start-storage-context": "1.166.28", "h3-v2": "npm:h3@2.0.1-rc.20", "seroval": "^1.5.0" }, "bin": { "intent": "bin/intent.js" } }, "sha512-JiV0Eqyj5SfEjGZrm/u3nBICeLZRQyeYNI7ine4PjTh0TVL7IklfN5Xfka+p80a/DuUvpHWqKvE4s3tdFZyc1A=="],
- "@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.166.23", "", { "dependencies": { "@tanstack/router-core": "1.168.9" } }, "sha512-3vEdiYRMx+r+Q7Xqxj3YmADPIpMm7fkKxDa8ITwodGXiw+SBJCGkpBXGUWjOXyXkIyqGHKM5UrReTcVUTkmaug=="],
+ "@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.166.28", "", { "dependencies": { "@tanstack/router-core": "1.168.14" } }, "sha512-CUQMd6YtJ7hejKXDqT1R4N5gQ9PYyxUCv/ERNJ6/c/8ohNuhMPlOGSFVvqy2BLYNTFSj9GWjahMazeQpQomPgw=="],
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
@@ -784,6 +787,14 @@
"@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
+ "@tokenlens/core": ["@tokenlens/core@1.3.0", "", {}, "sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ=="],
+
+ "@tokenlens/fetch": ["@tokenlens/fetch@1.3.0", "", { "dependencies": { "@tokenlens/core": "1.3.0" } }, "sha512-RONDRmETYly9xO8XMKblmrZjKSwCva4s5ebJwQNfNlChZoA5kplPoCgnWceHnn1J1iRjLVlrCNB43ichfmGBKQ=="],
+
+ "@tokenlens/helpers": ["@tokenlens/helpers@1.3.1", "", { "dependencies": { "@tokenlens/core": "1.3.0", "@tokenlens/fetch": "1.3.0" } }, "sha512-t6yL8N6ES8337E6eVSeH4hCKnPdWkZRFpupy9w5E66Q9IeqQ9IO7XQ6gh12JKjvWiRHuyyJ8MBP5I549Cr41EQ=="],
+
+ "@tokenlens/models": ["@tokenlens/models@1.3.0", "", { "dependencies": { "@tokenlens/core": "1.3.0" } }, "sha512-9mx7ZGeewW4ndXAiD7AT1bbCk4OpJeortbjHHyNkgap+pMPPn1chY6R5zqe1ggXIUzZ2l8VOAKfPqOvpcrisJw=="],
+
"@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
"@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="],
@@ -798,7 +809,7 @@
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
- "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
+ "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
@@ -912,21 +923,21 @@
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
- "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260408.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260408.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260408.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260408.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260408.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260408.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260408.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260408.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-N0MZLEUnAoP/aRVk7MY119LDsESkbtEwIw+YeXi/jjx2XCqf7ni3GxIVsUYtf/troyuSedq3V/OUrkoCh5A9gA=="],
+ "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260412.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260412.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260412.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260412.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260412.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260412.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260412.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260412.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-tDw3XZt2BkjAlt/MJmnFGmbe9lgKmc5wezmrMoBIEvJcqz+/KVpVBVvjbkZoaiABnJmuG3G3b6IUFrEveTw6UQ=="],
- "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260408.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcPczNLfPDB13eUBYHkTOkL7HyWqqqEhho4eSxhAvigZuxvtHQ1uyILIvLVAwipEVzhJ8QciKmLdLucpfi4XyA=="],
+ "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260412.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sSkFG+hjtRWffg6FddF3dEkk7N3TRMEqfiUpixwcWhXgyocMdPw8wutTvQRBxQdgxeL9y01M2SO8A8YPPiEgVg=="],
- "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260408.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cHqkDg53xxxz21MThLBf4vx1kyIpRPEYNdEiQlvu9O35Tth49+aub6F+/YEMd9MG4TYZmxh1bEjkjErTUIElpA=="],
+ "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260412.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2BTeaLkrHEEDg0D9snigddy01qTY+wgx+W+GpXAfx36PPvW4xWuGXNVWfSaB8bqAC9C8NeLnT/C9/G/rJ5v2w=="],
- "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260408.1", "", { "os": "linux", "cpu": "arm" }, "sha512-w26Gv9yq9LIYIhxjkQC+i0wBPDdQdX+H06ZhyVRL5grKWTIsk9Xwjp9mDRB/dGlXBKcvnM25JH16OyAA0rFH3A=="],
+ "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260412.1", "", { "os": "linux", "cpu": "arm" }, "sha512-wDLekbfsfmKMWORg7CTnEnpKj8oXpU/6AEBrtVN9CEUCiQAe6yH878nZHhJNzWQXHtrtFf3lY49Yplqmdxja3w=="],
- "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260408.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-iHG0FEXq/QFsn+qlTPllxdcbvfQ9aRYggy4lc1z0+f11Nyk4YDNCSiR8WW7pbnOTx/VreGbbXhlpuJXTidqL8g=="],
+ "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260412.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JAdsG6MlVV1hoAUKPy8zxAL7xLeNxz8JgCbLCJVqW8EyH29R9FD4cFTqr7CSIRTNUEDzDTrgnXUyoRtDe1gr+w=="],
- "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260408.1", "", { "os": "linux", "cpu": "x64" }, "sha512-hMcUlUIzYbvbdq6j/B4RPL+kZR917NGnE9AgPZ7dJ92yamH/7LGT1Mnlc6McUx31yqTFBFHdTc7Cfx+ynua7Iw=="],
+ "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260412.1", "", { "os": "linux", "cpu": "x64" }, "sha512-gYgppiQIqid3jZ7D8THh4k3Q+4bwidrQH6SL9Xgbk1qfP6/jwv8twuPqDOfZ+cK2OD55lQHp15fOh2lMNAC40Q=="],
- "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260408.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-avJWIEKSx4rdBLZD1FOOTuxTU51dQfYb3jZvZMaXD4thJjq+6eSwfzu2elwL36AZDlnaxggGjB5nBxp0t54iOA=="],
+ "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260412.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-TOh7rH5H3jisHJqRXJSjmUGMzcbNBocS/hufhXPQIv+g3pdG5IKZoSnv3SV62I5d12FFDSS5KQon5MQPnOKAHg=="],
- "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260408.1", "", { "os": "win32", "cpu": "x64" }, "sha512-gpvEHkF/WoxkA3711c4uWNCZO9WAuwrq49COdNwxgOTzYHnMc1yCj8CpkCUJwU0f/Ydwp2s6/efn6gTMvtckPg=="],
+ "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260412.1", "", { "os": "win32", "cpu": "x64" }, "sha512-u+70wL89wspN1wKoX6FVNUATRGCG3BpleByP3H/UqOZvlwuMm8N7Gy8hEbM0U8bDyAuyP/daUfTBVkqXjjv9mA=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
@@ -936,21 +947,21 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
- "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.2", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.2", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.2", "vitest": "4.1.2" }, "optionalPeers": ["@vitest/browser"] }, "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg=="],
+ "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.4", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.4", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.4", "vitest": "4.1.4" }, "optionalPeers": ["@vitest/browser"] }, "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w=="],
- "@vitest/expect": ["@vitest/expect@4.1.3", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.3", "@vitest/utils": "4.1.3", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ=="],
+ "@vitest/expect": ["@vitest/expect@4.1.4", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.4", "@vitest/utils": "4.1.4", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww=="],
- "@vitest/mocker": ["@vitest/mocker@4.1.3", "", { "dependencies": { "@vitest/spy": "4.1.3", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw=="],
+ "@vitest/mocker": ["@vitest/mocker@4.1.4", "", { "dependencies": { "@vitest/spy": "4.1.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg=="],
- "@vitest/pretty-format": ["@vitest/pretty-format@4.1.3", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg=="],
+ "@vitest/pretty-format": ["@vitest/pretty-format@4.1.4", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A=="],
- "@vitest/runner": ["@vitest/runner@4.1.3", "", { "dependencies": { "@vitest/utils": "4.1.3", "pathe": "^2.0.3" } }, "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA=="],
+ "@vitest/runner": ["@vitest/runner@4.1.4", "", { "dependencies": { "@vitest/utils": "4.1.4", "pathe": "^2.0.3" } }, "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ=="],
- "@vitest/snapshot": ["@vitest/snapshot@4.1.3", "", { "dependencies": { "@vitest/pretty-format": "4.1.3", "@vitest/utils": "4.1.3", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ=="],
+ "@vitest/snapshot": ["@vitest/snapshot@4.1.4", "", { "dependencies": { "@vitest/pretty-format": "4.1.4", "@vitest/utils": "4.1.4", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw=="],
- "@vitest/spy": ["@vitest/spy@4.1.3", "", {}, "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw=="],
+ "@vitest/spy": ["@vitest/spy@4.1.4", "", {}, "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ=="],
- "@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="],
+ "@vitest/utils": ["@vitest/utils@4.1.4", "", { "dependencies": { "@vitest/pretty-format": "4.1.4", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
@@ -962,7 +973,7 @@
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
- "ai": ["ai@6.0.154", "", { "dependencies": { "@ai-sdk/gateway": "3.0.94", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HfKJKCTJsDZxqrIUDSVnBQ7DpQlx5WI4ExqtLd7Bl70epLmvkpc/HYMzU1hP9W+g9VEAcvZo4fbMqc3v5D+9gQ=="],
+ "ai": ["ai@6.0.158", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
@@ -998,7 +1009,7 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
- "baseline-browser-mapping": ["baseline-browser-mapping@2.10.16", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA=="],
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
@@ -1008,13 +1019,13 @@
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
- "brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="],
+ "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
- "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
+ "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -1092,7 +1103,7 @@
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
- "cookie-es": ["cookie-es@2.0.1", "", {}, "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA=="],
+ "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
@@ -1242,7 +1253,7 @@
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
- "dotenv": ["dotenv@17.4.1", "", {}, "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw=="],
+ "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -1250,7 +1261,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
- "electron-to-chromium": ["electron-to-chromium@1.5.334", "", {}, "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog=="],
+ "electron-to-chromium": ["electron-to-chromium@1.5.335", "", {}, "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
@@ -1388,7 +1399,7 @@
"graphql": ["graphql@16.13.2", "", {}, "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="],
- "h3-v2": ["h3@2.0.1-rc.16", "", { "dependencies": { "rou3": "^0.8.0", "srvx": "^0.11.9" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-h+pjvyujdo9way8qj6FUbhaQcHlR8FEq65EhTX9ViT5pK8aLj68uFl4hBkF+hsTJAH+H1END2Yv6hTIsabGfag=="],
+ "h3-v2": ["h3@2.0.1-rc.20", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.13" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg=="],
"hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="],
@@ -1806,7 +1817,7 @@
"outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
- "oxfmt": ["oxfmt@0.43.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.43.0", "@oxfmt/binding-android-arm64": "0.43.0", "@oxfmt/binding-darwin-arm64": "0.43.0", "@oxfmt/binding-darwin-x64": "0.43.0", "@oxfmt/binding-freebsd-x64": "0.43.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.43.0", "@oxfmt/binding-linux-arm-musleabihf": "0.43.0", "@oxfmt/binding-linux-arm64-gnu": "0.43.0", "@oxfmt/binding-linux-arm64-musl": "0.43.0", "@oxfmt/binding-linux-ppc64-gnu": "0.43.0", "@oxfmt/binding-linux-riscv64-gnu": "0.43.0", "@oxfmt/binding-linux-riscv64-musl": "0.43.0", "@oxfmt/binding-linux-s390x-gnu": "0.43.0", "@oxfmt/binding-linux-x64-gnu": "0.43.0", "@oxfmt/binding-linux-x64-musl": "0.43.0", "@oxfmt/binding-openharmony-arm64": "0.43.0", "@oxfmt/binding-win32-arm64-msvc": "0.43.0", "@oxfmt/binding-win32-ia32-msvc": "0.43.0", "@oxfmt/binding-win32-x64-msvc": "0.43.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-KTYNG5ISfHSdmeZ25Xzb3qgz9EmQvkaGAxgBY/p38+ZiAet3uZeu7FnMwcSQJg152Qwl0wnYAxDc+Z/H6cvrwA=="],
+ "oxfmt": ["oxfmt@0.44.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.44.0", "@oxfmt/binding-android-arm64": "0.44.0", "@oxfmt/binding-darwin-arm64": "0.44.0", "@oxfmt/binding-darwin-x64": "0.44.0", "@oxfmt/binding-freebsd-x64": "0.44.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.44.0", "@oxfmt/binding-linux-arm-musleabihf": "0.44.0", "@oxfmt/binding-linux-arm64-gnu": "0.44.0", "@oxfmt/binding-linux-arm64-musl": "0.44.0", "@oxfmt/binding-linux-ppc64-gnu": "0.44.0", "@oxfmt/binding-linux-riscv64-gnu": "0.44.0", "@oxfmt/binding-linux-riscv64-musl": "0.44.0", "@oxfmt/binding-linux-s390x-gnu": "0.44.0", "@oxfmt/binding-linux-x64-gnu": "0.44.0", "@oxfmt/binding-linux-x64-musl": "0.44.0", "@oxfmt/binding-openharmony-arm64": "0.44.0", "@oxfmt/binding-win32-arm64-msvc": "0.44.0", "@oxfmt/binding-win32-ia32-msvc": "0.44.0", "@oxfmt/binding-win32-x64-msvc": "0.44.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-lnncqvHewyRvaqdrnntVIrZV2tEddz8lbvPsQzG/zlkfvgZkwy0HP1p/2u1aCDToeg1jb9zBpbJdfkV73Itw+w=="],
"oxlint": ["oxlint@1.59.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.59.0", "@oxlint/binding-android-arm64": "1.59.0", "@oxlint/binding-darwin-arm64": "1.59.0", "@oxlint/binding-darwin-x64": "1.59.0", "@oxlint/binding-freebsd-x64": "1.59.0", "@oxlint/binding-linux-arm-gnueabihf": "1.59.0", "@oxlint/binding-linux-arm-musleabihf": "1.59.0", "@oxlint/binding-linux-arm64-gnu": "1.59.0", "@oxlint/binding-linux-arm64-musl": "1.59.0", "@oxlint/binding-linux-ppc64-gnu": "1.59.0", "@oxlint/binding-linux-riscv64-gnu": "1.59.0", "@oxlint/binding-linux-riscv64-musl": "1.59.0", "@oxlint/binding-linux-s390x-gnu": "1.59.0", "@oxlint/binding-linux-x64-gnu": "1.59.0", "@oxlint/binding-linux-x64-musl": "1.59.0", "@oxlint/binding-openharmony-arm64": "1.59.0", "@oxlint/binding-win32-arm64-msvc": "1.59.0", "@oxlint/binding-win32-ia32-msvc": "1.59.0", "@oxlint/binding-win32-x64-msvc": "1.59.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-0xBLeGGjP4vD9pygRo8iuOkOzEU1MqOnfiOl7KYezL/QvWL8NUg6n03zXc7ZVqltiOpUxBk2zgHI3PnRIEdAvw=="],
@@ -1864,7 +1875,7 @@
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
- "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
+ "prettier": ["prettier@3.8.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q=="],
"pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="],
@@ -2104,6 +2115,8 @@
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
+ "tokenlens": ["tokenlens@1.3.1", "", { "dependencies": { "@tokenlens/core": "1.3.0", "@tokenlens/fetch": "1.3.0", "@tokenlens/helpers": "1.3.1", "@tokenlens/models": "1.3.0" } }, "sha512-7oxmsS5PNCX3z+b+z07hL5vCzlgHKkCGrEQjQmWl5l+v5cUrtL7S1cuST4XThaL1XyjbTX8J5hfP0cjDJRkaLA=="],
+
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
@@ -2194,7 +2207,7 @@
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
- "vitest": ["vitest@4.1.3", "", { "dependencies": { "@vitest/expect": "4.1.3", "@vitest/mocker": "4.1.3", "@vitest/pretty-format": "4.1.3", "@vitest/runner": "4.1.3", "@vitest/snapshot": "4.1.3", "@vitest/spy": "4.1.3", "@vitest/utils": "4.1.3", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.3", "@vitest/browser-preview": "4.1.3", "@vitest/browser-webdriverio": "4.1.3", "@vitest/coverage-istanbul": "4.1.3", "@vitest/coverage-v8": "4.1.3", "@vitest/ui": "4.1.3", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw=="],
+ "vitest": ["vitest@4.1.4", "", { "dependencies": { "@vitest/expect": "4.1.4", "@vitest/mocker": "4.1.4", "@vitest/pretty-format": "4.1.4", "@vitest/runner": "4.1.4", "@vitest/snapshot": "4.1.4", "@vitest/spy": "4.1.4", "@vitest/utils": "4.1.4", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.4", "@vitest/browser-preview": "4.1.4", "@vitest/browser-webdriverio": "4.1.4", "@vitest/coverage-istanbul": "4.1.4", "@vitest/coverage-v8": "4.1.4", "@vitest/ui": "4.1.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg=="],
"vitest-browser-react": ["vitest-browser-react@2.2.0", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "vitest": "^4.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oY3KM6305kwJMa6nHo92vVtkOsih7mjEf12dLKuphaF+9ywWPEc+qanIBd394SZ6m5LadVEaG6dicvvizOzmjA=="],
@@ -2336,14 +2349,6 @@
"@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
- "@vitest/expect/@vitest/utils": ["@vitest/utils@4.1.3", "", { "dependencies": { "@vitest/pretty-format": "4.1.3", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw=="],
-
- "@vitest/runner/@vitest/utils": ["@vitest/utils@4.1.3", "", { "dependencies": { "@vitest/pretty-format": "4.1.3", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw=="],
-
- "@vitest/snapshot/@vitest/utils": ["@vitest/utils@4.1.3", "", { "dependencies": { "@vitest/pretty-format": "4.1.3", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw=="],
-
- "@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.2", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA=="],
-
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"cheerio/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
@@ -2354,6 +2359,8 @@
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+ "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "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-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
+
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="],
@@ -2430,8 +2437,6 @@
"typescript-json-schema/typescript": ["typescript@5.5.4", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q=="],
- "vitest/@vitest/utils": ["@vitest/utils@4.1.3", "", { "dependencies": { "@vitest/pretty-format": "4.1.3", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw=="],
-
"whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
diff --git a/package.json b/package.json
index a897852..75d3ec6 100644
--- a/package.json
+++ b/package.json
@@ -22,8 +22,8 @@
"tools:build": "bun run ./scripts/generate-tools-json.ts"
},
"dependencies": {
- "@ai-sdk/anthropic": "^3.0.68",
- "@ai-sdk/google": "^3.0.60",
+ "@ai-sdk/anthropic": "^3.0.69",
+ "@ai-sdk/google": "^3.0.62",
"@ai-sdk/groq": "^3.0.35",
"@ai-sdk/mistral": "^3.0.30",
"@ai-sdk/openai": "^3.0.52",
@@ -41,11 +41,11 @@
"@tailwindcss/postcss": "^4.2.2",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-pacer": "^0.21.1",
- "@tanstack/react-query": "^5.96.2",
- "@tanstack/react-router": "1.168.10",
+ "@tanstack/react-query": "^5.99.0",
+ "@tanstack/react-router": "1.168.18",
"@tanstack/react-router-with-query": "1.130.17",
- "@tanstack/react-start": "^1.167.16",
- "ai": "^6.0.154",
+ "@tanstack/react-start": "^1.167.32",
+ "ai": "^6.0.158",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -63,29 +63,30 @@
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tailwindcss-animate": "^1.0.7",
+ "tokenlens": "^1.3.1",
"tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.3",
"zod": "^4.3.6"
},
"devDependencies": {
"@mdx-js/rollup": "^3.1.1",
- "@tanstack/react-query-devtools": "^5.96.2",
- "@tanstack/react-router-devtools": "1.166.11",
- "@tanstack/router-plugin": "1.167.12",
+ "@tanstack/react-query-devtools": "^5.99.0",
+ "@tanstack/react-router-devtools": "1.166.13",
+ "@tanstack/router-plugin": "1.167.18",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
- "@types/bun": "^1.3.11",
+ "@types/bun": "^1.3.12",
"@types/jest": "^30.0.0",
"@types/json-schema": "^7.0.15",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
- "@typescript/native-preview": "^7.0.0-dev.20260408.1",
+ "@typescript/native-preview": "^7.0.0-dev.20260412.1",
"@vitejs/plugin-react": "^6.0.1",
- "@vitest/coverage-v8": "4.1.2",
+ "@vitest/coverage-v8": "4.1.4",
"ajv": "^8.18.0",
"jsdom": "^29.0.2",
- "oxfmt": "^0.43.0",
+ "oxfmt": "^0.44.0",
"oxlint": "^1.59.0",
"oxlint-tsgolint": "^0.20.0",
"rehype-pretty-code": "^0.14.3",
@@ -95,7 +96,7 @@
"typescript": "^6.0.2",
"typescript-json-schema": "^0.67.1",
"vite": "^8.0.8",
- "vitest": "^4.1.3",
+ "vitest": "^4.1.4",
"vitest-browser-react": "^2.2.0"
}
}
diff --git a/public/specification/flat.json b/public/specification/flat.json
index bc38b91..e0e2751 100644
--- a/public/specification/flat.json
+++ b/public/specification/flat.json
@@ -2,33 +2,40 @@
"type": "object",
"properties": {
"name": {
+ "description": "Unique machine-readable identifier for the tool (e.g. \"httpx\").",
"type": "string"
},
"displayName": {
+ "description": "Human-readable display name for the tool (e.g. \"HTTPx\").",
"type": "string"
},
"info": {
+ "description": "General information about the tool such as description, version, and URL.",
"$ref": "#/definitions/ToolInfo"
},
"commands": {
+ "description": "List of all commands and subcommands defined for this tool.",
"type": "array",
"items": {
"$ref": "#/definitions/Command"
}
},
"parameters": {
+ "description": "Flat list of all parameters across all commands and global scope.",
"type": "array",
"items": {
"$ref": "#/definitions/Parameter"
}
},
"exclusionGroups": {
+ "description": "Groups of parameters with mutual exclusion or required-one-of constraints.",
"type": "array",
"items": {
"$ref": "#/definitions/ExclusionGroup"
}
},
"metadata": {
+ "description": "Arbitrary metadata attached to the tool.",
"$ref": "#/definitions/ToolMetadata"
}
},
@@ -43,12 +50,15 @@
"type": "object",
"properties": {
"description": {
+ "description": "A brief human-readable description of what the tool does.",
"type": "string"
},
"version": {
+ "description": "The version string of the tool (e.g. \"1.0.0\").",
"type": "string"
},
"url": {
+ "description": "The homepage or documentation URL for the tool.",
"type": "string"
}
}
@@ -57,24 +67,31 @@
"type": "object",
"properties": {
"key": {
+ "description": "Unique identifier for this command within the tool.",
"type": "string"
},
"parentCommandKey": {
+ "description": "Key of the parent command; used to represent subcommand nesting.",
"type": "string"
},
"name": {
+ "description": "Human-readable display name of the command.",
"type": "string"
},
"description": {
+ "description": "Brief description of what this command does.",
"type": "string"
},
"interactive": {
+ "description": "Whether this command opens an interactive session or prompt.",
"type": "boolean"
},
"isDefault": {
+ "description": "Whether this is the default command when no subcommand is specified.",
"type": "boolean"
},
"sortOrder": {
+ "description": "Display sort position relative to sibling commands.",
"type": "number"
}
},
@@ -87,66 +104,86 @@
"type": "object",
"properties": {
"key": {
+ "description": "Unique identifier for this parameter within the tool.",
"type": "string"
},
"name": {
+ "description": "Human-readable display name of the parameter.",
"type": "string"
},
"commandKey": {
+ "description": "Key of the command this parameter belongs to; omit for global parameters.",
"type": "string"
},
"description": {
+ "description": "Brief description of what this parameter does or accepts.",
"type": "string"
},
"group": {
+ "description": "Optional grouping label for organising related parameters in the UI.",
"type": "string"
},
"metadata": {
+ "description": "Additional metadata such as tags.",
"$ref": "#/definitions/ParameterMetadata"
},
"parameterType": {
- "$ref": "#/definitions/ParameterType"
+ "$ref": "#/definitions/ParameterType",
+ "description": "Whether this is a boolean flag, a key-value option, or a positional argument."
},
"dataType": {
- "$ref": "#/definitions/ParameterDataType"
+ "$ref": "#/definitions/ParameterDataType",
+ "description": "The data type of the parameter's value."
},
"isRequired": {
+ "description": "Whether the user must provide this parameter.",
"type": "boolean"
},
"isRepeatable": {
+ "description": "Whether this parameter can be specified multiple times.",
"type": "boolean"
},
"isGlobal": {
+ "description": "Whether this parameter applies to all commands rather than a single command.",
"type": "boolean"
},
"shortFlag": {
+ "description": "The single-character short flag (e.g. \"-v\").",
"type": "string"
},
"longFlag": {
+ "description": "The long-form flag or option name (e.g. \"--verbose\").",
"type": "string"
},
"position": {
+ "description": "Zero-based position index for positional arguments.",
"type": "number"
},
"sortOrder": {
+ "description": "Display sort position relative to sibling parameters.",
"type": "number"
},
"arraySeparator": {
+ "description": "Separator character used when the option accepts multiple values in one argument (e.g. \",\").",
"type": "string"
},
"keyValueSeparator": {
+ "description": "Separator between key and value for key=value style options (e.g. \"=\").",
"type": "string"
},
"enum": {
+ "description": "Allowed enum choices when dataType is \"Enum\".",
"$ref": "#/definitions/ParameterEnumValues"
},
"validations": {
+ "description": "Validation rules applied to this parameter's value.",
"type": "array",
"items": {
"$ref": "#/definitions/ParameterValidation"
}
},
"dependencies": {
+ "description": "Dependencies on other parameters (requires or conflicts-with relationships).",
"type": "array",
"items": {
"$ref": "#/definitions/ParameterDependency"
@@ -164,6 +201,7 @@
"type": "object",
"properties": {
"tags": {
+ "description": "Arbitrary tags for categorising or filtering parameters.",
"type": "array",
"items": {
"type": "string"
@@ -192,15 +230,18 @@
"type": "object",
"properties": {
"values": {
+ "description": "The list of allowed enum choices.",
"type": "array",
"items": {
"$ref": "#/definitions/ParameterEnumValue"
}
},
"allowMultiple": {
+ "description": "Whether the user can select multiple values at once.",
"type": "boolean"
},
"separator": {
+ "description": "Separator character used when joining multiple selected values.",
"type": "string"
}
},
@@ -212,18 +253,23 @@
"type": "object",
"properties": {
"value": {
+ "description": "The raw value passed to the CLI for this choice.",
"type": "string"
},
"displayName": {
+ "description": "Human-readable label shown to the user for this enum choice.",
"type": "string"
},
"description": {
+ "description": "Description of what this enum value does or represents.",
"type": "string"
},
"isDefault": {
+ "description": "Whether this is the default selection when no value is provided.",
"type": "boolean"
},
"sortOrder": {
+ "description": "Display sort position relative to sibling enum values.",
"type": "number"
}
},
@@ -236,15 +282,19 @@
"type": "object",
"properties": {
"key": {
+ "description": "Unique identifier for this validation rule.",
"type": "string"
},
"validationType": {
- "$ref": "#/definitions/ParameterValidationType"
+ "$ref": "#/definitions/ParameterValidationType",
+ "description": "The type of validation to apply."
},
"validationValue": {
+ "description": "The value to validate against (e.g. the max length number, or a regex pattern).",
"type": "string"
},
"errorMessage": {
+ "description": "The error message to display when validation fails.",
"type": "string"
}
},
@@ -269,18 +319,23 @@
"type": "object",
"properties": {
"key": {
+ "description": "Unique identifier for this dependency rule.",
"type": "string"
},
"parameterKey": {
+ "description": "Key of the parameter that owns this dependency.",
"type": "string"
},
"dependsOnParameterKey": {
+ "description": "Key of the parameter this dependency references.",
"type": "string"
},
"dependencyType": {
- "$ref": "#/definitions/ParameterDependencyType"
+ "$ref": "#/definitions/ParameterDependencyType",
+ "description": "Whether this parameter requires or conflicts with the referenced parameter."
},
"conditionValue": {
+ "description": "Optional value that the referenced parameter must have for this dependency to apply.",
"type": "string"
}
},
@@ -302,18 +357,23 @@
"type": "object",
"properties": {
"key": {
+ "description": "Unique identifier for this exclusion group.",
"type": "string"
},
"commandKey": {
+ "description": "Key of the command this exclusion group belongs to; omit for global groups.",
"type": "string"
},
"name": {
+ "description": "Human-readable name for this exclusion group.",
"type": "string"
},
"exclusionType": {
- "$ref": "#/definitions/ExclusionType"
+ "$ref": "#/definitions/ExclusionType",
+ "description": "Whether parameters in this group are mutually exclusive or one is required."
},
"parameterKeys": {
+ "description": "Keys of the parameters that participate in this exclusion group.",
"type": "array",
"items": {
"type": "string"
diff --git a/public/specification/nested.json b/public/specification/nested.json
index b7dd801..2de1a07 100644
--- a/public/specification/nested.json
+++ b/public/specification/nested.json
@@ -2,33 +2,41 @@
"type": "object",
"properties": {
"$schema": {
+ "description": "Optional JSON schema URI for validation.",
"type": "string"
},
"name": {
+ "description": "Unique machine-readable identifier for the tool (e.g. \"httpx\").",
"type": "string"
},
"displayName": {
+ "description": "Human-readable display name for the tool (e.g. \"HTTPx\").",
"type": "string"
},
"info": {
+ "description": "General information about the tool such as description, version, and URL.",
"$ref": "#/definitions/ToolInfo"
},
"url": {
+ "description": "The homepage or documentation URL for the tool.",
"type": "string"
},
"globalParameters": {
+ "description": "Parameters that apply to all commands globally.",
"type": "array",
"items": {
"$ref": "#/definitions/NestedParameter"
}
},
"commands": {
+ "description": "Hierarchical list of commands and their nested subcommands.",
"type": "array",
"items": {
"$ref": "#/definitions/NestedCommand"
}
},
"exclusionGroups": {
+ "description": "Groups of parameters with mutual exclusion or required-one-of constraints.",
"anyOf": [
{
"type": "array",
@@ -42,6 +50,7 @@
]
},
"metadata": {
+ "description": "Arbitrary metadata attached to the tool.",
"$ref": "#/definitions/ToolMetadata"
}
},
@@ -56,12 +65,15 @@
"type": "object",
"properties": {
"description": {
+ "description": "A brief human-readable description of what the tool does.",
"type": "string"
},
"version": {
+ "description": "The version string of the tool (e.g. \"1.0.0\").",
"type": "string"
},
"url": {
+ "description": "The homepage or documentation URL for the tool.",
"type": "string"
}
}
@@ -70,60 +82,78 @@
"type": "object",
"properties": {
"name": {
+ "description": "Human-readable display name of the parameter.",
"type": "string"
},
"description": {
+ "description": "Brief description of what this parameter does or accepts.",
"type": "string"
},
"group": {
+ "description": "Optional grouping label for organising related parameters in the UI.",
"type": "string"
},
"parameterType": {
- "$ref": "#/definitions/ParameterType"
+ "$ref": "#/definitions/ParameterType",
+ "description": "Whether this is a boolean flag, a key-value option, or a positional argument."
},
"dataType": {
- "$ref": "#/definitions/ParameterDataType"
+ "$ref": "#/definitions/ParameterDataType",
+ "description": "The data type of the parameter's value."
},
"metadata": {
+ "description": "Additional metadata such as tags.",
"$ref": "#/definitions/ParameterMetadata"
},
"isRequired": {
+ "description": "Whether the user must provide this parameter.",
"type": "boolean"
},
"isRepeatable": {
+ "description": "Whether this parameter can be specified multiple times.",
"type": "boolean"
},
"isGlobal": {
+ "description": "Whether this parameter applies to all commands rather than a single command.",
"type": "boolean"
},
"shortFlag": {
+ "description": "The single-character short flag (e.g. \"-v\").",
"type": "string"
},
"longFlag": {
+ "description": "The long-form flag or option name (e.g. \"--verbose\").",
"type": "string"
},
"position": {
+ "description": "Zero-based position index for positional arguments.",
"type": "number"
},
"sortOrder": {
+ "description": "Display sort position relative to sibling parameters.",
"type": "number"
},
"arraySeparator": {
+ "description": "Separator character used when the option accepts multiple values in one argument (e.g. \",\").",
"type": "string"
},
"keyValueSeparator": {
+ "description": "Separator between key and value for key=value style options (e.g. \"=\").",
"type": "string"
},
"enum": {
+ "description": "Allowed enum choices when dataType is \"Enum\".",
"$ref": "#/definitions/ParameterEnumValues"
},
"validations": {
+ "description": "Validation rules applied to this parameter's value.",
"type": "array",
"items": {
"$ref": "#/definitions/NestedParameterValidation"
}
},
"dependencies": {
+ "description": "Dependencies on other parameters (requires or conflicts-with relationships).",
"type": "array",
"items": {
"$ref": "#/definitions/NestedParameterDependency"
@@ -157,6 +187,7 @@
"type": "object",
"properties": {
"tags": {
+ "description": "Arbitrary tags for categorising or filtering parameters.",
"type": "array",
"items": {
"type": "string"
@@ -168,15 +199,18 @@
"type": "object",
"properties": {
"values": {
+ "description": "The list of allowed enum choices.",
"type": "array",
"items": {
"$ref": "#/definitions/ParameterEnumValue"
}
},
"allowMultiple": {
+ "description": "Whether the user can select multiple values at once.",
"type": "boolean"
},
"separator": {
+ "description": "Separator character used when joining multiple selected values.",
"type": "string"
}
},
@@ -188,18 +222,23 @@
"type": "object",
"properties": {
"value": {
+ "description": "The raw value passed to the CLI for this choice.",
"type": "string"
},
"displayName": {
+ "description": "Human-readable label shown to the user for this enum choice.",
"type": "string"
},
"description": {
+ "description": "Description of what this enum value does or represents.",
"type": "string"
},
"isDefault": {
+ "description": "Whether this is the default selection when no value is provided.",
"type": "boolean"
},
"sortOrder": {
+ "description": "Display sort position relative to sibling enum values.",
"type": "number"
}
},
@@ -212,12 +251,15 @@
"type": "object",
"properties": {
"validationType": {
- "$ref": "#/definitions/ParameterValidationType"
+ "$ref": "#/definitions/ParameterValidationType",
+ "description": "The type of validation to apply."
},
"validationValue": {
+ "description": "The value to validate against (e.g. max length number or regex pattern).",
"type": "string"
},
"errorMessage": {
+ "description": "The error message to display when validation fails.",
"type": "string"
}
},
@@ -241,12 +283,15 @@
"type": "object",
"properties": {
"dependsOnParameter": {
+ "description": "Name of the parameter this dependency references.",
"type": "string"
},
"dependencyType": {
- "$ref": "#/definitions/ParameterDependencyType"
+ "$ref": "#/definitions/ParameterDependencyType",
+ "description": "Whether this parameter requires or conflicts with the referenced parameter."
},
"conditionValue": {
+ "description": "Optional value that the referenced parameter must have for this dependency to apply.",
"type": "string"
}
},
@@ -266,27 +311,34 @@
"type": "object",
"properties": {
"name": {
+ "description": "Human-readable display name of the command.",
"type": "string"
},
"description": {
+ "description": "Brief description of what this command does.",
"type": "string"
},
"interactive": {
+ "description": "Whether this command opens an interactive session or prompt.",
"type": "boolean"
},
"isDefault": {
+ "description": "Whether this is the default command when no subcommand is specified.",
"type": "boolean"
},
"sortOrder": {
+ "description": "Display sort position relative to sibling commands.",
"type": "number"
},
"parameters": {
+ "description": "Parameters that belong directly to this command.",
"type": "array",
"items": {
"$ref": "#/definitions/NestedParameter"
}
},
"subcommands": {
+ "description": "Nested subcommands of this command.",
"type": "array",
"items": {
"$ref": "#/definitions/NestedCommand"
@@ -305,12 +357,15 @@
"type": "object",
"properties": {
"name": {
+ "description": "Human-readable name for this exclusion group.",
"type": "string"
},
"exclusionType": {
- "$ref": "#/definitions/ExclusionType"
+ "$ref": "#/definitions/ExclusionType",
+ "description": "Whether parameters in this group are mutually exclusive or one is required."
},
"parameters": {
+ "description": "Names of the parameters that participate in this exclusion group.",
"type": "array",
"items": {
"type": "string"
diff --git a/public/tools.json b/public/tools.json
index 164d488..3cdb231 100644
--- a/public/tools.json
+++ b/public/tools.json
@@ -2,66 +2,131 @@
{
"name": "asnmap",
"displayName": "ASNMap",
- "description": "Go CLI and Library for quickly mapping organization network ranges using ASN information."
+ "description": "Go CLI and Library for quickly mapping organization network ranges using ASN information.",
+ "info": {
+ "description": "Go CLI and Library for quickly mapping organization network ranges using ASN information.",
+ "version": "1.1.1",
+ "url": "https://github.com/projectdiscovery/asnmap"
+ }
},
{
"name": "cdncheck",
"displayName": "CDNCheck",
- "description": "cdncheck is a tool for identifying the technology associated with dns / ip network addresses."
+ "description": "cdncheck is a tool for identifying the technology associated with dns / ip network addresses.",
+ "info": {
+ "description": "cdncheck is a tool for identifying the technology associated with dns / ip network addresses.",
+ "version": "1.2.27",
+ "url": "https://github.com/projectdiscovery/cdncheck"
+ }
},
{
"name": "curl",
"displayName": "Curl",
- "description": "curl is a command line tool and library for transferring data with URLs."
+ "description": "curl is a command line tool and library for transferring data with URLs.",
+ "info": {
+ "description": "curl is a command line tool and library for transferring data with URLs.",
+ "version": "8.19.0",
+ "url": "https://curl.se/"
+ }
},
{
"name": "dnsx",
"displayName": "DNSX",
- "description": "A fast and multi-purpose DNS toolkit designed for running DNS queries."
+ "description": "A fast and multi-purpose DNS toolkit designed for running DNS queries.",
+ "info": {
+ "description": "A fast and multi-purpose DNS toolkit designed for running DNS queries.",
+ "version": "1.2.3",
+ "url": "https://github.com/projectdiscovery/dnsx"
+ }
},
{
"name": "gospider",
"displayName": "GoSpider",
- "description": "Fast web spider written in Go."
+ "description": "Fast web spider written in Go.",
+ "info": {
+ "description": "Fast web spider written in Go.",
+ "version": "1.1.6",
+ "url": "https://github.com/jaeles-project/gospider"
+ }
},
{
"name": "httpx",
"displayName": "Httpx",
- "description": "Httpx is a fast and multi-purpose HTTP toolkit that allows running multiple probes using the retryablehttp library."
+ "description": "Httpx is a fast and multi-purpose HTTP toolkit that allows running multiple probes using the retryablehttp library.",
+ "info": {
+ "description": "Httpx is a fast and multi-purpose HTTP toolkit that allows running multiple probes using the retryablehttp library.",
+ "version": "1.9.0",
+ "url": "https://github.com/projectdiscovery/httpx"
+ }
},
{
"name": "katana",
"displayName": "Katana",
- "description": "Katana is a fast crawler focused on execution in automation pipelines offering both headless and non-headless crawling."
+ "description": "Katana is a fast crawler focused on execution in automation pipelines offering both headless and non-headless crawling.",
+ "info": {
+ "description": "Katana is a fast crawler focused on execution in automation pipelines offering both headless and non-headless crawling.",
+ "version": "1.5.0",
+ "url": "https://github.com/projectdiscovery/katana"
+ }
},
{
"name": "naabu",
"displayName": "Naabu",
- "description": "Fast port scanner for discovering open ports on hosts."
+ "description": "Fast port scanner for discovering open ports on hosts.",
+ "info": {
+ "description": "Fast port scanner for discovering open ports on hosts.",
+ "version": "2.5.0",
+ "url": "https://github.com/projectdiscovery/naabu"
+ }
},
{
"name": "nuclei",
"displayName": "Nuclei",
- "description": "Nuclei is a fast, template based vulnerability scanner focusing on extensive configurability, massive extensibility and ease of use."
+ "description": "Nuclei is a fast, template based vulnerability scanner focusing on extensive configurability, massive extensibility and ease of use.",
+ "info": {
+ "description": "Nuclei is a fast, template based vulnerability scanner focusing on extensive configurability, massive extensibility and ease of use.",
+ "version": "3.7.1",
+ "url": "https://github.com/projectdiscovery/nuclei"
+ }
},
{
"name": "shuffledns",
"displayName": "ShuffleDNS",
- "description": "MassDNS wrapper written in go to enumerate valid subdomains using active bruteforce as well as resolve subdomains with wildcard filtering and easy input-output support."
+ "description": "MassDNS wrapper written in go to enumerate valid subdomains using active bruteforce as well as resolve subdomains with wildcard filtering and easy input-output support.",
+ "info": {
+ "description": "MassDNS wrapper written in go to enumerate valid subdomains using active bruteforce as well as resolve subdomains with wildcard filtering and easy input-output support.",
+ "version": "1.2.1",
+ "url": "https://github.com/projectdiscovery/shuffledns"
+ }
},
{
"name": "subfinder",
"displayName": "Subfinder",
- "description": "Subfinder is a subdomain discovery tool that discovers subdomains for websites by using passive online sources."
+ "description": "Subfinder is a subdomain discovery tool that discovers subdomains for websites by using passive online sources.",
+ "info": {
+ "description": "Subfinder is a subdomain discovery tool that discovers subdomains for websites by using passive online sources.",
+ "version": "2.13.0",
+ "url": "https://github.com/projectdiscovery/subfinder"
+ }
},
{
"name": "urlfinder",
"displayName": "URLFinder",
- "description": "A streamlined tool for discovering associated URLs."
+ "description": "A streamlined tool for discovering associated URLs.",
+ "info": {
+ "description": "A streamlined tool for discovering associated URLs.",
+ "version": "0.0.3",
+ "url": "https://github.com/projectdiscovery/urlfinder"
+ }
},
{
"name": "yt-dlp",
"displayName": "yt-dlp",
- "description": "yt-dlp is a command-line program to download videos from YouTube and other sites."
+ "description": "yt-dlp is a command-line program to download videos from YouTube and other sites.",
+ "info": {
+ "description": "yt-dlp is a command-line program to download videos from YouTube and other sites.",
+ "version": "2026.03.17",
+ "url": "https://github.com/yt-dlp/yt-dlp"
+ }
}
]
\ No newline at end of file
diff --git a/registry/commandly/__tests__/tool-renderer.test.tsx b/registry/commandly/__tests__/tool-renderer.test.tsx
index 9cb222e..55fdbdb 100644
--- a/registry/commandly/__tests__/tool-renderer.test.tsx
+++ b/registry/commandly/__tests__/tool-renderer.test.tsx
@@ -165,6 +165,36 @@ describe("ToolRenderer", () => {
expect(inputs).toHaveLength(2);
});
+ it("renders an allowMultiple Enum parameter without crashing when value is an array (repeatable-to-non-repeatable transition)", () => {
+ const param = {
+ ...createNewParameter(false, "my-tool"),
+ key: "format",
+ name: "Format",
+ parameterType: "Option" as const,
+ dataType: "Enum" as const,
+ isRepeatable: false,
+ enum: {
+ values: [
+ { value: "json", displayName: "JSON" },
+ { value: "xml", displayName: "XML" },
+ ],
+ allowMultiple: true,
+ separator: ",",
+ },
+ };
+ expect(() =>
+ render(
+ {}}
+ />,
+ ),
+ ).not.toThrow();
+ expect(screen.getByText("Format")).toBeInTheDocument();
+ });
+
it("custom catalog entry takes precedence over built-in", () => {
const param = {
...createNewParameter(false, "my-tool"),
diff --git a/registry/commandly/json-output.tsx b/registry/commandly/json-output.tsx
index dbed8a2..e260f92 100644
--- a/registry/commandly/json-output.tsx
+++ b/registry/commandly/json-output.tsx
@@ -1,6 +1,7 @@
import { Tool } from "@/components/commandly/types/flat";
import { exportToStructuredJSON } from "@/components/commandly/utils/flat";
import { convertToNestedStructure } from "@/components/commandly/utils/nested";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@@ -14,7 +15,7 @@ import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { CheckIcon, ChevronsUpDownIcon, CopyIcon, Edit2Icon, XIcon } from "lucide-react";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
const jsonOptions = [
@@ -22,17 +23,53 @@ const jsonOptions = [
{ value: "flat", label: "Flat" },
];
+type DiffLine = { type: "same" | "added" | "removed"; text: string };
+
+function diffLines(before: string, after: string): DiffLine[] {
+ const a = before.split("\n");
+ const b = after.split("\n");
+ const m = a.length;
+ const n = b.length;
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
+ for (let i = 1; i <= m; i++)
+ for (let j = 1; j <= n; j++)
+ dp[i][j] =
+ a[i - 1] === b[j - 1]
+ ? dp[i - 1][j - 1] + 1
+ : Math.max(dp[i - 1][j], dp[i][j - 1]);
+ const result: DiffLine[] = [];
+ let i = m,
+ j = n;
+ while (i > 0 || j > 0) {
+ if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
+ result.unshift({ type: "same", text: a[i - 1] });
+ i--;
+ j--;
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
+ result.unshift({ type: "added", text: b[j - 1] });
+ j--;
+ } else {
+ result.unshift({ type: "removed", text: a[i - 1] });
+ i--;
+ }
+ }
+ return result;
+}
+
interface JsonTypeComponentProps {
tool: Tool;
+ originalTool?: Tool;
onApply?: (tool: Tool) => void;
}
-export function JsonOutput({ tool, onApply }: JsonTypeComponentProps) {
+export function JsonOutput({ tool, originalTool, onApply }: JsonTypeComponentProps) {
const [open, setOpen] = useState(false);
const [jsonString, setJsonString] = useState();
+ const [originalJsonString, setOriginalJsonString] = useState();
const [jsonType, setJsonType] = useState<"nested" | "flat">("flat");
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState("");
+ const [showDiff, setShowDiff] = useState(true);
useEffect(() => {
const config =
@@ -40,6 +77,30 @@ export function JsonOutput({ tool, onApply }: JsonTypeComponentProps) {
setJsonString(JSON.stringify(config, null, 2));
}, [jsonType, tool]);
+ useEffect(() => {
+ if (!originalTool) {
+ setOriginalJsonString(undefined);
+ return;
+ }
+ const config =
+ jsonType === "flat"
+ ? exportToStructuredJSON(originalTool)
+ : convertToNestedStructure(originalTool);
+ setOriginalJsonString(JSON.stringify(config, null, 2));
+ }, [jsonType, originalTool]);
+
+ const diff = useMemo(() => {
+ if (!originalJsonString || !jsonString || originalJsonString === jsonString) return null;
+ return diffLines(originalJsonString, jsonString);
+ }, [originalJsonString, jsonString]);
+
+ const diffStats = useMemo(() => {
+ if (!diff) return null;
+ const added = diff.filter((l) => l.type === "added").length;
+ const removed = diff.filter((l) => l.type === "removed").length;
+ return { added, removed };
+ }, [diff]);
+
const handleEditToggle = () => {
setEditValue(jsonString ?? "");
setIsEditing(true);
@@ -137,6 +198,34 @@ export function JsonOutput({ tool, onApply }: JsonTypeComponentProps) {
+ {diffStats && !isEditing && (
+
+ {diffStats.added > 0 && (
+
+ +{diffStats.added} added
+
+ )}
+ {diffStats.removed > 0 && (
+
+ -{diffStats.removed} removed
+
+ )}
+ setShowDiff((v) => !v)}
+ >
+ {showDiff ? "Full view" : "Diff view"}
+
+
+ )}
{isEditing ? (
+ ) : diff && showDiff ? (
+
+
+ {diff.map((line, idx) => (
+
+
+ {line.type === "added" ? "+ " : line.type === "removed" ? "- " : " "}
+
+ {line.text}
+
+ ))}
+
+
+
+
) : (
);
}
+
diff --git a/registry/commandly/tool-renderer.tsx b/registry/commandly/tool-renderer.tsx
index 7633d30..b88f54a 100644
--- a/registry/commandly/tool-renderer.tsx
+++ b/registry/commandly/tool-renderer.tsx
@@ -129,7 +129,11 @@ function OptionEnumInput({ parameter, value, onUpdate }: ParameterRenderContext)
);
if (parameter.enum?.allowMultiple) {
- const selected = value ? (value as string).split(separator).filter(Boolean) : [];
+ const selected = Array.isArray(value)
+ ? (value as string[]).filter(Boolean)
+ : value
+ ? (value as string).split(separator).filter(Boolean)
+ : [];
return (
{label}
diff --git a/registry/commandly/types/flat.ts b/registry/commandly/types/flat.ts
index 7d31e20..c74a2c6 100644
--- a/registry/commandly/types/flat.ts
+++ b/registry/commandly/types/flat.ts
@@ -1,30 +1,48 @@
export interface ToolInfo {
+ /** A brief human-readable description of what the tool does. */
description?: string;
+ /** The version string of the tool (e.g. "1.0.0"). */
version?: string;
+ /** The homepage or documentation URL for the tool. */
url?: string;
}
export interface Command {
+ /** Unique identifier for this command within the tool. */
key: string;
+ /** Key of the parent command; used to represent subcommand nesting. */
parentCommandKey?: string;
+ /** Human-readable display name of the command. */
name: string;
+ /** Brief description of what this command does. */
description?: string;
+ /** Whether this command opens an interactive session or prompt. */
interactive?: boolean;
+ /** Whether this is the default command when no subcommand is specified. */
isDefault?: boolean;
+ /** Display sort position relative to sibling commands. */
sortOrder?: number;
}
export interface ParameterEnumValue {
+ /** The raw value passed to the CLI for this choice. */
value: string;
+ /** Human-readable label shown to the user for this enum choice. */
displayName: string;
+ /** Description of what this enum value does or represents. */
description?: string;
+ /** Whether this is the default selection when no value is provided. */
isDefault?: boolean;
+ /** Display sort position relative to sibling enum values. */
sortOrder?: number;
}
export interface ParameterEnumValues {
+ /** The list of allowed enum choices. */
values: ParameterEnumValue[];
+ /** Whether the user can select multiple values at once. */
allowMultiple?: boolean;
+ /** Separator character used when joining multiple selected values. */
separator?: string;
}
@@ -36,19 +54,28 @@ export type ParameterValidationType =
| "regex";
export interface ParameterValidation {
+ /** Unique identifier for this validation rule. */
key: string;
+ /** The type of validation to apply. */
validationType: ParameterValidationType;
+ /** The value to validate against (e.g. the max length number, or a regex pattern). */
validationValue: string;
+ /** The error message to display when validation fails. */
errorMessage: string;
}
export type ParameterDependencyType = "requires" | "conflicts_with";
export interface ParameterDependency {
+ /** Unique identifier for this dependency rule. */
key: string;
+ /** Key of the parameter that owns this dependency. */
parameterKey: string;
+ /** Key of the parameter this dependency references. */
dependsOnParameterKey: string;
+ /** Whether this parameter requires or conflicts with the referenced parameter. */
dependencyType: ParameterDependencyType;
+ /** Optional value that the referenced parameter must have for this dependency to apply. */
conditionValue?: string;
}
@@ -59,6 +86,7 @@ export interface ToolMetadata {}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ParameterMetadata {
+ /** Arbitrary tags for categorising or filtering parameters. */
tags?: string[];
}
@@ -67,44 +95,76 @@ export type ParameterType = "Flag" | "Option" | "Argument";
export type ParameterDataType = "String" | "Number" | "Boolean" | "Enum";
export interface Parameter {
+ /** Unique identifier for this parameter within the tool. */
key: string;
+ /** Human-readable display name of the parameter. */
name: string;
+ /** Key of the command this parameter belongs to; omit for global parameters. */
commandKey?: string;
+ /** Brief description of what this parameter does or accepts. */
description?: string;
+ /** Optional grouping label for organising related parameters in the UI. */
group?: string;
+ /** Additional metadata such as tags. */
metadata?: ParameterMetadata;
+ /** Whether this is a boolean flag, a key-value option, or a positional argument. */
parameterType: ParameterType;
+ /** The data type of the parameter's value. */
dataType: ParameterDataType;
+ /** Whether the user must provide this parameter. */
isRequired?: boolean;
+ /** Whether this parameter can be specified multiple times. */
isRepeatable?: boolean;
+ /** Whether this parameter applies to all commands rather than a single command. */
isGlobal?: boolean;
+ /** The single-character short flag (e.g. "-v"). */
shortFlag?: string;
+ /** The long-form flag or option name (e.g. "--verbose"). */
longFlag?: string;
+ /** Zero-based position index for positional arguments. */
position?: number;
+ /** Display sort position relative to sibling parameters. */
sortOrder?: number;
+ /** Separator character used when the option accepts multiple values in one argument (e.g. ","). */
arraySeparator?: string;
+ /** Separator between key and value for key=value style options (e.g. "="). */
keyValueSeparator?: string;
+ /** Allowed enum choices when dataType is "Enum". */
enum?: ParameterEnumValues;
+ /** Validation rules applied to this parameter's value. */
validations?: ParameterValidation[];
+ /** Dependencies on other parameters (requires or conflicts-with relationships). */
dependencies?: ParameterDependency[];
}
export type ExclusionType = "mutual_exclusive" | "required_one_of";
export interface ExclusionGroup {
+ /** Unique identifier for this exclusion group. */
key?: string;
+ /** Key of the command this exclusion group belongs to; omit for global groups. */
commandKey?: string;
+ /** Human-readable name for this exclusion group. */
name: string;
+ /** Whether parameters in this group are mutually exclusive or one is required. */
exclusionType: ExclusionType;
+ /** Keys of the parameters that participate in this exclusion group. */
parameterKeys: string[];
}
export interface Tool {
+ /** Unique machine-readable identifier for the tool (e.g. "httpx"). */
name: string;
+ /** Human-readable display name for the tool (e.g. "HTTPx"). */
displayName: string;
+ /** General information about the tool such as description, version, and URL. */
info?: ToolInfo;
+ /** List of all commands and subcommands defined for this tool. */
commands: Command[];
+ /** Flat list of all parameters across all commands and global scope. */
parameters: Parameter[];
+ /** Groups of parameters with mutual exclusion or required-one-of constraints. */
exclusionGroups?: ExclusionGroup[];
+ /** Arbitrary metadata attached to the tool. */
metadata?: ToolMetadata;
}
diff --git a/registry/commandly/types/nested.ts b/registry/commandly/types/nested.ts
index 8606997..ede55d3 100644
--- a/registry/commandly/types/nested.ts
+++ b/registry/commandly/types/nested.ts
@@ -11,62 +11,105 @@ import type {
} from "@/components/commandly/types/flat";
export interface NestedParameterValidation {
+ /** The type of validation to apply. */
validationType: ParameterValidationType;
+ /** The value to validate against (e.g. max length number or regex pattern). */
validationValue: string;
+ /** The error message to display when validation fails. */
errorMessage: string;
}
export interface NestedParameterDependency {
+ /** Name of the parameter this dependency references. */
dependsOnParameter: string;
+ /** Whether this parameter requires or conflicts with the referenced parameter. */
dependencyType: ParameterDependencyType;
+ /** Optional value that the referenced parameter must have for this dependency to apply. */
conditionValue?: string;
}
export interface NestedParameter {
+ /** Human-readable display name of the parameter. */
name: string;
+ /** Brief description of what this parameter does or accepts. */
description?: string;
+ /** Optional grouping label for organising related parameters in the UI. */
group?: string;
+ /** Whether this is a boolean flag, a key-value option, or a positional argument. */
parameterType: ParameterType;
+ /** The data type of the parameter's value. */
dataType: ParameterDataType;
+ /** Additional metadata such as tags. */
metadata?: ParameterMetadata;
+ /** Whether the user must provide this parameter. */
isRequired?: boolean;
+ /** Whether this parameter can be specified multiple times. */
isRepeatable?: boolean;
+ /** Whether this parameter applies to all commands rather than a single command. */
isGlobal?: boolean;
+ /** The single-character short flag (e.g. "-v"). */
shortFlag?: string;
+ /** The long-form flag or option name (e.g. "--verbose"). */
longFlag?: string;
+ /** Zero-based position index for positional arguments. */
position?: number;
+ /** Display sort position relative to sibling parameters. */
sortOrder?: number;
+ /** Separator character used when the option accepts multiple values in one argument (e.g. ","). */
arraySeparator?: string;
+ /** Separator between key and value for key=value style options (e.g. "="). */
keyValueSeparator?: string;
+ /** Allowed enum choices when dataType is "Enum". */
enum?: ParameterEnumValues;
+ /** Validation rules applied to this parameter's value. */
validations?: NestedParameterValidation[];
+ /** Dependencies on other parameters (requires or conflicts-with relationships). */
dependencies?: NestedParameterDependency[];
}
export interface NestedCommand {
+ /** Human-readable display name of the command. */
name: string;
+ /** Brief description of what this command does. */
description?: string;
+ /** Whether this command opens an interactive session or prompt. */
interactive?: boolean;
+ /** Whether this is the default command when no subcommand is specified. */
isDefault: boolean;
+ /** Display sort position relative to sibling commands. */
sortOrder: number;
+ /** Parameters that belong directly to this command. */
parameters: NestedParameter[];
+ /** Nested subcommands of this command. */
subcommands: NestedCommand[];
}
export interface NestedExclusionGroup {
+ /** Human-readable name for this exclusion group. */
name: string;
+ /** Whether parameters in this group are mutually exclusive or one is required. */
exclusionType: ExclusionType;
+ /** Names of the parameters that participate in this exclusion group. */
parameters: string[];
}
export interface NestedTool {
+ /** Optional JSON schema URI for validation. */
$schema?: string;
+ /** Unique machine-readable identifier for the tool (e.g. "httpx"). */
name: string;
+ /** Human-readable display name for the tool (e.g. "HTTPx"). */
displayName: string;
+ /** General information about the tool such as description, version, and URL. */
info?: ToolInfo;
+ /** The homepage or documentation URL for the tool. */
url?: string;
+ /** Parameters that apply to all commands globally. */
globalParameters: NestedParameter[];
+ /** Hierarchical list of commands and their nested subcommands. */
commands: NestedCommand[];
+ /** Groups of parameters with mutual exclusion or required-one-of constraints. */
exclusionGroups?: NestedExclusionGroup[] | null;
+ /** Arbitrary metadata attached to the tool. */
metadata?: ToolMetadata;
}
diff --git a/scripts/generate-tools-json.ts b/scripts/generate-tools-json.ts
index 2dd8cea..f18091d 100644
--- a/scripts/generate-tools-json.ts
+++ b/scripts/generate-tools-json.ts
@@ -13,7 +13,7 @@ const tools = files.sort().map((file) => {
name: tool.name,
displayName: tool.displayName || tool.name,
description: tool.info?.description,
- url: tool.info?.url,
+ info: tool.info
};
});
diff --git a/src/components/ai-elements/chain-of-thought.tsx b/src/components/ai-elements/chain-of-thought.tsx
new file mode 100644
index 0000000..914aa3e
--- /dev/null
+++ b/src/components/ai-elements/chain-of-thought.tsx
@@ -0,0 +1,222 @@
+"use client";
+
+import { useControllableState } from "@radix-ui/react-use-controllable-state";
+import { Badge } from "@/components/ui/badge";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import type { LucideIcon } from "lucide-react";
+import { BrainIcon, ChevronDownIcon, DotIcon } from "lucide-react";
+import type { ComponentProps, ReactNode } from "react";
+import { createContext, memo, useContext, useMemo } from "react";
+
+interface ChainOfThoughtContextValue {
+ isOpen: boolean;
+ setIsOpen: (open: boolean) => void;
+}
+
+const ChainOfThoughtContext = createContext
(
+ null
+);
+
+const useChainOfThought = () => {
+ const context = useContext(ChainOfThoughtContext);
+ if (!context) {
+ throw new Error(
+ "ChainOfThought components must be used within ChainOfThought"
+ );
+ }
+ return context;
+};
+
+export type ChainOfThoughtProps = ComponentProps<"div"> & {
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?: (open: boolean) => void;
+};
+
+export const ChainOfThought = memo(
+ ({
+ className,
+ open,
+ defaultOpen = false,
+ onOpenChange,
+ children,
+ ...props
+ }: ChainOfThoughtProps) => {
+ const [isOpen, setIsOpen] = useControllableState({
+ defaultProp: defaultOpen,
+ onChange: onOpenChange,
+ prop: open,
+ });
+
+ const chainOfThoughtContext = useMemo(
+ () => ({ isOpen, setIsOpen }),
+ [isOpen, setIsOpen]
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+);
+
+export type ChainOfThoughtHeaderProps = ComponentProps<
+ typeof CollapsibleTrigger
+>;
+
+export const ChainOfThoughtHeader = memo(
+ ({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
+ const { isOpen, setIsOpen } = useChainOfThought();
+
+ return (
+
+
+
+
+ {children ?? "Chain of Thought"}
+
+
+
+
+ );
+ }
+);
+
+export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
+ icon?: LucideIcon;
+ label: ReactNode;
+ description?: ReactNode;
+ status?: "complete" | "active" | "pending";
+};
+
+const stepStatusStyles = {
+ active: "text-foreground",
+ complete: "text-muted-foreground",
+ pending: "text-muted-foreground/50",
+};
+
+export const ChainOfThoughtStep = memo(
+ ({
+ className,
+ icon: Icon = DotIcon,
+ label,
+ description,
+ status = "complete",
+ children,
+ ...props
+ }: ChainOfThoughtStepProps) => (
+
+
+
+
{label}
+ {description && (
+
{description}
+ )}
+ {children}
+
+
+ )
+);
+
+export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
+
+export const ChainOfThoughtSearchResults = memo(
+ ({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
+
+ )
+);
+
+export type ChainOfThoughtSearchResultProps = ComponentProps;
+
+export const ChainOfThoughtSearchResult = memo(
+ ({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
+
+ {children}
+
+ )
+);
+
+export type ChainOfThoughtContentProps = ComponentProps<
+ typeof CollapsibleContent
+>;
+
+export const ChainOfThoughtContent = memo(
+ ({ className, children, ...props }: ChainOfThoughtContentProps) => {
+ const { isOpen } = useChainOfThought();
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+);
+
+export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
+ caption?: string;
+};
+
+export const ChainOfThoughtImage = memo(
+ ({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
+
+
+ {children}
+
+ {caption &&
{caption}
}
+
+ )
+);
+
+ChainOfThought.displayName = "ChainOfThought";
+ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader";
+ChainOfThoughtStep.displayName = "ChainOfThoughtStep";
+ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults";
+ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult";
+ChainOfThoughtContent.displayName = "ChainOfThoughtContent";
+ChainOfThoughtImage.displayName = "ChainOfThoughtImage";
diff --git a/src/components/ai-elements/context.tsx b/src/components/ai-elements/context.tsx
new file mode 100644
index 0000000..1327616
--- /dev/null
+++ b/src/components/ai-elements/context.tsx
@@ -0,0 +1,409 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import { Progress } from "@/components/ui/progress";
+import { cn } from "@/lib/utils";
+import type { LanguageModelUsage } from "ai";
+import type { ComponentProps } from "react";
+import { createContext, useContext, useMemo } from "react";
+import { getUsage } from "tokenlens";
+
+const PERCENT_MAX = 100;
+const ICON_RADIUS = 10;
+const ICON_VIEWBOX = 24;
+const ICON_CENTER = 12;
+const ICON_STROKE_WIDTH = 2;
+
+type ModelId = string;
+
+interface ContextSchema {
+ usedTokens: number;
+ maxTokens: number;
+ usage?: LanguageModelUsage;
+ modelId?: ModelId;
+}
+
+const ContextContext = createContext(null);
+
+const useContextValue = () => {
+ const context = useContext(ContextContext);
+
+ if (!context) {
+ throw new Error("Context components must be used within Context");
+ }
+
+ return context;
+};
+
+export type ContextProps = ComponentProps & ContextSchema;
+
+export const Context = ({
+ usedTokens,
+ maxTokens,
+ usage,
+ modelId,
+ ...props
+}: ContextProps) => {
+ const contextValue = useMemo(
+ () => ({ maxTokens, modelId, usage, usedTokens }),
+ [maxTokens, modelId, usage, usedTokens]
+ );
+
+ return (
+
+
+
+ );
+};
+
+const ContextIcon = () => {
+ const { usedTokens, maxTokens } = useContextValue();
+ const circumference = 2 * Math.PI * ICON_RADIUS;
+ const usedPercent = usedTokens / maxTokens;
+ const dashOffset = circumference * (1 - usedPercent);
+
+ return (
+
+
+
+
+ );
+};
+
+export type ContextTriggerProps = ComponentProps;
+
+export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
+ const { usedTokens, maxTokens } = useContextValue();
+ const usedPercent = usedTokens / maxTokens;
+ const renderedPercent = new Intl.NumberFormat("en-US", {
+ maximumFractionDigits: 1,
+ style: "percent",
+ }).format(usedPercent);
+
+ return (
+
+ {children ?? (
+
+
+ {renderedPercent}
+
+
+
+ )}
+
+ );
+};
+
+export type ContextContentProps = ComponentProps;
+
+export const ContextContent = ({
+ className,
+ ...props
+}: ContextContentProps) => (
+
+);
+
+export type ContextContentHeaderProps = ComponentProps<"div">;
+
+export const ContextContentHeader = ({
+ children,
+ className,
+ ...props
+}: ContextContentHeaderProps) => {
+ const { usedTokens, maxTokens } = useContextValue();
+ const usedPercent = usedTokens / maxTokens;
+ const displayPct = new Intl.NumberFormat("en-US", {
+ maximumFractionDigits: 1,
+ style: "percent",
+ }).format(usedPercent);
+ const used = new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ }).format(usedTokens);
+ const total = new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ }).format(maxTokens);
+
+ return (
+
+ {children ?? (
+ <>
+
+
{displayPct}
+
+ {used} / {total}
+
+
+
+ >
+ )}
+
+ );
+};
+
+export type ContextContentBodyProps = ComponentProps<"div">;
+
+export const ContextContentBody = ({
+ children,
+ className,
+ ...props
+}: ContextContentBodyProps) => (
+
+ {children}
+
+);
+
+export type ContextContentFooterProps = ComponentProps<"div">;
+
+export const ContextContentFooter = ({
+ children,
+ className,
+ ...props
+}: ContextContentFooterProps) => {
+ const { modelId, usage } = useContextValue();
+ const costUSD = modelId
+ ? getUsage({
+ modelId,
+ usage: {
+ input: usage?.inputTokens ?? 0,
+ output: usage?.outputTokens ?? 0,
+ },
+ }).costUSD?.totalUSD
+ : undefined;
+ const totalCost = new Intl.NumberFormat("en-US", {
+ currency: "USD",
+ style: "currency",
+ }).format(costUSD ?? 0);
+
+ return (
+
+ {children ?? (
+ <>
+ Total cost
+ {totalCost}
+ >
+ )}
+
+ );
+};
+
+const TokensWithCost = ({
+ tokens,
+ costText,
+}: {
+ tokens?: number;
+ costText?: string;
+}) => (
+
+ {tokens === undefined
+ ? "—"
+ : new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ }).format(tokens)}
+ {costText ? (
+ • {costText}
+ ) : null}
+
+);
+
+export type ContextInputUsageProps = ComponentProps<"div">;
+
+export const ContextInputUsage = ({
+ className,
+ children,
+ ...props
+}: ContextInputUsageProps) => {
+ const { usage, modelId } = useContextValue();
+ const inputTokens = usage?.inputTokens ?? 0;
+
+ if (children) {
+ return children;
+ }
+
+ if (!inputTokens) {
+ return null;
+ }
+
+ const inputCost = modelId
+ ? getUsage({
+ modelId,
+ usage: { input: inputTokens, output: 0 },
+ }).costUSD?.totalUSD
+ : undefined;
+ const inputCostText = new Intl.NumberFormat("en-US", {
+ currency: "USD",
+ style: "currency",
+ }).format(inputCost ?? 0);
+
+ return (
+
+ Input
+
+
+ );
+};
+
+export type ContextOutputUsageProps = ComponentProps<"div">;
+
+export const ContextOutputUsage = ({
+ className,
+ children,
+ ...props
+}: ContextOutputUsageProps) => {
+ const { usage, modelId } = useContextValue();
+ const outputTokens = usage?.outputTokens ?? 0;
+
+ if (children) {
+ return children;
+ }
+
+ if (!outputTokens) {
+ return null;
+ }
+
+ const outputCost = modelId
+ ? getUsage({
+ modelId,
+ usage: { input: 0, output: outputTokens },
+ }).costUSD?.totalUSD
+ : undefined;
+ const outputCostText = new Intl.NumberFormat("en-US", {
+ currency: "USD",
+ style: "currency",
+ }).format(outputCost ?? 0);
+
+ return (
+
+ Output
+
+
+ );
+};
+
+export type ContextReasoningUsageProps = ComponentProps<"div">;
+
+export const ContextReasoningUsage = ({
+ className,
+ children,
+ ...props
+}: ContextReasoningUsageProps) => {
+ const { usage, modelId } = useContextValue();
+ const reasoningTokens = usage?.reasoningTokens ?? 0;
+
+ if (children) {
+ return children;
+ }
+
+ if (!reasoningTokens) {
+ return null;
+ }
+
+ const reasoningCost = modelId
+ ? getUsage({
+ modelId,
+ usage: { reasoningTokens },
+ }).costUSD?.totalUSD
+ : undefined;
+ const reasoningCostText = new Intl.NumberFormat("en-US", {
+ currency: "USD",
+ style: "currency",
+ }).format(reasoningCost ?? 0);
+
+ return (
+
+ Reasoning
+
+
+ );
+};
+
+export type ContextCacheUsageProps = ComponentProps<"div">;
+
+export const ContextCacheUsage = ({
+ className,
+ children,
+ ...props
+}: ContextCacheUsageProps) => {
+ const { usage, modelId } = useContextValue();
+ const cacheTokens = usage?.cachedInputTokens ?? 0;
+
+ if (children) {
+ return children;
+ }
+
+ if (!cacheTokens) {
+ return null;
+ }
+
+ const cacheCost = modelId
+ ? getUsage({
+ modelId,
+ usage: { cacheReads: cacheTokens, input: 0, output: 0 },
+ }).costUSD?.totalUSD
+ : undefined;
+ const cacheCostText = new Intl.NumberFormat("en-US", {
+ currency: "USD",
+ style: "currency",
+ }).format(cacheCost ?? 0);
+
+ return (
+
+ Cache
+
+
+ );
+};
diff --git a/src/components/ai-elements/prompt-input.tsx b/src/components/ai-elements/prompt-input.tsx
new file mode 100644
index 0000000..512bec6
--- /dev/null
+++ b/src/components/ai-elements/prompt-input.tsx
@@ -0,0 +1,1463 @@
+"use client";
+
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from "@/components/ui/command";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupTextarea,
+} from "@/components/ui/input-group";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Spinner } from "@/components/ui/spinner";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai";
+import {
+ ArrowUpIcon,
+ ImageIcon,
+ MonitorIcon,
+ PlusIcon,
+ SquareIcon,
+ XIcon,
+} from "lucide-react";
+import { nanoid } from "nanoid";
+import type {
+ ChangeEvent,
+ ChangeEventHandler,
+ ClipboardEventHandler,
+ ComponentProps,
+ FormEvent,
+ FormEventHandler,
+ HTMLAttributes,
+ KeyboardEventHandler,
+ PropsWithChildren,
+ ReactNode,
+ RefObject,
+} from "react";
+import {
+ Children,
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+const convertBlobUrlToDataUrl = async (url: string): Promise => {
+ try {
+ const response = await fetch(url);
+ const blob = await response.blob();
+ // FileReader uses callback-based API, wrapping in Promise is necessary
+ // oxlint-disable-next-line eslint-plugin-promise(avoid-new)
+ return new Promise((resolve) => {
+ const reader = new FileReader();
+ // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
+ reader.onloadend = () => resolve(reader.result as string);
+ // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
+ reader.onerror = () => resolve(null);
+ reader.readAsDataURL(blob);
+ });
+ } catch {
+ return null;
+ }
+};
+
+const captureScreenshot = async (): Promise => {
+ if (
+ typeof navigator === "undefined" ||
+ !navigator.mediaDevices?.getDisplayMedia
+ ) {
+ return null;
+ }
+
+ let stream: MediaStream | null = null;
+ const video = document.createElement("video");
+ video.muted = true;
+ video.playsInline = true;
+
+ try {
+ stream = await navigator.mediaDevices.getDisplayMedia({
+ audio: false,
+ video: true,
+ });
+
+ video.srcObject = stream;
+
+ // Video element uses callback-based API, wrapping in Promise is necessary
+ // oxlint-disable-next-line eslint-plugin-promise(avoid-new)
+ await new Promise((resolve, reject) => {
+ // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
+ video.onloadedmetadata = () => resolve();
+ // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
+ video.onerror = () => reject(new Error("Failed to load screen stream"));
+ });
+
+ await video.play();
+
+ const width = video.videoWidth;
+ const height = video.videoHeight;
+ if (!width || !height) {
+ return null;
+ }
+
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ const context = canvas.getContext("2d");
+ if (!context) {
+ return null;
+ }
+
+ context.drawImage(video, 0, 0, width, height);
+ // canvas.toBlob uses callback-based API, wrapping in Promise is necessary
+ // oxlint-disable-next-line eslint-plugin-promise(avoid-new)
+ const blob = await new Promise((resolve) => {
+ canvas.toBlob(resolve, "image/png");
+ });
+ if (!blob) {
+ return null;
+ }
+
+ const timestamp = new Date()
+ .toISOString()
+ .replaceAll(/[:.]/g, "-")
+ .replace("T", "_")
+ .replace("Z", "");
+
+ return new File([blob], `screenshot-${timestamp}.png`, {
+ lastModified: Date.now(),
+ type: "image/png",
+ });
+ } finally {
+ if (stream) {
+ for (const track of stream.getTracks()) {
+ track.stop();
+ }
+ }
+ video.pause();
+ video.srcObject = null;
+ }
+};
+
+// ============================================================================
+// Provider Context & Types
+// ============================================================================
+
+export interface AttachmentsContext {
+ files: (FileUIPart & { id: string })[];
+ add: (files: File[] | FileList) => void;
+ remove: (id: string) => void;
+ clear: () => void;
+ openFileDialog: () => void;
+ fileInputRef: RefObject;
+}
+
+export interface TextInputContext {
+ value: string;
+ setInput: (v: string) => void;
+ clear: () => void;
+}
+
+export interface PromptInputControllerProps {
+ textInput: TextInputContext;
+ attachments: AttachmentsContext;
+ /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
+ __registerFileInput: (
+ ref: RefObject,
+ open: () => void
+ ) => void;
+}
+
+const PromptInputController = createContext(
+ null
+);
+const ProviderAttachmentsContext = createContext(
+ null
+);
+
+export const usePromptInputController = () => {
+ const ctx = useContext(PromptInputController);
+ if (!ctx) {
+ throw new Error(
+ "Wrap your component inside to use usePromptInputController()."
+ );
+ }
+ return ctx;
+};
+
+// Optional variants (do NOT throw). Useful for dual-mode components.
+const useOptionalPromptInputController = () =>
+ useContext(PromptInputController);
+
+export const useProviderAttachments = () => {
+ const ctx = useContext(ProviderAttachmentsContext);
+ if (!ctx) {
+ throw new Error(
+ "Wrap your component inside to use useProviderAttachments()."
+ );
+ }
+ return ctx;
+};
+
+const useOptionalProviderAttachments = () =>
+ useContext(ProviderAttachmentsContext);
+
+export type PromptInputProviderProps = PropsWithChildren<{
+ initialInput?: string;
+}>;
+
+/**
+ * Optional global provider that lifts PromptInput state outside of PromptInput.
+ * If you don't use it, PromptInput stays fully self-managed.
+ */
+export const PromptInputProvider = ({
+ initialInput: initialTextInput = "",
+ children,
+}: PromptInputProviderProps) => {
+ // ----- textInput state
+ const [textInput, setTextInput] = useState(initialTextInput);
+ const clearInput = useCallback(() => setTextInput(""), []);
+
+ // ----- attachments state (global when wrapped)
+ const [attachmentFiles, setAttachmentFiles] = useState<
+ (FileUIPart & { id: string })[]
+ >([]);
+ const fileInputRef = useRef(null);
+ // oxlint-disable-next-line eslint(no-empty-function)
+ const openRef = useRef<() => void>(() => {});
+
+ const add = useCallback((files: File[] | FileList) => {
+ const incoming = [...files];
+ if (incoming.length === 0) {
+ return;
+ }
+
+ setAttachmentFiles((prev) => [
+ ...prev,
+ ...incoming.map((file) => ({
+ filename: file.name,
+ id: nanoid(),
+ mediaType: file.type,
+ type: "file" as const,
+ url: URL.createObjectURL(file),
+ })),
+ ]);
+ }, []);
+
+ const remove = useCallback((id: string) => {
+ setAttachmentFiles((prev) => {
+ const found = prev.find((f) => f.id === id);
+ if (found?.url) {
+ URL.revokeObjectURL(found.url);
+ }
+ return prev.filter((f) => f.id !== id);
+ });
+ }, []);
+
+ const clear = useCallback(() => {
+ setAttachmentFiles((prev) => {
+ for (const f of prev) {
+ if (f.url) {
+ URL.revokeObjectURL(f.url);
+ }
+ }
+ return [];
+ });
+ }, []);
+
+ // Keep a ref to attachments for cleanup on unmount (avoids stale closure)
+ const attachmentsRef = useRef(attachmentFiles);
+
+ useEffect(() => {
+ attachmentsRef.current = attachmentFiles;
+ }, [attachmentFiles]);
+
+ // Cleanup blob URLs on unmount to prevent memory leaks
+ useEffect(
+ () => () => {
+ for (const f of attachmentsRef.current) {
+ if (f.url) {
+ URL.revokeObjectURL(f.url);
+ }
+ }
+ },
+ []
+ );
+
+ const openFileDialog = useCallback(() => {
+ openRef.current?.();
+ }, []);
+
+ const attachments = useMemo(
+ () => ({
+ add,
+ clear,
+ fileInputRef,
+ files: attachmentFiles,
+ openFileDialog,
+ remove,
+ }),
+ [attachmentFiles, add, remove, clear, openFileDialog]
+ );
+
+ const __registerFileInput = useCallback(
+ (ref: RefObject, open: () => void) => {
+ fileInputRef.current = ref.current;
+ openRef.current = open;
+ },
+ []
+ );
+
+ const controller = useMemo(
+ () => ({
+ __registerFileInput,
+ attachments,
+ textInput: {
+ clear: clearInput,
+ setInput: setTextInput,
+ value: textInput,
+ },
+ }),
+ [textInput, clearInput, attachments, __registerFileInput]
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+// ============================================================================
+// Component Context & Hooks
+// ============================================================================
+
+const LocalAttachmentsContext = createContext(null);
+
+export const usePromptInputAttachments = () => {
+ // Prefer local context (inside PromptInput) as it has validation, fall back to provider
+ const provider = useOptionalProviderAttachments();
+ const local = useContext(LocalAttachmentsContext);
+ const context = local ?? provider;
+ if (!context) {
+ throw new Error(
+ "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider"
+ );
+ }
+ return context;
+};
+
+// ============================================================================
+// Referenced Sources (Local to PromptInput)
+// ============================================================================
+
+export interface ReferencedSourcesContext {
+ sources: (SourceDocumentUIPart & { id: string })[];
+ add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void;
+ remove: (id: string) => void;
+ clear: () => void;
+}
+
+export const LocalReferencedSourcesContext =
+ createContext(null);
+
+export const usePromptInputReferencedSources = () => {
+ const ctx = useContext(LocalReferencedSourcesContext);
+ if (!ctx) {
+ throw new Error(
+ "usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider"
+ );
+ }
+ return ctx;
+};
+
+export type PromptInputActionAddAttachmentsProps = ComponentProps<
+ typeof DropdownMenuItem
+> & {
+ label?: string;
+};
+
+export const PromptInputActionAddAttachments = ({
+ label = "Add photos or files",
+ ...props
+}: PromptInputActionAddAttachmentsProps) => {
+ const attachments = usePromptInputAttachments();
+
+ const handleSelect = useCallback(
+ (e: Event) => {
+ e.preventDefault();
+ attachments.openFileDialog();
+ },
+ [attachments]
+ );
+
+ return (
+
+ {label}
+
+ );
+};
+
+export type PromptInputActionAddScreenshotProps = ComponentProps<
+ typeof DropdownMenuItem
+> & {
+ label?: string;
+};
+
+export const PromptInputActionAddScreenshot = ({
+ label = "Take screenshot",
+ onSelect,
+ ...props
+}: PromptInputActionAddScreenshotProps) => {
+ const attachments = usePromptInputAttachments();
+
+ const handleSelect = useCallback(
+ async (event: Event) => {
+ onSelect?.(event);
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ try {
+ const screenshot = await captureScreenshot();
+ if (screenshot) {
+ attachments.add([screenshot]);
+ }
+ } catch (error) {
+ if (
+ error instanceof DOMException &&
+ (error.name === "NotAllowedError" || error.name === "AbortError")
+ ) {
+ return;
+ }
+ throw error;
+ }
+ },
+ [onSelect, attachments]
+ );
+
+ return (
+
+
+ {label}
+
+ );
+};
+
+export interface PromptInputMessage {
+ text: string;
+ files: FileUIPart[];
+}
+
+export type PromptInputProps = Omit<
+ HTMLAttributes,
+ "onSubmit" | "onError"
+> & {
+ // e.g., "image/*" or leave undefined for any
+ accept?: string;
+ multiple?: boolean;
+ // When true, accepts drops anywhere on document. Default false (opt-in).
+ globalDrop?: boolean;
+ // Render a hidden input with given name and keep it in sync for native form posts. Default false.
+ syncHiddenInput?: boolean;
+ // Minimal constraints
+ maxFiles?: number;
+ // bytes
+ maxFileSize?: number;
+ onError?: (err: {
+ code: "max_files" | "max_file_size" | "accept";
+ message: string;
+ }) => void;
+ onSubmit: (
+ message: PromptInputMessage,
+ event: FormEvent
+ ) => void | Promise;
+};
+
+export const PromptInput = ({
+ className,
+ accept,
+ multiple,
+ globalDrop,
+ syncHiddenInput,
+ maxFiles,
+ maxFileSize,
+ onError,
+ onSubmit,
+ children,
+ ...props
+}: PromptInputProps) => {
+ // Try to use a provider controller if present
+ const controller = useOptionalPromptInputController();
+ const usingProvider = !!controller;
+
+ // Refs
+ const inputRef = useRef(null);
+ const formRef = useRef(null);
+
+ // ----- Local attachments (only used when no provider)
+ const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
+ const files = usingProvider ? controller.attachments.files : items;
+
+ // ----- Local referenced sources (always local to PromptInput)
+ const [referencedSources, setReferencedSources] = useState<
+ (SourceDocumentUIPart & { id: string })[]
+ >([]);
+
+ // Keep a ref to files for cleanup on unmount (avoids stale closure)
+ const filesRef = useRef(files);
+
+ useEffect(() => {
+ filesRef.current = files;
+ }, [files]);
+
+ const openFileDialogLocal = useCallback(() => {
+ inputRef.current?.click();
+ }, []);
+
+ const matchesAccept = useCallback(
+ (f: File) => {
+ if (!accept || accept.trim() === "") {
+ return true;
+ }
+
+ const patterns = accept
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+
+ return patterns.some((pattern) => {
+ if (pattern.endsWith("/*")) {
+ // e.g: image/* -> image/
+ const prefix = pattern.slice(0, -1);
+ return f.type.startsWith(prefix);
+ }
+ return f.type === pattern;
+ });
+ },
+ [accept]
+ );
+
+ const addLocal = useCallback(
+ (fileList: File[] | FileList) => {
+ const incoming = [...fileList];
+ const accepted = incoming.filter((f) => matchesAccept(f));
+ if (incoming.length && accepted.length === 0) {
+ onError?.({
+ code: "accept",
+ message: "No files match the accepted types.",
+ });
+ return;
+ }
+ const withinSize = (f: File) =>
+ maxFileSize ? f.size <= maxFileSize : true;
+ const sized = accepted.filter(withinSize);
+ if (accepted.length > 0 && sized.length === 0) {
+ onError?.({
+ code: "max_file_size",
+ message: "All files exceed the maximum size.",
+ });
+ return;
+ }
+
+ setItems((prev) => {
+ const capacity =
+ typeof maxFiles === "number"
+ ? Math.max(0, maxFiles - prev.length)
+ : undefined;
+ const capped =
+ typeof capacity === "number" ? sized.slice(0, capacity) : sized;
+ if (typeof capacity === "number" && sized.length > capacity) {
+ onError?.({
+ code: "max_files",
+ message: "Too many files. Some were not added.",
+ });
+ }
+ const next: (FileUIPart & { id: string })[] = [];
+ for (const file of capped) {
+ next.push({
+ filename: file.name,
+ id: nanoid(),
+ mediaType: file.type,
+ type: "file",
+ url: URL.createObjectURL(file),
+ });
+ }
+ return [...prev, ...next];
+ });
+ },
+ [matchesAccept, maxFiles, maxFileSize, onError]
+ );
+
+ const removeLocal = useCallback(
+ (id: string) =>
+ setItems((prev) => {
+ const found = prev.find((file) => file.id === id);
+ if (found?.url) {
+ URL.revokeObjectURL(found.url);
+ }
+ return prev.filter((file) => file.id !== id);
+ }),
+ []
+ );
+
+ // Wrapper that validates files before calling provider's add
+ const addWithProviderValidation = useCallback(
+ (fileList: File[] | FileList) => {
+ const incoming = [...fileList];
+ const accepted = incoming.filter((f) => matchesAccept(f));
+ if (incoming.length && accepted.length === 0) {
+ onError?.({
+ code: "accept",
+ message: "No files match the accepted types.",
+ });
+ return;
+ }
+ const withinSize = (f: File) =>
+ maxFileSize ? f.size <= maxFileSize : true;
+ const sized = accepted.filter(withinSize);
+ if (accepted.length > 0 && sized.length === 0) {
+ onError?.({
+ code: "max_file_size",
+ message: "All files exceed the maximum size.",
+ });
+ return;
+ }
+
+ const currentCount = files.length;
+ const capacity =
+ typeof maxFiles === "number"
+ ? Math.max(0, maxFiles - currentCount)
+ : undefined;
+ const capped =
+ typeof capacity === "number" ? sized.slice(0, capacity) : sized;
+ if (typeof capacity === "number" && sized.length > capacity) {
+ onError?.({
+ code: "max_files",
+ message: "Too many files. Some were not added.",
+ });
+ }
+
+ if (capped.length > 0) {
+ controller?.attachments.add(capped);
+ }
+ },
+ [matchesAccept, maxFileSize, maxFiles, onError, files.length, controller]
+ );
+
+ const clearAttachments = useCallback(
+ () =>
+ usingProvider
+ ? controller?.attachments.clear()
+ : setItems((prev) => {
+ for (const file of prev) {
+ if (file.url) {
+ URL.revokeObjectURL(file.url);
+ }
+ }
+ return [];
+ }),
+ [usingProvider, controller]
+ );
+
+ const clearReferencedSources = useCallback(
+ () => setReferencedSources([]),
+ []
+ );
+
+ const add = usingProvider ? addWithProviderValidation : addLocal;
+ const remove = usingProvider ? controller.attachments.remove : removeLocal;
+ const openFileDialog = usingProvider
+ ? controller.attachments.openFileDialog
+ : openFileDialogLocal;
+
+ const clear = useCallback(() => {
+ clearAttachments();
+ clearReferencedSources();
+ }, [clearAttachments, clearReferencedSources]);
+
+ // Let provider know about our hidden file input so external menus can call openFileDialog()
+ useEffect(() => {
+ if (!usingProvider) {
+ return;
+ }
+ controller.__registerFileInput(inputRef, () => inputRef.current?.click());
+ }, [usingProvider, controller]);
+
+ // Note: File input cannot be programmatically set for security reasons
+ // The syncHiddenInput prop is no longer functional
+ useEffect(() => {
+ if (syncHiddenInput && inputRef.current && files.length === 0) {
+ inputRef.current.value = "";
+ }
+ }, [files, syncHiddenInput]);
+
+ // Attach drop handlers on nearest form and document (opt-in)
+ useEffect(() => {
+ const form = formRef.current;
+ if (!form) {
+ return;
+ }
+ if (globalDrop) {
+ // when global drop is on, let the document-level handler own drops
+ return;
+ }
+
+ const onDragOver = (e: DragEvent) => {
+ if (e.dataTransfer?.types?.includes("Files")) {
+ e.preventDefault();
+ }
+ };
+ const onDrop = (e: DragEvent) => {
+ if (e.dataTransfer?.types?.includes("Files")) {
+ e.preventDefault();
+ }
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
+ add(e.dataTransfer.files);
+ }
+ };
+ form.addEventListener("dragover", onDragOver);
+ form.addEventListener("drop", onDrop);
+ return () => {
+ form.removeEventListener("dragover", onDragOver);
+ form.removeEventListener("drop", onDrop);
+ };
+ }, [add, globalDrop]);
+
+ useEffect(() => {
+ if (!globalDrop) {
+ return;
+ }
+
+ const onDragOver = (e: DragEvent) => {
+ if (e.dataTransfer?.types?.includes("Files")) {
+ e.preventDefault();
+ }
+ };
+ const onDrop = (e: DragEvent) => {
+ if (e.dataTransfer?.types?.includes("Files")) {
+ e.preventDefault();
+ }
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
+ add(e.dataTransfer.files);
+ }
+ };
+ document.addEventListener("dragover", onDragOver);
+ document.addEventListener("drop", onDrop);
+ return () => {
+ document.removeEventListener("dragover", onDragOver);
+ document.removeEventListener("drop", onDrop);
+ };
+ }, [add, globalDrop]);
+
+ useEffect(
+ () => () => {
+ if (!usingProvider) {
+ for (const f of filesRef.current) {
+ if (f.url) {
+ URL.revokeObjectURL(f.url);
+ }
+ }
+ }
+ },
+ [usingProvider]
+ );
+
+ const handleChange: ChangeEventHandler = useCallback(
+ (event) => {
+ if (event.currentTarget.files) {
+ add(event.currentTarget.files);
+ }
+ // Reset input value to allow selecting files that were previously removed
+ event.currentTarget.value = "";
+ },
+ [add]
+ );
+
+ const attachmentsCtx = useMemo(
+ () => ({
+ add,
+ clear: clearAttachments,
+ fileInputRef: inputRef,
+ files: files.map((item) => ({ ...item, id: item.id })),
+ openFileDialog,
+ remove,
+ }),
+ [files, add, remove, clearAttachments, openFileDialog]
+ );
+
+ const refsCtx = useMemo(
+ () => ({
+ add: (incoming: SourceDocumentUIPart[] | SourceDocumentUIPart) => {
+ const array = Array.isArray(incoming) ? incoming : [incoming];
+ setReferencedSources((prev) => [
+ ...prev,
+ ...array.map((s) => ({ ...s, id: nanoid() })),
+ ]);
+ },
+ clear: clearReferencedSources,
+ remove: (id: string) => {
+ setReferencedSources((prev) => prev.filter((s) => s.id !== id));
+ },
+ sources: referencedSources,
+ }),
+ [referencedSources, clearReferencedSources]
+ );
+
+ const handleSubmit: FormEventHandler = useCallback(
+ async (event) => {
+ event.preventDefault();
+
+ const form = event.currentTarget;
+ const text = usingProvider
+ ? controller.textInput.value
+ : (() => {
+ const formData = new FormData(form);
+ return (formData.get("message") as string) || "";
+ })();
+
+ // Reset form immediately after capturing text to avoid race condition
+ // where user input during async blob conversion would be lost
+ if (!usingProvider) {
+ form.reset();
+ }
+
+ try {
+ // Convert blob URLs to data URLs asynchronously
+ const convertedFiles: FileUIPart[] = await Promise.all(
+ files.map(async ({ id: _id, ...item }) => {
+ if (item.url?.startsWith("blob:")) {
+ const dataUrl = await convertBlobUrlToDataUrl(item.url);
+ // If conversion failed, keep the original blob URL
+ return {
+ ...item,
+ url: dataUrl ?? item.url,
+ };
+ }
+ return item;
+ })
+ );
+
+ const result = onSubmit({ files: convertedFiles, text }, event);
+
+ // Handle both sync and async onSubmit
+ if (result instanceof Promise) {
+ try {
+ await result;
+ clear();
+ if (usingProvider) {
+ controller.textInput.clear();
+ }
+ } catch {
+ // Don't clear on error - user may want to retry
+ }
+ } else {
+ // Sync function completed without throwing, clear inputs
+ clear();
+ if (usingProvider) {
+ controller.textInput.clear();
+ }
+ }
+ } catch {
+ // Don't clear on error - user may want to retry
+ }
+ },
+ [usingProvider, controller, files, onSubmit, clear]
+ );
+
+ // Render with or without local provider
+ const inner = (
+ <>
+
+
+ >
+ );
+
+ const withReferencedSources = (
+
+ {inner}
+
+ );
+
+ // Always provide LocalAttachmentsContext so children get validated add function
+ return (
+
+ {withReferencedSources}
+
+ );
+};
+
+export type PromptInputBodyProps = HTMLAttributes;
+
+export const PromptInputBody = ({
+ className,
+ ...props
+}: PromptInputBodyProps) => (
+
+);
+
+export type PromptInputTextareaProps = ComponentProps<
+ typeof InputGroupTextarea
+>;
+
+export const PromptInputTextarea = ({
+ onChange,
+ onKeyDown,
+ className,
+ placeholder = "What would you like to know?",
+ ...props
+}: PromptInputTextareaProps) => {
+ const controller = useOptionalPromptInputController();
+ const attachments = usePromptInputAttachments();
+ const [isComposing, setIsComposing] = useState(false);
+
+ const handleKeyDown: KeyboardEventHandler = useCallback(
+ (e) => {
+ // Call the external onKeyDown handler first
+ onKeyDown?.(e);
+
+ // If the external handler prevented default, don't run internal logic
+ if (e.defaultPrevented) {
+ return;
+ }
+
+ if (e.key === "Enter") {
+ if (isComposing || e.nativeEvent.isComposing) {
+ return;
+ }
+ if (e.shiftKey) {
+ return;
+ }
+ e.preventDefault();
+
+ // Check if the submit button is disabled before submitting
+ const { form } = e.currentTarget;
+ const submitButton = form?.querySelector(
+ 'button[type="submit"]'
+ ) as HTMLButtonElement | null;
+ if (submitButton?.disabled) {
+ return;
+ }
+
+ form?.requestSubmit();
+ }
+
+ // Remove last attachment when Backspace is pressed and textarea is empty
+ if (
+ e.key === "Backspace" &&
+ e.currentTarget.value === "" &&
+ attachments.files.length > 0
+ ) {
+ e.preventDefault();
+ const lastAttachment = attachments.files.at(-1);
+ if (lastAttachment) {
+ attachments.remove(lastAttachment.id);
+ }
+ }
+ },
+ [onKeyDown, isComposing, attachments]
+ );
+
+ const handlePaste: ClipboardEventHandler = useCallback(
+ (event) => {
+ const items = event.clipboardData?.items;
+
+ if (!items) {
+ return;
+ }
+
+ const files: File[] = [];
+
+ for (const item of items) {
+ if (item.kind === "file") {
+ const file = item.getAsFile();
+ if (file) {
+ files.push(file);
+ }
+ }
+ }
+
+ if (files.length > 0) {
+ event.preventDefault();
+ attachments.add(files);
+ }
+ },
+ [attachments]
+ );
+
+ const handleCompositionEnd = useCallback(() => setIsComposing(false), []);
+ const handleCompositionStart = useCallback(() => setIsComposing(true), []);
+
+ const controlledProps = controller
+ ? {
+ onChange: (e: ChangeEvent) => {
+ controller.textInput.setInput(e.currentTarget.value);
+ onChange?.(e);
+ },
+ value: controller.textInput.value,
+ }
+ : {
+ onChange,
+ };
+
+ return (
+
+ );
+};
+
+export type PromptInputHeaderProps = Omit<
+ ComponentProps,
+ "align"
+>;
+
+export const PromptInputHeader = ({
+ className,
+ ...props
+}: PromptInputHeaderProps) => (
+
+);
+
+export type PromptInputFooterProps = Omit<
+ ComponentProps,
+ "align"
+>;
+
+export const PromptInputFooter = ({
+ className,
+ ...props
+}: PromptInputFooterProps) => (
+
+);
+
+export type PromptInputToolsProps = HTMLAttributes;
+
+export const PromptInputTools = ({
+ className,
+ ...props
+}: PromptInputToolsProps) => (
+
+);
+
+export type PromptInputButtonTooltip =
+ | string
+ | {
+ content: ReactNode;
+ shortcut?: string;
+ side?: ComponentProps["side"];
+ };
+
+export type PromptInputButtonProps = ComponentProps & {
+ tooltip?: PromptInputButtonTooltip;
+};
+
+export const PromptInputButton = ({
+ variant = "ghost",
+ className,
+ size,
+ tooltip,
+ ...props
+}: PromptInputButtonProps) => {
+ const newSize =
+ size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm");
+
+ const button = (
+
+ );
+
+ if (!tooltip) {
+ return button;
+ }
+
+ const tooltipContent =
+ typeof tooltip === "string" ? tooltip : tooltip.content;
+ const shortcut = typeof tooltip === "string" ? undefined : tooltip.shortcut;
+ const side = typeof tooltip === "string" ? "top" : (tooltip.side ?? "top");
+
+ return (
+
+ {button}
+
+ {tooltipContent}
+ {shortcut && (
+ {shortcut}
+ )}
+
+
+ );
+};
+
+export type PromptInputActionMenuProps = ComponentProps;
+export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
+
+);
+
+export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
+
+export const PromptInputActionMenuTrigger = ({
+ className,
+ children,
+ ...props
+}: PromptInputActionMenuTriggerProps) => (
+
+
+ {children ?? }
+
+
+);
+
+export type PromptInputActionMenuContentProps = ComponentProps<
+ typeof DropdownMenuContent
+>;
+export const PromptInputActionMenuContent = ({
+ className,
+ ...props
+}: PromptInputActionMenuContentProps) => (
+
+);
+
+export type PromptInputActionMenuItemProps = ComponentProps<
+ typeof DropdownMenuItem
+>;
+export const PromptInputActionMenuItem = ({
+ className,
+ ...props
+}: PromptInputActionMenuItemProps) => (
+
+);
+
+// Note: Actions that perform side-effects (like opening a file dialog)
+// are provided in opt-in modules (e.g., prompt-input-attachments).
+
+export type PromptInputSubmitProps = ComponentProps & {
+ status?: ChatStatus;
+ onStop?: () => void;
+};
+
+export const PromptInputSubmit = ({
+ className,
+ variant = "default",
+ size = "icon-sm",
+ status,
+ onStop,
+ onClick,
+ children,
+ ...props
+}: PromptInputSubmitProps) => {
+ const isGenerating = status === "submitted" || status === "streaming";
+
+ let Icon = ;
+
+ if (status === "submitted") {
+ Icon = ;
+ } else if (status === "streaming") {
+ Icon = ;
+ } else if (status === "error") {
+ Icon = ;
+ }
+
+ const handleClick = useCallback(
+ (e: React.MouseEvent) => {
+ if (isGenerating && onStop) {
+ e.preventDefault();
+ onStop();
+ return;
+ }
+ onClick?.(e);
+ },
+ [isGenerating, onStop, onClick]
+ );
+
+ return (
+
+ {children ?? Icon}
+
+ );
+};
+
+export type PromptInputSelectProps = ComponentProps;
+
+export const PromptInputSelect = (props: PromptInputSelectProps) => (
+
+);
+
+export type PromptInputSelectTriggerProps = ComponentProps<
+ typeof SelectTrigger
+>;
+
+export const PromptInputSelectTrigger = ({
+ className,
+ ...props
+}: PromptInputSelectTriggerProps) => (
+
+);
+
+export type PromptInputSelectContentProps = ComponentProps<
+ typeof SelectContent
+>;
+
+export const PromptInputSelectContent = ({
+ className,
+ ...props
+}: PromptInputSelectContentProps) => (
+
+);
+
+export type PromptInputSelectItemProps = ComponentProps;
+
+export const PromptInputSelectItem = ({
+ className,
+ ...props
+}: PromptInputSelectItemProps) => (
+
+);
+
+export type PromptInputSelectValueProps = ComponentProps;
+
+export const PromptInputSelectValue = ({
+ className,
+ ...props
+}: PromptInputSelectValueProps) => (
+
+);
+
+export type PromptInputHoverCardProps = ComponentProps;
+
+export const PromptInputHoverCard = ({
+ openDelay = 0,
+ closeDelay = 0,
+ ...props
+}: PromptInputHoverCardProps) => (
+
+);
+
+export type PromptInputHoverCardTriggerProps = ComponentProps<
+ typeof HoverCardTrigger
+>;
+
+export const PromptInputHoverCardTrigger = (
+ props: PromptInputHoverCardTriggerProps
+) => ;
+
+export type PromptInputHoverCardContentProps = ComponentProps<
+ typeof HoverCardContent
+>;
+
+export const PromptInputHoverCardContent = ({
+ align = "start",
+ ...props
+}: PromptInputHoverCardContentProps) => (
+
+);
+
+export type PromptInputTabsListProps = HTMLAttributes;
+
+export const PromptInputTabsList = ({
+ className,
+ ...props
+}: PromptInputTabsListProps) =>
;
+
+export type PromptInputTabProps = HTMLAttributes;
+
+export const PromptInputTab = ({
+ className,
+ ...props
+}: PromptInputTabProps) =>
;
+
+export type PromptInputTabLabelProps = HTMLAttributes;
+
+export const PromptInputTabLabel = ({
+ className,
+ ...props
+}: PromptInputTabLabelProps) => (
+ // Content provided via children in props
+ // oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)
+
+);
+
+export type PromptInputTabBodyProps = HTMLAttributes;
+
+export const PromptInputTabBody = ({
+ className,
+ ...props
+}: PromptInputTabBodyProps) => (
+
+);
+
+export type PromptInputTabItemProps = HTMLAttributes;
+
+export const PromptInputTabItem = ({
+ className,
+ ...props
+}: PromptInputTabItemProps) => (
+
+);
+
+export type PromptInputCommandProps = ComponentProps;
+
+export const PromptInputCommand = ({
+ className,
+ ...props
+}: PromptInputCommandProps) => ;
+
+export type PromptInputCommandInputProps = ComponentProps;
+
+export const PromptInputCommandInput = ({
+ className,
+ ...props
+}: PromptInputCommandInputProps) => (
+
+);
+
+export type PromptInputCommandListProps = ComponentProps;
+
+export const PromptInputCommandList = ({
+ className,
+ ...props
+}: PromptInputCommandListProps) => (
+
+);
+
+export type PromptInputCommandEmptyProps = ComponentProps;
+
+export const PromptInputCommandEmpty = ({
+ className,
+ ...props
+}: PromptInputCommandEmptyProps) => (
+
+);
+
+export type PromptInputCommandGroupProps = ComponentProps;
+
+export const PromptInputCommandGroup = ({
+ className,
+ ...props
+}: PromptInputCommandGroupProps) => (
+
+);
+
+export type PromptInputCommandItemProps = ComponentProps;
+
+export const PromptInputCommandItem = ({
+ className,
+ ...props
+}: PromptInputCommandItemProps) => (
+
+);
+
+export type PromptInputCommandSeparatorProps = ComponentProps<
+ typeof CommandSeparator
+>;
+
+export const PromptInputCommandSeparator = ({
+ className,
+ ...props
+}: PromptInputCommandSeparatorProps) => (
+
+);
diff --git a/src/components/ai-elements/reasoning.tsx b/src/components/ai-elements/reasoning.tsx
new file mode 100644
index 0000000..7ae3390
--- /dev/null
+++ b/src/components/ai-elements/reasoning.tsx
@@ -0,0 +1,221 @@
+"use client";
+
+import { useControllableState } from "@radix-ui/react-use-controllable-state";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import { BrainIcon, ChevronDownIcon } from "lucide-react";
+import type { ComponentProps, ReactNode } from "react";
+import {
+ createContext,
+ memo,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { Streamdown } from "streamdown";
+
+import { Shimmer } from "./shimmer";
+
+interface ReasoningContextValue {
+ isStreaming: boolean;
+ isOpen: boolean;
+ setIsOpen: (open: boolean) => void;
+ duration: number | undefined;
+}
+
+const ReasoningContext = createContext(null);
+
+export const useReasoning = () => {
+ const context = useContext(ReasoningContext);
+ if (!context) {
+ throw new Error("Reasoning components must be used within Reasoning");
+ }
+ return context;
+};
+
+export type ReasoningProps = ComponentProps & {
+ isStreaming?: boolean;
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ duration?: number;
+};
+
+const AUTO_CLOSE_DELAY = 1000;
+const MS_IN_S = 1000;
+
+export const Reasoning = memo(
+ ({
+ className,
+ isStreaming = false,
+ open,
+ defaultOpen,
+ // oxlint-disable-next-line typescript/unbound-method
+ onOpenChange,
+ duration: durationProp,
+ children,
+ ...props
+ }: ReasoningProps) => {
+ const resolvedDefaultOpen = defaultOpen ?? isStreaming;
+ // Track if defaultOpen was explicitly set to false (to prevent auto-open)
+ const isExplicitlyClosed = defaultOpen === false;
+
+ const [isOpen, setIsOpen] = useControllableState({
+ defaultProp: resolvedDefaultOpen,
+ onChange: onOpenChange,
+ prop: open,
+ });
+ const [duration, setDuration] = useControllableState({
+ defaultProp: undefined,
+ prop: durationProp,
+ });
+
+ const hasEverStreamedRef = useRef(isStreaming);
+ const [hasAutoClosed, setHasAutoClosed] = useState(false);
+ const startTimeRef = useRef(null);
+
+ // Track when streaming starts and compute duration
+ useEffect(() => {
+ if (isStreaming) {
+ hasEverStreamedRef.current = true;
+ if (startTimeRef.current === null) {
+ startTimeRef.current = Date.now();
+ }
+ } else if (startTimeRef.current !== null) {
+ setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
+ startTimeRef.current = null;
+ }
+ }, [isStreaming, setDuration]);
+
+ // Auto-open when streaming starts (unless explicitly closed)
+ useEffect(() => {
+ if (isStreaming && !isOpen && !isExplicitlyClosed) {
+ setIsOpen(true);
+ }
+ }, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
+
+ // Auto-close when streaming ends (once only, and only if it ever streamed)
+ useEffect(() => {
+ if (
+ hasEverStreamedRef.current &&
+ !isStreaming &&
+ isOpen &&
+ !hasAutoClosed
+ ) {
+ const timer = setTimeout(() => {
+ setIsOpen(false);
+ setHasAutoClosed(true);
+ }, AUTO_CLOSE_DELAY);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
+
+ const handleOpenChange = useCallback(
+ (newOpen: boolean) => {
+ setIsOpen(newOpen);
+ },
+ [setIsOpen]
+ );
+
+ const contextValue = useMemo(
+ () => ({ duration, isOpen, isStreaming, setIsOpen }),
+ [duration, isOpen, isStreaming, setIsOpen]
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+);
+
+export type ReasoningTriggerProps = ComponentProps<
+ typeof CollapsibleTrigger
+> & {
+ getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
+};
+
+const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
+ if (isStreaming || duration === 0) {
+ return Thinking... ;
+ }
+ if (duration === undefined) {
+ return Thought for a few seconds
;
+ }
+ return Thought for {duration} seconds
;
+};
+
+export const ReasoningTrigger = memo(
+ ({
+ className,
+ children,
+ getThinkingMessage = defaultGetThinkingMessage,
+ ...props
+ }: ReasoningTriggerProps) => {
+ const { isStreaming, isOpen, duration } = useReasoning();
+
+ return (
+
+ {children ?? (
+ <>
+
+ {getThinkingMessage(isStreaming, duration)}
+
+ >
+ )}
+
+ );
+ }
+);
+
+export type ReasoningContentProps = ComponentProps<
+ typeof CollapsibleContent
+> & {
+ children: string;
+};
+
+export const ReasoningContent = memo(
+ ({ className, children, ...props }: ReasoningContentProps) => (
+
+ {children}
+
+ )
+);
+
+Reasoning.displayName = "Reasoning";
+ReasoningTrigger.displayName = "ReasoningTrigger";
+ReasoningContent.displayName = "ReasoningContent";
diff --git a/src/components/tool-card.tsx b/src/components/tool-card.tsx
index c667899..77f317f 100644
--- a/src/components/tool-card.tsx
+++ b/src/components/tool-card.tsx
@@ -21,9 +21,6 @@ export function ToolCard({
isLocal?: boolean;
onDelete?: (tool: Partial) => void;
}) {
- const description =
- tool.info?.description ?? (tool as Partial & { description?: string }).description;
-
return (
- {description ? (
+ {tool.info?.description ? (
- {description}
+ {tool.info?.description}
- {description}
+ {tool.info?.description}
) : (
No description available
diff --git a/src/components/tool-editor/ai-chat.tsx b/src/components/tool-editor/ai-chat.tsx
index 693b504..0cbbdd8 100644
--- a/src/components/tool-editor/ai-chat.tsx
+++ b/src/components/tool-editor/ai-chat.tsx
@@ -1,3 +1,12 @@
+import { ApiKeySettings } from "./api-key-settings";
+import {
+ ModelPicker,
+ ReasoningEffortPicker,
+ getProviderOptions,
+ providerForModel,
+ MODEL_GROUPS,
+ type ReasoningEffort,
+} from "./model-picker";
import { generatePrompt } from "./prompt";
import { useToolBuilder } from "./tool-editor.context";
import {
@@ -15,21 +24,53 @@ import {
} from "@/components/ai-elements/message";
import { Shimmer } from "@/components/ai-elements/shimmer";
import { Suggestion, Suggestions } from "@/components/ai-elements/suggestion";
+import {
+ ChainOfThought,
+ ChainOfThoughtContent,
+ ChainOfThoughtHeader,
+ ChainOfThoughtStep,
+} from "@/components/ai-elements/chain-of-thought";
+import {
+ Reasoning,
+ ReasoningContent,
+ ReasoningTrigger,
+} from "@/components/ai-elements/reasoning";
+import {
+ PromptInput,
+ PromptInputBody,
+ PromptInputFooter,
+ PromptInputHeader,
+ PromptInputSubmit,
+ PromptInputTextarea,
+ PromptInputTools,
+} from "@/components/ai-elements/prompt-input";
+import {
+ Context,
+ ContextCacheUsage,
+ ContextContent,
+ ContextContentBody,
+ ContextContentFooter,
+ ContextContentHeader,
+ ContextInputUsage,
+ ContextOutputUsage,
+ ContextReasoningUsage,
+ ContextTrigger,
+} from "@/components/ai-elements/context";
import {
Tool as ToolCard,
ToolContent,
ToolHeader,
- ToolInput,
- ToolOutput,
} from "@/components/ai-elements/tool";
import { Tool } from "@/components/commandly/types/flat";
import { exportToStructuredJSON } from "@/components/commandly/utils/flat";
+import {
+ createApplyToolDefinitionTool,
+ createEditTool,
+ createReadTool,
+ createTavilyExtractTool,
+ createTavilySearchTool
+} from "./tools";
import { Button } from "@/components/ui/button";
-import { Checkbox } from "@/components/ui/checkbox";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
-import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { useAIKeys, type AIProvider } from "@/lib/ai-keys";
import { cn, replaceKey } from "@/lib/utils";
@@ -39,33 +80,42 @@ import { createGroq } from "@ai-sdk/groq";
import { createMistral } from "@ai-sdk/mistral";
import { createOpenAI } from "@ai-sdk/openai";
import { createXai } from "@ai-sdk/xai";
-import { stepCountIs, streamText, tool } from "ai";
+import { hasToolCall, stepCountIs, streamText, type LanguageModelUsage, type ModelMessage } from "ai";
import {
CheckIcon,
- ChevronsUpDownIcon,
CopyIcon,
- GlobeIcon,
+ FlagIcon,
+ HashIcon,
+ LinkIcon,
PencilIcon,
RefreshCcwIcon,
- SendIcon,
- Settings2Icon,
+ SearchIcon,
SparklesIcon,
- SquareIcon,
SquarePenIcon,
+ TerminalIcon,
XIcon,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
-import { z } from "zod";
interface ToolCallEntry {
toolCallId: string;
toolName: string;
title: string;
input: Record;
- state: "input-available" | "output-available" | "output-error";
+ state: "input-available" | "approval-requested" | "output-available" | "output-error";
output?: unknown;
errorText?: string;
+ originalTool?: Tool;
+ previewTool?: Tool;
+}
+
+interface ChatSession {
+ id: string;
+ toolName: string;
+ messages: ChatMessage[];
+ updatedAt: number;
+ preview: string;
}
interface ChatMessage {
@@ -75,6 +125,7 @@ interface ChatMessage {
toolCalls?: ToolCallEntry[];
isEditing?: boolean;
editingContent?: string;
+ reasoningContent?: string;
}
const PROMPT_PILLS = [
@@ -92,111 +143,112 @@ const PROMPT_PILLS = [
},
];
-const MODEL_GROUPS: {
- provider: AIProvider;
- label: string;
- logoUrl: string;
- keyPlaceholder: string;
- models: { value: string; label: string }[];
-}[] = [
- {
- provider: "openai",
- label: "OpenAI",
- logoUrl: "https://www.google.com/s2/favicons?domain=openai.com&sz=32",
- keyPlaceholder: "sk-...",
- models: [
- { value: "gpt-5-2025-08-07", label: "GPT-5" },
- { value: "gpt-5.4-2026-03-05", label: "GPT-5.4" },
- { value: "gpt-5-mini-2025-08-07", label: "GPT-5 mini" },
- { value: "gpt-5-nano-2025-08-07", label: "GPT-5 nano" },
- { value: "gpt-5-codex", label: "GPT-5 Codex" },
- { value: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
- { value: "gpt-5.2-codex", label: "GPT-5.2 Codex" },
- { value: "gpt-5.1-codex", label: "GPT-5.1 Codex" },
- { value: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex mini" },
- { value: "o4-mini-2025-04-16", label: "o4-mini" },
- { value: "o3-2025-04-16", label: "o3" },
- { value: "gpt-4.1-2025-04-14", label: "GPT-4.1" },
- { value: "gpt-4.1-mini-2025-04-14", label: "GPT-4.1 mini" },
- { value: "gpt-4.1-nano-2025-04-14", label: "GPT-4.1 nano" },
- ],
- },
- {
- provider: "anthropic",
- label: "Anthropic",
- logoUrl: "https://www.google.com/s2/favicons?domain=anthropic.com&sz=32",
- keyPlaceholder: "sk-ant-...",
- models: [
- { value: "claude-opus-4-6", label: "Claude Opus 4.6" },
- { value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
- { value: "claude-haiku-4-5", label: "Claude Haiku 4.5" },
- { value: "claude-opus-4-5", label: "Claude Opus 4.5" },
- { value: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" },
- ],
- },
- {
- provider: "google",
- label: "Google",
- logoUrl: "https://www.google.com/s2/favicons?domain=google.com&sz=32",
- keyPlaceholder: "AIza...",
- models: [
- { value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro" },
- { value: "gemini-3-flash-preview", label: "Gemini 3 Flash" },
- { value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
- { value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
- { value: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" },
- ],
- },
- {
- provider: "groq",
- label: "Groq",
- logoUrl: "https://www.google.com/s2/favicons?domain=groq.com&sz=32",
- keyPlaceholder: "gsk_...",
- models: [
- { value: "meta-llama/llama-4-maverick-17b-128e-instruct", label: "Llama 4 Maverick" },
- { value: "meta-llama/llama-4-scout-17b-16e-instruct", label: "Llama 4 Scout" },
- { value: "qwen/qwen3-32b", label: "Qwen 3 32B" },
- { value: "llama-3.3-70b-versatile", label: "Llama 3.3 70B" },
- { value: "llama-3.1-8b-instant", label: "Llama 3.1 8B" },
- ],
- },
- {
- provider: "mistral",
- label: "Mistral",
- logoUrl: "https://www.google.com/s2/favicons?domain=mistral.ai&sz=32",
- keyPlaceholder: "...",
- models: [
- { value: "mistral-large-latest", label: "Mistral Large" },
- { value: "magistral-medium-latest", label: "Magistral Medium" },
- { value: "codestral-latest", label: "Codestral" },
- { value: "mistral-medium-latest", label: "Mistral Medium" },
- { value: "mistral-small-latest", label: "Mistral Small" },
- ],
- },
- {
- provider: "xai",
- label: "xAI",
- logoUrl: "https://www.google.com/s2/favicons?domain=x.ai&sz=32",
- keyPlaceholder: "xai-...",
- models: [
- { value: "grok-4-0709", label: "Grok 4" },
- { value: "grok-3", label: "Grok 3" },
- { value: "grok-3-mini", label: "Grok 3 mini" },
- ],
- },
-];
+const MODEL_MAX_TOKENS: Record = {
+ "claude-opus-4-5": 200000,
+ "claude-sonnet-4-5": 200000,
+ "claude-haiku-3-5": 200000,
+ "gpt-4o": 128000,
+ "gpt-4o-mini": 128000,
+ "o1": 200000,
+ "o3": 200000,
+ "o4-mini": 200000,
+ "gemini-2.0-flash": 1048576,
+ "gemini-2.5-pro": 1048576,
+ "grok-3": 131072,
+ "llama-3.3-70b-versatile": 128000,
+};
+const DEFAULT_MAX_TOKENS = 128_000;
+
+function openSessionsDB(): Promise {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open("commandly", 2);
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains("keys")) {
+ db.createObjectStore("keys");
+ }
+ if (!db.objectStoreNames.contains("sessions")) {
+ const store = db.createObjectStore("sessions", { keyPath: "id" });
+ store.createIndex("toolName", "toolName", { unique: false });
+ }
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
-function providerForModel(model: string): AIProvider {
- if (model.startsWith("claude")) return "anthropic";
- if (model.startsWith("gemini")) return "google";
- if (model.startsWith("llama") || model.startsWith("meta-llama") || model.startsWith("qwen"))
- return "groq";
- if (model.startsWith("mistral") || model.startsWith("codestral") || model.startsWith("magistral"))
- return "mistral";
- if (model.startsWith("grok")) return "xai";
- return "openai";
+async function saveChatSession(session: ChatSession): Promise {
+ const db = await openSessionsDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction("sessions", "readwrite");
+ tx.objectStore("sessions").put(session);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+}
+
+async function loadRecentSessions(toolName: string, limit = 5): Promise {
+ const db = await openSessionsDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction("sessions", "readonly");
+ const index = tx.objectStore("sessions").index("toolName");
+ const req = index.getAll(toolName);
+ req.onsuccess = () => {
+ const all = (req.result as ChatSession[]).sort((a, b) => b.updatedAt - a.updatedAt);
+ resolve(all.slice(0, limit));
+ };
+ req.onerror = () => reject(req.error);
+ });
+}
+
+function formatRelativeTime(ts: number): string {
+ const diff = Date.now() - ts;
+ const mins = Math.floor(diff / 60_000);
+ if (mins < 1) return "Just now";
+ if (mins < 60) return `${mins}m ago`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ return `${days}d ago`;
+}
+
+function computeLineDiff(
+ a: string,
+ b: string,
+): Array<{ type: "same" | "add" | "remove"; text: string }> {
+ const aLines = a.split("\n");
+ const bLines = b.split("\n");
+ const m = aLines.length;
+ const n = bLines.length;
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ dp[i][j] =
+ aLines[i - 1] === bLines[j - 1]
+ ? dp[i - 1][j - 1] + 1
+ : Math.max(dp[i - 1][j], dp[i][j - 1]);
+ }
+ }
+ const result: Array<{ type: "same" | "add" | "remove"; text: string }> = [];
+ let i = m;
+ let j = n;
+ while (i > 0 || j > 0) {
+ if (i > 0 && j > 0 && aLines[i - 1] === bLines[j - 1]) {
+ result.unshift({ type: "same", text: aLines[i - 1] });
+ i--;
+ j--;
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
+ result.unshift({ type: "add", text: bLines[j - 1] });
+ j--;
+ } else {
+ result.unshift({ type: "remove", text: aLines[i - 1] });
+ i--;
+ }
+ }
+ return result;
}
+
function createModelInstance(provider: AIProvider, key: string, model: string) {
switch (provider) {
case "anthropic":
@@ -214,69 +266,62 @@ function createModelInstance(provider: AIProvider, key: string, model: string) {
}
}
-function tryParsePartialTool(json: string): Tool | null {
- if (!json.trim()) return null;
- try {
- const parsed = JSON.parse(json) as Tool;
- if (parsed?.name && Array.isArray(parsed?.commands) && Array.isArray(parsed?.parameters))
- return parsed;
- } catch {
- /* continue */
- }
-
- const lines = json.split("\n");
- let candidate = lines.slice(0, -1).join("\n").trimEnd().replace(/,\s*$/, "");
- if (!candidate) return null;
-
- const stack: string[] = [];
- let inStr = false;
- let escaped = false;
- for (const ch of candidate) {
- if (escaped) {
- escaped = false;
- continue;
- }
- if (ch === "\\" && inStr) {
- escaped = true;
- continue;
- }
- if (ch === '"') {
- inStr = !inStr;
- continue;
- }
- if (inStr) continue;
- if (ch === "{") stack.push("}");
- else if (ch === "[") stack.push("]");
- else if (ch === "}" || ch === "]") stack.pop();
- }
- candidate += stack.reverse().join("");
-
- try {
- const parsed = JSON.parse(candidate) as Tool;
- if (parsed?.name && Array.isArray(parsed?.commands) && Array.isArray(parsed?.parameters))
- return parsed;
- } catch {
- /* ignore */
- }
-
- return null;
-}
-
function useAIChat(
currentTool: Tool,
onApply: (tool: Tool) => void,
- context: { selectedCommandName?: string; selectedParameterName?: string },
onStreamingTool?: (tool: Tool | null) => void,
onGeneratingChange?: (isGenerating: boolean) => void,
) {
+ const { contextSelection } = useToolBuilder();
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
- const [model, setModel] = useState(MODEL_GROUPS[0].models[0].value);
+ const [modelInternal, setModelInternal] = useState(
+ () => localStorage.getItem("ai-model") ?? MODEL_GROUPS[0].models[0].value,
+ );
+ const [reasoningEffort, setReasoningEffortState] = useState(
+ () => (localStorage.getItem("ai-reasoning-effort") as ReasoningEffort | null),
+ );
const schemaRef = useRef(null);
const abortControllerRef = useRef(null);
- const toolSnapshotRef = useRef(null);
- const isToolAppliedRef = useRef(false);
+ const [pendingApproval, setPendingApproval] = useState<{
+ approvalId: string;
+ toolCallId: string;
+ continuationMessages: ModelMessage[];
+ previewTool: Tool;
+ originalTool: Tool;
+ summary: string;
+ messageIndex: number;
+ } | null>(null);
+ const [usage, setUsage] = useState(null);
+ const [toolCallCount, setToolCallCount] = useState(0);
+ const [recentSessions, setRecentSessions] = useState([]);
+ const sessionIdRef = useRef(crypto.randomUUID());
+
+ useEffect(() => {
+ loadRecentSessions(currentTool.name).then(setRecentSessions).catch(() => { });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [currentTool.name]);
+
+ const model = modelInternal;
+ const setModel = useCallback((m: string) => {
+ setModelInternal(m);
+ localStorage.setItem("ai-model", m);
+ const isReasoning = MODEL_GROUPS.flatMap((g) => g.models).find((mo) => mo.value === m)?.reasoning === true;
+ if (!isReasoning) {
+ setReasoningEffortState(null);
+ localStorage.removeItem("ai-reasoning-effort");
+ }
+ }, []);
+
+ const setReasoningEffort = useCallback((effort: ReasoningEffort | null) => {
+ setReasoningEffortState(effort);
+ if (effort === null) {
+ localStorage.removeItem("ai-reasoning-effort");
+ } else {
+ localStorage.setItem("ai-reasoning-effort", effort);
+ }
+ }, []);
const provider = providerForModel(model);
const openAIKeys = useAIKeys("openai");
@@ -303,103 +348,126 @@ function useAIChat(
.then((s) => {
schemaRef.current = s;
})
- .catch(() => {});
+ .catch(() => { });
}, []);
const runStream = useCallback(
async (userText: string, history: ChatMessage[]) => {
abortControllerRef.current = new AbortController();
- toolSnapshotRef.current = currentTool;
- isToolAppliedRef.current = false;
+ const toolSnapshot = currentTool;
+ let isToolApplied = false;
+ let pendingPreview: Tool | null = null;
+ let waitingForApproval = false;
try {
- const toolJson = JSON.stringify(exportToStructuredJSON(currentTool), null, 2);
const schema = schemaRef.current ? JSON.stringify(schemaRef.current, null, 2) : "{}";
+ const contextCommands = currentTool.commands.filter((c) =>
+ contextSelection.commandKeys.includes(c.key),
+ );
+ const contextParameters = currentTool.parameters.filter((p) =>
+ contextSelection.parameterKeys.includes(p.key),
+ );
const systemPrompt = generatePrompt(schema, {
- currentToolJson: toolJson,
context: {
- selectedCommand: context.selectedCommandName,
- selectedParameter: context.selectedParameterName,
+ selectedCommands: contextCommands.map((c) => ({ key: c.key, name: c.name })),
+ selectedParameters: contextParameters.map((p) => ({
+ key: p.key,
+ name: p.name,
+ longFlag: p.longFlag,
+ shortFlag: p.shortFlag,
+ })),
},
});
const aiModel = createModelInstance(provider, currentKeys.key, model);
const userMessage: ChatMessage = { role: "user", content: userText };
+ const assistantMessageIndex = history.length + 1;
+ const priorModelMessages: ModelMessage[] = [...history, userMessage].map((m) => ({
+ role: m.role as "user" | "assistant",
+ content: m.content,
+ }));
- const editToolDef = tool({
- description: "Apply the updated CLI tool definition to the editor.",
- inputSchema: z.object({
- name: z.string(),
- displayName: z.string().optional(),
- description: z.string().optional(),
- version: z.string().optional(),
- url: z.string().optional(),
- category: z.string().optional(),
- tags: z.array(z.string()).optional(),
- commands: z.array(z.any()),
- parameters: z.array(z.any()),
- }),
- execute: async (input) => {
- try {
- isToolAppliedRef.current = true;
- onApply(replaceKey(input as unknown as Tool) as Tool);
- return { success: true };
- } catch {
- return { success: false };
+ const editToolDef = createEditTool(
+ () => pendingPreview ?? toolSnapshot,
+ (t) => { pendingPreview = t; onStreamingTool?.(t); }
+ );
+
+ const applyToolDefinitionDef = createApplyToolDefinitionTool(
+ () => { isToolApplied = true; },
+ () => {
+ if (pendingPreview) {
+ onApply(replaceKey(pendingPreview) as Tool);
+ pendingPreview = null;
}
- },
- });
+ onStreamingTool?.(null);
+ }
+ );
- const webSearchTool =
- tavilyKeys.isSaved && tavilyKeys.key
- ? tool({
- description:
- "Search the web for CLI tool documentation, help text, or related information.",
- inputSchema: z.object({ query: z.string().describe("The search query") }),
- execute: async ({ query }: { query: string }) => {
- const resp = await fetch("https://api.tavily.com/search", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- api_key: tavilyKeys.key,
- query,
- search_depth: "basic",
- max_results: 5,
- }),
- });
- if (!resp.ok) throw new Error("Web search failed");
- const data = (await resp.json()) as {
- results?: { title: string; url: string; content: string }[];
- };
- return (
- data.results?.map((r) => ({
- title: r.title,
- url: r.url,
- content: r.content,
- })) ?? []
- );
- },
- })
- : undefined;
+ const readToolDef = createReadTool(() => pendingPreview ?? toolSnapshot);
+
+ const tavilySearchTool = tavilyKeys.isSaved && tavilyKeys.key
+ ? createTavilySearchTool(tavilyKeys.key)
+ : undefined;
+ const tavilyExtractTool = tavilyKeys.isSaved && tavilyKeys.key
+ ? createTavilyExtractTool(tavilyKeys.key)
+ : undefined;
const { fullStream } = streamText({
model: aiModel,
system: systemPrompt,
- messages: [...history, userMessage].map((m) => ({ role: m.role, content: m.content })),
+ messages: priorModelMessages,
tools: {
editTool: editToolDef,
- ...(webSearchTool ? { webSearch: webSearchTool } : {}),
+ applyToolDefinition: applyToolDefinitionDef,
+ readTool: readToolDef,
+ ...(tavilySearchTool ? { tavilySearch: tavilySearchTool } : {}),
+ ...(tavilyExtractTool ? { tavilyExtract: tavilyExtractTool } : {}),
},
- stopWhen: stepCountIs(5),
+ stopWhen: [
+ stepCountIs(20), // Maximum 20 steps
+ hasToolCall('applyToolDefinition'), // Stop after calling 'applyToolDefinition'
+ ],
abortSignal: abortControllerRef.current.signal,
+ onFinish: ({ usage: u }) => setUsage(u),
+ providerOptions: (reasoningEffort && MODEL_GROUPS.flatMap((g) => g.models).find((m) => m.value === model)?.reasoning === true
+ ? getProviderOptions(provider, model, reasoningEffort)
+ : undefined) as Record>> | undefined,
});
let fullText = "";
- let editToolCallId: string | null = null;
- let editToolBuffer = "";
+ let applyToolCallId: string | null = null;
+ let reasoningBuffer = "";
+ const continuationHistory: ModelMessage[] = [...priorModelMessages];
+ let currentStepText = "";
+ const currentStepCalls: Array<{ toolCallId: string; toolName: string; input: Record }> = [];
+ const resolvedResults = new Map();
+ const flushCompletedStep = () => {
+ const assistantContent = [
+ ...(currentStepText ? [{ type: "text" as const, text: currentStepText }] : []),
+ ...currentStepCalls.map((c) => ({ type: "tool-call" as const, toolCallId: c.toolCallId, toolName: c.toolName, input: c.input })),
+ ];
+ if (assistantContent.length > 0) {
+ continuationHistory.push({ role: "assistant", content: assistantContent } as ModelMessage);
+ }
+ if (currentStepCalls.length > 0) {
+ continuationHistory.push({
+ role: "tool",
+ content: currentStepCalls.map((c) => ({
+ type: "tool-result" as const,
+ toolCallId: c.toolCallId,
+ toolName: c.toolName,
+ output: resolvedResults.get(c.toolCallId),
+ })),
+ } as ModelMessage);
+ }
+ currentStepText = "";
+ currentStepCalls.length = 0;
+ resolvedResults.clear();
+ };
for await (const part of fullStream) {
if (part.type === "text-delta") {
fullText += part.text;
+ currentStepText += part.text;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = {
@@ -407,19 +475,64 @@ function useAIChat(
content: fullText,
toolCalls: updated[updated.length - 1].toolCalls,
toolApplied: updated[updated.length - 1].toolApplied,
+ reasoningContent: updated[updated.length - 1].reasoningContent,
+ };
+ return updated;
+ });
+ } else if (part.type === "reasoning-delta") {
+ reasoningBuffer += part.text;
+ setMessages((prev) => {
+ const updated = [...prev];
+ updated[updated.length - 1] = {
+ ...updated[updated.length - 1],
+ reasoningContent: reasoningBuffer,
};
return updated;
});
} else if (part.type === "tool-input-start" && part.toolName === "editTool") {
- editToolCallId = part.id;
- onGeneratingChange?.(true);
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
const entry: ToolCallEntry = {
toolCallId: part.id,
toolName: "editTool",
- title: "Applying changes",
+ title: "Editing…",
+ input: {},
+ state: "input-available",
+ };
+ updated[updated.length - 1] = {
+ ...last,
+ toolCalls: [...(last.toolCalls ?? []), entry],
+ };
+ return updated;
+ });
+ } else if (part.type === "tool-input-start" && part.toolName === "readTool") {
+ setMessages((prev) => {
+ const updated = [...prev];
+ const last = updated[updated.length - 1];
+ const entry: ToolCallEntry = {
+ toolCallId: part.id,
+ toolName: "readTool",
+ title: "Reading tool JSON…",
+ input: {},
+ state: "input-available",
+ };
+ updated[updated.length - 1] = {
+ ...last,
+ toolCalls: [...(last.toolCalls ?? []), entry],
+ };
+ return updated;
+ });
+ } else if (part.type === "tool-input-start" && part.toolName === "applyToolDefinition") {
+ applyToolCallId = part.id;
+ onGeneratingChange?.(true);
+ setMessages((prev) => {
+ const updated = [...prev];
+ const last = updated[updated.length - 1];
+ const entry: ToolCallEntry = {
+ toolCallId: part.id,
+ toolName: "applyToolDefinition",
+ title: "Preparing to apply",
input: {},
state: "input-available",
};
@@ -430,23 +543,17 @@ function useAIChat(
return updated;
});
} else if (
- part.type === "tool-input-delta" &&
- editToolCallId &&
- part.id === editToolCallId
+ part.type === "tool-input-start" &&
+ (part.toolName === "tavilySearch" || part.toolName === "tavilyExtract")
) {
- editToolBuffer += part.delta;
- const partial = tryParsePartialTool(editToolBuffer);
- if (partial) onStreamingTool?.(partial);
- } else if (part.type === "tool-call" && part.toolName === "webSearch") {
- const query = (part.input as { query: string }).query;
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
const entry: ToolCallEntry = {
- toolCallId: part.toolCallId,
- toolName: "webSearch",
- title: "Web Search",
- input: { query },
+ toolCallId: part.id,
+ toolName: part.toolName,
+ title: part.toolName === "tavilySearch" ? "Web Search" : "Extract Content",
+ input: {},
state: "input-available",
};
updated[updated.length - 1] = {
@@ -455,44 +562,168 @@ function useAIChat(
};
return updated;
});
- } else if (part.type === "tool-result" && part.toolName === "webSearch") {
+ } else if (part.type === "tool-call" && part.toolName !== "applyToolDefinition") {
+ currentStepCalls.push({ toolCallId: part.toolCallId, toolName: part.toolName, input: part.input as Record });
+ if (part.toolName === "editTool") {
+ const editInput = part.input as { summary?: string };
+ setMessages((prev) => {
+ const updated = [...prev];
+ const last = updated[updated.length - 1];
+ const toolCalls = (last.toolCalls ?? []).map((tc) =>
+ tc.toolCallId === part.toolCallId
+ ? { ...tc, title: editInput.summary ?? "Editing…", input: part.input as Record }
+ : tc,
+ );
+ updated[updated.length - 1] = { ...last, toolCalls };
+ return updated;
+ });
+ } else if (part.toolName === "tavilySearch" || part.toolName === "tavilyExtract") {
+ setMessages((prev) => {
+ const updated = [...prev];
+ const last = updated[updated.length - 1];
+ const toolCalls = (last.toolCalls ?? []).map((tc) =>
+ tc.toolCallId === part.toolCallId
+ ? { ...tc, input: part.input as Record }
+ : tc,
+ );
+ updated[updated.length - 1] = { ...last, toolCalls };
+ return updated;
+ });
+ }
+ } else if (part.type === "tool-result" && part.toolName === "editTool") {
+ setToolCallCount((c) => c + 1);
+ resolvedResults.set(part.toolCallId, part.output);
+ if (resolvedResults.size === currentStepCalls.length && currentStepCalls.length > 0) {
+ flushCompletedStep();
+ }
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
const toolCalls = (last.toolCalls ?? []).map((tc) =>
tc.toolCallId === part.toolCallId
- ? { ...tc, state: "output-available" as const, output: part.output }
+ ? { ...tc, state: "output-available" as const }
: tc,
);
updated[updated.length - 1] = { ...last, toolCalls };
return updated;
});
- } else if (part.type === "tool-result" && part.toolName === "editTool") {
+ } else if (part.type === "tool-result" && part.toolName === "readTool") {
+ setToolCallCount((c) => c + 1);
+ resolvedResults.set(part.toolCallId, part.output);
+ if (resolvedResults.size === currentStepCalls.length && currentStepCalls.length > 0) {
+ flushCompletedStep();
+ }
+ setMessages((prev) => {
+ const updated = [...prev];
+ const last = updated[updated.length - 1];
+ const toolCalls = (last.toolCalls ?? []).map((tc) =>
+ tc.toolCallId === part.toolCallId
+ ? { ...tc, state: "output-available" as const }
+ : tc,
+ );
+ updated[updated.length - 1] = { ...last, toolCalls };
+ return updated;
+ });
+ } else if (
+ part.type === "tool-approval-request" &&
+ part.toolCall?.toolName === "applyToolDefinition"
+ ) {
onGeneratingChange?.(false);
- onStreamingTool?.(null);
+ const input = (part.toolCall?.input ?? {}) as { summary: string };
+ const toolCallId = (part.toolCall as { toolCallId?: string } | undefined)?.toolCallId ?? part.approvalId;
+ const previewTool = pendingPreview ?? toolSnapshot;
+ const originalTool = toolSnapshot;
+
+ const finalStepContent = [
+ ...(currentStepText ? [{ type: "text" as const, text: currentStepText }] : []),
+ { type: "tool-call" as const, toolCallId, toolName: "applyToolDefinition", input: input as Record },
+ ];
+ continuationHistory.push({ role: "assistant", content: finalStepContent } as ModelMessage);
+
+ waitingForApproval = true;
+ onStreamingTool?.(previewTool);
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
const toolCalls = (last.toolCalls ?? []).map((tc) =>
- tc.toolCallId === editToolCallId
+ tc.toolCallId === applyToolCallId
+ ? { ...tc, state: "approval-requested" as const, originalTool, previewTool }
+ : tc,
+ );
+ updated[updated.length - 1] = { ...last, toolCalls };
+ return updated;
+ });
+ setPendingApproval({
+ approvalId: part.approvalId,
+ toolCallId,
+ continuationMessages: continuationHistory,
+ previewTool,
+ originalTool,
+ summary: input.summary,
+ messageIndex: assistantMessageIndex,
+ });
+ } else if (part.type === "tool-result" && part.toolName === "tavilySearch") {
+ setToolCallCount((c) => c + 1);
+ resolvedResults.set(part.toolCallId, part.output);
+ if (resolvedResults.size === currentStepCalls.length && currentStepCalls.length > 0) {
+ flushCompletedStep();
+ }
+ setMessages((prev) => {
+ const updated = [...prev];
+ const last = updated[updated.length - 1];
+ const toolCalls = (last.toolCalls ?? []).map((tc) =>
+ tc.toolCallId === part.toolCallId
? { ...tc, state: "output-available" as const, output: part.output }
: tc,
);
- updated[updated.length - 1] = { ...last, toolCalls, toolApplied: true };
+ updated[updated.length - 1] = { ...last, toolCalls };
+ return updated;
+ });
+ } else if (part.type === "tool-result" && part.toolName === "tavilyExtract") {
+ setToolCallCount((c) => c + 1);
+ resolvedResults.set(part.toolCallId, part.output);
+ if (resolvedResults.size === currentStepCalls.length && currentStepCalls.length > 0) {
+ flushCompletedStep();
+ }
+ setMessages((prev) => {
+ const updated = [...prev];
+ const last = updated[updated.length - 1];
+ const toolCalls = (last.toolCalls ?? []).map((tc) =>
+ tc.toolCallId === part.toolCallId
+ ? { ...tc, state: "output-available" as const, output: part.output }
+ : tc,
+ );
+ updated[updated.length - 1] = { ...last, toolCalls };
return updated;
});
}
}
onGeneratingChange?.(false);
- onStreamingTool?.(null);
+ if (!waitingForApproval) onStreamingTool?.(null);
+
+ setMessages((prev) => {
+ const messages = prev.filter(
+ (m) => m.role !== "assistant" || m.content !== "" || (m.toolCalls && m.toolCalls.length > 0),
+ );
+ const session: ChatSession = {
+ id: sessionIdRef.current,
+ toolName: currentTool.name,
+ messages,
+ updatedAt: Date.now(),
+ preview: (messages.find((m) => m.role === "user")?.content ?? "").slice(0, 80),
+ };
+ saveChatSession(session).catch(() => { });
+ return prev;
+ });
} catch (error) {
onGeneratingChange?.(false);
onStreamingTool?.(null);
+ setPendingApproval(null);
const isAbort = error instanceof Error && error.name === "AbortError";
if (isAbort) {
- if (isToolAppliedRef.current && toolSnapshotRef.current) {
- onApply(toolSnapshotRef.current);
+ if (isToolApplied) {
+ onApply(toolSnapshot);
}
} else {
toast.error(error instanceof Error ? error.message : "AI request failed");
@@ -504,10 +735,11 @@ function useAIChat(
},
[
currentTool,
- context,
+ contextSelection,
provider,
currentKeys.key,
model,
+ reasoningEffort,
tavilyKeys,
onApply,
onGeneratingChange,
@@ -535,6 +767,7 @@ function useAIChat(
{ role: "assistant", content: "" },
]);
setIsStreaming(true);
+ setToolCallCount(0);
await runStream(userText, history);
},
[input, isStreaming, model, currentKeys.key, messages, runStream],
@@ -547,6 +780,8 @@ function useAIChat(
const resendFromIndex = useCallback(
async (index: number, newContent: string) => {
if (isStreaming || !newContent.trim() || !model || !currentKeys.key) return;
+ setPendingApproval(null);
+ onStreamingTool?.(null);
const history = messages.slice(0, index);
setMessages([
...history,
@@ -556,13 +791,203 @@ function useAIChat(
setIsStreaming(true);
await runStream(newContent, history);
},
- [isStreaming, model, currentKeys.key, messages, runStream],
+ [isStreaming, model, currentKeys.key, messages, runStream, onStreamingTool],
);
const clearMessages = useCallback(() => {
- setMessages([]);
+ const sessionIdToSave = sessionIdRef.current;
+ setMessages((prev) => {
+ if (prev.length > 0) {
+ const messages = prev.filter(
+ (m) => m.role !== "assistant" || m.content !== "" || (m.toolCalls && m.toolCalls.length > 0),
+ );
+ if (messages.length > 0) {
+ const session: ChatSession = {
+ id: sessionIdToSave,
+ toolName: currentTool.name,
+ messages,
+ updatedAt: Date.now(),
+ preview: (messages.find((m) => m.role === "user")?.content ?? "").slice(0, 80),
+ };
+ saveChatSession(session)
+ .then(() => loadRecentSessions(currentTool.name))
+ .then(setRecentSessions)
+ .catch(() => { });
+ }
+ }
+ return [];
+ });
setInput("");
- }, []);
+ sessionIdRef.current = crypto.randomUUID();
+ setPendingApproval(null);
+ setToolCallCount(0);
+ onStreamingTool?.(null);
+ }, [currentTool.name, onStreamingTool]);
+
+ const runContinuation = useCallback(
+ async (messages: ModelMessage[], approvedMessageIndex: number, toolSnapshot: Tool) => {
+ if (!model || !currentKeys.key) return;
+
+ setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
+ setIsStreaming(true);
+ abortControllerRef.current = new AbortController();
+
+ try {
+ const systemPrompt = generatePrompt(schemaRef.current ? JSON.stringify(schemaRef.current, null, 2) : "{}");
+ const aiModel = createModelInstance(provider, currentKeys.key, model);
+ let pendingPreview: Tool | null = null;
+
+ const continuationEditTool = createEditTool(
+ () => pendingPreview ?? toolSnapshot,
+ (t) => { pendingPreview = t; onStreamingTool?.(t); }
+ );
+
+ const continuationApplyToolDefinition = createApplyToolDefinitionTool(
+ () => { },
+ () => {
+ if (pendingPreview) {
+ onApply(replaceKey(pendingPreview) as Tool);
+ pendingPreview = null;
+ }
+ onStreamingTool?.(null);
+ }
+ );
+
+ const tavilySearchTool = tavilyKeys.isSaved && tavilyKeys.key
+ ? createTavilySearchTool(tavilyKeys.key)
+ : undefined;
+
+ const continuationReadTool = createReadTool(() => pendingPreview ?? toolSnapshot);
+
+ const { fullStream } = streamText({
+ model: aiModel,
+ system: systemPrompt,
+ messages,
+ tools: {
+ editTool: continuationEditTool,
+ applyToolDefinition: continuationApplyToolDefinition,
+ readTool: continuationReadTool,
+ ...(tavilySearchTool ? { tavilySearch: tavilySearchTool } : {}),
+ },
+ stopWhen: [
+ stepCountIs(20), // Maximum 20 steps
+ hasToolCall('applyToolDefinition'), // Stop after calling 'applyToolDefinition'
+ ],
+ abortSignal: abortControllerRef.current.signal,
+ onFinish: ({ usage: u }) => setUsage(u),
+ providerOptions: (reasoningEffort && MODEL_GROUPS.flatMap((g) => g.models).find((m) => m.value === model)?.reasoning === true
+ ? getProviderOptions(provider, model, reasoningEffort)
+ : undefined) as Record>> | undefined,
+ });
+
+ let fullText = "";
+ let reasoningBuffer = "";
+
+ for await (const part of fullStream) {
+ if (part.type === "text-delta") {
+ fullText += part.text;
+ setMessages((prev) => {
+ const updated = [...prev];
+ updated[updated.length - 1] = { ...updated[updated.length - 1], content: fullText };
+ return updated;
+ });
+ } else if (part.type === "reasoning-delta") {
+ reasoningBuffer += part.text;
+ setMessages((prev) => {
+ const updated = [...prev];
+ updated[updated.length - 1] = { ...updated[updated.length - 1], reasoningContent: reasoningBuffer };
+ return updated;
+ });
+ } else if (part.type === "tool-result" && part.toolName === "applyToolDefinition") {
+ setToolCallCount((c) => c + 1);
+ setMessages((prev) => {
+ const updated = [...prev];
+ if (approvedMessageIndex < updated.length) {
+ const approvedMsg = updated[approvedMessageIndex];
+ const toolCalls = (approvedMsg.toolCalls ?? []).map((tc) =>
+ tc.toolName === "applyToolDefinition"
+ ? { ...tc, state: "output-available" as const }
+ : tc,
+ );
+ updated[approvedMessageIndex] = {
+ ...approvedMsg,
+ toolApplied: true,
+ toolCalls,
+ };
+ }
+ return updated;
+ });
+ }
+ }
+
+ onGeneratingChange?.(false);
+ onStreamingTool?.(null);
+
+ setMessages((prev) => {
+ const messages = prev.filter(
+ (m) => m.role !== "assistant" || m.content !== "" || (m.toolCalls && m.toolCalls.length > 0),
+ );
+ const session: ChatSession = {
+ id: sessionIdRef.current,
+ toolName: currentTool.name,
+ messages,
+ updatedAt: Date.now(),
+ preview: (messages.find((m) => m.role === "user")?.content ?? "").slice(0, 80),
+ };
+ saveChatSession(session).catch(() => { });
+ return prev;
+ });
+ } catch (error) {
+ const isAbort = error instanceof Error && error.name === "AbortError";
+ if (!isAbort) toast.error(error instanceof Error ? error.message : "AI request failed");
+ setMessages((prev) => prev.slice(0, -1));
+ onStreamingTool?.(null);
+ } finally {
+ setIsStreaming(false);
+ }
+ },
+ [currentTool, provider, currentKeys.key, model, reasoningEffort, tavilyKeys, onApply, onGeneratingChange, onStreamingTool],
+ );
+
+ const confirmPatch = useCallback(async () => {
+ if (!pendingApproval) return;
+ const { toolCallId, continuationMessages: priorMessages, previewTool, originalTool, messageIndex } = pendingApproval;
+
+ onApply(replaceKey(previewTool) as Tool);
+ onStreamingTool?.(null);
+
+ const continuationMessages: ModelMessage[] = [
+ ...priorMessages,
+ {
+ role: "tool",
+ content: [{ type: "tool-result", toolCallId, toolName: "applyToolDefinition", output: { type: "text", value: "Applied successfully. Provide a concise summary of all the changes you made to the tool." } }],
+ } as ModelMessage,
+ ];
+ setPendingApproval(null);
+ await runContinuation(continuationMessages, messageIndex, originalTool);
+ }, [pendingApproval, runContinuation, onApply, onStreamingTool]);
+
+ const rejectPatch = useCallback(async () => {
+ if (!pendingApproval) return;
+ const { toolCallId, continuationMessages: priorMessages, originalTool, messageIndex } = pendingApproval;
+ const continuationMessages: ModelMessage[] = [
+ ...priorMessages,
+ {
+ role: "tool",
+ content: [{ type: "tool-result", toolCallId, toolName: "applyToolDefinition", output: { type: "text", value: "User rejected the changes" } }],
+ } as ModelMessage,
+ ];
+ setPendingApproval(null);
+ onStreamingTool?.(null);
+ await runContinuation(continuationMessages, messageIndex, originalTool);
+ }, [pendingApproval, runContinuation, onStreamingTool]);
+
+ const loadSession = useCallback((session: ChatSession) => {
+ setMessages(session.messages);
+ sessionIdRef.current = session.id as `${string}-${string}-${string}-${string}-${string}`;
+ setPendingApproval(null);
+ onStreamingTool?.(null);
+ }, [onStreamingTool]);
return {
messages,
@@ -571,6 +996,8 @@ function useAIChat(
isStreaming,
model,
setModel,
+ reasoningEffort,
+ setReasoningEffort,
provider,
sendMessage,
stopStreaming,
@@ -578,9 +1005,153 @@ function useAIChat(
clearMessages,
allProviderKeys,
tavilyKeys,
+ pendingApproval,
+ confirmPatch,
+ rejectPatch,
+ usage,
+ toolCallCount,
+ recentSessions,
+ loadSession,
};
}
+function DiffView({ original, updated }: { original: Tool; updated: Tool }) {
+ const aJson = JSON.stringify(exportToStructuredJSON(original), null, 2);
+ const bJson = JSON.stringify(exportToStructuredJSON(updated), null, 2);
+ const diff = computeLineDiff(aJson, bJson);
+ const CONTEXT = 2;
+ const changedSet = new Set(diff.flatMap((d, idx) => (d.type !== "same" ? [idx] : [])));
+ const visibleSet = new Set();
+ for (const idx of changedSet) {
+ for (let k = Math.max(0, idx - CONTEXT); k <= Math.min(diff.length - 1, idx + CONTEXT); k++) {
+ visibleSet.add(k);
+ }
+ }
+ if (visibleSet.size === 0) {
+ return No changes
;
+ }
+ const sortedIndices = [...visibleSet].sort((a, b) => a - b);
+ const chunks: number[][] = [];
+ let current: number[] = [];
+ for (let k = 0; k < sortedIndices.length; k++) {
+ if (current.length === 0 || sortedIndices[k] === sortedIndices[k - 1] + 1) {
+ current.push(sortedIndices[k]);
+ } else {
+ chunks.push(current);
+ current = [sortedIndices[k]];
+ }
+ }
+ if (current.length > 0) chunks.push(current);
+ return (
+
+ {chunks.map((chunk, ci) => (
+
+ {ci > 0 && (
+
···
+ )}
+ {chunk.map((lineIdx) => {
+ const line = diff[lineIdx];
+ return (
+
+ {line.type === "add" ? "+ " : line.type === "remove" ? "- " : " "}
+ {line.text}
+
+ );
+ })}
+
+ ))}
+
+ );
+}
+
+interface WebSearchResult {
+ title: string;
+ url: string;
+ content: string;
+}
+
+function WebSearchResults({ results }: { results: WebSearchResult[] }) {
+ return (
+
+ );
+}
+
+interface ExtractResult {
+ url: string;
+ raw_content: string;
+ hasMore?: boolean;
+}
+
+function ExtractedContent({ results }: { results: ExtractResult[] }) {
+ return (
+
+ {results.map((r) => {
+ let hostname = r.url;
+ try {
+ hostname = new URL(r.url).hostname;
+ } catch { /* ignore */ }
+ return (
+
+
+
+ {r.raw_content}
+
+
+ );
+ })}
+
+ );
+}
+
interface MessagePartProps {
msg: ChatMessage;
index: number;
@@ -592,6 +1163,9 @@ interface MessagePartProps {
setEditingValue: (v: string) => void;
onResend: (index: number, content: string) => void;
onRetry: () => void;
+ pendingApproval: { approvalId: string; previewTool: Tool; originalTool: Tool; summary: string; messageIndex: number } | null;
+ onConfirmPatch: () => void;
+ onRejectPatch: () => void;
}
function MessagePart({
@@ -605,33 +1179,104 @@ function MessagePart({
setEditingValue,
onResend,
onRetry,
+ pendingApproval,
+ onConfirmPatch,
+ onRejectPatch,
}: MessagePartProps) {
+ const chainCalls = msg.toolCalls?.filter((tc) => tc.toolName !== "applyToolDefinition") ?? [];
+ const applyToolCall = msg.toolCalls?.find((tc) => tc.toolName === "applyToolDefinition");
+ const isPending = applyToolCall && pendingApproval?.messageIndex === index;
+
return (
- {msg.toolCalls?.map((tc) => (
-
+ {chainCalls.length > 0 && (
+
+
+
+ {chainCalls.map((tc) => (
+ {
+ const urls = (tc.input.urls as string[] | undefined) ?? [];
+ try { return `Extracted from ${new URL(urls[0]).hostname}`; } catch { return "Extracted content"; }
+ })()
+ : "Extracting content…"
+ : (tc.title ?? "Editing…")
+ }
+ status={tc.state === "output-available" ? "complete" : "active"}
+ >
+ {tc.toolName === "tavilySearch" && tc.state === "output-available" && (
+
+ )}
+ {tc.toolName === "tavilyExtract" && tc.state === "output-available" && (
+
+ )}
+
+ ))}
+
+
+ )}
+ {applyToolCall && (
+
- {tc.toolName !== "editTool" && (
-
-
- {tc.state === "output-available" && (
-
- )}
-
- )}
+
+ {(applyToolCall.originalTool && applyToolCall.previewTool) && (
+
+ )}
+ {isPending && (
+
+
{pendingApproval.summary}
+
+
+
+ Apply
+
+
+
+ Dismiss
+
+
+
+ )}
+
- ))}
+ )}
{msg.role === "user" ? (
editingIndex === index ? (
@@ -674,7 +1319,7 @@ function MessagePart({
) : (
<>
- {msg.content}
+ {msg.content}
{!isStreaming && (
+ {msg.reasoningContent && (
+
+
+ {msg.reasoningContent}
+
+ )}
{msg.content ? (
{msg.content}
- ) : (
+ ) : isStreaming && isLast && !applyToolCall ? (
Generating response...
- )}
+ ) : null}
{msg.content && !isStreaming && isLast && (
@@ -744,29 +1395,18 @@ export function AIChatPanel({
onStreamingTool,
onGeneratingChange,
}: AIChatPanelProps) {
- const { selectedCommand, selectedParameter } = useToolBuilder();
- const chat = useAIChat(
- tool,
- onApply,
- {
- selectedCommandName: selectedCommand?.name,
- selectedParameterName: selectedParameter?.name ?? undefined,
- },
- onStreamingTool,
- onGeneratingChange,
- );
- const [modelOpen, setModelOpen] = useState(false);
- const [selectedProvider, setSelectedProvider] = useState(chat.provider);
+ const { contextSelection, clearContextSelection, tool: currentTool } = useToolBuilder();
+ const chat = useAIChat(tool, onApply, onStreamingTool, onGeneratingChange);
const [editingIndex, setEditingIndex] = useState(null);
const [editingValue, setEditingValue] = useState("");
- const currentGroup = MODEL_GROUPS.find((g) => g.provider === chat.provider);
- const currentModelLabel =
- MODEL_GROUPS.flatMap((g) => g.models).find((m) => m.value === chat.model)?.label ?? chat.model;
- const selectedGroup =
- MODEL_GROUPS.find((g) => g.provider === selectedProvider) ?? MODEL_GROUPS[0];
- const selectedProviderKeys =
- chat.allProviderKeys[selectedProvider as Exclude];
+ const contextCommands = currentTool.commands.filter((c) =>
+ contextSelection.commandKeys.includes(c.key),
+ );
+ const contextParameters = currentTool.parameters.filter((p) =>
+ contextSelection.parameterKeys.includes(p.key),
+ );
+ const hasContextItems = contextCommands.length > 0 || contextParameters.length > 0;
return (
-
-
-
-
-
-
-
- API Keys
-
- {MODEL_GROUPS.map((g, gi) => {
- const keys = chat.allProviderKeys[g.provider as Exclude
];
- return (
-
- {gi > 0 &&
}
-
-
-
-
{g.label}
- {keys.isSaved &&
}
-
-
keys.setKey(e.target.value)}
- className="h-8 text-xs"
- placeholder={g.keyPlaceholder}
- />
-
-
- keys.setSaved(c === "indeterminate" ? false : c)
- }
- />
-
- Save key locally
-
-
-
-
- );
- })}
-
-
-
-
- Tavily (Web Search)
- {chat.tavilyKeys.isSaved && (
-
- )}
-
-
chat.tavilyKeys.setKey(e.target.value)}
- className="h-8 text-xs"
- placeholder="tvly-..."
- />
-
-
- chat.tavilyKeys.setSaved(c === "indeterminate" ? false : c)
- }
- />
-
- Save key locally
-
-
-
-
-
-
+
@@ -896,7 +1447,26 @@ export function AIChatPanel({
Describe what you want or ask to modify the tool
-
+ {chat.recentSessions.length > 0 && (
+
+
+ Recent sessions
+
+
+ {chat.recentSessions.map((session) => (
+ chat.loadSession(session)}
+ className="flex w-full items-start justify-between gap-2 rounded border border-border/40 bg-muted/30 px-3 py-2 text-left text-xs transition-colors hover:border-border/60 hover:bg-muted/50"
+ >
+ {session.preview || "Empty session"}
+ {formatRelativeTime(session.updatedAt)}
+
+ ))}
+
+
+ )}
+
{PROMPT_PILLS.map((pill) => (
chat.resendFromIndex(i - 1, chat.messages[i - 1]?.content ?? "")}
+ pendingApproval={chat.pendingApproval}
+ onConfirmPatch={chat.confirmPatch}
+ onRejectPatch={chat.rejectPatch}
/>
))
)}
@@ -931,127 +1504,108 @@ export function AIChatPanel({
-
-
);
diff --git a/src/components/tool-editor/api-key-settings.tsx b/src/components/tool-editor/api-key-settings.tsx
new file mode 100644
index 0000000..b4e5411
--- /dev/null
+++ b/src/components/tool-editor/api-key-settings.tsx
@@ -0,0 +1,144 @@
+import { MODEL_GROUPS } from "./model-picker";
+import { type AIProvider } from "@/lib/ai-keys";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Separator } from "@/components/ui/separator";
+import { CheckIcon, EyeIcon, EyeOffIcon, GlobeIcon, Settings2Icon } from "lucide-react";
+import { useState } from "react";
+
+interface AIKeyState {
+ key: string;
+ setKey: (key: string) => void;
+ isSaved: boolean;
+ setSaved: (saved: boolean) => void;
+}
+
+interface ApiKeySettingsProps {
+ allProviderKeys: Record
, AIKeyState>;
+ tavilyKeys: AIKeyState;
+}
+
+export function ApiKeySettings({ allProviderKeys, tavilyKeys }: ApiKeySettingsProps) {
+ const [revealed, setRevealed] = useState>({});
+
+ const toggleReveal = (key: string) =>
+ setRevealed((prev) => ({ ...prev, [key]: !prev[key] }));
+ return (
+
+
+
+
+
+
+
+ API Keys
+
+ {MODEL_GROUPS.map((g, gi) => {
+ const keys = allProviderKeys[g.provider as Exclude
];
+ return (
+
+ {gi > 0 &&
}
+
+
+
+
{g.label}
+ {keys.isSaved &&
}
+
+
+ keys.setKey(e.target.value)}
+ className="h-8 pr-8 text-xs"
+ placeholder={g.keyPlaceholder}
+ />
+ toggleReveal(g.provider)}
+ tabIndex={-1}
+ aria-label={revealed[g.provider] ? "Hide key" : "Reveal key"}
+ >
+ {revealed[g.provider]
+ ?
+ : }
+
+
+
+ keys.setSaved(c === "indeterminate" ? false : c)}
+ />
+
+ Save key locally
+
+
+
+
+ );
+ })}
+
+
+
+
+ Tavily (Web Search)
+ {tavilyKeys.isSaved && }
+
+
+ tavilyKeys.setKey(e.target.value)}
+ className="h-8 pr-8 text-xs"
+ placeholder="tvly-..."
+ />
+ toggleReveal("tavily")}
+ tabIndex={-1}
+ aria-label={revealed["tavily"] ? "Hide key" : "Reveal key"}
+ >
+ {revealed["tavily"]
+ ?
+ : }
+
+
+
+ tavilyKeys.setSaved(c === "indeterminate" ? false : c)}
+ />
+
+ Save key locally
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/tool-editor/command-tree.tsx b/src/components/tool-editor/command-tree.tsx
index c22a4f9..4871a9b 100644
--- a/src/components/tool-editor/command-tree.tsx
+++ b/src/components/tool-editor/command-tree.tsx
@@ -4,6 +4,7 @@ import { Command } from "@/components/commandly/types/flat";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
+import { cn } from "@/lib/utils";
import { ChevronDownIcon, ChevronRightIcon, Edit2Icon, PlusIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
@@ -13,9 +14,11 @@ interface CommandNodeProps {
allCommands: Command[];
toolName: string;
selectedCommandKey?: string;
+ contextCommandKeys: string[];
+ isChatOpen: boolean;
expandedCommands: Set;
onToggle: (commandKey: string) => void;
- onSelect: (command: Command) => void;
+ onSelect: (command: Command, e: React.MouseEvent) => void;
onEdit: (command: Command) => void;
onAddSubcommand: (parentKey?: string) => void;
onDelete: (commandKey: string) => void;
@@ -27,6 +30,8 @@ function CommandNode({
allCommands,
toolName,
selectedCommandKey,
+ contextCommandKeys,
+ isChatOpen,
expandedCommands,
onToggle,
onSelect,
@@ -38,16 +43,19 @@ function CommandNode({
const subcommands = allCommands.filter((cmd) => cmd.parentCommandKey === command.key);
const hasSubcommands = subcommands.length > 0;
const isSelected = selectedCommandKey === command.key;
+ const isContextSelected = contextCommandKeys.includes(command.key);
const isRoot = command.name === toolName;
return (
onSelect(command)}
+ onClick={(e) => onSelect(command, e)}
>
{hasSubcommands ? (
>(
new Set([tool.commands[0]?.key]),
@@ -151,6 +169,8 @@ export function CommandTree() {
const [dialogCommand, setDialogCommand] = useState(undefined);
const [pendingParentKey, setPendingParentKey] = useState(undefined);
+ const lastSelectedCommandIndexRef = { current: null as number | null };
+
const toggleExpanded = (commandKey: string) => {
setExpandedCommands((prev) => {
const newSet = new Set(prev);
@@ -175,6 +195,33 @@ export function CommandTree() {
setIsDialogOpen(true);
};
+ const handleSelect = (command: Command, e: React.MouseEvent) => {
+ e.stopPropagation();
+ const flatCommands = tool.commands;
+ const index = flatCommands.findIndex((c) => c.key === command.key);
+
+ if (e.ctrlKey || e.metaKey) {
+ const already = contextSelection.commandKeys.includes(command.key);
+ setContextSelection({
+ ...contextSelection,
+ commandKeys: already
+ ? contextSelection.commandKeys.filter((k) => k !== command.key)
+ : [...contextSelection.commandKeys, command.key],
+ });
+ } else if (e.shiftKey) {
+ const anchor = lastSelectedCommandIndexRef.current ?? index;
+ const from = Math.min(anchor, index);
+ const to = Math.max(anchor, index);
+ const rangeKeys = flatCommands.slice(from, to + 1).map((c) => c.key);
+ const merged = Array.from(new Set([...contextSelection.commandKeys, ...rangeKeys]));
+ setContextSelection({ ...contextSelection, commandKeys: merged });
+ } else {
+ setSelectedCommand(command);
+ setContextSelection({ commandKeys: [command.key], parameterKeys: [] });
+ lastSelectedCommandIndexRef.current = index;
+ }
+ };
+
const handleDialogSave = (savedCommand: Command) => {
if (!dialogCommand) {
addCommand(savedCommand);
@@ -204,9 +251,11 @@ export function CommandTree() {
allCommands={tool.commands}
toolName={tool.name}
selectedCommandKey={selectedCommand?.key}
+ contextCommandKeys={contextSelection.commandKeys}
+ isChatOpen={isChatOpen}
expandedCommands={expandedCommands}
onToggle={toggleExpanded}
- onSelect={setSelectedCommand}
+ onSelect={handleSelect}
onEdit={handleEdit}
onAddSubcommand={handleAddSubcommand}
onDelete={deleteCommand}
diff --git a/src/components/tool-editor/dialogs/command-dialog.tsx b/src/components/tool-editor/dialogs/command-dialog.tsx
index 88a0a56..1d4dc3c 100644
--- a/src/components/tool-editor/dialogs/command-dialog.tsx
+++ b/src/components/tool-editor/dialogs/command-dialog.tsx
@@ -104,7 +104,7 @@ export function CommandDialog({
}));
}}
/>
- Default Command
+ Default
void;
+ isSaved: boolean;
+ setSaved: (saved: boolean) => void;
+}
+
+export const MODEL_GROUPS: {
+ provider: AIProvider;
+ label: string;
+ logoUrl: string;
+ keyPlaceholder: string;
+ models: { value: string; label: string; reasoning?: true }[];
+}[] = [
+ {
+ provider: "openai",
+ label: "OpenAI",
+ logoUrl: "https://www.google.com/s2/favicons?domain=openai.com&sz=32",
+ keyPlaceholder: "sk-...",
+ models: [
+ { value: "gpt-5.4", label: "GPT-5.4", reasoning: true },
+ { value: "gpt-5.4-pro", label: "GPT-5.4 pro", reasoning: true },
+ { value: "gpt-5.4-mini", label: "GPT-5.4 mini", reasoning: true },
+ { value: "gpt-5.4-nano", label: "GPT-5.4 nano", reasoning: true },
+ { value: "gpt-5", label: "GPT-5", reasoning: true },
+ { value: "gpt-5-pro", label: "GPT-5 pro", reasoning: true },
+ { value: "gpt-5-mini", label: "GPT-5 mini", reasoning: true },
+ { value: "gpt-5-nano", label: "GPT-5 nano", reasoning: true },
+ { value: "gpt-5.2", label: "GPT-5.2", reasoning: true },
+ { value: "gpt-5.2-pro", label: "GPT-5.2 pro", reasoning: true },
+ { value: "gpt-5.1", label: "GPT-5.1", reasoning: true },
+ { value: "gpt-4.1", label: "GPT-4.1" },
+ { value: "gpt-4.1-mini", label: "GPT-4.1 mini" },
+ { value: "gpt-4.1-nano", label: "GPT-4.1 nano" },
+ { value: "o4-mini", label: "o4-mini", reasoning: true },
+ { value: "o3", label: "o3", reasoning: true },
+ { value: "o3-pro", label: "o3-pro", reasoning: true },
+ { value: "o1-pro", label: "o1-pro", reasoning: true },
+ { value: "gpt-5-codex", label: "GPT-5 Codex", reasoning: true },
+ { value: "gpt-5.3-codex", label: "GPT-5.3 Codex", reasoning: true },
+ { value: "gpt-5.2-codex", label: "GPT-5.2 Codex", reasoning: true },
+ { value: "gpt-5.1-codex", label: "GPT-5.1 Codex", reasoning: true },
+ { value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max", reasoning: true },
+ { value: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex mini", reasoning: true },
+ ],
+ },
+ {
+ provider: "anthropic",
+ label: "Anthropic",
+ logoUrl: "https://www.google.com/s2/favicons?domain=anthropic.com&sz=32",
+ keyPlaceholder: "sk-ant-...",
+ models: [
+ { value: "claude-opus-4-6", label: "Claude Opus 4.6", reasoning: true },
+ { value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", reasoning: true },
+ { value: "claude-haiku-4-5", label: "Claude Haiku 4.5" },
+ { value: "claude-opus-4-5", label: "Claude Opus 4.5", reasoning: true },
+ { value: "claude-sonnet-4-5", label: "Claude Sonnet 4.5", reasoning: true },
+ ],
+ },
+ {
+ provider: "google",
+ label: "Google",
+ logoUrl: "https://www.google.com/s2/favicons?domain=google.com&sz=32",
+ keyPlaceholder: "AIza...",
+ models: [
+ { value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro", reasoning: true },
+ { value: "gemini-3-flash-preview", label: "Gemini 3 Flash", reasoning: true },
+ { value: "gemini-2.5-pro", label: "Gemini 2.5 Pro", reasoning: true },
+ { value: "gemini-2.5-flash", label: "Gemini 2.5 Flash", reasoning: true },
+ { value: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" },
+ ],
+ },
+ {
+ provider: "groq",
+ label: "Groq",
+ logoUrl: "https://www.google.com/s2/favicons?domain=groq.com&sz=32",
+ keyPlaceholder: "gsk_...",
+ models: [
+ { value: "meta-llama/llama-4-maverick-17b-128e-instruct", label: "Llama 4 Maverick" },
+ { value: "meta-llama/llama-4-scout-17b-16e-instruct", label: "Llama 4 Scout" },
+ { value: "qwen/qwen3-32b", label: "Qwen 3 32B" },
+ { value: "llama-3.3-70b-versatile", label: "Llama 3.3 70B" },
+ { value: "llama-3.1-8b-instant", label: "Llama 3.1 8B" },
+ ],
+ },
+ {
+ provider: "mistral",
+ label: "Mistral",
+ logoUrl: "https://www.google.com/s2/favicons?domain=mistral.ai&sz=32",
+ keyPlaceholder: "...",
+ models: [
+ { value: "mistral-large-latest", label: "Mistral Large" },
+ { value: "magistral-medium-latest", label: "Magistral Medium" },
+ { value: "codestral-latest", label: "Codestral" },
+ { value: "mistral-medium-latest", label: "Mistral Medium" },
+ { value: "mistral-small-latest", label: "Mistral Small" },
+ ],
+ },
+ {
+ provider: "xai",
+ label: "xAI",
+ logoUrl: "https://www.google.com/s2/favicons?domain=x.ai&sz=32",
+ keyPlaceholder: "xai-...",
+ models: [
+ { value: "grok-4-0709", label: "Grok 4" },
+ { value: "grok-3", label: "Grok 3" },
+ { value: "grok-3-mini", label: "Grok 3 mini" },
+ ],
+ },
+];
+
+export function providerForModel(model: string): AIProvider {
+ if (model.startsWith("claude")) return "anthropic";
+ if (model.startsWith("gemini")) return "google";
+ if (
+ model.startsWith("llama") ||
+ model.startsWith("meta-llama") ||
+ model.startsWith("qwen")
+ )
+ return "groq";
+ if (
+ model.startsWith("mistral") ||
+ model.startsWith("codestral") ||
+ model.startsWith("magistral")
+ )
+ return "mistral";
+ if (model.startsWith("grok")) return "xai";
+ return "openai";
+}
+
+export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high";
+
+export function getProviderOptions(
+ provider: AIProvider,
+ model: string,
+ effort: ReasoningEffort,
+): Record {
+ if (provider === "openai") {
+ if (effort === "none") return {};
+ const effortMap = { minimal: "low", low: "low", medium: "medium", high: "high" } as const;
+ return { openai: { reasoningEffort: effortMap[effort] } };
+ }
+ if (provider === "anthropic") {
+ if (effort === "none") return { anthropic: { thinking: { type: "disabled" } } };
+ const budgetTokens = { minimal: 256, low: 1024, medium: 5000, high: 16000 }[effort];
+ return { anthropic: { thinking: { type: "enabled", budgetTokens } } };
+ }
+ if (provider === "google") {
+ if (effort === "none") return { google: { thinkingConfig: { thinkingBudget: 0 } } };
+ const thinkingBudget = { minimal: 128, low: 512, medium: 4096, high: 16000 }[effort];
+ return { google: { thinkingConfig: { thinkingBudget } } };
+ }
+ return {};
+}
+
+interface ModelPickerProps {
+ model: string;
+ setModel: (m: string) => void;
+ provider: AIProvider;
+ allProviderKeys: Record, AIKeyState>;
+}
+
+export function ModelPicker({ model, setModel, provider, allProviderKeys }: ModelPickerProps) {
+ const [open, setOpen] = useState(false);
+ const [selectedProvider, setSelectedProvider] = useState(provider);
+
+ const currentGroup = MODEL_GROUPS.find((g) => g.provider === provider);
+ const currentModelLabel =
+ MODEL_GROUPS.flatMap((g) => g.models).find((m) => m.value === model)?.label ?? model;
+ const selectedGroup =
+ MODEL_GROUPS.find((g) => g.provider === selectedProvider) ?? MODEL_GROUPS[0];
+ const selectedProviderKeys = allProviderKeys[selectedProvider as Exclude];
+
+ return (
+ {
+ setOpen(o);
+ if (o) setSelectedProvider(provider);
+ }}
+ >
+
+
+
+ {currentGroup && (
+
+ )}
+ {currentModelLabel}
+
+
+
+
+
+
+
+ {MODEL_GROUPS.map((g) => {
+ const isKeyConfigured =
+ allProviderKeys[g.provider as Exclude
]?.isSaved;
+ return (
+ setSelectedProvider(g.provider)}
+ title={`${g.label}${!isKeyConfigured ? " (no API key)" : ""}`}
+ className={cn(
+ "flex h-9 w-10 items-center justify-center rounded border border-transparent transition-all",
+ selectedProvider === g.provider
+ ? "border-border bg-accent"
+ : "opacity-50 hover:bg-muted hover:opacity-80",
+ )}
+ >
+
+
+ );
+ })}
+
+
+
+ {selectedGroup.label}
+
+ {!selectedProviderKeys?.isSaved && (
+
No API key configured
+ )}
+
+ {selectedGroup.models.map((m) => {
+ const isKeyConfigured = selectedProviderKeys?.isSaved;
+ return (
+ {
+ setModel(m.value);
+ setOpen(false);
+ }}
+ className={cn(
+ "flex w-full items-center justify-between rounded px-2 py-2 text-xs transition-colors",
+ isKeyConfigured
+ ? "cursor-pointer hover:bg-accent hover:text-accent-foreground"
+ : "cursor-not-allowed opacity-40",
+ model === m.value && "bg-accent text-accent-foreground",
+ )}
+ >
+ {m.label}
+ {model === m.value && }
+
+ );
+ })}
+
+
+
+
+
+ );
+}
+
+const EFFORT_OPTIONS: { value: ReasoningEffort; label: string }[] = [
+ { value: "none", label: "Off" },
+ { value: "minimal", label: "Min" },
+ { value: "low", label: "Low" },
+ { value: "medium", label: "Med" },
+ { value: "high", label: "High" },
+];
+
+interface ReasoningEffortPickerProps {
+ model: string;
+ effort: ReasoningEffort | null;
+ onEffortChange: (e: ReasoningEffort | null) => void;
+}
+
+export function ReasoningEffortPicker({
+ model,
+ effort,
+ onEffortChange,
+}: ReasoningEffortPickerProps) {
+ const [open, setOpen] = useState(false);
+
+ const isReasoningModel = MODEL_GROUPS.flatMap((g) => g.models).find((m) => m.value === model)?.reasoning === true;
+ if (!isReasoningModel) return null;
+
+ const current = EFFORT_OPTIONS.find((o) => o.value === effort);
+
+ return (
+
+
+
+ Think:
+ {current?.label ?? "Off"}
+
+
+
+
+ {EFFORT_OPTIONS.map(({ value, label }) => (
+ {
+ onEffortChange(value);
+ setOpen(false);
+ }}
+ className={cn(
+ "flex w-full items-center justify-between rounded px-2 py-1.5 text-xs transition-colors hover:bg-accent hover:text-accent-foreground",
+ effort === value && "bg-accent text-accent-foreground",
+ )}
+ >
+ {label}
+ {effort === value && }
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/tool-editor/parameter-list.tsx b/src/components/tool-editor/parameter-list.tsx
index 43a1aa9..dc29621 100644
--- a/src/components/tool-editor/parameter-list.tsx
+++ b/src/components/tool-editor/parameter-list.tsx
@@ -3,7 +3,9 @@ import { ExclusionGroup, ParameterType } from "@/components/commandly/types/flat
import { createNewParameter } from "@/components/commandly/utils/flat";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
import {
+ Edit2Icon,
FileTextIcon,
FlagIcon,
GlobeIcon,
@@ -12,10 +14,13 @@ import {
PlusIcon,
Trash2Icon,
} from "lucide-react";
+import { useRef } from "react";
interface ParameterListProps {
title: string;
isGlobal?: boolean;
+ isChatOpen?: boolean;
+ pendingChanges?: { updated: Set; added: Set; removed: Set };
}
function ParameterIcon({ type }: { type: ParameterType }) {
@@ -31,16 +36,20 @@ function ParameterIcon({ type }: { type: ParameterType }) {
}
}
-export function ParameterList({ title, isGlobal = false }: ParameterListProps) {
+export function ParameterList({ title, isGlobal = false, isChatOpen = false, pendingChanges }: ParameterListProps) {
const {
selectedCommand,
+ contextSelection,
getGlobalParameters,
getParametersForCommand,
getExclusionGroupsForCommand,
setSelectedParameter,
+ setContextSelection,
removeParameter,
} = useToolBuilder();
+ const lastSelectedIndexRef = useRef(null);
+
const globalParameters = getGlobalParameters();
const commandParameters = selectedCommand?.key
? getParametersForCommand(selectedCommand.key)
@@ -51,10 +60,49 @@ export function ParameterList({ title, isGlobal = false }: ParameterListProps) {
const parameters = isGlobal ? globalParameters : commandParameters;
+ const removedParameters = isGlobal
+ ? (pendingChanges
+ ? [...pendingChanges.removed].filter((k) =>
+ !parameters.some((p) => p.key === k),
+ )
+ : [])
+ : (pendingChanges
+ ? [...pendingChanges.removed].filter(
+ (k) =>
+ !parameters.some((p) => p.key === k) &&
+ !globalParameters.some((p) => p.key === k),
+ )
+ : []);
+
const getParameterExclusionGroups = (parameterKey: string): ExclusionGroup[] => {
return exclusionGroups.filter((group) => group.parameterKeys.includes(parameterKey));
};
+ const handleParameterClick = (e: React.MouseEvent, paramKey: string, index: number) => {
+ e.stopPropagation();
+
+ if (e.ctrlKey || e.metaKey) {
+ const already = contextSelection.parameterKeys.includes(paramKey);
+ setContextSelection({
+ ...contextSelection,
+ parameterKeys: already
+ ? contextSelection.parameterKeys.filter((k) => k !== paramKey)
+ : [...contextSelection.parameterKeys, paramKey],
+ });
+ lastSelectedIndexRef.current = index;
+ } else if (e.shiftKey) {
+ const anchor = lastSelectedIndexRef.current ?? index;
+ const from = Math.min(anchor, index);
+ const to = Math.max(anchor, index);
+ const rangeKeys = parameters.slice(from, to + 1).map((p) => p.key);
+ const merged = Array.from(new Set([...contextSelection.parameterKeys, ...rangeKeys]));
+ setContextSelection({ ...contextSelection, parameterKeys: merged });
+ } else {
+ setContextSelection({ commandKeys: [], parameterKeys: [paramKey] });
+ lastSelectedIndexRef.current = index;
+ }
+ };
+
return (
@@ -71,14 +119,26 @@ export function ParameterList({ title, isGlobal = false }: ParameterListProps) {
- {parameters.map((parameter) => {
+ {parameters.map((parameter, index) => {
const paramGroups = getParameterExclusionGroups(parameter.key);
+ const isContextSelected = contextSelection.parameterKeys.includes(parameter.key);
+ const isAdded = pendingChanges?.added.has(parameter.key);
+ const isUpdated = pendingChanges?.updated.has(parameter.key);
return (
setSelectedParameter(parameter)}
+ className={cn(
+ "group cursor-pointer rounded border p-3 hover:bg-muted/50",
+ isAdded && "border-l-2 border-l-green-500",
+ isUpdated && "border-l-2 border-l-amber-500",
+ !isAdded && !isUpdated && isChatOpen && isContextSelected
+ ? "border-primary bg-accent/30 ring-1 ring-primary"
+ : !isAdded && !isUpdated
+ ? "border-muted"
+ : "",
+ )}
+ onClick={(e) => handleParameterClick(e, parameter.key, index)}
>
@@ -92,17 +152,30 @@ export function ParameterList({ title, isGlobal = false }: ParameterListProps) {
)}
-
{
- e.stopPropagation();
- removeParameter(parameter.key);
- }}
- >
-
-
+
+ {
+ e.stopPropagation();
+ setSelectedParameter(parameter);
+ }}
+ >
+
+
+ {
+ e.stopPropagation();
+ removeParameter(parameter.key);
+ }}
+ >
+
+
+
{parameter.isRequired && (
@@ -143,10 +216,42 @@ export function ParameterList({ title, isGlobal = false }: ParameterListProps) {
{group.name}
))}
+ {isAdded && (
+
+ Added
+
+ )}
+ {isUpdated && (
+
+ Updated
+
+ )}
);
})}
+ {removedParameters.map((key) => (
+
+
+ {key}
+
+ Removed
+
+
+
+ ))}
);
diff --git a/src/components/tool-editor/preview-tabs.tsx b/src/components/tool-editor/preview-tabs.tsx
index d6e988c..527e2eb 100644
--- a/src/components/tool-editor/preview-tabs.tsx
+++ b/src/components/tool-editor/preview-tabs.tsx
@@ -20,7 +20,7 @@ interface PreviewTabsProps {
export function PreviewTabs({ onSaveCommand, streamingTool, isAIGenerating }: PreviewTabsProps) {
const [currentTab, setActiveTab] = useState("ui");
- const { selectedCommand, tool, parameterValues, setParameterValue, initializeTool } =
+ const { selectedCommand, tool, originalTool, parameterValues, setParameterValue, initializeTool } =
useToolBuilder();
const displayTool = streamingTool ?? tool;
@@ -56,13 +56,17 @@ export function PreviewTabs({ onSaveCommand, streamingTool, isAIGenerating }: Pr
-
+
>
) : (
)}
diff --git a/src/components/tool-editor/prompt.ts b/src/components/tool-editor/prompt.ts
index 64bb73f..f5f6240 100644
--- a/src/components/tool-editor/prompt.ts
+++ b/src/components/tool-editor/prompt.ts
@@ -2,34 +2,32 @@ export const generatePrompt = (
jsonSchema: string,
options?: {
helpText?: string;
- currentToolJson?: string;
- context?: { selectedCommand?: string; selectedParameter?: string };
+ context?: {
+ selectedCommands?: { key: string; name: string }[];
+ selectedParameters?: { key: string; name: string; longFlag?: string; shortFlag?: string }[];
+ };
},
) => {
- const hasCurrentTool = !!options?.currentToolJson;
- const hasHelpText = !!options?.helpText;
-
- const contextBlock =
- options?.context?.selectedCommand || options?.context?.selectedParameter
- ? `\n
\n${options.context.selectedCommand ? `Selected command: ${options.context.selectedCommand}` : ""}${options.context.selectedParameter ? `\nSelected parameter: ${options.context.selectedParameter}` : ""}\n \n`
- : "";
-
- const currentToolBlock = hasCurrentTool
- ? `\n
\n${options?.currentToolJson}\n \n`
+ const selectedCommands = options?.context?.selectedCommands ?? [];
+ const selectedParameters = options?.context?.selectedParameters ?? [];
+ const hasFocusedContext = selectedCommands.length > 0 || selectedParameters.length > 0;
+ const focusedContextBlock = hasFocusedContext
+ ? `\n
\nIMPORTANT: Focus your changes EXCLUSIVELY on the items listed below. Do not modify any other commands or parameters — preserve them exactly as-is.\n${
+ selectedCommands.length > 0
+ ? `Commands:\n${selectedCommands.map((c) => ` - ${c.name} (key: ${c.key})`).join("\n")}\n`
+ : ""
+ }${
+ selectedParameters.length > 0
+ ? `Parameters:\n${selectedParameters.map((p) => ` - ${p.name}${p.longFlag ? ` (${p.longFlag})` : p.shortFlag ? ` (${p.shortFlag})` : ""} (key: ${p.key})`).join("\n")}\n`
+ : ""
+ } \n`
: "";
-
- const helpTextBlock = hasHelpText ? `\n
\n${options?.helpText}\n \n` : "";
-
return `You are **CommandlyAssistant**, an expert AI assistant for building, editing, and parsing CLI tool definitions in the Commandly visual command-builder.
-${hasCurrentTool ? "
\nYou are helping the user modify an existing CLI tool definition.\n " : ""}
-${hasHelpText ? "
\nYou are parsing raw CLI help text and converting it to a structured JSON definition.\n " : ""}
-
+${focusedContextBlock}
${jsonSchema}
-${currentToolBlock}
-${contextBlock}
1. **flag** - Boolean switches that take no value (\`--verbose\`, \`-h\`).
2. **option** - Key-value pairs (\`--name value\`, \`-n=value\`). Always require a value.
@@ -69,24 +67,39 @@ ${contextBlock}
You can:
-1. Parse CLI help text and produce a complete tool JSON from scratch.
-2. Modify an existing tool definition by calling the \`editTool\` function with the complete updated tool object.
-3. Answer questions about CLI tool structure.
-4. Search the web for CLI documentation when needed.
+1. Read the current tool JSON using \`readTool\` — always call this first before making any edits to understand the current structure. For large tools, use the \`fields\` parameter to read only specific sections (e.g. \`["parameters"]\` or \`["commands"]\`).
+2. Parse CLI help text and produce a complete tool JSON from scratch.
+3. Modify an existing tool definition incrementally using \`editTool\` — can be called multiple times for separate logical groups of changes.
+4. Call \`applyToolDefinition\` exactly once when all edits are complete to present the final changes to the user for approval. This is always the last tool call — do not call any other tool after it.
+5. Answer questions about CLI tool structure.
+6. Search the web for CLI documentation when needed. For large pages, use \`startOffset\` and \`maxChars\` parameters on \`tavilyExtract\` to read content in chunks.
-- To apply changes to the tool, call the \`editTool\` function with the complete updated tool object. Do NOT output JSON in a code fence for modifications.
-- When modifying an existing tool, make only the requested changes. Preserve all other fields, keys, and structure exactly as-is — including validations, exclusionGroups, dependencies, enum, tags, and any other existing data.
-- Do not add empty arrays or objects for optional properties that have no values (e.g. do not include \`"validations": []\`, \`"exclusionGroups": []\`, \`"tags": []\`, \`"dependencies": []\`, or \`"enum": { "values": [] }\` unless explicitly requested or they already exist).
-- After calling editTool, write a brief plain-text explanation of what was changed.
+- Always call \`readTool\` first to inspect the current tool before making any changes.
+- Use \`editTool\` to apply incremental JSON merge patches (RFC 7396). Only include the fields that changed. When modifying arrays (parameters, commands), include the complete updated array.
+- You may call \`editTool\` multiple times for separate logical groups of changes. After a batch of edits, call \`readTool\` to verify the result before continuing.
+- Always include a concise \`summary\` on each \`editTool\` call describing what that specific edit changes.
+- When modifying an existing tool, preserve all other fields, keys, and structure exactly as-is — including validations, exclusionGroups, dependencies, enum, tags, and any other existing data.
+- Do not add empty arrays or objects for optional properties (e.g. do not include \`"validations": []\`, \`"exclusionGroups": []\`, \`"tags": []\`, \`"dependencies": []\`, or \`"enum": { "values": [] }\` unless already present).
+- After all edits are complete and verified with \`readTool\`, call \`applyToolDefinition\` once with an overall summary of all changes. Do NOT call any other tool after \`applyToolDefinition\`.
+- For large help text pages: process in sections — read a chunk using \`startOffset\` + \`maxChars\`, apply the relevant \`editTool\` patch, then continue with the next chunk. Do not try to process everything at once.
- If the user asks a question without requesting changes, answer in plain text without calling any tool.
-- All parameter keys must be unique values. It should be meaningful and derived from the parameter name or description.
+- All parameter keys must be unique. They should be meaningful and derived from the parameter name or description.
- All descriptions should be in sentence case.
- If a parameter is not global, it must have a commandKey. Global parameters must not have a commandKey.
- Do not add fields not present in the schema.
-- When parsing help text: Produce only the final JSON object. It must be syntactically valid, conform exactly to the schema, and nothing else.
+- When parsing help text: produce the JSON via \`editTool\` patches. Each patch must be syntactically valid and conform exactly to the schema.
-${helpTextBlock}
+
+
+- Get the help text or documentation for the CLI tool that you want to parse or edit.
+- Call \`readTool\` to inspect the current tool JSON structure before making any changes. Use the \`fields\` parameter if the tool is large to read only specific sections.
+- If parsing from help text, break the text into logical sections (e.g. global parameters, subcommands, command-specific parameters) and create a separate \`editTool\` patch for each section. Always include a concise \`summary\` describing what that patch changes.
+- When modifying an existing tool, make sure to preserve all other fields, keys, and structure exactly as-is in your patches. Only include the fields that changed.
+- After applying each \`editTool\` patch, call \`readTool\` again to verify that the changes were applied correctly and that no unintended modifications were made.
+- Once all edits are complete and verified, call \`applyToolDefinition\` once with an overall summary of all changes to present them to the user for approval.
+- At last generate a summary of all changes made to the tool definition, highlighting any new commands, parameters, or structural changes.
+
`;
};
diff --git a/src/components/tool-editor/tool-editor.context.tsx b/src/components/tool-editor/tool-editor.context.tsx
index d04f181..1d55a4f 100644
--- a/src/components/tool-editor/tool-editor.context.tsx
+++ b/src/components/tool-editor/tool-editor.context.tsx
@@ -17,10 +17,17 @@ import {
} from "react";
import { toast } from "sonner";
+export interface ContextSelection {
+ commandKeys: string[];
+ parameterKeys: string[];
+}
+
export interface ToolBuilderState {
tool: Tool;
+ originalTool: Tool;
selectedCommand: Command;
selectedParameter: Parameter | null;
+ contextSelection: ContextSelection;
parameterValues: Record;
dialogs: {
parameterDetails: boolean;
@@ -42,6 +49,8 @@ type Action =
| { type: "SET_DIALOG_OPEN"; payload: { dialog: DialogKey; open: boolean } }
| { type: "SET_SELECTED_COMMAND"; payload: Command }
| { type: "SET_SELECTED_PARAMETER"; payload: Parameter | null }
+ | { type: "SET_CONTEXT_SELECTION"; payload: ContextSelection }
+ | { type: "CLEAR_CONTEXT_SELECTION" }
| { type: "UPSERT_PARAMETER"; payload: Parameter & { originalKey?: string } }
| { type: "ADD_EXCLUSION_GROUP"; payload: ExclusionGroup }
| { type: "UPDATE_EXCLUSION_GROUP"; payload: ExclusionGroup }
@@ -49,10 +58,13 @@ type Action =
| { type: "SET_PARAMETER_VALUE"; payload: { key: string; value: ParameterValue } };
function getDefaultState(tool: Tool): ToolBuilderState {
+ const cleanTool = cleanupTool(tool);
return {
- tool,
+ tool: cleanTool,
+ originalTool: cleanTool,
selectedCommand: tool.commands[0] ?? ({} as Command),
selectedParameter: null,
+ contextSelection: { commandKeys: [], parameterKeys: [] },
parameterValues: {},
dialogs: {
parameterDetails: false,
@@ -66,7 +78,7 @@ function getDefaultState(tool: Tool): ToolBuilderState {
function toolBuilderReducer(state: ToolBuilderState, action: Action): ToolBuilderState {
switch (action.type) {
case "INITIALIZE_TOOL":
- return getDefaultState(cleanupTool(action.payload));
+ return getDefaultState(action.payload);
case "UPDATE_TOOL":
return { ...state, tool: cleanupTool({ ...state.tool, ...action.payload }) };
@@ -93,6 +105,12 @@ function toolBuilderReducer(state: ToolBuilderState, action: Action): ToolBuilde
(group) => !commandsToDelete.includes(group.commandKey || ""),
),
},
+ contextSelection: {
+ ...state.contextSelection,
+ commandKeys: state.contextSelection.commandKeys.filter(
+ (k) => !commandsToDelete.includes(k),
+ ),
+ },
selectedCommand:
state.selectedCommand?.key === action.payload ? newCommands[0] : state.selectedCommand,
};
@@ -122,6 +140,10 @@ function toolBuilderReducer(state: ToolBuilderState, action: Action): ToolBuilde
parameterKeys: group.parameterKeys.filter((key) => key !== action.payload),
})),
},
+ contextSelection: {
+ ...state.contextSelection,
+ parameterKeys: state.contextSelection.parameterKeys.filter((k) => k !== action.payload),
+ },
};
if (state.selectedParameter?.key === action.payload) next.selectedParameter = null;
return next;
@@ -202,6 +224,12 @@ function toolBuilderReducer(state: ToolBuilderState, action: Action): ToolBuilde
parameterValues: { ...state.parameterValues, [action.payload.key]: action.payload.value },
};
+ case "SET_CONTEXT_SELECTION":
+ return { ...state, contextSelection: action.payload };
+
+ case "CLEAR_CONTEXT_SELECTION":
+ return { ...state, contextSelection: { commandKeys: [], parameterKeys: [] } };
+
default:
return state;
}
@@ -220,6 +248,8 @@ interface ToolBuilderContextValue extends ToolBuilderState {
setDialogOpen: (dialog: DialogKey, open: boolean) => void;
setSelectedCommand: (command: Command) => void;
setSelectedParameter: (parameter: Parameter | null) => void;
+ setContextSelection: (selection: ContextSelection) => void;
+ clearContextSelection: () => void;
upsertParameter: (parameter: Parameter, originalKey?: string) => void;
setParameterValue: (key: string, value: ParameterValue) => void;
getParametersForCommand: (commandKey: string) => Parameter[];
@@ -309,6 +339,11 @@ export function ToolBuilderProvider({ tool, children, initialState }: ToolBuilde
setSelectedParameter: (parameter: Parameter | null) =>
dispatch({ type: "SET_SELECTED_PARAMETER", payload: parameter }),
+ setContextSelection: (selection: ContextSelection) =>
+ dispatch({ type: "SET_CONTEXT_SELECTION", payload: selection }),
+
+ clearContextSelection: () => dispatch({ type: "CLEAR_CONTEXT_SELECTION" }),
+
upsertParameter: (parameter: Parameter, originalKey?: string) => {
const matchKey = originalKey || parameter.key;
const exists = state.tool.parameters.some((p) => p.key === matchKey);
diff --git a/src/components/tool-editor/tool-editor.tsx b/src/components/tool-editor/tool-editor.tsx
index 3a707b1..a332e90 100644
--- a/src/components/tool-editor/tool-editor.tsx
+++ b/src/components/tool-editor/tool-editor.tsx
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SavedCommand } from "@/lib/types";
import { SaveIcon, Edit2Icon, LayersIcon, GitPullRequestIcon, SparklesIcon } from "lucide-react";
+import { parseAsBoolean, useQueryState } from "nuqs";
import { useState } from "react";
import { toast } from "sonner";
@@ -65,13 +66,14 @@ function ToolEditorContent({
}: ToolEditorContentProps) {
const {
tool,
+ originalTool,
setDialogOpen,
initializeTool,
selectedParameter,
upsertParameter,
setSelectedParameter,
} = useToolBuilder();
- const [chatOpen, setChatOpen] = useState(false);
+ const [chatOpen, setChatOpen] = useQueryState("ai", parseAsBoolean.withDefault(false));
const [streamingTool, setStreamingTool] = useState(null);
const [isAIGenerating, setIsAIGenerating] = useState(false);
@@ -80,6 +82,27 @@ function ToolEditorContent({
const hasAtLeastOneCommand = Array.isArray(tool.commands) && tool.commands.length > 0;
const isValid = tool.name.trim() !== "" && tool.displayName.trim() !== "" && hasAtLeastOneCommand;
+ const pendingChanges = (() => {
+ const currentParams = (streamingTool ?? tool).parameters;
+ const origParams = originalTool.parameters;
+ const origKeys = new Set(origParams.map((p) => p.key));
+ const currKeys = new Set(currentParams.map((p) => p.key));
+ const added = new Set([...currKeys].filter((k) => !origKeys.has(k)));
+ const removed = new Set([...origKeys].filter((k) => !currKeys.has(k)));
+ const updated = new Set(
+ currentParams
+ .filter((p) => origKeys.has(p.key))
+ .filter((p) => {
+ const orig = origParams.find((op) => op.key === p.key)!;
+ return JSON.stringify(p) !== JSON.stringify(orig);
+ })
+ .map((p) => p.key),
+ );
+ return added.size > 0 || removed.size > 0 || updated.size > 0
+ ? { added, removed, updated }
+ : undefined;
+ })();
+
const handleContribute = async () => {
const json = JSON.stringify(tool, null, 2);
const filePath = `public/tools-collection/${tool.name}.json`;
@@ -112,12 +135,14 @@ function ToolEditorContent({
};
return (
-
+
@@ -209,10 +234,14 @@ function ToolEditorContent({
diff --git a/src/components/tool-editor/tools.ts b/src/components/tool-editor/tools.ts
new file mode 100644
index 0000000..d25a5bf
--- /dev/null
+++ b/src/components/tool-editor/tools.ts
@@ -0,0 +1,141 @@
+import { Tool } from "@/components/commandly/types/flat";
+import { cleanupTool, exportToStructuredJSON } from "@/components/commandly/utils/flat";
+import { tool } from "ai";
+import { z } from "zod";
+
+export function applyMergePatch(base: Tool, patch: Partial
): Tool {
+ const merged: Record = { ...base };
+ for (const [k, v] of Object.entries(patch)) {
+ if (v === null) {
+ delete merged[k];
+ } else {
+ merged[k] = v;
+ }
+ }
+ return cleanupTool(merged as unknown as Tool);
+}
+
+export function createEditTool(getBase: () => Tool, onPreview: (tool: Tool) => void) {
+ return tool({
+ description:
+ "Apply a JSON merge patch to incrementally edit the CLI tool definition. Can be called multiple times for separate changes. Always follow up with applyTool when all edits are complete.",
+ inputSchema: z.object({
+ summary: z.string().describe("Brief description of this specific edit"),
+ patch: z
+ .record(z.string(), z.any())
+ .describe(
+ "Partial merge patch — only include top-level fields being changed. Arrays (parameters, commands) must be included in full when modified.",
+ ),
+ }),
+ execute: async ({ summary, patch }) => {
+ const base = getBase();
+ const previewTool = applyMergePatch(base, patch as Partial);
+ onPreview(previewTool);
+ return { success: true, summary };
+ },
+ });
+}
+
+export function createApplyToolDefinitionTool(onApplied: () => void, onApply: () => void) {
+ return tool({
+ description:
+ "Finalize all edits and present them to the user for approval. Call this exactly once after all editTool calls are complete. This is always the last tool call.",
+ inputSchema: z.object({
+ summary: z.string().describe("Overall summary of all changes being applied"),
+ }),
+ needsApproval: true,
+ execute: async () => {
+ onApplied();
+ onApply();
+ return { success: true };
+ },
+ });
+}
+
+export function createReadTool(getCurrent: () => Tool) {
+ return tool({
+ description:
+ "Read the current tool JSON to inspect its structure or verify changes. Call this first before any edits. Use the fields parameter to read only specific top-level sections for large tools.",
+ inputSchema: z.object({
+ reason: z.string().optional().describe("Why you are reading the tool JSON"),
+ fields: z
+ .array(z.enum(["info", "parameters", "commands", "exclusionGroups"]))
+ .optional()
+ .describe("Specific top-level fields to read. Omit to read all."),
+ }),
+ execute: async ({ fields }) => {
+ const current = getCurrent();
+ const exported = exportToStructuredJSON(current) as Record;
+ if (fields && fields.length > 0) {
+ const partial: Record = {};
+ for (const f of fields) {
+ if (f in exported) partial[f] = exported[f];
+ }
+ return { tool: partial, note: `Showing fields: ${fields.join(", ")}` };
+ }
+ return { tool: exported };
+ },
+ });
+}
+
+export function createTavilySearchTool(apiKey: string) {
+ return tool({
+ description: "Search the web for CLI tool documentation, help text, or related information.",
+ inputSchema: z.object({ query: z.string().describe("The search query") }),
+ execute: async ({ query }) => {
+ const resp = await fetch("https://api.tavily.com/search", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ api_key: apiKey, query, search_depth: "basic", max_results: 5 }),
+ });
+ if (!resp.ok) throw new Error("Web search failed");
+ const data = (await resp.json()) as {
+ results?: { title: string; url: string; content: string }[];
+ };
+ return {
+ query,
+ results: data.results?.map((r) => ({ title: r.title, url: r.url, content: r.content })) ?? [],
+ };
+ },
+ });
+}
+
+export function createTavilyExtractTool(apiKey: string) {
+ return tool({
+ description:
+ "Extract content from one or more web page URLs. For large pages, use startOffset and maxChars to read in chunks — call again with the next startOffset when hasMore is true.",
+ inputSchema: z.object({
+ urls: z.array(z.string()).describe("URLs to extract content from"),
+ startOffset: z
+ .number()
+ .optional()
+ .describe(
+ "Character offset to start reading from (default 0). Use nextOffset from a previous response to continue.",
+ ),
+ maxChars: z
+ .number()
+ .optional()
+ .describe("Max characters to return per URL (default 6000). Reduce if content is too large to process at once."),
+ }),
+ execute: async ({ urls, startOffset = 0, maxChars = 6000 }) => {
+ const resp = await fetch("https://api.tavily.com/extract", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ api_key: apiKey, urls }),
+ });
+ if (!resp.ok) throw new Error("Content extraction failed");
+ const data = (await resp.json()) as {
+ results?: { url: string; raw_content: string }[];
+ };
+ return {
+ results: data.results?.map((r) => ({
+ url: r.url,
+ raw_content: r.raw_content.slice(startOffset, startOffset + maxChars),
+ totalChars: r.raw_content.length,
+ hasMore: r.raw_content.length > startOffset + maxChars,
+ nextOffset: startOffset + maxChars,
+ })) ?? [],
+ };
+ },
+ });
+}
diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx
new file mode 100644
index 0000000..584011b
--- /dev/null
+++ b/src/components/ui/progress.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import * as React from "react"
+import { Progress as ProgressPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Progress({
+ className,
+ value,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export { Progress }
diff --git a/src/components/ui/spinner.tsx b/src/components/ui/spinner.tsx
new file mode 100644
index 0000000..91f6a63
--- /dev/null
+++ b/src/components/ui/spinner.tsx
@@ -0,0 +1,10 @@
+import { cn } from "@/lib/utils"
+import { Loader2Icon } from "lucide-react"
+
+function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
+ return (
+
+ )
+}
+
+export { Spinner }
diff --git a/src/index.css b/src/index.css
index 10307bd..d0a37f8 100644
--- a/src/index.css
+++ b/src/index.css
@@ -207,11 +207,9 @@ code[data-theme*=" "] span {
color: var(--shiki-light);
}
-@media (prefers-color-scheme: dark) {
- code[data-theme*=" "],
- code[data-theme*=" "] span {
- color: var(--shiki-dark);
- }
+html.dark code[data-theme*=" "],
+html.dark code[data-theme*=" "] span {
+ color: var(--shiki-dark);
}
html {
diff --git a/src/lib/ai-keys.ts b/src/lib/ai-keys.ts
index 97523dc..035b7b3 100644
--- a/src/lib/ai-keys.ts
+++ b/src/lib/ai-keys.ts
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
export type AIProvider = "openai" | "anthropic" | "google" | "groq" | "mistral" | "xai" | "tavily";
@@ -12,21 +12,105 @@ const STORAGE_KEYS: Record = {
tavily: "ai-api-key-tavily",
};
+const DB_NAME = "commandly";
+const STORE_NAME = "keys";
+const KEY_ID = "master-key";
+
+function getOrCreateCryptoKey(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(DB_NAME, 3);
+ request.onupgradeneeded = (event) => {
+ const db = request.result;
+ const oldVersion = (event as IDBVersionChangeEvent).oldVersion;
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ db.createObjectStore(STORE_NAME);
+ }
+ if (oldVersion < 3 && db.objectStoreNames.contains("sessions")) {
+ db.deleteObjectStore("sessions");
+ }
+ if (!db.objectStoreNames.contains("sessions")) {
+ const store = db.createObjectStore("sessions", { keyPath: "id" });
+ store.createIndex("toolName", "toolName", { unique: false });
+ }
+ };
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => {
+ const db = request.result;
+ const tx = db.transaction(STORE_NAME, "readwrite");
+ const store = tx.objectStore(STORE_NAME);
+ const getReq = store.get(KEY_ID);
+ getReq.onerror = () => reject(getReq.error);
+ getReq.onsuccess = () => {
+ if (getReq.result) {
+ resolve(getReq.result as CryptoKey);
+ } else {
+ crypto.subtle
+ .generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"])
+ .then((newKey) => {
+ store.put(newKey, KEY_ID);
+ resolve(newKey);
+ })
+ .catch(reject);
+ }
+ };
+ };
+ });
+}
+
+async function encryptValue(cryptoKey: CryptoKey, plaintext: string): Promise {
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const encoded = new TextEncoder().encode(plaintext);
+ const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, cryptoKey, encoded);
+ const ivB64 = btoa(String.fromCharCode(...iv));
+ const ctB64 = btoa(String.fromCharCode(...new Uint8Array(ciphertext)));
+ return `${ivB64}:${ctB64}`;
+}
+
+async function decryptValue(cryptoKey: CryptoKey, encrypted: string): Promise {
+ const colonIdx = encrypted.indexOf(":");
+ if (colonIdx === -1) throw new Error("Invalid encrypted value format");
+ const iv = Uint8Array.from(atob(encrypted.slice(0, colonIdx)), (c) => c.charCodeAt(0));
+ const ciphertext = Uint8Array.from(atob(encrypted.slice(colonIdx + 1)), (c) => c.charCodeAt(0));
+ const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, cryptoKey, ciphertext);
+ return new TextDecoder().decode(decrypted);
+}
+
export function useAIKeys(provider: AIProvider) {
const storageKey = STORAGE_KEYS[provider];
- const [key, setKeyState] = useState(() => localStorage.getItem(storageKey) ?? "");
- const [isSaved, setIsSavedState] = useState(() => !!localStorage.getItem(storageKey));
+ const [key, setKeyState] = useState("");
+ const [isSaved, setIsSavedState] = useState(false);
+ const cryptoKeyRef = useRef(null);
+
+ useEffect(() => {
+ getOrCreateCryptoKey().then(async (cryptoKey) => {
+ cryptoKeyRef.current = cryptoKey;
+ const stored = localStorage.getItem(storageKey);
+ if (stored) {
+ try {
+ const decrypted = await decryptValue(cryptoKey, stored);
+ setKeyState(decrypted);
+ setIsSavedState(true);
+ } catch {
+ localStorage.removeItem(storageKey);
+ }
+ }
+ });
+ }, [storageKey]);
const setKey = (newKey: string) => {
setKeyState(newKey);
- if (isSaved && newKey.trim()) {
- localStorage.setItem(storageKey, newKey);
+ if (isSaved && newKey.trim() && cryptoKeyRef.current) {
+ encryptValue(cryptoKeyRef.current, newKey).then((encrypted) => {
+ localStorage.setItem(storageKey, encrypted);
+ });
}
};
const setSaved = (save: boolean) => {
- if (save && key.trim()) {
- localStorage.setItem(storageKey, key);
+ if (save && key.trim() && cryptoKeyRef.current) {
+ encryptValue(cryptoKeyRef.current, key).then((encrypted) => {
+ localStorage.setItem(storageKey, encrypted);
+ });
} else {
localStorage.removeItem(storageKey);
}
From 38712237ae90b376af96e609a32457a5a18c56bf Mon Sep 17 00:00:00 2001
From: divyeshio <79130336+divyeshio@users.noreply.github.com>
Date: Wed, 15 Apr 2026 22:35:10 +0530
Subject: [PATCH 3/6] Add tests for AI chat persistence and update command
dialog labels
- Implement tests for saving and loading chat sessions, ensuring messages are stored correctly and previews are generated.
- Validate message integrity during save/load operations, including tool-invocation parts and reasoning parts.
- Update command dialog tests to reflect label changes from "Default Command" to "Default" for consistency.
- Enhance parameter list tests to clarify context selection behavior when interacting with parameter cards and remove buttons.
---
bun.lock | 14 +
package.json | 2 +
.../ai-chat/ai-chat-message-mapping.ts | 156 +++
src/components/ai-chat/ai-chat-persistence.ts | 103 ++
.../api-key-settings.tsx | 0
src/components/ai-chat/diff-view.tsx | 106 ++
src/components/ai-chat/extracted-content.tsx | 42 +
.../{tool-editor => ai-chat}/model-picker.tsx | 0
src/components/ai-chat/web-search-results.tsx | 44 +
src/components/tool-editor/ai-chat.tsx | 1225 +++++------------
tests/tool-editor/ai-chat-persistence.test.ts | 441 ++++++
.../dialogs/command-dialog.test.tsx | 12 +-
tests/tool-editor/parameter-list.test.tsx | 8 +-
13 files changed, 1273 insertions(+), 880 deletions(-)
create mode 100644 src/components/ai-chat/ai-chat-message-mapping.ts
create mode 100644 src/components/ai-chat/ai-chat-persistence.ts
rename src/components/{tool-editor => ai-chat}/api-key-settings.tsx (100%)
create mode 100644 src/components/ai-chat/diff-view.tsx
create mode 100644 src/components/ai-chat/extracted-content.tsx
rename src/components/{tool-editor => ai-chat}/model-picker.tsx (100%)
create mode 100644 src/components/ai-chat/web-search-results.tsx
create mode 100644 tests/tool-editor/ai-chat-persistence.test.ts
diff --git a/bun.lock b/bun.lock
index 7034417..3557ed6 100644
--- a/bun.lock
+++ b/bun.lock
@@ -10,6 +10,7 @@
"@ai-sdk/groq": "^3.0.35",
"@ai-sdk/mistral": "^3.0.30",
"@ai-sdk/openai": "^3.0.52",
+ "@ai-sdk/react": "3.0.163",
"@ai-sdk/xai": "^3.0.82",
"@base-ui/react": "^1.3.0",
"@dnd-kit/core": "^6.3.1",
@@ -68,6 +69,7 @@
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "4.1.4",
"ajv": "^8.18.0",
+ "fake-indexeddb": "^6.2.5",
"jsdom": "^29.0.2",
"oxfmt": "^0.44.0",
"oxlint": "^1.59.0",
@@ -105,6 +107,8 @@
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="],
+ "@ai-sdk/react": ["@ai-sdk/react@3.0.163", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.23", "ai": "6.0.161", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-UM8BwNx4YFcG1XIBSTepIGx48RXk974qVSplVZc2JPiY86tC4Qpb8trquh5MdtSKzlS6yrUX46n8gS2WZaUIXQ=="],
+
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -1333,6 +1337,8 @@
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
+ "fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="],
+
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@@ -2081,6 +2087,8 @@
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
+ "swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="],
+
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
@@ -2095,6 +2103,8 @@
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
+ "throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
+
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
@@ -2281,6 +2291,8 @@
"@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+ "@ai-sdk/react/ai": ["ai@6.0.161", "", { "dependencies": { "@ai-sdk/gateway": "3.0.98", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ufhmijmx2YyWTPAicGgtpLOB/xD7mG8zKs1pT1Trj+JL/3r1rS8fkMi/cHZoChSAQSGB4pgmcWVxDrVTUvK2IQ=="],
+
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@@ -2447,6 +2459,8 @@
"yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+ "@ai-sdk/react/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.98", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Ol+nP8PIlj8FjN8qKlxhE89N0woqAaGi9CUBGp1boe3RafpphJ7WMuq/RErSvxtwTqje03TP+zIdzP113krxRg=="],
+
"@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
diff --git a/package.json b/package.json
index 75d3ec6..d182b10 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"@ai-sdk/groq": "^3.0.35",
"@ai-sdk/mistral": "^3.0.30",
"@ai-sdk/openai": "^3.0.52",
+ "@ai-sdk/react": "3.0.163",
"@ai-sdk/xai": "^3.0.82",
"@base-ui/react": "^1.3.0",
"@dnd-kit/core": "^6.3.1",
@@ -85,6 +86,7 @@
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "4.1.4",
"ajv": "^8.18.0",
+ "fake-indexeddb": "^6.2.5",
"jsdom": "^29.0.2",
"oxfmt": "^0.44.0",
"oxlint": "^1.59.0",
diff --git a/src/components/ai-chat/ai-chat-message-mapping.ts b/src/components/ai-chat/ai-chat-message-mapping.ts
new file mode 100644
index 0000000..d295b32
--- /dev/null
+++ b/src/components/ai-chat/ai-chat-message-mapping.ts
@@ -0,0 +1,156 @@
+import { Tool } from "@/components/commandly/types/flat";
+import {
+ getToolName,
+ isReasoningUIPart,
+ isTextUIPart,
+ isToolUIPart,
+ type UIMessage,
+} from "ai";
+
+export interface ToolCallEntry {
+ toolCallId: string;
+ toolName: string;
+ approvalId?: string;
+ title: string;
+ input: Record;
+ state:
+ | "input-streaming"
+ | "input-available"
+ | "approval-requested"
+ | "approval-responded"
+ | "output-available"
+ | "output-denied"
+ | "output-error";
+ output?: unknown;
+ errorText?: string;
+ originalTool?: Tool;
+ previewTool?: Tool;
+}
+
+export interface ChatMessage {
+ id: string;
+ role: "user" | "assistant";
+ content: string;
+ toolApplied?: boolean;
+ toolCalls?: ToolCallEntry[];
+ isEditing?: boolean;
+ editingContent?: string;
+ reasoningContent?: string;
+}
+
+export interface ApprovalArtifact {
+ approvalId: string;
+ previewTool: Tool;
+ originalTool: Tool;
+ summary: string;
+}
+
+function getToolTitle(toolName: string, input: Record, fallbackTitle?: string) {
+ if (fallbackTitle) {
+ return fallbackTitle;
+ }
+
+ if (toolName === "editTool") {
+ return typeof input.summary === "string" ? input.summary : "Editing…";
+ }
+
+ if (toolName === "readTool") {
+ return "Reading tool JSON…";
+ }
+
+ if (toolName === "applyToolDefinition") {
+ return "Preparing to apply";
+ }
+
+ if (toolName === "tavilySearch") {
+ return "Web Search";
+ }
+
+ if (toolName === "tavilyExtract") {
+ return "Extract Content";
+ }
+
+ return toolName;
+}
+
+function toToolInput(value: unknown): Record {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? value as Record
+ : {};
+}
+
+export function toChatMessage(message: UIMessage, approvalArtifacts: Record): ChatMessage {
+ const content = message.parts
+ .filter(isTextUIPart)
+ .map((part) => part.text)
+ .join("");
+
+ const reasoningContent = message.parts
+ .filter(isReasoningUIPart)
+ .map((part) => part.text)
+ .join("");
+
+ const toolCalls = message.parts
+ .filter(isToolUIPart)
+ .map((part) => {
+ const toolName = getToolName(part);
+ const input = toToolInput("input" in part ? part.input : undefined);
+ const approvalId = part.approval?.id;
+ const artifact = approvalId ? approvalArtifacts[approvalId] : undefined;
+
+ return {
+ toolCallId: part.toolCallId,
+ toolName,
+ approvalId,
+ title: getToolTitle(toolName, input, part.title),
+ input,
+ state: part.state,
+ output: "output" in part ? part.output : undefined,
+ errorText: "errorText" in part ? part.errorText : undefined,
+ originalTool: artifact?.originalTool,
+ previewTool: artifact?.previewTool,
+ } satisfies ToolCallEntry;
+ });
+
+ return {
+ id: message.id,
+ role: message.role === "assistant" ? "assistant" : "user",
+ content,
+ toolApplied: toolCalls.some((toolCall) => toolCall.toolName === "applyToolDefinition" && toolCall.state === "output-available"),
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
+ reasoningContent: reasoningContent || undefined,
+ };
+}
+
+export function countCompletedToolCalls(messages: UIMessage[]): number {
+ return messages.reduce((count, message) => (
+ count + message.parts.filter(
+ (part) => isToolUIPart(part)
+ && (
+ part.state === "output-available"
+ || part.state === "output-error"
+ || part.state === "output-denied"
+ ),
+ ).length
+ ), 0);
+}
+
+export function findPendingApproval(messages: ChatMessage[]) {
+ for (let index = messages.length - 1; index >= 0; index--) {
+ const applyToolCall = messages[index].toolCalls?.find(
+ (toolCall) => toolCall.toolName === "applyToolDefinition" && toolCall.state === "approval-requested" && toolCall.approvalId,
+ );
+
+ if (applyToolCall?.approvalId && applyToolCall.originalTool && applyToolCall.previewTool) {
+ return {
+ approvalId: applyToolCall.approvalId,
+ previewTool: applyToolCall.previewTool,
+ originalTool: applyToolCall.originalTool,
+ summary: typeof applyToolCall.input.summary === "string" ? applyToolCall.input.summary : "Apply AI changes",
+ messageIndex: index,
+ };
+ }
+ }
+
+ return null;
+}
diff --git a/src/components/ai-chat/ai-chat-persistence.ts b/src/components/ai-chat/ai-chat-persistence.ts
new file mode 100644
index 0000000..37b0036
--- /dev/null
+++ b/src/components/ai-chat/ai-chat-persistence.ts
@@ -0,0 +1,103 @@
+import type { UIMessage } from "ai";
+
+export interface ChatSession {
+ id: string;
+ toolName: string;
+ messages: UI_MESSAGE[];
+ updatedAt: number;
+ preview: string;
+}
+
+function openSessionsDB(): Promise {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open("commandly", 3);
+ req.onupgradeneeded = (event) => {
+ const db = req.result;
+ const oldVersion = (event as IDBVersionChangeEvent).oldVersion;
+ if (!db.objectStoreNames.contains("keys")) {
+ db.createObjectStore("keys");
+ }
+ if (oldVersion < 3 && db.objectStoreNames.contains("sessions")) {
+ db.deleteObjectStore("sessions");
+ }
+ if (!db.objectStoreNames.contains("sessions")) {
+ const store = db.createObjectStore("sessions", { keyPath: "id" });
+ store.createIndex("toolName", "toolName", { unique: false });
+ }
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+function getMessageText(message: UIMessage): string {
+ return message.parts
+ .filter((part): part is Extract => part.type === "text")
+ .map((part) => part.text)
+ .join("");
+}
+
+function hasPersistableContent(message: UIMessage): boolean {
+ return message.parts.some((part) => {
+ if (part.type === "text" || part.type === "reasoning") {
+ return part.text.trim().length > 0;
+ }
+
+ return part.type !== "step-start";
+ });
+}
+
+function normalizeSession(session: ChatSession): ChatSession {
+ return {
+ id: session.id,
+ toolName: session.toolName,
+ messages: session.messages,
+ updatedAt: session.updatedAt,
+ preview: getSessionPreview(session.messages),
+ };
+}
+
+function toStoredMessages(messages: UI_MESSAGE[]): UI_MESSAGE[] {
+ return JSON.parse(JSON.stringify(messages)) as UI_MESSAGE[];
+}
+
+export function getSessionPreview(messages: UIMessage[]): string {
+ const firstUserMessage = messages.find((message) => message.role === "user");
+ return getMessageText(firstUserMessage ?? { id: "", role: "user", parts: [] }).slice(0, 80);
+}
+
+export function getPersistableMessages(messages: UIMessage[]): UIMessage[] {
+ return messages.filter((message) => message.role !== "assistant" || hasPersistableContent(message));
+}
+
+export async function saveChatSession(session: ChatSession): Promise {
+ const db = await openSessionsDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction("sessions", "readwrite");
+ tx.objectStore("sessions").put({
+ ...session,
+ messages: toStoredMessages(session.messages),
+ });
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+}
+
+export async function loadRecentSessions(
+ toolName: string,
+ limit = 5,
+): Promise>> {
+ const db = await openSessionsDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction("sessions", "readonly");
+ const index = tx.objectStore("sessions").index("toolName");
+ const req = index.getAll(toolName);
+ req.onsuccess = () => {
+ const sessions = (req.result as Array>)
+ .map(normalizeSession)
+ .sort((a, b) => b.updatedAt - a.updatedAt);
+ resolve(sessions.slice(0, limit));
+ };
+ req.onerror = () => reject(req.error);
+ });
+}
diff --git a/src/components/tool-editor/api-key-settings.tsx b/src/components/ai-chat/api-key-settings.tsx
similarity index 100%
rename from src/components/tool-editor/api-key-settings.tsx
rename to src/components/ai-chat/api-key-settings.tsx
diff --git a/src/components/ai-chat/diff-view.tsx b/src/components/ai-chat/diff-view.tsx
new file mode 100644
index 0000000..e68b042
--- /dev/null
+++ b/src/components/ai-chat/diff-view.tsx
@@ -0,0 +1,106 @@
+import { Tool } from "@/components/commandly/types/flat";
+import { exportToStructuredJSON } from "@/components/commandly/utils/flat";
+import { cn } from "@/lib/utils";
+
+function computeLineDiff(
+ a: string,
+ b: string,
+): Array<{ type: "same" | "add" | "remove"; text: string }> {
+ const aLines = a.split("\n");
+ const bLines = b.split("\n");
+ const m = aLines.length;
+ const n = bLines.length;
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
+
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ dp[i][j] = aLines[i - 1] === bLines[j - 1]
+ ? dp[i - 1][j - 1] + 1
+ : Math.max(dp[i - 1][j], dp[i][j - 1]);
+ }
+ }
+
+ const result: Array<{ type: "same" | "add" | "remove"; text: string }> = [];
+ let i = m;
+ let j = n;
+
+ while (i > 0 || j > 0) {
+ if (i > 0 && j > 0 && aLines[i - 1] === bLines[j - 1]) {
+ result.unshift({ type: "same", text: aLines[i - 1] });
+ i--;
+ j--;
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
+ result.unshift({ type: "add", text: bLines[j - 1] });
+ j--;
+ } else {
+ result.unshift({ type: "remove", text: aLines[i - 1] });
+ i--;
+ }
+ }
+
+ return result;
+}
+
+export function DiffView({ original, updated }: { original: Tool; updated: Tool }) {
+ const aJson = JSON.stringify(exportToStructuredJSON(original), null, 2);
+ const bJson = JSON.stringify(exportToStructuredJSON(updated), null, 2);
+ const diff = computeLineDiff(aJson, bJson);
+ const visibleSet = new Set();
+
+ for (const index of diff.flatMap((line, currentIndex) => (line.type !== "same" ? [currentIndex] : []))) {
+ for (let cursor = Math.max(0, index - 2); cursor <= Math.min(diff.length - 1, index + 2); cursor++) {
+ visibleSet.add(cursor);
+ }
+ }
+
+ if (visibleSet.size === 0) {
+ return No changes
;
+ }
+
+ const sortedIndices = [...visibleSet].sort((a, b) => a - b);
+ const chunks: number[][] = [];
+ let currentChunk: number[] = [];
+
+ for (let index = 0; index < sortedIndices.length; index++) {
+ if (currentChunk.length === 0 || sortedIndices[index] === sortedIndices[index - 1] + 1) {
+ currentChunk.push(sortedIndices[index]);
+ continue;
+ }
+
+ chunks.push(currentChunk);
+ currentChunk = [sortedIndices[index]];
+ }
+
+ if (currentChunk.length > 0) {
+ chunks.push(currentChunk);
+ }
+
+ return (
+
+ {chunks.map((chunk, chunkIndex) => (
+
+ {chunkIndex > 0 && (
+
···
+ )}
+ {chunk.map((lineIndex) => {
+ const line = diff[lineIndex];
+ return (
+
+ {line.type === "add" ? "+ " : line.type === "remove" ? "- " : " "}
+ {line.text}
+
+ );
+ })}
+
+ ))}
+
+ );
+}
diff --git a/src/components/ai-chat/extracted-content.tsx b/src/components/ai-chat/extracted-content.tsx
new file mode 100644
index 0000000..d228e6a
--- /dev/null
+++ b/src/components/ai-chat/extracted-content.tsx
@@ -0,0 +1,42 @@
+export interface ExtractResult {
+ url: string;
+ raw_content: string;
+ hasMore?: boolean;
+}
+
+export function ExtractedContent({ results }: { results: ExtractResult[] }) {
+ return (
+
+ {results.map((result) => {
+ let hostname = result.url;
+
+ try {
+ hostname = new URL(result.url).hostname;
+ } catch {
+ // Ignore invalid URLs in partial tool output.
+ }
+
+ return (
+
+
+
+ {result.raw_content}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/tool-editor/model-picker.tsx b/src/components/ai-chat/model-picker.tsx
similarity index 100%
rename from src/components/tool-editor/model-picker.tsx
rename to src/components/ai-chat/model-picker.tsx
diff --git a/src/components/ai-chat/web-search-results.tsx b/src/components/ai-chat/web-search-results.tsx
new file mode 100644
index 0000000..5f6d093
--- /dev/null
+++ b/src/components/ai-chat/web-search-results.tsx
@@ -0,0 +1,44 @@
+export interface WebSearchResult {
+ title: string;
+ url: string;
+ content: string;
+}
+
+export function WebSearchResults({ results }: { results: WebSearchResult[] }) {
+ return (
+
+ );
+}
diff --git a/src/components/tool-editor/ai-chat.tsx b/src/components/tool-editor/ai-chat.tsx
index 0cbbdd8..c4263b6 100644
--- a/src/components/tool-editor/ai-chat.tsx
+++ b/src/components/tool-editor/ai-chat.tsx
@@ -1,4 +1,13 @@
-import { ApiKeySettings } from "./api-key-settings";
+import { ApiKeySettings } from "../ai-chat/api-key-settings";
+import {
+ ApprovalArtifact,
+ ChatMessage,
+ countCompletedToolCalls,
+ findPendingApproval,
+ toChatMessage,
+} from "../ai-chat/ai-chat-message-mapping";
+import { DiffView } from "../ai-chat/diff-view";
+import { ExtractedContent, type ExtractResult } from "../ai-chat/extracted-content";
import {
ModelPicker,
ReasoningEffortPicker,
@@ -6,8 +15,10 @@ import {
providerForModel,
MODEL_GROUPS,
type ReasoningEffort,
-} from "./model-picker";
+} from "../ai-chat/model-picker";
import { generatePrompt } from "./prompt";
+import { ChatSession, getPersistableMessages, getSessionPreview, loadRecentSessions, saveChatSession } from "../ai-chat/ai-chat-persistence";
+import { WebSearchResults, type WebSearchResult } from "../ai-chat/web-search-results";
import { useToolBuilder } from "./tool-editor.context";
import {
Conversation,
@@ -62,7 +73,6 @@ import {
ToolHeader,
} from "@/components/ai-elements/tool";
import { Tool } from "@/components/commandly/types/flat";
-import { exportToStructuredJSON } from "@/components/commandly/utils/flat";
import {
createApplyToolDefinitionTool,
createEditTool,
@@ -80,7 +90,18 @@ import { createGroq } from "@ai-sdk/groq";
import { createMistral } from "@ai-sdk/mistral";
import { createOpenAI } from "@ai-sdk/openai";
import { createXai } from "@ai-sdk/xai";
-import { hasToolCall, stepCountIs, streamText, type LanguageModelUsage, type ModelMessage } from "ai";
+import { Chat, useChat } from "@ai-sdk/react";
+import {
+ DirectChatTransport,
+ ToolLoopAgent,
+ lastAssistantMessageIsCompleteWithApprovalResponses,
+ type InferUITools,
+ type LanguageModelUsage,
+ type Tool as AISDKTool,
+ type UIMessage,
+ isToolUIPart,
+ getToolName,
+} from "ai";
import {
CheckIcon,
CopyIcon,
@@ -95,39 +116,9 @@ import {
TerminalIcon,
XIcon,
} from "lucide-react";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
-interface ToolCallEntry {
- toolCallId: string;
- toolName: string;
- title: string;
- input: Record;
- state: "input-available" | "approval-requested" | "output-available" | "output-error";
- output?: unknown;
- errorText?: string;
- originalTool?: Tool;
- previewTool?: Tool;
-}
-
-interface ChatSession {
- id: string;
- toolName: string;
- messages: ChatMessage[];
- updatedAt: number;
- preview: string;
-}
-
-interface ChatMessage {
- role: "user" | "assistant";
- content: string;
- toolApplied?: boolean;
- toolCalls?: ToolCallEntry[];
- isEditing?: boolean;
- editingContent?: string;
- reasoningContent?: string;
-}
-
const PROMPT_PILLS = [
{
label: "Sorting & Grouping",
@@ -159,48 +150,6 @@ const MODEL_MAX_TOKENS: Record = {
};
const DEFAULT_MAX_TOKENS = 128_000;
-function openSessionsDB(): Promise {
- return new Promise((resolve, reject) => {
- const req = indexedDB.open("commandly", 2);
- req.onupgradeneeded = () => {
- const db = req.result;
- if (!db.objectStoreNames.contains("keys")) {
- db.createObjectStore("keys");
- }
- if (!db.objectStoreNames.contains("sessions")) {
- const store = db.createObjectStore("sessions", { keyPath: "id" });
- store.createIndex("toolName", "toolName", { unique: false });
- }
- };
- req.onsuccess = () => resolve(req.result);
- req.onerror = () => reject(req.error);
- });
-}
-
-async function saveChatSession(session: ChatSession): Promise {
- const db = await openSessionsDB();
- return new Promise((resolve, reject) => {
- const tx = db.transaction("sessions", "readwrite");
- tx.objectStore("sessions").put(session);
- tx.oncomplete = () => resolve();
- tx.onerror = () => reject(tx.error);
- });
-}
-
-async function loadRecentSessions(toolName: string, limit = 5): Promise {
- const db = await openSessionsDB();
- return new Promise((resolve, reject) => {
- const tx = db.transaction("sessions", "readonly");
- const index = tx.objectStore("sessions").index("toolName");
- const req = index.getAll(toolName);
- req.onsuccess = () => {
- const all = (req.result as ChatSession[]).sort((a, b) => b.updatedAt - a.updatedAt);
- resolve(all.slice(0, limit));
- };
- req.onerror = () => reject(req.error);
- });
-}
-
function formatRelativeTime(ts: number): string {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60_000);
@@ -212,43 +161,6 @@ function formatRelativeTime(ts: number): string {
return `${days}d ago`;
}
-function computeLineDiff(
- a: string,
- b: string,
-): Array<{ type: "same" | "add" | "remove"; text: string }> {
- const aLines = a.split("\n");
- const bLines = b.split("\n");
- const m = aLines.length;
- const n = bLines.length;
- const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
- for (let i = 1; i <= m; i++) {
- for (let j = 1; j <= n; j++) {
- dp[i][j] =
- aLines[i - 1] === bLines[j - 1]
- ? dp[i - 1][j - 1] + 1
- : Math.max(dp[i - 1][j], dp[i][j - 1]);
- }
- }
- const result: Array<{ type: "same" | "add" | "remove"; text: string }> = [];
- let i = m;
- let j = n;
- while (i > 0 || j > 0) {
- if (i > 0 && j > 0 && aLines[i - 1] === bLines[j - 1]) {
- result.unshift({ type: "same", text: aLines[i - 1] });
- i--;
- j--;
- } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
- result.unshift({ type: "add", text: bLines[j - 1] });
- j--;
- } else {
- result.unshift({ type: "remove", text: aLines[i - 1] });
- i--;
- }
- }
- return result;
-}
-
-
function createModelInstance(provider: AIProvider, key: string, model: string) {
switch (provider) {
case "anthropic":
@@ -266,6 +178,38 @@ function createModelInstance(provider: AIProvider, key: string, model: string) {
}
}
+type ChatProviderOptions = Record<
+ string,
+ Record>
+>;
+type AgentUIMessage = UIMessage>>;
+
+function createToolLoopAgent({
+ model,
+ instructions,
+ tools,
+ providerOptions,
+ onFinish,
+}: {
+ model: ReturnType;
+ instructions: string;
+ tools: Record;
+ providerOptions?: ChatProviderOptions;
+ onFinish: ({ usage }: { usage: LanguageModelUsage }) => void;
+}) {
+ return new ToolLoopAgent({
+ model,
+ instructions,
+ tools,
+ providerOptions,
+ onFinish,
+ });
+}
+
+function cloneTool(tool: Tool): Tool {
+ return structuredClone(tool);
+}
+
function useAIChat(
currentTool: Tool,
onApply: (tool: Tool) => void,
@@ -273,36 +217,37 @@ function useAIChat(
onGeneratingChange?: (isGenerating: boolean) => void,
) {
const { contextSelection } = useToolBuilder();
- const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
- const [isStreaming, setIsStreaming] = useState(false);
const [modelInternal, setModelInternal] = useState(
() => localStorage.getItem("ai-model") ?? MODEL_GROUPS[0].models[0].value,
);
const [reasoningEffort, setReasoningEffortState] = useState(
() => (localStorage.getItem("ai-reasoning-effort") as ReasoningEffort | null),
);
- const schemaRef = useRef(null);
- const abortControllerRef = useRef(null);
- const [pendingApproval, setPendingApproval] = useState<{
- approvalId: string;
- toolCallId: string;
- continuationMessages: ModelMessage[];
- previewTool: Tool;
- originalTool: Tool;
- summary: string;
- messageIndex: number;
- } | null>(null);
+ const [schema, setSchema] = useState(null);
const [usage, setUsage] = useState(null);
- const [toolCallCount, setToolCallCount] = useState(0);
- const [recentSessions, setRecentSessions] = useState([]);
- const sessionIdRef = useRef(crypto.randomUUID());
+ const [recentSessions, setRecentSessions] = useState>>([]);
+ const [approvalArtifacts, setApprovalArtifacts] = useState>({});
+ const [chatId, setChatId] = useState(() => crypto.randomUUID());
+ const currentToolRef = useRef(currentTool);
+ const chatMessagesRef = useRef([]);
+ const pendingPreviewRef = useRef(null);
+ const chatIdRef = useRef(chatId);
+ const onApplyRef = useRef(onApply);
+ const onStreamingToolRef = useRef(onStreamingTool);
+ const onGeneratingChangeRef = useRef(onGeneratingChange);
useEffect(() => {
- loadRecentSessions(currentTool.name).then(setRecentSessions).catch(() => { });
+ loadRecentSessions(currentTool.name).then(setRecentSessions).catch(console.error);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTool.name]);
+ currentToolRef.current = currentTool;
+ chatIdRef.current = chatId;
+ onApplyRef.current = onApply;
+ onStreamingToolRef.current = onStreamingTool;
+ onGeneratingChangeRef.current = onGeneratingChange;
+
const model = modelInternal;
const setModel = useCallback((m: string) => {
setModelInternal(m);
@@ -341,412 +286,225 @@ function useAIChat(
xai: xaiKeys,
};
const currentKeys = allProviderKeys[provider as Exclude];
+ const providerOptions = useMemo(
+ () => (reasoningEffort && MODEL_GROUPS.flatMap((g) => g.models).find((m) => m.value === model)?.reasoning === true
+ ? getProviderOptions(provider, model, reasoningEffort)
+ : undefined) as ChatProviderOptions | undefined,
+ [provider, model, reasoningEffort],
+ );
useEffect(() => {
fetch("/specification/flat.json")
.then((r) => r.json())
- .then((s) => {
- schemaRef.current = s;
- })
+ .then(setSchema)
.catch(() => { });
}, []);
- const runStream = useCallback(
- async (userText: string, history: ChatMessage[]) => {
- abortControllerRef.current = new AbortController();
- const toolSnapshot = currentTool;
- let isToolApplied = false;
- let pendingPreview: Tool | null = null;
- let waitingForApproval = false;
-
- try {
- const schema = schemaRef.current ? JSON.stringify(schemaRef.current, null, 2) : "{}";
- const contextCommands = currentTool.commands.filter((c) =>
- contextSelection.commandKeys.includes(c.key),
- );
- const contextParameters = currentTool.parameters.filter((p) =>
- contextSelection.parameterKeys.includes(p.key),
- );
- const systemPrompt = generatePrompt(schema, {
- context: {
- selectedCommands: contextCommands.map((c) => ({ key: c.key, name: c.name })),
- selectedParameters: contextParameters.map((p) => ({
- key: p.key,
- name: p.name,
- longFlag: p.longFlag,
- shortFlag: p.shortFlag,
- })),
- },
- });
- const aiModel = createModelInstance(provider, currentKeys.key, model);
- const userMessage: ChatMessage = { role: "user", content: userText };
- const assistantMessageIndex = history.length + 1;
- const priorModelMessages: ModelMessage[] = [...history, userMessage].map((m) => ({
- role: m.role as "user" | "assistant",
- content: m.content,
- }));
-
- const editToolDef = createEditTool(
- () => pendingPreview ?? toolSnapshot,
- (t) => { pendingPreview = t; onStreamingTool?.(t); }
- );
-
- const applyToolDefinitionDef = createApplyToolDefinitionTool(
- () => { isToolApplied = true; },
- () => {
- if (pendingPreview) {
- onApply(replaceKey(pendingPreview) as Tool);
- pendingPreview = null;
- }
- onStreamingTool?.(null);
- }
- );
-
- const readToolDef = createReadTool(() => pendingPreview ?? toolSnapshot);
-
- const tavilySearchTool = tavilyKeys.isSaved && tavilyKeys.key
- ? createTavilySearchTool(tavilyKeys.key)
- : undefined;
- const tavilyExtractTool = tavilyKeys.isSaved && tavilyKeys.key
- ? createTavilyExtractTool(tavilyKeys.key)
- : undefined;
-
- const { fullStream } = streamText({
- model: aiModel,
- system: systemPrompt,
- messages: priorModelMessages,
- tools: {
- editTool: editToolDef,
- applyToolDefinition: applyToolDefinitionDef,
- readTool: readToolDef,
- ...(tavilySearchTool ? { tavilySearch: tavilySearchTool } : {}),
- ...(tavilyExtractTool ? { tavilyExtract: tavilyExtractTool } : {}),
- },
- stopWhen: [
- stepCountIs(20), // Maximum 20 steps
- hasToolCall('applyToolDefinition'), // Stop after calling 'applyToolDefinition'
- ],
- abortSignal: abortControllerRef.current.signal,
- onFinish: ({ usage: u }) => setUsage(u),
- providerOptions: (reasoningEffort && MODEL_GROUPS.flatMap((g) => g.models).find((m) => m.value === model)?.reasoning === true
- ? getProviderOptions(provider, model, reasoningEffort)
- : undefined) as Record>> | undefined,
- });
+ const systemPrompt = useMemo(() => {
+ const serializedSchema = schema ? JSON.stringify(schema, null, 2) : "{}";
+ const contextCommands = currentTool.commands.filter((command) =>
+ contextSelection.commandKeys.includes(command.key),
+ );
+ const contextParameters = currentTool.parameters.filter((parameter) =>
+ contextSelection.parameterKeys.includes(parameter.key),
+ );
+
+ return generatePrompt(serializedSchema, {
+ context: {
+ selectedCommands: contextCommands.map((command) => ({ key: command.key, name: command.name })),
+ selectedParameters: contextParameters.map((parameter) => ({
+ key: parameter.key,
+ name: parameter.name,
+ longFlag: parameter.longFlag,
+ shortFlag: parameter.shortFlag,
+ })),
+ },
+ });
+ }, [schema, currentTool, contextSelection]);
+
+ const agent = useMemo(() => {
+ const aiModel = createModelInstance(provider, currentKeys.key ?? "", model);
+
+ const editToolDef = createEditTool(
+ () => pendingPreviewRef.current ?? currentToolRef.current,
+ (tool) => {
+ pendingPreviewRef.current = tool;
+ onStreamingToolRef.current?.(tool);
+ },
+ );
+
+ const applyToolDefinitionDef = createApplyToolDefinitionTool(
+ () => { },
+ () => {
+ if (pendingPreviewRef.current) {
+ onApplyRef.current(replaceKey(pendingPreviewRef.current) as Tool);
+ pendingPreviewRef.current = null;
+ }
+ onStreamingToolRef.current?.(null);
+ },
+ );
+
+ const tools = {
+ editTool: editToolDef,
+ applyToolDefinition: applyToolDefinitionDef,
+ readTool: createReadTool(() => pendingPreviewRef.current ?? currentToolRef.current),
+ ...(tavilyKeys.isSaved && tavilyKeys.key ? { tavilySearch: createTavilySearchTool(tavilyKeys.key) } : {}),
+ ...(tavilyKeys.isSaved && tavilyKeys.key ? { tavilyExtract: createTavilyExtractTool(tavilyKeys.key) } : {}),
+ };
- let fullText = "";
- let applyToolCallId: string | null = null;
- let reasoningBuffer = "";
- const continuationHistory: ModelMessage[] = [...priorModelMessages];
- let currentStepText = "";
- const currentStepCalls: Array<{ toolCallId: string; toolName: string; input: Record }> = [];
- const resolvedResults = new Map();
- const flushCompletedStep = () => {
- const assistantContent = [
- ...(currentStepText ? [{ type: "text" as const, text: currentStepText }] : []),
- ...currentStepCalls.map((c) => ({ type: "tool-call" as const, toolCallId: c.toolCallId, toolName: c.toolName, input: c.input })),
- ];
- if (assistantContent.length > 0) {
- continuationHistory.push({ role: "assistant", content: assistantContent } as ModelMessage);
- }
- if (currentStepCalls.length > 0) {
- continuationHistory.push({
- role: "tool",
- content: currentStepCalls.map((c) => ({
- type: "tool-result" as const,
- toolCallId: c.toolCallId,
- toolName: c.toolName,
- output: resolvedResults.get(c.toolCallId),
- })),
- } as ModelMessage);
+ return createToolLoopAgent({
+ model: aiModel,
+ instructions: systemPrompt,
+ tools,
+ providerOptions,
+ onFinish: ({ usage: currentUsage }) => setUsage(currentUsage),
+ });
+ }, [provider, currentKeys.key, model, systemPrompt, providerOptions, tavilyKeys.isSaved, tavilyKeys.key]);
+
+ const transport = useMemo(
+ () => new DirectChatTransport({
+ agent,
+ sendReasoning: true,
+ }),
+ [agent],
+ );
+
+ const chatCallbacksRef = useRef({
+ onFinish: () => { },
+ onError: (_error: Error) => { },
+ });
+
+ chatCallbacksRef.current = {
+ onFinish: () => {
+ onGeneratingChangeRef.current?.(false);
+ },
+ onError: (error) => {
+ onGeneratingChangeRef.current?.(false);
+ onStreamingToolRef.current?.(null);
+ if (error.name !== "AbortError") {
+ toast.error(error.message || "AI request failed");
+ }
+ },
+ };
+
+ const chatInstance = useMemo(
+ () => new Chat({
+ id: chatId,
+ messages: chatMessagesRef.current,
+ transport,
+ sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
+ onFinish: () => chatCallbacksRef.current.onFinish(),
+ onError: (error) => chatCallbacksRef.current.onError(error),
+ }),
+ [chatId, transport],
+ );
+
+ const chat = useChat({ chat: chatInstance });
+ const rawMessages = chat.messages;
+ const isStreaming = chat.status === "submitted" || chat.status === "streaming";
+ chatMessagesRef.current = rawMessages;
+
+ const persistTimerRef = useRef | null>(null);
+
+ useEffect(() => {
+ const persistableMessages = getPersistableMessages(rawMessages);
+ if (persistableMessages.length === 0) return;
+
+ if (persistTimerRef.current) {
+ clearTimeout(persistTimerRef.current);
+ }
+
+ const delay = isStreaming ? 2000 : 0;
+
+ persistTimerRef.current = setTimeout(() => {
+ persistTimerRef.current = null;
+ saveChatSession({
+ id: chatIdRef.current,
+ toolName: currentToolRef.current.name,
+ messages: persistableMessages,
+ updatedAt: Date.now(),
+ preview: getSessionPreview(persistableMessages),
+ })
+ .then(() => loadRecentSessions(currentToolRef.current.name))
+ .then(setRecentSessions)
+ .catch(console.error);
+ }, delay);
+
+ return () => {
+ if (persistTimerRef.current) {
+ clearTimeout(persistTimerRef.current);
+ }
+ };
+ }, [rawMessages, isStreaming]);
+
+ useEffect(() => {
+ setApprovalArtifacts((previous) => {
+ const next = { ...previous };
+ let changed = false;
+
+ for (const message of rawMessages) {
+ for (const part of message.parts) {
+ if (!isToolUIPart(part) || getToolName(part) !== "applyToolDefinition") {
+ continue;
}
- currentStepText = "";
- currentStepCalls.length = 0;
- resolvedResults.clear();
- };
-
- for await (const part of fullStream) {
- if (part.type === "text-delta") {
- fullText += part.text;
- currentStepText += part.text;
- setMessages((prev) => {
- const updated = [...prev];
- updated[updated.length - 1] = {
- role: "assistant",
- content: fullText,
- toolCalls: updated[updated.length - 1].toolCalls,
- toolApplied: updated[updated.length - 1].toolApplied,
- reasoningContent: updated[updated.length - 1].reasoningContent,
- };
- return updated;
- });
- } else if (part.type === "reasoning-delta") {
- reasoningBuffer += part.text;
- setMessages((prev) => {
- const updated = [...prev];
- updated[updated.length - 1] = {
- ...updated[updated.length - 1],
- reasoningContent: reasoningBuffer,
- };
- return updated;
- });
- } else if (part.type === "tool-input-start" && part.toolName === "editTool") {
- setMessages((prev) => {
- const updated = [...prev];
- const last = updated[updated.length - 1];
- const entry: ToolCallEntry = {
- toolCallId: part.id,
- toolName: "editTool",
- title: "Editing…",
- input: {},
- state: "input-available",
- };
- updated[updated.length - 1] = {
- ...last,
- toolCalls: [...(last.toolCalls ?? []), entry],
- };
- return updated;
- });
- } else if (part.type === "tool-input-start" && part.toolName === "readTool") {
- setMessages((prev) => {
- const updated = [...prev];
- const last = updated[updated.length - 1];
- const entry: ToolCallEntry = {
- toolCallId: part.id,
- toolName: "readTool",
- title: "Reading tool JSON…",
- input: {},
- state: "input-available",
- };
- updated[updated.length - 1] = {
- ...last,
- toolCalls: [...(last.toolCalls ?? []), entry],
- };
- return updated;
- });
- } else if (part.type === "tool-input-start" && part.toolName === "applyToolDefinition") {
- applyToolCallId = part.id;
- onGeneratingChange?.(true);
- setMessages((prev) => {
- const updated = [...prev];
- const last = updated[updated.length - 1];
- const entry: ToolCallEntry = {
- toolCallId: part.id,
- toolName: "applyToolDefinition",
- title: "Preparing to apply",
- input: {},
- state: "input-available",
- };
- updated[updated.length - 1] = {
- ...last,
- toolCalls: [...(last.toolCalls ?? []), entry],
- };
- return updated;
- });
- } else if (
- part.type === "tool-input-start" &&
- (part.toolName === "tavilySearch" || part.toolName === "tavilyExtract")
- ) {
- setMessages((prev) => {
- const updated = [...prev];
- const last = updated[updated.length - 1];
- const entry: ToolCallEntry = {
- toolCallId: part.id,
- toolName: part.toolName,
- title: part.toolName === "tavilySearch" ? "Web Search" : "Extract Content",
- input: {},
- state: "input-available",
- };
- updated[updated.length - 1] = {
- ...last,
- toolCalls: [...(last.toolCalls ?? []), entry],
- };
- return updated;
- });
- } else if (part.type === "tool-call" && part.toolName !== "applyToolDefinition") {
- currentStepCalls.push({ toolCallId: part.toolCallId, toolName: part.toolName, input: part.input as Record });
- if (part.toolName === "editTool") {
- const editInput = part.input as { summary?: string };
- setMessages((prev) => {
- const updated = [...prev];
- const last = updated[updated.length - 1];
- const toolCalls = (last.toolCalls ?? []).map((tc) =>
- tc.toolCallId === part.toolCallId
- ? { ...tc, title: editInput.summary ?? "Editing…", input: part.input as Record }
- : tc,
- );
- updated[updated.length - 1] = { ...last, toolCalls };
- return updated;
- });
- } else if (part.toolName === "tavilySearch" || part.toolName === "tavilyExtract") {
- setMessages((prev) => {
- const updated = [...prev];
- const last = updated[updated.length - 1];
- const toolCalls = (last.toolCalls ?? []).map((tc) =>
- tc.toolCallId === part.toolCallId
- ? { ...tc, input: part.input as Record }
- : tc,
- );
- updated[updated.length - 1] = { ...last, toolCalls };
- return updated;
- });
- }
- } else if (part.type === "tool-result" && part.toolName === "editTool") {
- setToolCallCount((c) => c + 1);
- resolvedResults.set(part.toolCallId, part.output);
- if (resolvedResults.size === currentStepCalls.length && currentStepCalls.length > 0) {
- flushCompletedStep();
- }
- setMessages((prev) => {
- const updated = [...prev];
- const last = updated[updated.length - 1];
- const toolCalls = (last.toolCalls ?? []).map((tc) =>
- tc.toolCallId === part.toolCallId
- ? { ...tc, state: "output-available" as const }
- : tc,
- );
- updated[updated.length - 1] = { ...last, toolCalls };
- return updated;
- });
- } else if (part.type === "tool-result" && part.toolName === "readTool") {
- setToolCallCount((c) => c + 1);
- resolvedResults.set(part.toolCallId, part.output);
- if (resolvedResults.size === currentStepCalls.length && currentStepCalls.length > 0) {
- flushCompletedStep();
- }
- setMessages((prev) => {
- const updated = [...prev];
- const last = updated[updated.length - 1];
- const toolCalls = (last.toolCalls ?? []).map((tc) =>
- tc.toolCallId === part.toolCallId
- ? { ...tc, state: "output-available" as const }
- : tc,
- );
- updated[updated.length - 1] = { ...last, toolCalls };
- return updated;
- });
- } else if (
- part.type === "tool-approval-request" &&
- part.toolCall?.toolName === "applyToolDefinition"
- ) {
- onGeneratingChange?.(false);
- const input = (part.toolCall?.input ?? {}) as { summary: string };
- const toolCallId = (part.toolCall as { toolCallId?: string } | undefined)?.toolCallId ?? part.approvalId;
- const previewTool = pendingPreview ?? toolSnapshot;
- const originalTool = toolSnapshot;
-
- const finalStepContent = [
- ...(currentStepText ? [{ type: "text" as const, text: currentStepText }] : []),
- { type: "tool-call" as const, toolCallId, toolName: "applyToolDefinition", input: input as Record },
- ];
- continuationHistory.push({ role: "assistant", content: finalStepContent } as ModelMessage);
-
- waitingForApproval = true;
- onStreamingTool?.(previewTool);
- setMessages((prev) => {
- const updated = [...prev];
- const last = updated[updated.length - 1];
- const toolCalls = (last.toolCalls ?? []).map((tc) =>
- tc.toolCallId === applyToolCallId
- ? { ...tc, state: "approval-requested" as const, originalTool, previewTool }
- : tc,
- );
- updated[updated.length - 1] = { ...last, toolCalls };
- return updated;
- });
- setPendingApproval({
- approvalId: part.approvalId,
- toolCallId,
- continuationMessages: continuationHistory,
- previewTool,
- originalTool,
- summary: input.summary,
- messageIndex: assistantMessageIndex,
- });
- } else if (part.type === "tool-result" && part.toolName === "tavilySearch") {
- setToolCallCount((c) => c + 1);
- resolvedResults.set(part.toolCallId, part.output);
- if (resolvedResults.size === currentStepCalls.length && currentStepCalls.length > 0) {
- flushCompletedStep();
- }
- setMessages((prev) => {
- const updated = [...prev];
- const last = updated[updated.length - 1];
- const toolCalls = (last.toolCalls ?? []).map((tc) =>
- tc.toolCallId === part.toolCallId
- ? { ...tc, state: "output-available" as const, output: part.output }
- : tc,
- );
- updated[updated.length - 1] = { ...last, toolCalls };
- return updated;
- });
- } else if (part.type === "tool-result" && part.toolName === "tavilyExtract") {
- setToolCallCount((c) => c + 1);
- resolvedResults.set(part.toolCallId, part.output);
- if (resolvedResults.size === currentStepCalls.length && currentStepCalls.length > 0) {
- flushCompletedStep();
- }
- setMessages((prev) => {
- const updated = [...prev];
- const last = updated[updated.length - 1];
- const toolCalls = (last.toolCalls ?? []).map((tc) =>
- tc.toolCallId === part.toolCallId
- ? { ...tc, state: "output-available" as const, output: part.output }
- : tc,
- );
- updated[updated.length - 1] = { ...last, toolCalls };
- return updated;
- });
+
+ const approvalId = part.approval?.id;
+ if (!approvalId || next[approvalId]) {
+ continue;
}
- }
- onGeneratingChange?.(false);
- if (!waitingForApproval) onStreamingTool?.(null);
-
- setMessages((prev) => {
- const messages = prev.filter(
- (m) => m.role !== "assistant" || m.content !== "" || (m.toolCalls && m.toolCalls.length > 0),
- );
- const session: ChatSession = {
- id: sessionIdRef.current,
- toolName: currentTool.name,
- messages,
- updatedAt: Date.now(),
- preview: (messages.find((m) => m.role === "user")?.content ?? "").slice(0, 80),
+ next[approvalId] = {
+ approvalId,
+ previewTool: cloneTool(pendingPreviewRef.current ?? currentToolRef.current),
+ originalTool: cloneTool(currentToolRef.current),
+ summary: typeof part.input === "object" && part.input && "summary" in part.input && typeof part.input.summary === "string"
+ ? part.input.summary
+ : "Apply AI changes",
};
- saveChatSession(session).catch(() => { });
- return prev;
- });
- } catch (error) {
- onGeneratingChange?.(false);
- onStreamingTool?.(null);
- setPendingApproval(null);
- const isAbort = error instanceof Error && error.name === "AbortError";
- if (isAbort) {
- if (isToolApplied) {
- onApply(toolSnapshot);
- }
- } else {
- toast.error(error instanceof Error ? error.message : "AI request failed");
+ changed = true;
}
- setMessages((prev) => prev.slice(0, -2));
- } finally {
- setIsStreaming(false);
}
- },
- [
- currentTool,
- contextSelection,
- provider,
- currentKeys.key,
- model,
- reasoningEffort,
- tavilyKeys,
- onApply,
- onGeneratingChange,
- onStreamingTool,
- ],
+
+ return changed ? next : previous;
+ });
+ }, [rawMessages]);
+
+ const messages = useMemo(
+ () => rawMessages
+ .filter((message) => message.role === "user" || message.role === "assistant")
+ .map((message) => toChatMessage(message, approvalArtifacts)),
+ [rawMessages, approvalArtifacts],
);
+ const pendingApproval = useMemo(() => findPendingApproval(messages), [messages]);
+
+ const toolCallCount = useMemo(() => countCompletedToolCalls(rawMessages), [rawMessages]);
+
+ useEffect(() => {
+ const hasPendingApproval = rawMessages.some((message) =>
+ message.parts.some((part) =>
+ isToolUIPart(part)
+ && getToolName(part) === "applyToolDefinition"
+ && part.state === "approval-requested",
+ ),
+ );
+
+ const isApplying = rawMessages.some((message) =>
+ message.parts.some((part) =>
+ isToolUIPart(part)
+ && getToolName(part) === "applyToolDefinition"
+ && (part.state === "input-available" || part.state === "approval-responded"),
+ ),
+ );
+
+ onGeneratingChangeRef.current?.(isStreaming && isApplying);
+
+ if (!hasPendingApproval && !isApplying && !isStreaming) {
+ pendingPreviewRef.current = null;
+ onStreamingToolRef.current?.(null);
+ }
+ }, [rawMessages, isStreaming]);
+
const sendMessage = useCallback(
async (textOverride?: string) => {
const userText = (textOverride ?? input).trim();
@@ -759,235 +517,99 @@ function useAIChat(
return;
}
- if (!textOverride) setInput("");
- const history = messages;
- setMessages((prev) => [
- ...prev,
- { role: "user", content: userText },
- { role: "assistant", content: "" },
- ]);
- setIsStreaming(true);
- setToolCallCount(0);
- await runStream(userText, history);
+ pendingPreviewRef.current = null;
+ setApprovalArtifacts({});
+ if (!textOverride) {
+ setInput("");
+ }
+
+ await chat.sendMessage({ text: userText });
},
- [input, isStreaming, model, currentKeys.key, messages, runStream],
+ [input, isStreaming, model, currentKeys.key, chat],
);
- const stopStreaming = useCallback(() => {
- abortControllerRef.current?.abort();
- }, []);
-
const resendFromIndex = useCallback(
async (index: number, newContent: string) => {
if (isStreaming || !newContent.trim() || !model || !currentKeys.key) return;
- setPendingApproval(null);
- onStreamingTool?.(null);
- const history = messages.slice(0, index);
- setMessages([
- ...history,
- { role: "user", content: newContent },
- { role: "assistant", content: "" },
- ]);
- setIsStreaming(true);
- await runStream(newContent, history);
+
+ pendingPreviewRef.current = null;
+ setApprovalArtifacts({});
+ onStreamingToolRef.current?.(null);
+ chat.setMessages(rawMessages.slice(0, index));
+ await chat.sendMessage({ text: newContent });
},
- [isStreaming, model, currentKeys.key, messages, runStream, onStreamingTool],
+ [isStreaming, model, currentKeys.key, chat, rawMessages],
);
const clearMessages = useCallback(() => {
- const sessionIdToSave = sessionIdRef.current;
- setMessages((prev) => {
- if (prev.length > 0) {
- const messages = prev.filter(
- (m) => m.role !== "assistant" || m.content !== "" || (m.toolCalls && m.toolCalls.length > 0),
- );
- if (messages.length > 0) {
- const session: ChatSession = {
- id: sessionIdToSave,
- toolName: currentTool.name,
- messages,
- updatedAt: Date.now(),
- preview: (messages.find((m) => m.role === "user")?.content ?? "").slice(0, 80),
- };
- saveChatSession(session)
- .then(() => loadRecentSessions(currentTool.name))
- .then(setRecentSessions)
- .catch(() => { });
- }
- }
- return [];
- });
- setInput("");
- sessionIdRef.current = crypto.randomUUID();
- setPendingApproval(null);
- setToolCallCount(0);
- onStreamingTool?.(null);
- }, [currentTool.name, onStreamingTool]);
-
- const runContinuation = useCallback(
- async (messages: ModelMessage[], approvedMessageIndex: number, toolSnapshot: Tool) => {
- if (!model || !currentKeys.key) return;
-
- setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
- setIsStreaming(true);
- abortControllerRef.current = new AbortController();
-
- try {
- const systemPrompt = generatePrompt(schemaRef.current ? JSON.stringify(schemaRef.current, null, 2) : "{}");
- const aiModel = createModelInstance(provider, currentKeys.key, model);
- let pendingPreview: Tool | null = null;
-
- const continuationEditTool = createEditTool(
- () => pendingPreview ?? toolSnapshot,
- (t) => { pendingPreview = t; onStreamingTool?.(t); }
- );
-
- const continuationApplyToolDefinition = createApplyToolDefinitionTool(
- () => { },
- () => {
- if (pendingPreview) {
- onApply(replaceKey(pendingPreview) as Tool);
- pendingPreview = null;
- }
- onStreamingTool?.(null);
- }
- );
-
- const tavilySearchTool = tavilyKeys.isSaved && tavilyKeys.key
- ? createTavilySearchTool(tavilyKeys.key)
- : undefined;
-
- const continuationReadTool = createReadTool(() => pendingPreview ?? toolSnapshot);
-
- const { fullStream } = streamText({
- model: aiModel,
- system: systemPrompt,
- messages,
- tools: {
- editTool: continuationEditTool,
- applyToolDefinition: continuationApplyToolDefinition,
- readTool: continuationReadTool,
- ...(tavilySearchTool ? { tavilySearch: tavilySearchTool } : {}),
- },
- stopWhen: [
- stepCountIs(20), // Maximum 20 steps
- hasToolCall('applyToolDefinition'), // Stop after calling 'applyToolDefinition'
- ],
- abortSignal: abortControllerRef.current.signal,
- onFinish: ({ usage: u }) => setUsage(u),
- providerOptions: (reasoningEffort && MODEL_GROUPS.flatMap((g) => g.models).find((m) => m.value === model)?.reasoning === true
- ? getProviderOptions(provider, model, reasoningEffort)
- : undefined) as Record>> | undefined,
- });
+ if (persistTimerRef.current) {
+ clearTimeout(persistTimerRef.current);
+ persistTimerRef.current = null;
+ }
- let fullText = "";
- let reasoningBuffer = "";
-
- for await (const part of fullStream) {
- if (part.type === "text-delta") {
- fullText += part.text;
- setMessages((prev) => {
- const updated = [...prev];
- updated[updated.length - 1] = { ...updated[updated.length - 1], content: fullText };
- return updated;
- });
- } else if (part.type === "reasoning-delta") {
- reasoningBuffer += part.text;
- setMessages((prev) => {
- const updated = [...prev];
- updated[updated.length - 1] = { ...updated[updated.length - 1], reasoningContent: reasoningBuffer };
- return updated;
- });
- } else if (part.type === "tool-result" && part.toolName === "applyToolDefinition") {
- setToolCallCount((c) => c + 1);
- setMessages((prev) => {
- const updated = [...prev];
- if (approvedMessageIndex < updated.length) {
- const approvedMsg = updated[approvedMessageIndex];
- const toolCalls = (approvedMsg.toolCalls ?? []).map((tc) =>
- tc.toolName === "applyToolDefinition"
- ? { ...tc, state: "output-available" as const }
- : tc,
- );
- updated[approvedMessageIndex] = {
- ...approvedMsg,
- toolApplied: true,
- toolCalls,
- };
- }
- return updated;
- });
- }
- }
+ const persistableMessages = getPersistableMessages(rawMessages);
+
+ const resetChat = () => {
+ chatMessagesRef.current = [];
+ setChatId(crypto.randomUUID());
+ setInput("");
+ setUsage(null);
+ setApprovalArtifacts({});
+ pendingPreviewRef.current = null;
+ onStreamingToolRef.current?.(null);
+ onGeneratingChangeRef.current?.(false);
+ };
- onGeneratingChange?.(false);
- onStreamingTool?.(null);
-
- setMessages((prev) => {
- const messages = prev.filter(
- (m) => m.role !== "assistant" || m.content !== "" || (m.toolCalls && m.toolCalls.length > 0),
- );
- const session: ChatSession = {
- id: sessionIdRef.current,
- toolName: currentTool.name,
- messages,
- updatedAt: Date.now(),
- preview: (messages.find((m) => m.role === "user")?.content ?? "").slice(0, 80),
- };
- saveChatSession(session).catch(() => { });
- return prev;
- });
- } catch (error) {
- const isAbort = error instanceof Error && error.name === "AbortError";
- if (!isAbort) toast.error(error instanceof Error ? error.message : "AI request failed");
- setMessages((prev) => prev.slice(0, -1));
- onStreamingTool?.(null);
- } finally {
- setIsStreaming(false);
- }
- },
- [currentTool, provider, currentKeys.key, model, reasoningEffort, tavilyKeys, onApply, onGeneratingChange, onStreamingTool],
- );
+ if (persistableMessages.length === 0) {
+ resetChat();
+ return;
+ }
+
+ saveChatSession({
+ id: chatIdRef.current,
+ toolName: currentTool.name,
+ messages: persistableMessages,
+ updatedAt: Date.now(),
+ preview: getSessionPreview(persistableMessages),
+ })
+ .then(() => loadRecentSessions(currentTool.name))
+ .then(setRecentSessions)
+ .catch(console.error)
+ .finally(resetChat);
+ }, [rawMessages, currentTool.name]);
const confirmPatch = useCallback(async () => {
if (!pendingApproval) return;
- const { toolCallId, continuationMessages: priorMessages, previewTool, originalTool, messageIndex } = pendingApproval;
-
- onApply(replaceKey(previewTool) as Tool);
- onStreamingTool?.(null);
-
- const continuationMessages: ModelMessage[] = [
- ...priorMessages,
- {
- role: "tool",
- content: [{ type: "tool-result", toolCallId, toolName: "applyToolDefinition", output: { type: "text", value: "Applied successfully. Provide a concise summary of all the changes you made to the tool." } }],
- } as ModelMessage,
- ];
- setPendingApproval(null);
- await runContinuation(continuationMessages, messageIndex, originalTool);
- }, [pendingApproval, runContinuation, onApply, onStreamingTool]);
+
+ await chat.addToolApprovalResponse({
+ id: pendingApproval.approvalId,
+ approved: true,
+ reason: "Applied successfully. Provide a concise summary of all the changes you made to the tool.",
+ });
+ }, [pendingApproval, chat]);
const rejectPatch = useCallback(async () => {
if (!pendingApproval) return;
- const { toolCallId, continuationMessages: priorMessages, originalTool, messageIndex } = pendingApproval;
- const continuationMessages: ModelMessage[] = [
- ...priorMessages,
- {
- role: "tool",
- content: [{ type: "tool-result", toolCallId, toolName: "applyToolDefinition", output: { type: "text", value: "User rejected the changes" } }],
- } as ModelMessage,
- ];
- setPendingApproval(null);
- onStreamingTool?.(null);
- await runContinuation(continuationMessages, messageIndex, originalTool);
- }, [pendingApproval, runContinuation, onStreamingTool]);
-
- const loadSession = useCallback((session: ChatSession) => {
- setMessages(session.messages);
- sessionIdRef.current = session.id as `${string}-${string}-${string}-${string}-${string}`;
- setPendingApproval(null);
- onStreamingTool?.(null);
- }, [onStreamingTool]);
+
+ await chat.addToolApprovalResponse({
+ id: pendingApproval.approvalId,
+ approved: false,
+ reason: "User rejected the changes",
+ });
+ pendingPreviewRef.current = null;
+ onStreamingToolRef.current?.(null);
+ }, [pendingApproval, chat]);
+
+ const loadSession = useCallback((session: ChatSession) => {
+ chatMessagesRef.current = session.messages;
+ setChatId(session.id);
+ setUsage(null);
+ setInput("");
+ setApprovalArtifacts({});
+ pendingPreviewRef.current = null;
+ onStreamingToolRef.current?.(null);
+ onGeneratingChangeRef.current?.(false);
+ }, []);
return {
messages,
@@ -1000,7 +622,7 @@ function useAIChat(
setReasoningEffort,
provider,
sendMessage,
- stopStreaming,
+ stopStreaming: chat.stop,
resendFromIndex,
clearMessages,
allProviderKeys,
@@ -1015,143 +637,6 @@ function useAIChat(
};
}
-function DiffView({ original, updated }: { original: Tool; updated: Tool }) {
- const aJson = JSON.stringify(exportToStructuredJSON(original), null, 2);
- const bJson = JSON.stringify(exportToStructuredJSON(updated), null, 2);
- const diff = computeLineDiff(aJson, bJson);
- const CONTEXT = 2;
- const changedSet = new Set(diff.flatMap((d, idx) => (d.type !== "same" ? [idx] : [])));
- const visibleSet = new Set();
- for (const idx of changedSet) {
- for (let k = Math.max(0, idx - CONTEXT); k <= Math.min(diff.length - 1, idx + CONTEXT); k++) {
- visibleSet.add(k);
- }
- }
- if (visibleSet.size === 0) {
- return No changes
;
- }
- const sortedIndices = [...visibleSet].sort((a, b) => a - b);
- const chunks: number[][] = [];
- let current: number[] = [];
- for (let k = 0; k < sortedIndices.length; k++) {
- if (current.length === 0 || sortedIndices[k] === sortedIndices[k - 1] + 1) {
- current.push(sortedIndices[k]);
- } else {
- chunks.push(current);
- current = [sortedIndices[k]];
- }
- }
- if (current.length > 0) chunks.push(current);
- return (
-
- {chunks.map((chunk, ci) => (
-
- {ci > 0 && (
-
···
- )}
- {chunk.map((lineIdx) => {
- const line = diff[lineIdx];
- return (
-
- {line.type === "add" ? "+ " : line.type === "remove" ? "- " : " "}
- {line.text}
-
- );
- })}
-
- ))}
-
- );
-}
-
-interface WebSearchResult {
- title: string;
- url: string;
- content: string;
-}
-
-function WebSearchResults({ results }: { results: WebSearchResult[] }) {
- return (
-
- );
-}
-
-interface ExtractResult {
- url: string;
- raw_content: string;
- hasMore?: boolean;
-}
-
-function ExtractedContent({ results }: { results: ExtractResult[] }) {
- return (
-
- {results.map((r) => {
- let hostname = r.url;
- try {
- hostname = new URL(r.url).hostname;
- } catch { /* ignore */ }
- return (
-
-
-
- {r.raw_content}
-
-
- );
- })}
-
- );
-}
-
interface MessagePartProps {
msg: ChatMessage;
index: number;
diff --git a/tests/tool-editor/ai-chat-persistence.test.ts b/tests/tool-editor/ai-chat-persistence.test.ts
new file mode 100644
index 0000000..699b3d1
--- /dev/null
+++ b/tests/tool-editor/ai-chat-persistence.test.ts
@@ -0,0 +1,441 @@
+import "fake-indexeddb/auto";
+import type { UIMessage } from "ai";
+import {
+ saveChatSession,
+ loadRecentSessions,
+ getPersistableMessages,
+ getSessionPreview,
+ type ChatSession,
+} from "@/components/ai-chat/ai-chat-persistence";
+
+// Persistence is type-agnostic (JSON serialization), so we cast complex
+// SDK tool-invocation parts to avoid fighting deeply generic UIMessage types.
+function makeToolPart(toolName: string, overrides: Record = {}): UIMessage["parts"][number] {
+ return {
+ type: `tool-${toolName}`,
+ toolCallId: crypto.randomUUID(),
+ state: "output-available",
+ input: {},
+ output: {},
+ ...overrides,
+ } as unknown as UIMessage["parts"][number];
+}
+
+function makeTextMessage(role: "user" | "assistant", text: string, id?: string): UIMessage {
+ return {
+ id: id ?? crypto.randomUUID(),
+ role,
+ parts: [{ type: "text", text }],
+ };
+}
+
+function makeEmptyAssistantMessage(): UIMessage {
+ return {
+ id: crypto.randomUUID(),
+ role: "assistant",
+ parts: [{ type: "step-start" }],
+ };
+}
+
+function makeSession(overrides: Partial = {}): ChatSession {
+ return {
+ id: crypto.randomUUID(),
+ toolName: "test-tool",
+ messages: [
+ makeTextMessage("user", "Hello"),
+ makeTextMessage("assistant", "Hi there"),
+ ],
+ updatedAt: Date.now(),
+ preview: "Hello",
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ // Reset IndexedDB between tests to avoid cross-test contamination
+ indexedDB = new IDBFactory();
+});
+
+describe("saveChatSession", () => {
+ it("saves a session and retrieves it by toolName", async () => {
+ const session = makeSession();
+
+ await saveChatSession(session);
+ const loaded = await loadRecentSessions(session.toolName);
+
+ expect(loaded).toHaveLength(1);
+ expect(loaded[0].id).toBe(session.id);
+ expect(loaded[0].toolName).toBe(session.toolName);
+ expect(loaded[0].messages).toHaveLength(2);
+ expect(loaded[0].messages[0].parts[0]).toEqual({ type: "text", text: "Hello" });
+ });
+
+ it("overwrites a session with the same id", async () => {
+ const session = makeSession();
+ await saveChatSession(session);
+
+ const updated: ChatSession = {
+ ...session,
+ messages: [
+ makeTextMessage("user", "Updated message"),
+ makeTextMessage("assistant", "Updated response"),
+ ],
+ updatedAt: Date.now() + 1000,
+ preview: "Updated message",
+ };
+ await saveChatSession(updated);
+
+ const loaded = await loadRecentSessions(session.toolName);
+ expect(loaded).toHaveLength(1);
+ expect(loaded[0].preview).toBe("Updated message");
+ });
+
+ it("stores messages as plain JSON-safe objects", async () => {
+ const msg = makeTextMessage("user", "test");
+ // Attach a non-serializable property to verify JSON roundtrip strips it
+ (msg as unknown as Record).fn = () => {};
+
+ const session = makeSession({ messages: [msg] });
+ await saveChatSession(session);
+
+ const loaded = await loadRecentSessions(session.toolName);
+ expect(loaded[0].messages[0]).not.toHaveProperty("fn");
+ });
+});
+
+describe("loadRecentSessions", () => {
+ it("returns empty array when no sessions exist", async () => {
+ const loaded = await loadRecentSessions("nonexistent-tool");
+ expect(loaded).toEqual([]);
+ });
+
+ it("filters sessions by toolName", async () => {
+ await saveChatSession(makeSession({ toolName: "tool-a" }));
+ await saveChatSession(makeSession({ toolName: "tool-b" }));
+ await saveChatSession(makeSession({ toolName: "tool-a" }));
+
+ const sessionsA = await loadRecentSessions("tool-a");
+ const sessionsB = await loadRecentSessions("tool-b");
+
+ expect(sessionsA).toHaveLength(2);
+ expect(sessionsB).toHaveLength(1);
+ expect(sessionsA.every((s) => s.toolName === "tool-a")).toBe(true);
+ });
+
+ it("returns sessions sorted by updatedAt descending", async () => {
+ const now = Date.now();
+ await saveChatSession(makeSession({ toolName: "t", updatedAt: now - 2000 }));
+ await saveChatSession(makeSession({ toolName: "t", updatedAt: now }));
+ await saveChatSession(makeSession({ toolName: "t", updatedAt: now - 1000 }));
+
+ const loaded = await loadRecentSessions("t");
+ expect(loaded[0].updatedAt).toBe(now);
+ expect(loaded[1].updatedAt).toBe(now - 1000);
+ expect(loaded[2].updatedAt).toBe(now - 2000);
+ });
+
+ it("respects the limit parameter", async () => {
+ for (let i = 0; i < 10; i++) {
+ await saveChatSession(makeSession({ toolName: "t", updatedAt: i }));
+ }
+
+ const loaded = await loadRecentSessions("t", 3);
+ expect(loaded).toHaveLength(3);
+ });
+
+ it("defaults to a limit of 5", async () => {
+ for (let i = 0; i < 8; i++) {
+ await saveChatSession(makeSession({ toolName: "t", updatedAt: i }));
+ }
+
+ const loaded = await loadRecentSessions("t");
+ expect(loaded).toHaveLength(5);
+ });
+
+ it("regenerates preview from messages on load", async () => {
+ const session = makeSession({
+ messages: [makeTextMessage("user", "My actual question")],
+ preview: "stale-preview",
+ });
+ await saveChatSession(session);
+
+ const loaded = await loadRecentSessions(session.toolName);
+ expect(loaded[0].preview).toBe("My actual question");
+ });
+});
+
+describe("getPersistableMessages", () => {
+ it("keeps user messages regardless of content", () => {
+ const messages: UIMessage[] = [
+ makeTextMessage("user", "hello"),
+ makeTextMessage("user", ""),
+ ];
+ expect(getPersistableMessages(messages)).toHaveLength(2);
+ });
+
+ it("filters out assistant messages that only have step-start parts", () => {
+ const messages: UIMessage[] = [
+ makeTextMessage("user", "hello"),
+ makeEmptyAssistantMessage(),
+ makeTextMessage("assistant", "response"),
+ ];
+ const result = getPersistableMessages(messages);
+ expect(result).toHaveLength(2);
+ expect(result[0].role).toBe("user");
+ expect(result[1].role).toBe("assistant");
+ expect(result[1].parts[0]).toEqual({ type: "text", text: "response" });
+ });
+
+ it("keeps assistant messages with text content", () => {
+ const messages: UIMessage[] = [
+ makeTextMessage("assistant", "I have content"),
+ ];
+ expect(getPersistableMessages(messages)).toHaveLength(1);
+ });
+
+ it("keeps assistant messages with tool-invocation parts", () => {
+ const messages: UIMessage[] = [
+ {
+ id: "1",
+ role: "assistant",
+ parts: [
+ { type: "step-start" },
+ makeToolPart("editTool", { input: {}, output: "done" }),
+ ],
+ },
+ ];
+ expect(getPersistableMessages(messages)).toHaveLength(1);
+ });
+
+ it("filters assistant messages with only whitespace text", () => {
+ const messages: UIMessage[] = [
+ {
+ id: "1",
+ role: "assistant",
+ parts: [{ type: "text", text: " \n " }],
+ },
+ ];
+ expect(getPersistableMessages(messages)).toHaveLength(0);
+ });
+});
+
+describe("getSessionPreview", () => {
+ it("returns text from the first user message", () => {
+ const messages: UIMessage[] = [
+ makeTextMessage("assistant", "Welcome"),
+ makeTextMessage("user", "My question here"),
+ ];
+ expect(getSessionPreview(messages)).toBe("My question here");
+ });
+
+ it("truncates to 80 characters", () => {
+ const longText = "a".repeat(120);
+ const messages: UIMessage[] = [makeTextMessage("user", longText)];
+ expect(getSessionPreview(messages)).toHaveLength(80);
+ });
+
+ it("returns empty string when no user messages exist", () => {
+ const messages: UIMessage[] = [makeTextMessage("assistant", "Hello")];
+ expect(getSessionPreview(messages)).toBe("");
+ });
+
+ it("joins multiple text parts from the same message", () => {
+ const messages: UIMessage[] = [
+ {
+ id: "1",
+ role: "user",
+ parts: [
+ { type: "text", text: "Part one " },
+ { type: "text", text: "Part two" },
+ ],
+ },
+ ];
+ expect(getSessionPreview(messages)).toBe("Part one Part two");
+ });
+});
+
+describe("onFinish callback integration", () => {
+ it("persists when onFinish fires with valid messages", async () => {
+ const chatId = crypto.randomUUID();
+ const toolName = "test-tool";
+ const finishedMessages: UIMessage[] = [
+ makeTextMessage("user", "Hello"),
+ makeTextMessage("assistant", "Hi there!"),
+ ];
+
+ // Simulate the exact onFinish flow from ai-chat.tsx
+ const persistableMessages = getPersistableMessages(finishedMessages);
+ expect(persistableMessages.length).toBeGreaterThan(0);
+
+ await saveChatSession({
+ id: chatId,
+ toolName,
+ messages: persistableMessages,
+ updatedAt: Date.now(),
+ preview: getSessionPreview(persistableMessages),
+ });
+
+ const loaded = await loadRecentSessions(toolName);
+ expect(loaded).toHaveLength(1);
+ expect(loaded[0].id).toBe(chatId);
+ expect(loaded[0].messages).toHaveLength(2);
+ });
+
+ it("skips persistence when all assistant messages are empty step-starts", async () => {
+ const finishedMessages: UIMessage[] = [
+ makeTextMessage("user", "Hello"),
+ makeEmptyAssistantMessage(),
+ ];
+
+ const persistableMessages = getPersistableMessages(finishedMessages);
+ // User messages always persist, but empty assistant messages are filtered
+ expect(persistableMessages).toHaveLength(1);
+ expect(persistableMessages[0].role).toBe("user");
+ });
+
+ it("persists multi-turn conversations with tool calls", async () => {
+ const chatId = crypto.randomUUID();
+ const toolName = "test-tool";
+ const finishedMessages: UIMessage[] = [
+ makeTextMessage("user", "Edit the tool name"),
+ {
+ id: "a1",
+ role: "assistant",
+ parts: [
+ { type: "step-start" },
+ { type: "text", text: "I'll edit the tool name for you." },
+ makeToolPart("editTool", {
+ input: { field: "name", value: "newName" },
+ output: { success: true },
+ }),
+ ],
+ },
+ {
+ id: "a2",
+ role: "assistant",
+ parts: [
+ { type: "step-start" },
+ makeToolPart("applyToolDefinition", {
+ input: {},
+ output: { applied: true },
+ }),
+ ],
+ },
+ {
+ id: "a3",
+ role: "assistant",
+ parts: [{ type: "text", text: "Done! I've updated the tool name." }],
+ },
+ ];
+
+ const persistableMessages = getPersistableMessages(finishedMessages);
+ expect(persistableMessages).toHaveLength(4);
+
+ await saveChatSession({
+ id: chatId,
+ toolName,
+ messages: persistableMessages,
+ updatedAt: Date.now(),
+ preview: getSessionPreview(persistableMessages),
+ });
+
+ const loaded = await loadRecentSessions(toolName);
+ expect(loaded).toHaveLength(1);
+ expect(loaded[0].messages).toHaveLength(4);
+ });
+
+ it("updates existing session on subsequent onFinish calls", async () => {
+ const chatId = crypto.randomUUID();
+ const toolName = "test-tool";
+
+ // First onFinish — one exchange
+ await saveChatSession({
+ id: chatId,
+ toolName,
+ messages: [makeTextMessage("user", "First message"), makeTextMessage("assistant", "First reply")],
+ updatedAt: Date.now(),
+ preview: "First message",
+ });
+
+ // Second onFinish — conversation continued
+ await saveChatSession({
+ id: chatId,
+ toolName,
+ messages: [
+ makeTextMessage("user", "First message"),
+ makeTextMessage("assistant", "First reply"),
+ makeTextMessage("user", "Second message"),
+ makeTextMessage("assistant", "Second reply"),
+ ],
+ updatedAt: Date.now() + 1000,
+ preview: "First message",
+ });
+
+ const loaded = await loadRecentSessions(toolName);
+ expect(loaded).toHaveLength(1);
+ expect(loaded[0].messages).toHaveLength(4);
+ });
+});
+
+describe("round-trip message integrity", () => {
+ it("preserves tool-invocation parts through save/load", async () => {
+ const messages: UIMessage[] = [
+ makeTextMessage("user", "edit the tool"),
+ {
+ id: "a1",
+ role: "assistant",
+ parts: [
+ { type: "text", text: "I'll edit the tool." },
+ makeToolPart("editTool", {
+ input: { field: "name", value: "newName" },
+ output: { success: true },
+ }),
+ ],
+ },
+ ];
+
+ const session = makeSession({ messages });
+ await saveChatSession(session);
+
+ const loaded = await loadRecentSessions(session.toolName);
+ const assistantMsg = loaded[0].messages.find((m) => m.role === "assistant")!;
+
+ expect(assistantMsg.parts).toHaveLength(2);
+ const toolPart = assistantMsg.parts[1] as Record;
+ expect(toolPart.type).toBe("tool-editTool");
+ expect(toolPart.input).toEqual({ field: "name", value: "newName" });
+ expect(toolPart.output).toEqual({ success: true });
+ });
+
+ it("preserves reasoning parts through save/load", async () => {
+ const messages: UIMessage[] = [
+ makeTextMessage("user", "think about this"),
+ {
+ id: "a1",
+ role: "assistant",
+ parts: [
+ { type: "reasoning", text: "Let me think...", providerMetadata: {} },
+ { type: "text", text: "Here's my answer" },
+ ],
+ },
+ ];
+
+ const session = makeSession({ messages });
+ await saveChatSession(session);
+
+ const loaded = await loadRecentSessions(session.toolName);
+ const parts = loaded[0].messages[1].parts;
+ expect(parts[0].type).toBe("reasoning");
+ if (parts[0].type === "reasoning") {
+ expect(parts[0].text).toBe("Let me think...");
+ }
+ });
+
+ it("handles empty messages array", async () => {
+ const session = makeSession({ messages: [] });
+ await saveChatSession(session);
+
+ const loaded = await loadRecentSessions(session.toolName);
+ expect(loaded[0].messages).toEqual([]);
+ });
+});
diff --git a/tests/tool-editor/dialogs/command-dialog.test.tsx b/tests/tool-editor/dialogs/command-dialog.test.tsx
index 696f11d..bef63b7 100644
--- a/tests/tool-editor/dialogs/command-dialog.test.tsx
+++ b/tests/tool-editor/dialogs/command-dialog.test.tsx
@@ -60,7 +60,7 @@ describe("CommandDialog - Rendering & Structure", () => {
expect(screen.getByText("Edit Command Settings")).toBeInTheDocument();
expect(screen.getByLabelText("Command Name")).toBeInTheDocument();
expect(screen.getByLabelText("Sort Order")).toBeInTheDocument();
- expect(screen.getByLabelText("Default Command")).toBeInTheDocument();
+ expect(screen.getByLabelText("Default")).toBeInTheDocument();
expect(screen.getByLabelText("Description")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument();
});
@@ -337,7 +337,7 @@ describe("CommandDialog - Default Command Switch", () => {
createTestState(command),
);
- const defaultSwitch = screen.getByLabelText("Default Command");
+ const defaultSwitch = screen.getByLabelText("Default");
expect(defaultSwitch).toBeChecked();
});
@@ -354,7 +354,7 @@ describe("CommandDialog - Default Command Switch", () => {
createTestState(command),
);
- const defaultSwitch = screen.getByLabelText("Default Command");
+ const defaultSwitch = screen.getByLabelText("Default");
fireEvent.click(defaultSwitch);
expect(defaultSwitch).toBeChecked();
@@ -373,7 +373,7 @@ describe("CommandDialog - Default Command Switch", () => {
createTestState(command),
);
- const defaultSwitch = screen.getByLabelText("Default Command");
+ const defaultSwitch = screen.getByLabelText("Default");
expect(defaultSwitch).toBeDisabled();
});
});
@@ -440,7 +440,7 @@ describe("CommandDialog - Save Functionality", () => {
fireEvent.change(screen.getByLabelText("Description"), {
target: { value: "New description" },
});
- fireEvent.click(screen.getByLabelText("Default Command"));
+ fireEvent.click(screen.getByLabelText("Default"));
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
@@ -561,7 +561,7 @@ describe("CommandDialog - UI Elements and Layout", () => {
expect(screen.getByLabelText("Command Name")).toBeInTheDocument();
expect(screen.getByLabelText("Sort Order")).toBeInTheDocument();
- expect(screen.getByLabelText("Default Command")).toBeInTheDocument();
+ expect(screen.getByLabelText("Default")).toBeInTheDocument();
expect(screen.getByLabelText("Description")).toBeInTheDocument();
});
diff --git a/tests/tool-editor/parameter-list.test.tsx b/tests/tool-editor/parameter-list.test.tsx
index 6affd87..088c8eb 100644
--- a/tests/tool-editor/parameter-list.test.tsx
+++ b/tests/tool-editor/parameter-list.test.tsx
@@ -290,7 +290,7 @@ describe("ParameterList - Rendering & Structure", () => {
});
describe("Interactions", () => {
- it("clicking a parameter card selects it", () => {
+ it("clicking a parameter card selects it in context", () => {
const parameter = createTestParameter();
const state = baseTestState();
state.tool = { ...state.tool!, parameters: [parameter] };
@@ -300,10 +300,10 @@ describe("ParameterList - Rendering & Structure", () => {
expect(paramCard).toBeInTheDocument();
fireEvent.click(paramCard!);
- expect(capturedCtx.selectedParameter?.key).toBe(parameter.key);
+ expect(capturedCtx.contextSelection.parameterKeys).toContain(parameter.key);
});
- it("clicking the remove button does not select the parameter", () => {
+ it("clicking the remove button does not select the parameter in context", () => {
const parameter = createTestParameter();
const state = baseTestState();
state.tool = { ...state.tool!, parameters: [parameter] };
@@ -315,7 +315,7 @@ describe("ParameterList - Rendering & Structure", () => {
);
fireEvent.click(removeButton!);
- expect(capturedCtx.selectedParameter).toBeNull();
+ expect(capturedCtx.contextSelection.parameterKeys).not.toContain(parameter.key);
});
it("add button creates a new parameter with correct context for command parameters", () => {
From 093f5486d471532015d4851249357ff5aef31255 Mon Sep 17 00:00:00 2001
From: divyeshio <79130336+divyeshio@users.noreply.github.com>
Date: Thu, 16 Apr 2026 01:03:03 +0530
Subject: [PATCH 4/6] Refactor command structure and remove default flags
- Removed `isDefault` property from various command definitions in `command-tree.test.tsx` and related tests.
- Eliminated tests related to default command functionality in `command-dialog.test.tsx` and `parameter-list.test.tsx`.
- Introduced new tests for `SavedCommandsDialog` to validate rendering and interactions.
- Added unit tests for editor utility functions managing saved commands in local storage.
- Cleaned up unused imports and adjusted test cases for consistency.
---
bun.lock | 9 +
package.json | 1 +
public/specification/flat.json | 6 +-
public/specification/nested.json | 7 +-
public/tools-collection/asnmap.json | 1 -
public/tools-collection/cdncheck.json | 1 -
public/tools-collection/curl.json | 1 -
public/tools-collection/dnsx.json | 1 -
public/tools-collection/gospider.json | 1 -
public/tools-collection/httpx.json | 1 -
public/tools-collection/katana.json | 3 +-
public/tools-collection/naabu.json | 31 +-
public/tools-collection/nuclei.json | 231 +++---
public/tools-collection/shuffledns.json | 1 -
public/tools-collection/subfinder.json | 1 -
public/tools-collection/urlfinder.json | 1 -
public/tools-collection/yt-dlp.json | 1 -
.../__tests__/generated-command.test.tsx | 12 +-
.../commandly/__tests__/json-output.test.tsx | 1 -
.../__tests__/tool-renderer.test.tsx | 2 +-
registry/commandly/json-output.tsx | 13 +-
registry/commandly/tool-renderer.tsx | 3 -
registry/commandly/types/flat.ts | 2 -
registry/commandly/types/nested.ts | 2 -
registry/commandly/utils/flat.ts | 11 -
registry/commandly/utils/nested.ts | 1 -
scripts/generate-tools-json.ts | 2 +-
.../ai-chat/ai-chat-message-mapping.ts | 111 +--
src/components/ai-chat/ai-chat-persistence.ts | 265 +++++--
src/components/ai-chat/api-key-settings.tsx | 251 +++---
src/components/ai-chat/diff-view.tsx | 22 +-
src/components/ai-chat/extracted-content.tsx | 11 +-
src/components/ai-chat/model-picker.tsx | 29 +-
src/components/ai-chat/web-search-results.tsx | 6 +-
.../ai-elements/chain-of-thought.tsx | 87 +--
src/components/ai-elements/context.tsx | 110 ++-
src/components/ai-elements/prompt-input.tsx | 424 +++++-----
src/components/ai-elements/reasoning.tsx | 69 +-
.../docs/demos/generated-command-demo.tsx | 2 +-
.../docs/demos/json-output-demo.tsx | 2 +-
.../docs/demos/tool-renderer-demo.tsx | 2 +-
src/components/tool-card.tsx | 6 +-
src/components/tool-editor/ai-chat-store.ts | 429 ++++++++++
src/components/tool-editor/ai-chat.tsx | 730 ++++++++----------
src/components/tool-editor/command-tree.tsx | 9 -
.../tool-editor/dialogs/command-dialog.tsx | 15 -
src/components/tool-editor/parameter-list.tsx | 35 +-
src/components/tool-editor/preview-tabs.tsx | 10 +-
.../tool-editor/tool-editor.context.tsx | 5 +-
src/components/tool-editor/tool-editor.tsx | 12 +-
src/components/tool-editor/tools.ts | 51 +-
src/lib/ai-keys.ts | 24 +-
src/lib/editor-utils.ts | 4 +-
src/lib/utils.ts | 1 -
src/routes/tools/$toolName/edit.tsx | 24 +-
src/routes/tools/$toolName/index.tsx | 4 +-
src/routes/tools/index.tsx | 90 ++-
.../ai-chat-message-mapping.test.ts | 431 +++++++++++
tests/tool-editor/ai-chat-persistence.test.ts | 631 ++++++++-------
tests/tool-editor/command-tree.test.tsx | 26 -
.../dialogs/command-dialog.test.tsx | 68 --
.../dialogs/parameter-details-dialog.test.tsx | 1 -
.../dialogs/saved-commands-dialog.test.tsx | 95 +++
tests/tool-editor/editor-utils.test.ts | 109 +++
tests/tool-editor/parameter-list.test.tsx | 2 -
tests/tool-editor/tool-editor.test.tsx | 45 +-
tests/vitest.setup.ts | 1 +
67 files changed, 2811 insertions(+), 1785 deletions(-)
create mode 100644 src/components/tool-editor/ai-chat-store.ts
create mode 100644 tests/tool-editor/ai-chat-message-mapping.test.ts
create mode 100644 tests/tool-editor/dialogs/saved-commands-dialog.test.tsx
create mode 100644 tests/tool-editor/editor-utils.test.ts
diff --git a/bun.lock b/bun.lock
index 3557ed6..1a01381 100644
--- a/bun.lock
+++ b/bun.lock
@@ -34,6 +34,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
+ "jsonpath-plus": "^10.4.0",
"lucide-react": "^1.8.0",
"motion": "^12.38.0",
"next-themes": "^0.4.6",
@@ -335,6 +336,10 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+ "@jsep-plugin/assignment": ["@jsep-plugin/assignment@1.3.0", "", { "peerDependencies": { "jsep": "^0.4.0||^1.0.0" } }, "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ=="],
+
+ "@jsep-plugin/regex": ["@jsep-plugin/regex@1.0.4", "", { "peerDependencies": { "jsep": "^0.4.0||^1.0.0" } }, "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg=="],
+
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
"@mdx-js/rollup": ["@mdx-js/rollup@3.1.1", "", { "dependencies": { "@mdx-js/mdx": "^3.0.0", "@rollup/pluginutils": "^5.0.0", "source-map": "^0.7.0", "vfile": "^6.0.0" }, "peerDependencies": { "rollup": ">=2" } }, "sha512-v8satFmBB+DqDzYohnm1u2JOvxx6Hl3pUvqzJvfs2Zk/ngZ1aRUhsWpXvwPkNeGN9c2NCm/38H29ZqXQUjf8dw=="],
@@ -1557,6 +1562,8 @@
"jsdom": ["jsdom@29.0.2", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.5", "@asamuzakjp/dom-selector": "^7.0.6", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w=="],
+ "jsep": ["jsep@1.4.0", "", {}, "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw=="],
+
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
@@ -1571,6 +1578,8 @@
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
+ "jsonpath-plus": ["jsonpath-plus@10.4.0", "", { "dependencies": { "@jsep-plugin/assignment": "^1.3.0", "@jsep-plugin/regex": "^1.0.4", "jsep": "^1.4.0" }, "bin": { "jsonpath": "bin/jsonpath-cli.js", "jsonpath-plus": "bin/jsonpath-cli.js" } }, "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA=="],
+
"katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="],
"khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
diff --git a/package.json b/package.json
index d182b10..74ffddb 100644
--- a/package.json
+++ b/package.json
@@ -51,6 +51,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
+ "jsonpath-plus": "^10.4.0",
"lucide-react": "^1.8.0",
"motion": "^12.38.0",
"next-themes": "^0.4.6",
diff --git a/public/specification/flat.json b/public/specification/flat.json
index e0e2751..2f62d16 100644
--- a/public/specification/flat.json
+++ b/public/specification/flat.json
@@ -86,10 +86,6 @@
"description": "Whether this command opens an interactive session or prompt.",
"type": "boolean"
},
- "isDefault": {
- "description": "Whether this is the default command when no subcommand is specified.",
- "type": "boolean"
- },
"sortOrder": {
"description": "Display sort position relative to sibling commands.",
"type": "number"
@@ -398,4 +394,4 @@
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
-}
\ No newline at end of file
+}
diff --git a/public/specification/nested.json b/public/specification/nested.json
index 2de1a07..074b99c 100644
--- a/public/specification/nested.json
+++ b/public/specification/nested.json
@@ -322,10 +322,6 @@
"description": "Whether this command opens an interactive session or prompt.",
"type": "boolean"
},
- "isDefault": {
- "description": "Whether this is the default command when no subcommand is specified.",
- "type": "boolean"
- },
"sortOrder": {
"description": "Display sort position relative to sibling commands.",
"type": "number"
@@ -346,7 +342,6 @@
}
},
"required": [
- "isDefault",
"name",
"parameters",
"sortOrder",
@@ -390,4 +385,4 @@
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
-}
\ No newline at end of file
+}
diff --git a/public/tools-collection/asnmap.json b/public/tools-collection/asnmap.json
index bc16d05..3add69a 100644
--- a/public/tools-collection/asnmap.json
+++ b/public/tools-collection/asnmap.json
@@ -11,7 +11,6 @@
{
"name": "asnmap",
"description": "Map organization network ranges using ASN information.",
- "isDefault": true,
"sortOrder": 1,
"key": "asnmap"
}
diff --git a/public/tools-collection/cdncheck.json b/public/tools-collection/cdncheck.json
index 8c430e6..c94b5e6 100644
--- a/public/tools-collection/cdncheck.json
+++ b/public/tools-collection/cdncheck.json
@@ -10,7 +10,6 @@
"commands": [
{
"name": "cdncheck",
- "isDefault": true,
"sortOrder": 1,
"key": "cdncheck"
}
diff --git a/public/tools-collection/curl.json b/public/tools-collection/curl.json
index fe3b5d1..8948d85 100644
--- a/public/tools-collection/curl.json
+++ b/public/tools-collection/curl.json
@@ -11,7 +11,6 @@
{
"name": "curl",
"description": "Run curl to download files.",
- "isDefault": true,
"sortOrder": 1,
"key": "curl"
}
diff --git a/public/tools-collection/dnsx.json b/public/tools-collection/dnsx.json
index f435ec7..c9bd643 100644
--- a/public/tools-collection/dnsx.json
+++ b/public/tools-collection/dnsx.json
@@ -11,7 +11,6 @@
{
"name": "dnsx",
"description": "Root command for dnsx CLI.",
- "isDefault": true,
"sortOrder": 1,
"key": "dnsx"
}
diff --git a/public/tools-collection/gospider.json b/public/tools-collection/gospider.json
index 8ec28ec..70404d0 100644
--- a/public/tools-collection/gospider.json
+++ b/public/tools-collection/gospider.json
@@ -11,7 +11,6 @@
{
"name": "gospider",
"description": "Fast web spider written in Go.",
- "isDefault": true,
"sortOrder": 1,
"key": "gospider"
}
diff --git a/public/tools-collection/httpx.json b/public/tools-collection/httpx.json
index 858b756..a2083c5 100644
--- a/public/tools-collection/httpx.json
+++ b/public/tools-collection/httpx.json
@@ -11,7 +11,6 @@
{
"name": "httpx",
"description": "Run httpx with the specified parameters.",
- "isDefault": true,
"sortOrder": 1,
"key": "httpx"
}
diff --git a/public/tools-collection/katana.json b/public/tools-collection/katana.json
index 0174f2b..c8803d2 100644
--- a/public/tools-collection/katana.json
+++ b/public/tools-collection/katana.json
@@ -11,7 +11,6 @@
{
"name": "katana",
"description": "Primary command for katana crawler.",
- "isDefault": true,
"sortOrder": 0,
"key": "katana"
}
@@ -1114,4 +1113,4 @@
"commandKey": "katana"
}
]
-}
\ No newline at end of file
+}
diff --git a/public/tools-collection/naabu.json b/public/tools-collection/naabu.json
index 35a1b43..8dcbd0f 100644
--- a/public/tools-collection/naabu.json
+++ b/public/tools-collection/naabu.json
@@ -11,7 +11,6 @@
{
"name": "naabu",
"description": "Run naabu with flags.",
- "isDefault": true,
"sortOrder": 1,
"key": "naabu"
}
@@ -85,22 +84,22 @@
"value": "full",
"displayName": "Full",
"description": "Scan full set of top ports.",
- "isDefault": false,
- "sortOrder": 1
+ "sortOrder": 1,
+ "isDefault": false
},
{
"value": "100",
"displayName": "100",
"description": "Scan top 100 ports.",
- "isDefault": true,
- "sortOrder": 2
+ "sortOrder": 2,
+ "isDefault": true
},
{
"value": "1000",
"displayName": "1000",
"description": "Scan top 1000 ports.",
- "isDefault": false,
- "sortOrder": 3
+ "sortOrder": 3,
+ "isDefault": false
}
]
},
@@ -271,15 +270,15 @@
"value": "4",
"displayName": "4",
"description": "IPv4.",
- "isDefault": true,
- "sortOrder": 1
+ "sortOrder": 1,
+ "isDefault": true
},
{
"value": "6",
"displayName": "6",
"description": "IPv6.",
- "isDefault": false,
- "sortOrder": 2
+ "sortOrder": 2,
+ "isDefault": false
}
]
},
@@ -300,15 +299,15 @@
"value": "SYN",
"displayName": "SYN",
"description": "TCP SYN scan.",
- "isDefault": false,
- "sortOrder": 1
+ "sortOrder": 1,
+ "isDefault": false
},
{
"value": "CONNECT",
"displayName": "CONNECT",
"description": "TCP connect scan.",
- "isDefault": true,
- "sortOrder": 2
+ "sortOrder": 2,
+ "isDefault": true
}
]
},
@@ -845,4 +844,4 @@
"commandKey": "naabu"
}
]
-}
+}
\ No newline at end of file
diff --git a/public/tools-collection/nuclei.json b/public/tools-collection/nuclei.json
index 4404cc4..44b73ab 100644
--- a/public/tools-collection/nuclei.json
+++ b/public/tools-collection/nuclei.json
@@ -11,7 +11,6 @@
{
"name": "nuclei",
"description": "Run nuclei vulnerability scanner.",
- "isDefault": true,
"sortOrder": 1,
"key": "nuclei"
}
@@ -82,15 +81,15 @@
"value": "4",
"displayName": "4",
"description": "Use IPv4.",
- "isDefault": true,
- "sortOrder": 1
+ "sortOrder": 1,
+ "isDefault": true
},
{
"value": "6",
"displayName": "6",
"description": "Use IPv6.",
- "isDefault": false,
- "sortOrder": 2
+ "sortOrder": 2,
+ "isDefault": false
}
]
},
@@ -110,43 +109,43 @@
"value": "list",
"displayName": "List",
"description": "Use list input mode.",
- "isDefault": true,
- "sortOrder": 1
+ "sortOrder": 1,
+ "isDefault": true
},
{
"value": "burp",
"displayName": "Burp",
"description": "Use burp input mode.",
- "isDefault": false,
- "sortOrder": 2
+ "sortOrder": 2,
+ "isDefault": false
},
{
"value": "jsonl",
"displayName": "JSONL",
"description": "Use jsonl input mode.",
- "isDefault": false,
- "sortOrder": 3
+ "sortOrder": 3,
+ "isDefault": false
},
{
"value": "yaml",
"displayName": "Yaml",
"description": "Use yaml input mode.",
- "isDefault": false,
- "sortOrder": 4
+ "sortOrder": 4,
+ "isDefault": false
},
{
"value": "openapi",
"displayName": "OpenAPI",
"description": "Use openapi input mode.",
- "isDefault": false,
- "sortOrder": 5
+ "sortOrder": 5,
+ "isDefault": false
},
{
"value": "swagger",
"displayName": "Swagger",
"description": "Use swagger input mode.",
- "isDefault": false,
- "sortOrder": 6
+ "sortOrder": 6,
+ "isDefault": false
}
]
},
@@ -474,43 +473,43 @@
"value": "info",
"displayName": "Info",
"description": "Information severity.",
- "isDefault": false,
- "sortOrder": 1
+ "sortOrder": 1,
+ "isDefault": false
},
{
"value": "low",
"displayName": "Low",
"description": "Low severity.",
- "isDefault": false,
- "sortOrder": 2
+ "sortOrder": 2,
+ "isDefault": false
},
{
"value": "medium",
"displayName": "Medium",
"description": "Medium severity.",
- "isDefault": false,
- "sortOrder": 3
+ "sortOrder": 3,
+ "isDefault": false
},
{
"value": "high",
"displayName": "High",
"description": "High severity.",
- "isDefault": false,
- "sortOrder": 4
+ "sortOrder": 4,
+ "isDefault": false
},
{
"value": "critical",
"displayName": "Critical",
"description": "Critical severity.",
- "isDefault": false,
- "sortOrder": 5
+ "sortOrder": 5,
+ "isDefault": false
},
{
"value": "unknown",
"displayName": "Unknown",
"description": "Unknown severity.",
- "isDefault": false,
- "sortOrder": 6
+ "sortOrder": 6,
+ "isDefault": false
}
]
},
@@ -531,43 +530,43 @@
"value": "info",
"displayName": "Info",
"description": "Exclude information severity.",
- "isDefault": false,
- "sortOrder": 1
+ "sortOrder": 1,
+ "isDefault": false
},
{
"value": "low",
"displayName": "Low",
"description": "Exclude low severity.",
- "isDefault": false,
- "sortOrder": 2
+ "sortOrder": 2,
+ "isDefault": false
},
{
"value": "medium",
"displayName": "Medium",
"description": "Exclude medium severity.",
- "isDefault": false,
- "sortOrder": 3
+ "sortOrder": 3,
+ "isDefault": false
},
{
"value": "high",
"displayName": "High",
"description": "Exclude high severity.",
- "isDefault": false,
- "sortOrder": 4
+ "sortOrder": 4,
+ "isDefault": false
},
{
"value": "critical",
"displayName": "Critical",
"description": "Exclude critical severity.",
- "isDefault": false,
- "sortOrder": 5
+ "sortOrder": 5,
+ "isDefault": false
},
{
"value": "unknown",
"displayName": "Unknown",
"description": "Exclude unknown severity.",
- "isDefault": false,
- "sortOrder": 6
+ "sortOrder": 6,
+ "isDefault": false
}
]
},
@@ -588,78 +587,78 @@
"value": "dns",
"displayName": "Dns",
"description": "DNS templates.",
- "isDefault": false,
- "sortOrder": 1
+ "sortOrder": 1,
+ "isDefault": false
},
{
"value": "file",
"displayName": "File",
"description": "File templates.",
- "isDefault": false,
- "sortOrder": 2
+ "sortOrder": 2,
+ "isDefault": false
},
{
"value": "http",
"displayName": "Http",
"description": "HTTP templates.",
- "isDefault": false,
- "sortOrder": 3
+ "sortOrder": 3,
+ "isDefault": false
},
{
"value": "headless",
"displayName": "Headless",
"description": "Headless templates.",
- "isDefault": false,
- "sortOrder": 4
+ "sortOrder": 4,
+ "isDefault": false
},
{
"value": "tcp",
"displayName": "Tcp",
"description": "TCP templates.",
- "isDefault": false,
- "sortOrder": 5
+ "sortOrder": 5,
+ "isDefault": false
},
{
"value": "workflow",
"displayName": "Workflow",
"description": "Workflow templates.",
- "isDefault": false,
- "sortOrder": 6
+ "sortOrder": 6,
+ "isDefault": false
},
{
"value": "ssl",
"displayName": "Ssl",
"description": "SSL templates.",
- "isDefault": false,
- "sortOrder": 7
+ "sortOrder": 7,
+ "isDefault": false
},
{
"value": "websocket",
"displayName": "Websocket",
"description": "Websocket templates.",
- "isDefault": false,
- "sortOrder": 8
+ "sortOrder": 8,
+ "isDefault": false
},
{
"value": "whois",
"displayName": "Whois",
"description": "WHOIS templates.",
- "isDefault": false,
- "sortOrder": 9
+ "sortOrder": 9,
+ "isDefault": false
},
{
"value": "code",
"displayName": "Code",
"description": "Code templates.",
- "isDefault": false,
- "sortOrder": 10
+ "sortOrder": 10,
+ "isDefault": false
},
{
"value": "javascript",
"displayName": "Javascript",
"description": "JavaScript templates.",
- "isDefault": false,
- "sortOrder": 11
+ "sortOrder": 11,
+ "isDefault": false
}
]
},
@@ -680,78 +679,78 @@
"value": "dns",
"displayName": "Dns",
"description": "Exclude DNS templates.",
- "isDefault": false,
- "sortOrder": 1
+ "sortOrder": 1,
+ "isDefault": false
},
{
"value": "file",
"displayName": "File",
"description": "Exclude file templates.",
- "isDefault": false,
- "sortOrder": 2
+ "sortOrder": 2,
+ "isDefault": false
},
{
"value": "http",
"displayName": "Http",
"description": "Exclude HTTP templates.",
- "isDefault": false,
- "sortOrder": 3
+ "sortOrder": 3,
+ "isDefault": false
},
{
"value": "headless",
"displayName": "Headless",
"description": "Exclude headless templates.",
- "isDefault": false,
- "sortOrder": 4
+ "sortOrder": 4,
+ "isDefault": false
},
{
"value": "tcp",
"displayName": "Tcp",
"description": "Exclude TCP templates.",
- "isDefault": false,
- "sortOrder": 5
+ "sortOrder": 5,
+ "isDefault": false
},
{
"value": "workflow",
"displayName": "Workflow",
"description": "Exclude workflow templates.",
- "isDefault": false,
- "sortOrder": 6
+ "sortOrder": 6,
+ "isDefault": false
},
{
"value": "ssl",
"displayName": "Ssl",
"description": "Exclude SSL templates.",
- "isDefault": false,
- "sortOrder": 7
+ "sortOrder": 7,
+ "isDefault": false
},
{
"value": "websocket",
"displayName": "Websocket",
"description": "Exclude websocket templates.",
- "isDefault": false,
- "sortOrder": 8
+ "sortOrder": 8,
+ "isDefault": false
},
{
"value": "whois",
"displayName": "Whois",
"description": "Exclude WHOIS templates.",
- "isDefault": false,
- "sortOrder": 9
+ "sortOrder": 9,
+ "isDefault": false
},
{
"value": "code",
"displayName": "Code",
"description": "Exclude code templates.",
- "isDefault": false,
- "sortOrder": 10
+ "sortOrder": 10,
+ "isDefault": false
},
{
"value": "javascript",
"displayName": "Javascript",
"description": "Exclude JavaScript templates.",
- "isDefault": false,
- "sortOrder": 11
+ "sortOrder": 11,
+ "isDefault": false
}
]
},
@@ -1219,22 +1218,22 @@
"value": "batteringram",
"displayName": "Batteringram",
"description": "Use battering ram attack type.",
- "isDefault": false,
- "sortOrder": 1
+ "sortOrder": 1,
+ "isDefault": false
},
{
"value": "pitchfork",
"displayName": "Pitchfork",
"description": "Use pitchfork attack type.",
- "isDefault": false,
- "sortOrder": 2
+ "sortOrder": 2,
+ "isDefault": false
},
{
"value": "clusterbomb",
"displayName": "Clusterbomb",
"description": "Use cluster bomb attack type.",
- "isDefault": false,
- "sortOrder": 3
+ "sortOrder": 3,
+ "isDefault": false
}
]
},
@@ -1379,29 +1378,29 @@
"value": "replace",
"displayName": "Replace",
"description": "Replace fuzzing type.",
- "isDefault": false,
- "sortOrder": 1
+ "sortOrder": 1,
+ "isDefault": false
},
{
"value": "prefix",
"displayName": "Prefix",
"description": "Prefix fuzzing type.",
- "isDefault": false,
- "sortOrder": 2
+ "sortOrder": 2,
+ "isDefault": false
},
{
"value": "postfix",
"displayName": "Postfix",
"description": "Postfix fuzzing type.",
- "isDefault": false,
- "sortOrder": 3
+ "sortOrder": 3,
+ "isDefault": false
},
{
"value": "infix",
"displayName": "Infix",
"description": "Infix fuzzing type.",
- "isDefault": false,
- "sortOrder": 4
+ "sortOrder": 4,
+ "isDefault": false
}
]
},
@@ -1421,15 +1420,15 @@
"value": "multiple",
"displayName": "Multiple",
"description": "Multiple fuzzing mode.",
- "isDefault": false,
- "sortOrder": 1
+ "sortOrder": 1,
+ "isDefault": false
},
{
"value": "single",
"displayName": "Single",
"description": "Single fuzzing mode.",
- "isDefault": false,
- "sortOrder": 2
+ "sortOrder": 2,
+ "isDefault": false
}
]
},
@@ -1525,22 +1524,22 @@
"value": "low",
"displayName": "Low",
"description": "Low fuzzing aggression.",
- "isDefault": true,
- "sortOrder": 1
+ "sortOrder": 1,
+ "isDefault": true
},
{
"value": "medium",
"displayName": "Medium",
"description": "Medium fuzzing aggression.",
- "isDefault": false,
- "sortOrder": 2
+ "sortOrder": 2,
+ "isDefault": false
},
{
"value": "high",
"displayName": "High",
"description": "High fuzzing aggression.",
- "isDefault": false,
- "sortOrder": 3
+ "sortOrder": 3,
+ "isDefault": false
}
]
},
@@ -1839,22 +1838,22 @@
"value": "auto",
"displayName": "Auto",
"description": "Automatic scan strategy.",
- "isDefault": true,
- "sortOrder": 1
+ "sortOrder": 1,
+ "isDefault": true
},
{
"value": "host-spray",
"displayName": "Host-spray",
"description": "Host spray scan strategy.",
- "isDefault": false,
- "sortOrder": 2
+ "sortOrder": 2,
+ "isDefault": false
},
{
"value": "template-spray",
"displayName": "Template-spray",
"description": "Template spray scan strategy.",
- "isDefault": false,
- "sortOrder": 3
+ "sortOrder": 3,
+ "isDefault": false
}
]
},
@@ -2306,4 +2305,4 @@
"commandKey": "nuclei"
}
]
-}
+}
\ No newline at end of file
diff --git a/public/tools-collection/shuffledns.json b/public/tools-collection/shuffledns.json
index 1fa6eb3..917428e 100644
--- a/public/tools-collection/shuffledns.json
+++ b/public/tools-collection/shuffledns.json
@@ -11,7 +11,6 @@
{
"name": "shuffledns",
"description": "shuffleDNS is a wrapper around massdns written in go that allows you to enumerate valid subdomains using active bruteforce as well as resolve subdomains with wildcard handling and easy input-output support.",
- "isDefault": true,
"sortOrder": 1,
"key": "shuffledns"
}
diff --git a/public/tools-collection/subfinder.json b/public/tools-collection/subfinder.json
index 28bf51a..26cd4f8 100644
--- a/public/tools-collection/subfinder.json
+++ b/public/tools-collection/subfinder.json
@@ -11,7 +11,6 @@
{
"name": "subfinder",
"description": "Subfinder is a subdomain discovery tool that discovers subdomains for websites by using passive online sources.",
- "isDefault": true,
"sortOrder": 1,
"key": "subfinder"
}
diff --git a/public/tools-collection/urlfinder.json b/public/tools-collection/urlfinder.json
index 58070ce..887958d 100644
--- a/public/tools-collection/urlfinder.json
+++ b/public/tools-collection/urlfinder.json
@@ -11,7 +11,6 @@
{
"name": "urlfinder",
"description": "A streamlined tool for discovering associated URLs.",
- "isDefault": true,
"sortOrder": 1,
"key": "urlfinder"
}
diff --git a/public/tools-collection/yt-dlp.json b/public/tools-collection/yt-dlp.json
index 2308297..853d16e 100644
--- a/public/tools-collection/yt-dlp.json
+++ b/public/tools-collection/yt-dlp.json
@@ -11,7 +11,6 @@
{
"name": "yt-dlp",
"description": "yt-dlp is a command-line program to download videos from YouTube and other sites.",
- "isDefault": true,
"sortOrder": 1,
"commandKey": "yt-dlp",
"key": "yt-dlp"
diff --git a/registry/commandly/__tests__/generated-command.test.tsx b/registry/commandly/__tests__/generated-command.test.tsx
index 581e806..20ad239 100644
--- a/registry/commandly/__tests__/generated-command.test.tsx
+++ b/registry/commandly/__tests__/generated-command.test.tsx
@@ -4,7 +4,7 @@ import { render, screen } from "@testing-library/react";
const testTool = {
name: "tool",
displayName: "Tool",
- commands: [{ key: "test-key", name: "test", isDefault: true, sortOrder: 0 }],
+ commands: [{ key: "test-key", name: "test", sortOrder: 0 }],
parameters: [],
};
@@ -23,7 +23,7 @@ describe("GeneratedCommand", () => {
const tool = {
name: "curl",
displayName: "Curl",
- commands: [{ key: "curl", name: "curl", isDefault: true, sortOrder: 1 }],
+ commands: [{ key: "curl", name: "curl", sortOrder: 1 }],
parameters: [
{
key: "header",
@@ -55,7 +55,7 @@ describe("GeneratedCommand", () => {
const tool = {
name: "mytool",
displayName: "My Tool",
- commands: [{ key: "mytool", name: "mytool", isDefault: true, sortOrder: 1 }],
+ commands: [{ key: "mytool", name: "mytool", sortOrder: 1 }],
parameters: [
{
key: "filter",
@@ -84,7 +84,7 @@ describe("GeneratedCommand", () => {
const tool = {
name: "curl",
displayName: "Curl",
- commands: [{ key: "curl", name: "curl", isDefault: true, sortOrder: 1 }],
+ commands: [{ key: "curl", name: "curl", sortOrder: 1 }],
parameters: [
{
key: "header",
@@ -111,7 +111,7 @@ describe("GeneratedCommand", () => {
const tool = {
name: "ssh",
displayName: "SSH",
- commands: [{ key: "ssh", name: "ssh", isDefault: true, sortOrder: 1 }],
+ commands: [{ key: "ssh", name: "ssh", sortOrder: 1 }],
parameters: [
{
key: "verbose",
@@ -139,7 +139,7 @@ describe("GeneratedCommand", () => {
const tool = {
name: "curl",
displayName: "Curl",
- commands: [{ key: "curl", name: "curl", isDefault: true, sortOrder: 1 }],
+ commands: [{ key: "curl", name: "curl", sortOrder: 1 }],
parameters: [
{
key: "target",
diff --git a/registry/commandly/__tests__/json-output.test.tsx b/registry/commandly/__tests__/json-output.test.tsx
index 548763e..53404bf 100644
--- a/registry/commandly/__tests__/json-output.test.tsx
+++ b/registry/commandly/__tests__/json-output.test.tsx
@@ -45,7 +45,6 @@ describe("exportToStructuredJSON", () => {
value: "val",
displayName: "Val",
description: "",
- isDefault: true,
sortOrder: 0,
},
],
diff --git a/registry/commandly/__tests__/tool-renderer.test.tsx b/registry/commandly/__tests__/tool-renderer.test.tsx
index 55fdbdb..565afa2 100644
--- a/registry/commandly/__tests__/tool-renderer.test.tsx
+++ b/registry/commandly/__tests__/tool-renderer.test.tsx
@@ -4,7 +4,7 @@ import { createNewParameter } from "@/components/commandly/utils/flat";
import { defaultTool } from "@/lib/utils";
import { render, screen } from "@testing-library/react";
-const baseCommand = { key: "my-tool", name: "my-tool", isDefault: true, sortOrder: 0 };
+const baseCommand = { key: "my-tool", name: "my-tool", sortOrder: 0 };
const baseTool = { ...defaultTool(), commands: [baseCommand] };
describe("ToolRenderer", () => {
diff --git a/registry/commandly/json-output.tsx b/registry/commandly/json-output.tsx
index e260f92..f241483 100644
--- a/registry/commandly/json-output.tsx
+++ b/registry/commandly/json-output.tsx
@@ -34,9 +34,7 @@ function diffLines(before: string, after: string): DiffLine[] {
for (let i = 1; i <= m; i++)
for (let j = 1; j <= n; j++)
dp[i][j] =
- a[i - 1] === b[j - 1]
- ? dp[i - 1][j - 1] + 1
- : Math.max(dp[i - 1][j], dp[i][j - 1]);
+ a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
const result: DiffLine[] = [];
let i = m,
j = n;
@@ -268,14 +266,12 @@ export function JsonOutput({ tool, originalTool, onApply }: JsonTypeComponentPro
key={idx}
className={cn(
"px-1",
- line.type === "added" &&
- "bg-green-500/10 text-green-700 dark:text-green-400",
- line.type === "removed" &&
- "bg-red-500/10 text-red-700 dark:text-red-400",
+ line.type === "added" && "bg-green-500/10 text-green-700 dark:text-green-400",
+ line.type === "removed" && "bg-red-500/10 text-red-700 dark:text-red-400",
line.type === "same" && "text-foreground/80",
)}
>
-
+
{line.type === "added" ? "+ " : line.type === "removed" ? "- " : " "}
{line.text}
@@ -301,4 +297,3 @@ export function JsonOutput({ tool, originalTool, onApply }: JsonTypeComponentPro
);
}
-
diff --git a/registry/commandly/tool-renderer.tsx b/registry/commandly/tool-renderer.tsx
index b88f54a..364f9a9 100644
--- a/registry/commandly/tool-renderer.tsx
+++ b/registry/commandly/tool-renderer.tsx
@@ -25,9 +25,6 @@ import { CheckIcon, ChevronsUpDownIcon, InfoIcon, PlusIcon, XIcon } from "lucide
import React from "react";
const findDefaultCommand = (tool: Tool): Command | null => {
- const defaultCommand = tool.commands.find((command) => command.isDefault);
- if (defaultCommand) return defaultCommand;
-
const nameMatchCommand = tool.commands.find(
(command) => command.name.toLowerCase() === tool.name.toLowerCase(),
);
diff --git a/registry/commandly/types/flat.ts b/registry/commandly/types/flat.ts
index c74a2c6..5728c12 100644
--- a/registry/commandly/types/flat.ts
+++ b/registry/commandly/types/flat.ts
@@ -18,8 +18,6 @@ export interface Command {
description?: string;
/** Whether this command opens an interactive session or prompt. */
interactive?: boolean;
- /** Whether this is the default command when no subcommand is specified. */
- isDefault?: boolean;
/** Display sort position relative to sibling commands. */
sortOrder?: number;
}
diff --git a/registry/commandly/types/nested.ts b/registry/commandly/types/nested.ts
index ede55d3..4bd6b54 100644
--- a/registry/commandly/types/nested.ts
+++ b/registry/commandly/types/nested.ts
@@ -74,8 +74,6 @@ export interface NestedCommand {
description?: string;
/** Whether this command opens an interactive session or prompt. */
interactive?: boolean;
- /** Whether this is the default command when no subcommand is specified. */
- isDefault: boolean;
/** Display sort position relative to sibling commands. */
sortOrder: number;
/** Parameters that belong directly to this command. */
diff --git a/registry/commandly/utils/flat.ts b/registry/commandly/utils/flat.ts
index 2e4ed6a..1ee93bd 100644
--- a/registry/commandly/utils/flat.ts
+++ b/registry/commandly/utils/flat.ts
@@ -39,15 +39,6 @@ export const getCommandPath = (command: Command, tool: Tool): string => {
if (!path) return command.name;
- if (command.name === tool.name && command.isDefault) {
- return tool.name;
- }
-
- const rootCommand = tool.commands.find((c) => c.name === tool.name);
- if (rootCommand?.isDefault && path[0] === tool.name) {
- path[0] = tool.name;
- }
-
return path.join(" ");
};
@@ -85,7 +76,6 @@ export const exportToStructuredJSON = (tool: Tool) => {
name: tool.name,
displayName: tool.displayName,
info: tool.info,
- url: tool.info?.url,
commands: tool.commands.map((cmd) => ({ ...cmd })),
parameters: tool.parameters.map(({ metadata: _metadata, ...param }) => param),
exclusionGroups: tool.exclusionGroups,
@@ -93,7 +83,6 @@ export const exportToStructuredJSON = (tool: Tool) => {
};
};
-
export const createNewParameter = (isGlobal: boolean, commandKey?: string): Parameter => {
return {
key: "",
diff --git a/registry/commandly/utils/nested.ts b/registry/commandly/utils/nested.ts
index a215760..9b9ce2e 100644
--- a/registry/commandly/utils/nested.ts
+++ b/registry/commandly/utils/nested.ts
@@ -44,7 +44,6 @@ export const convertToNestedStructure = (tool: Tool): NestedTool => {
name: cmd.name,
description: cmd.description,
interactive: cmd.interactive,
- isDefault: cmd.isDefault ?? false,
sortOrder: cmd.sortOrder ?? 0,
parameters: commandParameters.map(convertParameter),
subcommands: buildNestedCommands(commands, cmd.key),
diff --git a/scripts/generate-tools-json.ts b/scripts/generate-tools-json.ts
index f18091d..71d0ec9 100644
--- a/scripts/generate-tools-json.ts
+++ b/scripts/generate-tools-json.ts
@@ -13,7 +13,7 @@ const tools = files.sort().map((file) => {
name: tool.name,
displayName: tool.displayName || tool.name,
description: tool.info?.description,
- info: tool.info
+ info: tool.info,
};
});
diff --git a/src/components/ai-chat/ai-chat-message-mapping.ts b/src/components/ai-chat/ai-chat-message-mapping.ts
index d295b32..943e1a1 100644
--- a/src/components/ai-chat/ai-chat-message-mapping.ts
+++ b/src/components/ai-chat/ai-chat-message-mapping.ts
@@ -1,11 +1,5 @@
import { Tool } from "@/components/commandly/types/flat";
-import {
- getToolName,
- isReasoningUIPart,
- isTextUIPart,
- isToolUIPart,
- type UIMessage,
-} from "ai";
+import { getToolName, isReasoningUIPart, isTextUIPart, isToolUIPart, type UIMessage } from "ai";
export interface ToolCallEntry {
toolCallId: string;
@@ -14,13 +8,13 @@ export interface ToolCallEntry {
title: string;
input: Record;
state:
- | "input-streaming"
- | "input-available"
- | "approval-requested"
- | "approval-responded"
- | "output-available"
- | "output-denied"
- | "output-error";
+ | "input-streaming"
+ | "input-available"
+ | "approval-requested"
+ | "approval-responded"
+ | "output-available"
+ | "output-denied"
+ | "output-error";
output?: unknown;
errorText?: string;
originalTool?: Tool;
@@ -55,7 +49,10 @@ function getToolTitle(toolName: string, input: Record, fallback
}
if (toolName === "readTool") {
- return "Reading tool JSON…";
+ const summary = input.summary as string | undefined;
+ const jsonPath = input.jsonPath as string | undefined;
+ const pathLabel = jsonPath && jsonPath !== "$" ? jsonPath : "whole tool";
+ return summary ? `${summary} (${pathLabel})` : `Reading ${pathLabel}…`;
}
if (toolName === "applyToolDefinition") {
@@ -75,11 +72,14 @@ function getToolTitle(toolName: string, input: Record, fallback
function toToolInput(value: unknown): Record {
return value && typeof value === "object" && !Array.isArray(value)
- ? value as Record
+ ? (value as Record)
: {};
}
-export function toChatMessage(message: UIMessage, approvalArtifacts: Record): ChatMessage {
+export function toChatMessage(
+ message: UIMessage,
+ approvalArtifacts: Record,
+): ChatMessage {
const content = message.parts
.filter(isTextUIPart)
.map((part) => part.text)
@@ -90,55 +90,61 @@ export function toChatMessage(message: UIMessage, approvalArtifacts: Record part.text)
.join("");
- const toolCalls = message.parts
- .filter(isToolUIPart)
- .map((part) => {
- const toolName = getToolName(part);
- const input = toToolInput("input" in part ? part.input : undefined);
- const approvalId = part.approval?.id;
- const artifact = approvalId ? approvalArtifacts[approvalId] : undefined;
-
- return {
- toolCallId: part.toolCallId,
- toolName,
- approvalId,
- title: getToolTitle(toolName, input, part.title),
- input,
- state: part.state,
- output: "output" in part ? part.output : undefined,
- errorText: "errorText" in part ? part.errorText : undefined,
- originalTool: artifact?.originalTool,
- previewTool: artifact?.previewTool,
- } satisfies ToolCallEntry;
- });
+ const toolCalls = message.parts.filter(isToolUIPart).map((part) => {
+ const toolName = getToolName(part);
+ const input = toToolInput("input" in part ? part.input : undefined);
+ const approvalId = part.approval?.id;
+ const artifact = approvalId ? approvalArtifacts[approvalId] : undefined;
+
+ return {
+ toolCallId: part.toolCallId,
+ toolName,
+ approvalId,
+ title: getToolTitle(toolName, input, part.title),
+ input,
+ state: part.state,
+ output: "output" in part ? part.output : undefined,
+ errorText: "errorText" in part ? part.errorText : undefined,
+ originalTool: artifact?.originalTool,
+ previewTool: artifact?.previewTool,
+ } satisfies ToolCallEntry;
+ });
return {
id: message.id,
role: message.role === "assistant" ? "assistant" : "user",
content,
- toolApplied: toolCalls.some((toolCall) => toolCall.toolName === "applyToolDefinition" && toolCall.state === "output-available"),
+ toolApplied: toolCalls.some(
+ (toolCall) =>
+ toolCall.toolName === "applyToolDefinition" && toolCall.state === "output-available",
+ ),
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
reasoningContent: reasoningContent || undefined,
};
}
export function countCompletedToolCalls(messages: UIMessage[]): number {
- return messages.reduce((count, message) => (
- count + message.parts.filter(
- (part) => isToolUIPart(part)
- && (
- part.state === "output-available"
- || part.state === "output-error"
- || part.state === "output-denied"
- ),
- ).length
- ), 0);
+ return messages.reduce(
+ (count, message) =>
+ count +
+ message.parts.filter(
+ (part) =>
+ isToolUIPart(part) &&
+ (part.state === "output-available" ||
+ part.state === "output-error" ||
+ part.state === "output-denied"),
+ ).length,
+ 0,
+ );
}
export function findPendingApproval(messages: ChatMessage[]) {
for (let index = messages.length - 1; index >= 0; index--) {
const applyToolCall = messages[index].toolCalls?.find(
- (toolCall) => toolCall.toolName === "applyToolDefinition" && toolCall.state === "approval-requested" && toolCall.approvalId,
+ (toolCall) =>
+ toolCall.toolName === "applyToolDefinition" &&
+ toolCall.state === "approval-requested" &&
+ toolCall.approvalId,
);
if (applyToolCall?.approvalId && applyToolCall.originalTool && applyToolCall.previewTool) {
@@ -146,7 +152,10 @@ export function findPendingApproval(messages: ChatMessage[]) {
approvalId: applyToolCall.approvalId,
previewTool: applyToolCall.previewTool,
originalTool: applyToolCall.originalTool,
- summary: typeof applyToolCall.input.summary === "string" ? applyToolCall.input.summary : "Apply AI changes",
+ summary:
+ typeof applyToolCall.input.summary === "string"
+ ? applyToolCall.input.summary
+ : "Apply AI changes",
messageIndex: index,
};
}
diff --git a/src/components/ai-chat/ai-chat-persistence.ts b/src/components/ai-chat/ai-chat-persistence.ts
index 37b0036..17fd826 100644
--- a/src/components/ai-chat/ai-chat-persistence.ts
+++ b/src/components/ai-chat/ai-chat-persistence.ts
@@ -1,28 +1,58 @@
import type { UIMessage } from "ai";
-export interface ChatSession {
+const DB_NAME = "commandly";
+const DB_VERSION = 4;
+
+export interface Chat {
id: string;
toolName: string;
- messages: UI_MESSAGE[];
- updatedAt: number;
+ createdAt: number;
+}
+
+export interface StoredMessage {
+ id: string;
+ chatId: string;
+ role: UIMessage["role"];
+ parts: UIMessage["parts"];
+ order: number;
+}
+
+export interface ChatWithPreview extends Chat {
preview: string;
+ updatedAt: number;
+ messageCount: number;
}
-function openSessionsDB(): Promise {
+function openDB(): Promise {
return new Promise((resolve, reject) => {
- const req = indexedDB.open("commandly", 3);
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (event) => {
const db = req.result;
const oldVersion = (event as IDBVersionChangeEvent).oldVersion;
+
if (!db.objectStoreNames.contains("keys")) {
db.createObjectStore("keys");
}
- if (oldVersion < 3 && db.objectStoreNames.contains("sessions")) {
- db.deleteObjectStore("sessions");
+
+ if (oldVersion < 4) {
+ if (db.objectStoreNames.contains("sessions")) {
+ db.deleteObjectStore("sessions");
+ }
+ if (db.objectStoreNames.contains("chats")) {
+ db.deleteObjectStore("chats");
+ }
+ if (db.objectStoreNames.contains("messages")) {
+ db.deleteObjectStore("messages");
+ }
+ }
+
+ if (!db.objectStoreNames.contains("chats")) {
+ const chatStore = db.createObjectStore("chats", { keyPath: "id" });
+ chatStore.createIndex("toolName", "toolName", { unique: false });
}
- if (!db.objectStoreNames.contains("sessions")) {
- const store = db.createObjectStore("sessions", { keyPath: "id" });
- store.createIndex("toolName", "toolName", { unique: false });
+ if (!db.objectStoreNames.contains("messages")) {
+ const msgStore = db.createObjectStore("messages", { keyPath: "id" });
+ msgStore.createIndex("chatId", "chatId", { unique: false });
}
};
req.onsuccess = () => resolve(req.result);
@@ -30,35 +60,32 @@ function openSessionsDB(): Promise {
});
}
+function toSerializable(value: T): T {
+ return JSON.parse(JSON.stringify(value)) as T;
+}
+
function getMessageText(message: UIMessage): string {
return message.parts
- .filter((part): part is Extract => part.type === "text")
+ .filter(
+ (part): part is Extract => part.type === "text",
+ )
.map((part) => part.text)
.join("");
}
-function hasPersistableContent(message: UIMessage): boolean {
+export function hasPersistableContent(message: UIMessage): boolean {
return message.parts.some((part) => {
if (part.type === "text" || part.type === "reasoning") {
return part.text.trim().length > 0;
}
-
return part.type !== "step-start";
});
}
-function normalizeSession(session: ChatSession): ChatSession {
- return {
- id: session.id,
- toolName: session.toolName,
- messages: session.messages,
- updatedAt: session.updatedAt,
- preview: getSessionPreview(session.messages),
- };
-}
-
-function toStoredMessages(messages: UI_MESSAGE[]): UI_MESSAGE[] {
- return JSON.parse(JSON.stringify(messages)) as UI_MESSAGE[];
+export function getPersistableMessages(messages: UIMessage[]): UIMessage[] {
+ return messages.filter(
+ (message) => message.role !== "assistant" || hasPersistableContent(message),
+ );
}
export function getSessionPreview(messages: UIMessage[]): string {
@@ -66,38 +93,184 @@ export function getSessionPreview(messages: UIMessage[]): string {
return getMessageText(firstUserMessage ?? { id: "", role: "user", parts: [] }).slice(0, 80);
}
-export function getPersistableMessages(messages: UIMessage[]): UIMessage[] {
- return messages.filter((message) => message.role !== "assistant" || hasPersistableContent(message));
+export async function truncateMessages(chatId: string, keepCount: number): Promise {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction("messages", "readwrite");
+ const index = tx.objectStore("messages").index("chatId");
+ const req = index.getAll(chatId);
+ req.onsuccess = () => {
+ for (const msg of req.result as StoredMessage[]) {
+ if (msg.order >= keepCount) {
+ tx.objectStore("messages").delete(msg.id);
+ }
+ }
+ };
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
}
-export async function saveChatSession(session: ChatSession): Promise {
- const db = await openSessionsDB();
+export async function createChat(id: string, toolName: string): Promise {
+ const db = await openDB();
return new Promise((resolve, reject) => {
- const tx = db.transaction("sessions", "readwrite");
- tx.objectStore("sessions").put({
- ...session,
- messages: toStoredMessages(session.messages),
- });
+ const tx = db.transaction("chats", "readwrite");
+ tx.objectStore("chats").put({ id, toolName, createdAt: Date.now() } satisfies Chat);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+}
+
+export async function upsertMessage(
+ chatId: string,
+ message: UIMessage,
+ order: number,
+): Promise {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(["chats", "messages"], "readwrite");
+
+ const chatStore = tx.objectStore("chats");
+ const getChat = chatStore.get(chatId);
+ getChat.onsuccess = () => {
+ if (!getChat.result) {
+ tx.abort();
+ reject(new Error(`Chat ${chatId} not found`));
+ return;
+ }
+
+ const stored: StoredMessage = {
+ id: message.id,
+ chatId,
+ role: message.role,
+ parts: toSerializable(message.parts),
+ order,
+ };
+ tx.objectStore("messages").put(stored);
+ };
+
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
-export async function loadRecentSessions(
- toolName: string,
- limit = 5,
-): Promise>> {
- const db = await openSessionsDB();
+export async function loadChat(
+ chatId: string,
+): Promise {
+ const db = await openDB();
return new Promise((resolve, reject) => {
- const tx = db.transaction("sessions", "readonly");
- const index = tx.objectStore("sessions").index("toolName");
- const req = index.getAll(toolName);
+ const tx = db.transaction("messages", "readonly");
+ const index = tx.objectStore("messages").index("chatId");
+ const req = index.getAll(chatId);
req.onsuccess = () => {
- const sessions = (req.result as Array>)
- .map(normalizeSession)
- .sort((a, b) => b.updatedAt - a.updatedAt);
- resolve(sessions.slice(0, limit));
+ const stored = (req.result as StoredMessage[]).sort((a, b) => a.order - b.order);
+ resolve(
+ stored.map((msg) => ({
+ id: msg.id,
+ role: msg.role,
+ parts: msg.parts,
+ })) as UI_MESSAGE[],
+ );
};
req.onerror = () => reject(req.error);
});
}
+
+export async function getChats(toolName: string, limit = 5): Promise {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(["chats", "messages"], "readonly");
+ const chatIndex = tx.objectStore("chats").index("toolName");
+ const chatReq = chatIndex.getAll(toolName);
+
+ chatReq.onsuccess = () => {
+ const chats = (chatReq.result as Chat[]).sort((a, b) => b.createdAt - a.createdAt);
+ const msgIndex = tx.objectStore("messages").index("chatId");
+ const results: ChatWithPreview[] = [];
+ let pending = chats.length;
+
+ if (pending === 0) {
+ resolve([]);
+ return;
+ }
+
+ for (const chat of chats) {
+ const msgReq = msgIndex.getAll(chat.id);
+ msgReq.onsuccess = () => {
+ const stored = (msgReq.result as StoredMessage[]).sort((a, b) => a.order - b.order);
+ const messages = stored.map(
+ (m) => ({ id: m.id, role: m.role, parts: m.parts }) as UIMessage,
+ );
+ results.push({
+ ...chat,
+ preview: getSessionPreview(messages),
+ updatedAt: chat.createdAt,
+ messageCount: stored.length,
+ });
+ pending--;
+ if (pending === 0) {
+ results.sort((a, b) => b.createdAt - a.createdAt);
+ resolve(results.slice(0, limit));
+ }
+ };
+ msgReq.onerror = () => {
+ pending--;
+ if (pending === 0) {
+ results.sort((a, b) => b.createdAt - a.createdAt);
+ resolve(results.slice(0, limit));
+ }
+ };
+ }
+ };
+ chatReq.onerror = () => reject(chatReq.error);
+ });
+}
+
+export async function deleteChat(chatId: string): Promise {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(["chats", "messages"], "readwrite");
+
+ tx.objectStore("chats").delete(chatId);
+
+ const msgIndex = tx.objectStore("messages").index("chatId");
+ const req = msgIndex.getAll(chatId);
+ req.onsuccess = () => {
+ for (const msg of req.result as StoredMessage[]) {
+ tx.objectStore("messages").delete(msg.id);
+ }
+ };
+
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+}
+
+export async function deleteMessage(messageId: string): Promise {
+ const db = await openDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction("messages", "readwrite");
+
+ const getReq = tx.objectStore("messages").get(messageId);
+ getReq.onsuccess = () => {
+ const target = getReq.result as StoredMessage | undefined;
+ if (!target) {
+ resolve();
+ return;
+ }
+
+ const chatIndex = tx.objectStore("messages").index("chatId");
+ const allReq = chatIndex.getAll(target.chatId);
+ allReq.onsuccess = () => {
+ for (const msg of allReq.result as StoredMessage[]) {
+ if (msg.order >= target.order) {
+ tx.objectStore("messages").delete(msg.id);
+ }
+ }
+ };
+ };
+
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+}
diff --git a/src/components/ai-chat/api-key-settings.tsx b/src/components/ai-chat/api-key-settings.tsx
index b4e5411..ddd459e 100644
--- a/src/components/ai-chat/api-key-settings.tsx
+++ b/src/components/ai-chat/api-key-settings.tsx
@@ -1,144 +1,147 @@
import { MODEL_GROUPS } from "./model-picker";
-import { type AIProvider } from "@/lib/ai-keys";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
+import { type AIProvider } from "@/lib/ai-keys";
import { CheckIcon, EyeIcon, EyeOffIcon, GlobeIcon, Settings2Icon } from "lucide-react";
import { useState } from "react";
interface AIKeyState {
- key: string;
- setKey: (key: string) => void;
- isSaved: boolean;
- setSaved: (saved: boolean) => void;
+ key: string;
+ setKey: (key: string) => void;
+ isSaved: boolean;
+ setSaved: (saved: boolean) => void;
}
interface ApiKeySettingsProps {
- allProviderKeys: Record, AIKeyState>;
- tavilyKeys: AIKeyState;
+ allProviderKeys: Record, AIKeyState>;
+ tavilyKeys: AIKeyState;
}
export function ApiKeySettings({ allProviderKeys, tavilyKeys }: ApiKeySettingsProps) {
- const [revealed, setRevealed] = useState>({});
+ const [revealed, setRevealed] = useState>({});
- const toggleReveal = (key: string) =>
- setRevealed((prev) => ({ ...prev, [key]: !prev[key] }));
- return (
-
-
-
-
-
-
-
- API Keys
-
- {MODEL_GROUPS.map((g, gi) => {
- const keys = allProviderKeys[g.provider as Exclude
];
- return (
-
- {gi > 0 &&
}
-
-
-
-
{g.label}
- {keys.isSaved &&
}
-
-
- keys.setKey(e.target.value)}
- className="h-8 pr-8 text-xs"
- placeholder={g.keyPlaceholder}
- />
- toggleReveal(g.provider)}
- tabIndex={-1}
- aria-label={revealed[g.provider] ? "Hide key" : "Reveal key"}
- >
- {revealed[g.provider]
- ?
- : }
-
-
-
- keys.setSaved(c === "indeterminate" ? false : c)}
- />
-
- Save key locally
-
-
-
-
- );
- })}
-
-
-
-
- Tavily (Web Search)
- {tavilyKeys.isSaved && }
-
-
- tavilyKeys.setKey(e.target.value)}
- className="h-8 pr-8 text-xs"
- placeholder="tvly-..."
- />
- toggleReveal("tavily")}
- tabIndex={-1}
- aria-label={revealed["tavily"] ? "Hide key" : "Reveal key"}
- >
- {revealed["tavily"]
- ?
- : }
-
-
-
- tavilyKeys.setSaved(c === "indeterminate" ? false : c)}
- />
-
- Save key locally
-
-
-
+ const toggleReveal = (key: string) => setRevealed((prev) => ({ ...prev, [key]: !prev[key] }));
+ return (
+
+
+
+
+
+
+
+ API Keys
+
+ {MODEL_GROUPS.map((g, gi) => {
+ const keys = allProviderKeys[g.provider as Exclude
];
+ return (
+
+ {gi > 0 &&
}
+
+
+
+
{g.label}
+ {keys.isSaved &&
}
+
+
+ keys.setKey(e.target.value)}
+ className="h-8 pr-8 text-xs"
+ placeholder={g.keyPlaceholder}
+ />
+ toggleReveal(g.provider)}
+ tabIndex={-1}
+ aria-label={revealed[g.provider] ? "Hide key" : "Reveal key"}
+ >
+ {revealed[g.provider] ? (
+
+ ) : (
+
+ )}
+
+
+
+ keys.setSaved(c === "indeterminate" ? false : c)}
+ />
+
+ Save key locally
+
+
-
-
- );
+
+ );
+ })}
+
+
+
+
+ Tavily (Web Search)
+ {tavilyKeys.isSaved && }
+
+
+ tavilyKeys.setKey(e.target.value)}
+ className="h-8 pr-8 text-xs"
+ placeholder="tvly-..."
+ />
+ toggleReveal("tavily")}
+ tabIndex={-1}
+ aria-label={revealed["tavily"] ? "Hide key" : "Reveal key"}
+ >
+ {revealed["tavily"] ? (
+
+ ) : (
+
+ )}
+
+
+
+ tavilyKeys.setSaved(c === "indeterminate" ? false : c)}
+ />
+
+ Save key locally
+
+
+
+
+
+
+ );
}
diff --git a/src/components/ai-chat/diff-view.tsx b/src/components/ai-chat/diff-view.tsx
index e68b042..e5753e0 100644
--- a/src/components/ai-chat/diff-view.tsx
+++ b/src/components/ai-chat/diff-view.tsx
@@ -14,9 +14,10 @@ function computeLineDiff(
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
- dp[i][j] = aLines[i - 1] === bLines[j - 1]
- ? dp[i - 1][j - 1] + 1
- : Math.max(dp[i - 1][j], dp[i][j - 1]);
+ dp[i][j] =
+ aLines[i - 1] === bLines[j - 1]
+ ? dp[i - 1][j - 1] + 1
+ : Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
@@ -47,8 +48,14 @@ export function DiffView({ original, updated }: { original: Tool; updated: Tool
const diff = computeLineDiff(aJson, bJson);
const visibleSet = new Set();
- for (const index of diff.flatMap((line, currentIndex) => (line.type !== "same" ? [currentIndex] : []))) {
- for (let cursor = Math.max(0, index - 2); cursor <= Math.min(diff.length - 1, index + 2); cursor++) {
+ for (const index of diff.flatMap((line, currentIndex) =>
+ line.type !== "same" ? [currentIndex] : [],
+ )) {
+ for (
+ let cursor = Math.max(0, index - 2);
+ cursor <= Math.min(diff.length - 1, index + 2);
+ cursor++
+ ) {
visibleSet.add(cursor);
}
}
@@ -88,9 +95,10 @@ export function DiffView({ original, updated }: { original: Tool; updated: Tool
diff --git a/src/components/ai-chat/extracted-content.tsx b/src/components/ai-chat/extracted-content.tsx
index d228e6a..e9d6410 100644
--- a/src/components/ai-chat/extracted-content.tsx
+++ b/src/components/ai-chat/extracted-content.tsx
@@ -17,7 +17,10 @@ export function ExtractedContent({ results }: { results: ExtractResult[] }) {
}
return (
-
+
{hostname}
- {result.hasMore && (
-
partial
- )}
+ {result.hasMore &&
partial }
-
+
{result.raw_content}
diff --git a/src/components/ai-chat/model-picker.tsx b/src/components/ai-chat/model-picker.tsx
index 7d6676b..8957469 100644
--- a/src/components/ai-chat/model-picker.tsx
+++ b/src/components/ai-chat/model-picker.tsx
@@ -1,6 +1,6 @@
-import { type AIProvider } from "@/lib/ai-keys";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { type AIProvider } from "@/lib/ai-keys";
import { cn } from "@/lib/utils";
import { CheckIcon, ChevronsUpDownIcon, ChevronUpIcon } from "lucide-react";
import { useState } from "react";
@@ -119,17 +119,9 @@ export const MODEL_GROUPS: {
export function providerForModel(model: string): AIProvider {
if (model.startsWith("claude")) return "anthropic";
if (model.startsWith("gemini")) return "google";
- if (
- model.startsWith("llama") ||
- model.startsWith("meta-llama") ||
- model.startsWith("qwen")
- )
+ if (model.startsWith("llama") || model.startsWith("meta-llama") || model.startsWith("qwen"))
return "groq";
- if (
- model.startsWith("mistral") ||
- model.startsWith("codestral") ||
- model.startsWith("magistral")
- )
+ if (model.startsWith("mistral") || model.startsWith("codestral") || model.startsWith("magistral"))
return "mistral";
if (model.startsWith("grok")) return "xai";
return "openai";
@@ -142,7 +134,7 @@ export function getProviderOptions(
model: string,
effort: ReasoningEffort,
): Record
{
- if (provider === "openai") {
+ if (provider === "openai") {
if (effort === "none") return {};
const effortMap = { minimal: "low", low: "low", medium: "medium", high: "high" } as const;
return { openai: { reasoningEffort: effortMap[effort] } };
@@ -294,13 +286,17 @@ export function ReasoningEffortPicker({
}: ReasoningEffortPickerProps) {
const [open, setOpen] = useState(false);
- const isReasoningModel = MODEL_GROUPS.flatMap((g) => g.models).find((m) => m.value === model)?.reasoning === true;
+ const isReasoningModel =
+ MODEL_GROUPS.flatMap((g) => g.models).find((m) => m.value === model)?.reasoning === true;
if (!isReasoningModel) return null;
const current = EFFORT_OPTIONS.find((o) => o.value === effort);
return (
-
+
-
+
{EFFORT_OPTIONS.map(({ value, label }) => (
-
+
{result.title}
@@ -35,7 +35,9 @@ export function WebSearchResults({ results }: { results: WebSearchResult[] }) {
{result.content && (
{result.content}
)}
- {hostname}
+
+ {hostname}
+
);
})}
diff --git a/src/components/ai-elements/chain-of-thought.tsx b/src/components/ai-elements/chain-of-thought.tsx
index 914aa3e..3c3461e 100644
--- a/src/components/ai-elements/chain-of-thought.tsx
+++ b/src/components/ai-elements/chain-of-thought.tsx
@@ -1,13 +1,9 @@
"use client";
-import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { Badge } from "@/components/ui/badge";
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from "@/components/ui/collapsible";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
+import { useControllableState } from "@radix-ui/react-use-controllable-state";
import type { LucideIcon } from "lucide-react";
import { BrainIcon, ChevronDownIcon, DotIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
@@ -18,16 +14,12 @@ interface ChainOfThoughtContextValue {
setIsOpen: (open: boolean) => void;
}
-const ChainOfThoughtContext = createContext(
- null
-);
+const ChainOfThoughtContext = createContext(null);
const useChainOfThought = () => {
const context = useContext(ChainOfThoughtContext);
if (!context) {
- throw new Error(
- "ChainOfThought components must be used within ChainOfThought"
- );
+ throw new Error("ChainOfThought components must be used within ChainOfThought");
}
return context;
};
@@ -53,52 +45,48 @@ export const ChainOfThought = memo(
prop: open,
});
- const chainOfThoughtContext = useMemo(
- () => ({ isOpen, setIsOpen }),
- [isOpen, setIsOpen]
- );
+ const chainOfThoughtContext = useMemo(() => ({ isOpen, setIsOpen }), [isOpen, setIsOpen]);
return (
-
+
{children}
);
- }
+ },
);
-export type ChainOfThoughtHeaderProps = ComponentProps<
- typeof CollapsibleTrigger
->;
+export type ChainOfThoughtHeaderProps = ComponentProps
;
export const ChainOfThoughtHeader = memo(
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
const { isOpen, setIsOpen } = useChainOfThought();
return (
-
+
-
- {children ?? "Chain of Thought"}
-
+ {children ?? "Chain of Thought"}
);
- }
+ },
);
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
@@ -128,8 +116,8 @@ export const ChainOfThoughtStep = memo(
className={cn(
"flex gap-2 text-sm",
stepStatusStyles[status],
- "fade-in-0 slide-in-from-top-2 animate-in",
- className
+ "animate-in fade-in-0 slide-in-from-top-2",
+ className,
)}
{...props}
>
@@ -139,13 +127,11 @@ export const ChainOfThoughtStep = memo(
{label}
- {description && (
-
{description}
- )}
+ {description &&
{description}
}
{children}
- )
+ ),
);
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
@@ -156,7 +142,7 @@ export const ChainOfThoughtSearchResults = memo(
className={cn("flex flex-wrap items-center gap-2", className)}
{...props}
/>
- )
+ ),
);
export type ChainOfThoughtSearchResultProps = ComponentProps;
@@ -164,18 +150,16 @@ export type ChainOfThoughtSearchResultProps = ComponentProps;
export const ChainOfThoughtSearchResult = memo(
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
{children}
- )
+ ),
);
-export type ChainOfThoughtContentProps = ComponentProps<
- typeof CollapsibleContent
->;
+export type ChainOfThoughtContentProps = ComponentProps;
export const ChainOfThoughtContent = memo(
({ className, children, ...props }: ChainOfThoughtContentProps) => {
@@ -186,8 +170,8 @@ export const ChainOfThoughtContent = memo(
@@ -195,7 +179,7 @@ export const ChainOfThoughtContent = memo(
);
- }
+ },
);
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
@@ -204,13 +188,16 @@ export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
export const ChainOfThoughtImage = memo(
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
-
+
{children}
- {caption &&
{caption}
}
+ {caption &&
{caption}
}
- )
+ ),
);
ChainOfThought.displayName = "ChainOfThought";
diff --git a/src/components/ai-elements/context.tsx b/src/components/ai-elements/context.tsx
index 1327616..96128a9 100644
--- a/src/components/ai-elements/context.tsx
+++ b/src/components/ai-elements/context.tsx
@@ -1,11 +1,7 @@
"use client";
import { Button } from "@/components/ui/button";
-import {
- HoverCard,
- HoverCardContent,
- HoverCardTrigger,
-} from "@/components/ui/hover-card";
+import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import type { LanguageModelUsage } from "ai";
@@ -42,21 +38,19 @@ const useContextValue = () => {
export type ContextProps = ComponentProps
& ContextSchema;
-export const Context = ({
- usedTokens,
- maxTokens,
- usage,
- modelId,
- ...props
-}: ContextProps) => {
+export const Context = ({ usedTokens, maxTokens, usage, modelId, ...props }: ContextProps) => {
const contextValue = useMemo(
() => ({ maxTokens, modelId, usage, usedTokens }),
- [maxTokens, modelId, usage, usedTokens]
+ [maxTokens, modelId, usage, usedTokens],
);
return (
-
+
);
};
@@ -115,10 +109,12 @@ export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
return (
{children ?? (
-
-
- {renderedPercent}
-
+
+ {renderedPercent}
)}
@@ -128,10 +124,7 @@ export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
export type ContextContentProps = ComponentProps;
-export const ContextContent = ({
- className,
- ...props
-}: ContextContentProps) => (
+export const ContextContent = ({ className, ...props }: ContextContentProps) => (
+
{children ?? (
<>
@@ -169,7 +165,10 @@ export const ContextContentHeader = ({
>
)}
@@ -179,12 +178,11 @@ export const ContextContentHeader = ({
export type ContextContentBodyProps = ComponentProps<"div">;
-export const ContextContentBody = ({
- children,
- className,
- ...props
-}: ContextContentBodyProps) => (
-
+export const ContextContentBody = ({ children, className, ...props }: ContextContentBodyProps) => (
+
{children}
);
@@ -215,7 +213,7 @@ export const ContextContentFooter = ({
@@ -229,32 +227,20 @@ export const ContextContentFooter = ({
);
};
-const TokensWithCost = ({
- tokens,
- costText,
-}: {
- tokens?: number;
- costText?: string;
-}) => (
+const TokensWithCost = ({ tokens, costText }: { tokens?: number; costText?: string }) => (
{tokens === undefined
? "—"
: new Intl.NumberFormat("en-US", {
notation: "compact",
}).format(tokens)}
- {costText ? (
- • {costText}
- ) : null}
+ {costText ? • {costText} : null}
);
export type ContextInputUsageProps = ComponentProps<"div">;
-export const ContextInputUsage = ({
- className,
- children,
- ...props
-}: ContextInputUsageProps) => {
+export const ContextInputUsage = ({ className, children, ...props }: ContextInputUsageProps) => {
const { usage, modelId } = useContextValue();
const inputTokens = usage?.inputTokens ?? 0;
@@ -283,18 +269,17 @@ export const ContextInputUsage = ({
{...props}
>
Input
-
+
);
};
export type ContextOutputUsageProps = ComponentProps<"div">;
-export const ContextOutputUsage = ({
- className,
- children,
- ...props
-}: ContextOutputUsageProps) => {
+export const ContextOutputUsage = ({ className, children, ...props }: ContextOutputUsageProps) => {
const { usage, modelId } = useContextValue();
const outputTokens = usage?.outputTokens ?? 0;
@@ -323,7 +308,10 @@ export const ContextOutputUsage = ({
{...props}
>
Output
-
+
);
};
@@ -363,18 +351,17 @@ export const ContextReasoningUsage = ({
{...props}
>
Reasoning
-
+
);
};
export type ContextCacheUsageProps = ComponentProps<"div">;
-export const ContextCacheUsage = ({
- className,
- children,
- ...props
-}: ContextCacheUsageProps) => {
+export const ContextCacheUsage = ({ className, children, ...props }: ContextCacheUsageProps) => {
const { usage, modelId } = useContextValue();
const cacheTokens = usage?.cachedInputTokens ?? 0;
@@ -403,7 +390,10 @@ export const ContextCacheUsage = ({
{...props}
>
Cache
-
+
);
};
diff --git a/src/components/ai-elements/prompt-input.tsx b/src/components/ai-elements/prompt-input.tsx
index 512bec6..2f81d6e 100644
--- a/src/components/ai-elements/prompt-input.tsx
+++ b/src/components/ai-elements/prompt-input.tsx
@@ -15,11 +15,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import {
- HoverCard,
- HoverCardContent,
- HoverCardTrigger,
-} from "@/components/ui/hover-card";
+import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import {
InputGroup,
InputGroupAddon,
@@ -34,21 +30,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai";
-import {
- ArrowUpIcon,
- ImageIcon,
- MonitorIcon,
- PlusIcon,
- SquareIcon,
- XIcon,
-} from "lucide-react";
+import { ArrowUpIcon, ImageIcon, MonitorIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
import { nanoid } from "nanoid";
import type {
ChangeEvent,
@@ -98,10 +83,7 @@ const convertBlobUrlToDataUrl = async (url: string): Promise => {
};
const captureScreenshot = async (): Promise => {
- if (
- typeof navigator === "undefined" ||
- !navigator.mediaDevices?.getDisplayMedia
- ) {
+ if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) {
return null;
}
@@ -197,45 +179,36 @@ export interface PromptInputControllerProps {
textInput: TextInputContext;
attachments: AttachmentsContext;
/** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
- __registerFileInput: (
- ref: RefObject,
- open: () => void
- ) => void;
+ __registerFileInput: (ref: RefObject, open: () => void) => void;
}
-const PromptInputController = createContext(
- null
-);
-const ProviderAttachmentsContext = createContext(
- null
-);
+const PromptInputController = createContext(null);
+const ProviderAttachmentsContext = createContext(null);
export const usePromptInputController = () => {
const ctx = useContext(PromptInputController);
if (!ctx) {
throw new Error(
- "Wrap your component inside to use usePromptInputController()."
+ "Wrap your component inside to use usePromptInputController().",
);
}
return ctx;
};
// Optional variants (do NOT throw). Useful for dual-mode components.
-const useOptionalPromptInputController = () =>
- useContext(PromptInputController);
+const useOptionalPromptInputController = () => useContext(PromptInputController);
export const useProviderAttachments = () => {
const ctx = useContext(ProviderAttachmentsContext);
if (!ctx) {
throw new Error(
- "Wrap your component inside to use useProviderAttachments()."
+ "Wrap your component inside to use useProviderAttachments().",
);
}
return ctx;
};
-const useOptionalProviderAttachments = () =>
- useContext(ProviderAttachmentsContext);
+const useOptionalProviderAttachments = () => useContext(ProviderAttachmentsContext);
export type PromptInputProviderProps = PropsWithChildren<{
initialInput?: string;
@@ -254,9 +227,7 @@ export const PromptInputProvider = ({
const clearInput = useCallback(() => setTextInput(""), []);
// ----- attachments state (global when wrapped)
- const [attachmentFiles, setAttachmentFiles] = useState<
- (FileUIPart & { id: string })[]
- >([]);
+ const [attachmentFiles, setAttachmentFiles] = useState<(FileUIPart & { id: string })[]>([]);
const fileInputRef = useRef(null);
// oxlint-disable-next-line eslint(no-empty-function)
const openRef = useRef<() => void>(() => {});
@@ -316,7 +287,7 @@ export const PromptInputProvider = ({
}
}
},
- []
+ [],
);
const openFileDialog = useCallback(() => {
@@ -332,7 +303,7 @@ export const PromptInputProvider = ({
openFileDialog,
remove,
}),
- [attachmentFiles, add, remove, clear, openFileDialog]
+ [attachmentFiles, add, remove, clear, openFileDialog],
);
const __registerFileInput = useCallback(
@@ -340,7 +311,7 @@ export const PromptInputProvider = ({
fileInputRef.current = ref.current;
openRef.current = open;
},
- []
+ [],
);
const controller = useMemo(
@@ -353,7 +324,7 @@ export const PromptInputProvider = ({
value: textInput,
},
}),
- [textInput, clearInput, attachments, __registerFileInput]
+ [textInput, clearInput, attachments, __registerFileInput],
);
return (
@@ -378,7 +349,7 @@ export const usePromptInputAttachments = () => {
const context = local ?? provider;
if (!context) {
throw new Error(
- "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider"
+ "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider",
);
}
return context;
@@ -395,22 +366,19 @@ export interface ReferencedSourcesContext {
clear: () => void;
}
-export const LocalReferencedSourcesContext =
- createContext(null);
+export const LocalReferencedSourcesContext = createContext(null);
export const usePromptInputReferencedSources = () => {
const ctx = useContext(LocalReferencedSourcesContext);
if (!ctx) {
throw new Error(
- "usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider"
+ "usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider",
);
}
return ctx;
};
-export type PromptInputActionAddAttachmentsProps = ComponentProps<
- typeof DropdownMenuItem
-> & {
+export type PromptInputActionAddAttachmentsProps = ComponentProps & {
label?: string;
};
@@ -425,19 +393,20 @@ export const PromptInputActionAddAttachments = ({
e.preventDefault();
attachments.openFileDialog();
},
- [attachments]
+ [attachments],
);
return (
-
+
{label}
);
};
-export type PromptInputActionAddScreenshotProps = ComponentProps<
- typeof DropdownMenuItem
-> & {
+export type PromptInputActionAddScreenshotProps = ComponentProps & {
label?: string;
};
@@ -470,11 +439,14 @@ export const PromptInputActionAddScreenshot = ({
throw error;
}
},
- [onSelect, attachments]
+ [onSelect, attachments],
);
return (
-
+
{label}
@@ -486,10 +458,7 @@ export interface PromptInputMessage {
files: FileUIPart[];
}
-export type PromptInputProps = Omit<
- HTMLAttributes,
- "onSubmit" | "onError"
-> & {
+export type PromptInputProps = Omit, "onSubmit" | "onError"> & {
// e.g., "image/*" or leave undefined for any
accept?: string;
multiple?: boolean;
@@ -501,13 +470,10 @@ export type PromptInputProps = Omit<
maxFiles?: number;
// bytes
maxFileSize?: number;
- onError?: (err: {
- code: "max_files" | "max_file_size" | "accept";
- message: string;
- }) => void;
+ onError?: (err: { code: "max_files" | "max_file_size" | "accept"; message: string }) => void;
onSubmit: (
message: PromptInputMessage,
- event: FormEvent
+ event: FormEvent,
) => void | Promise;
};
@@ -572,7 +538,7 @@ export const PromptInput = ({
return f.type === pattern;
});
},
- [accept]
+ [accept],
);
const addLocal = useCallback(
@@ -586,8 +552,7 @@ export const PromptInput = ({
});
return;
}
- const withinSize = (f: File) =>
- maxFileSize ? f.size <= maxFileSize : true;
+ const withinSize = (f: File) => (maxFileSize ? f.size <= maxFileSize : true);
const sized = accepted.filter(withinSize);
if (accepted.length > 0 && sized.length === 0) {
onError?.({
@@ -599,11 +564,8 @@ export const PromptInput = ({
setItems((prev) => {
const capacity =
- typeof maxFiles === "number"
- ? Math.max(0, maxFiles - prev.length)
- : undefined;
- const capped =
- typeof capacity === "number" ? sized.slice(0, capacity) : sized;
+ typeof maxFiles === "number" ? Math.max(0, maxFiles - prev.length) : undefined;
+ const capped = typeof capacity === "number" ? sized.slice(0, capacity) : sized;
if (typeof capacity === "number" && sized.length > capacity) {
onError?.({
code: "max_files",
@@ -623,7 +585,7 @@ export const PromptInput = ({
return [...prev, ...next];
});
},
- [matchesAccept, maxFiles, maxFileSize, onError]
+ [matchesAccept, maxFiles, maxFileSize, onError],
);
const removeLocal = useCallback(
@@ -635,7 +597,7 @@ export const PromptInput = ({
}
return prev.filter((file) => file.id !== id);
}),
- []
+ [],
);
// Wrapper that validates files before calling provider's add
@@ -650,8 +612,7 @@ export const PromptInput = ({
});
return;
}
- const withinSize = (f: File) =>
- maxFileSize ? f.size <= maxFileSize : true;
+ const withinSize = (f: File) => (maxFileSize ? f.size <= maxFileSize : true);
const sized = accepted.filter(withinSize);
if (accepted.length > 0 && sized.length === 0) {
onError?.({
@@ -663,11 +624,8 @@ export const PromptInput = ({
const currentCount = files.length;
const capacity =
- typeof maxFiles === "number"
- ? Math.max(0, maxFiles - currentCount)
- : undefined;
- const capped =
- typeof capacity === "number" ? sized.slice(0, capacity) : sized;
+ typeof maxFiles === "number" ? Math.max(0, maxFiles - currentCount) : undefined;
+ const capped = typeof capacity === "number" ? sized.slice(0, capacity) : sized;
if (typeof capacity === "number" && sized.length > capacity) {
onError?.({
code: "max_files",
@@ -679,7 +637,7 @@ export const PromptInput = ({
controller?.attachments.add(capped);
}
},
- [matchesAccept, maxFileSize, maxFiles, onError, files.length, controller]
+ [matchesAccept, maxFileSize, maxFiles, onError, files.length, controller],
);
const clearAttachments = useCallback(
@@ -694,13 +652,10 @@ export const PromptInput = ({
}
return [];
}),
- [usingProvider, controller]
+ [usingProvider, controller],
);
- const clearReferencedSources = useCallback(
- () => setReferencedSources([]),
- []
- );
+ const clearReferencedSources = useCallback(() => setReferencedSources([]), []);
const add = usingProvider ? addWithProviderValidation : addLocal;
const remove = usingProvider ? controller.attachments.remove : removeLocal;
@@ -797,7 +752,7 @@ export const PromptInput = ({
}
}
},
- [usingProvider]
+ [usingProvider],
);
const handleChange: ChangeEventHandler = useCallback(
@@ -808,7 +763,7 @@ export const PromptInput = ({
// Reset input value to allow selecting files that were previously removed
event.currentTarget.value = "";
},
- [add]
+ [add],
);
const attachmentsCtx = useMemo(
@@ -820,17 +775,14 @@ export const PromptInput = ({
openFileDialog,
remove,
}),
- [files, add, remove, clearAttachments, openFileDialog]
+ [files, add, remove, clearAttachments, openFileDialog],
);
const refsCtx = useMemo(
() => ({
add: (incoming: SourceDocumentUIPart[] | SourceDocumentUIPart) => {
const array = Array.isArray(incoming) ? incoming : [incoming];
- setReferencedSources((prev) => [
- ...prev,
- ...array.map((s) => ({ ...s, id: nanoid() })),
- ]);
+ setReferencedSources((prev) => [...prev, ...array.map((s) => ({ ...s, id: nanoid() }))]);
},
clear: clearReferencedSources,
remove: (id: string) => {
@@ -838,7 +790,7 @@ export const PromptInput = ({
},
sources: referencedSources,
}),
- [referencedSources, clearReferencedSources]
+ [referencedSources, clearReferencedSources],
);
const handleSubmit: FormEventHandler = useCallback(
@@ -872,7 +824,7 @@ export const PromptInput = ({
};
}
return item;
- })
+ }),
);
const result = onSubmit({ files: convertedFiles, text }, event);
@@ -899,7 +851,7 @@ export const PromptInput = ({
// Don't clear on error - user may want to retry
}
},
- [usingProvider, controller, files, onSubmit, clear]
+ [usingProvider, controller, files, onSubmit, clear],
);
// Render with or without local provider
@@ -942,16 +894,14 @@ export const PromptInput = ({
export type PromptInputBodyProps = HTMLAttributes;
-export const PromptInputBody = ({
- className,
- ...props
-}: PromptInputBodyProps) => (
-
+export const PromptInputBody = ({ className, ...props }: PromptInputBodyProps) => (
+
);
-export type PromptInputTextareaProps = ComponentProps<
- typeof InputGroupTextarea
->;
+export type PromptInputTextareaProps = ComponentProps;
export const PromptInputTextarea = ({
onChange,
@@ -986,7 +936,7 @@ export const PromptInputTextarea = ({
// Check if the submit button is disabled before submitting
const { form } = e.currentTarget;
const submitButton = form?.querySelector(
- 'button[type="submit"]'
+ 'button[type="submit"]',
) as HTMLButtonElement | null;
if (submitButton?.disabled) {
return;
@@ -996,11 +946,7 @@ export const PromptInputTextarea = ({
}
// Remove last attachment when Backspace is pressed and textarea is empty
- if (
- e.key === "Backspace" &&
- e.currentTarget.value === "" &&
- attachments.files.length > 0
- ) {
+ if (e.key === "Backspace" && e.currentTarget.value === "" && attachments.files.length > 0) {
e.preventDefault();
const lastAttachment = attachments.files.at(-1);
if (lastAttachment) {
@@ -1008,7 +954,7 @@ export const PromptInputTextarea = ({
}
}
},
- [onKeyDown, isComposing, attachments]
+ [onKeyDown, isComposing, attachments],
);
const handlePaste: ClipboardEventHandler = useCallback(
@@ -1035,7 +981,7 @@ export const PromptInputTextarea = ({
attachments.add(files);
}
},
- [attachments]
+ [attachments],
);
const handleCompositionEnd = useCallback(() => setIsComposing(false), []);
@@ -1068,15 +1014,9 @@ export const PromptInputTextarea = ({
);
};
-export type PromptInputHeaderProps = Omit<
- ComponentProps,
- "align"
->;
+export type PromptInputHeaderProps = Omit, "align">;
-export const PromptInputHeader = ({
- className,
- ...props
-}: PromptInputHeaderProps) => (
+export const PromptInputHeader = ({ className, ...props }: PromptInputHeaderProps) => (
);
-export type PromptInputFooterProps = Omit<
- ComponentProps,
- "align"
->;
+export type PromptInputFooterProps = Omit, "align">;
-export const PromptInputFooter = ({
- className,
- ...props
-}: PromptInputFooterProps) => (
+export const PromptInputFooter = ({ className, ...props }: PromptInputFooterProps) => (
;
-export const PromptInputTools = ({
- className,
- ...props
-}: PromptInputToolsProps) => (
+export const PromptInputTools = ({ className, ...props }: PromptInputToolsProps) => (
{
- const newSize =
- size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm");
+ const newSize = size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm");
const button = (
{button}
{tooltipContent}
- {shortcut && (
- {shortcut}
- )}
+ {shortcut && {shortcut} }
);
@@ -1179,30 +1106,36 @@ export const PromptInputActionMenuTrigger = ({
...props
}: PromptInputActionMenuTriggerProps) => (
-
+
{children ?? }
);
-export type PromptInputActionMenuContentProps = ComponentProps<
- typeof DropdownMenuContent
->;
+export type PromptInputActionMenuContentProps = ComponentProps;
export const PromptInputActionMenuContent = ({
className,
...props
}: PromptInputActionMenuContentProps) => (
-
+
);
-export type PromptInputActionMenuItemProps = ComponentProps<
- typeof DropdownMenuItem
->;
+export type PromptInputActionMenuItemProps = ComponentProps;
export const PromptInputActionMenuItem = ({
className,
...props
}: PromptInputActionMenuItemProps) => (
-
+
);
// Note: Actions that perform side-effects (like opening a file dialog)
@@ -1244,7 +1177,7 @@ export const PromptInputSubmit = ({
}
onClick?.(e);
},
- [isGenerating, onStop, onClick]
+ [isGenerating, onStop, onClick],
);
return (
@@ -1264,13 +1197,9 @@ export const PromptInputSubmit = ({
export type PromptInputSelectProps = ComponentProps;
-export const PromptInputSelect = (props: PromptInputSelectProps) => (
-
-);
+export const PromptInputSelect = (props: PromptInputSelectProps) => ;
-export type PromptInputSelectTriggerProps = ComponentProps<
- typeof SelectTrigger
->;
+export type PromptInputSelectTriggerProps = ComponentProps;
export const PromptInputSelectTrigger = ({
className,
@@ -1280,39 +1209,40 @@ export const PromptInputSelectTrigger = ({
className={cn(
"border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors",
"hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
- className
+ className,
)}
{...props}
/>
);
-export type PromptInputSelectContentProps = ComponentProps<
- typeof SelectContent
->;
+export type PromptInputSelectContentProps = ComponentProps;
export const PromptInputSelectContent = ({
className,
...props
}: PromptInputSelectContentProps) => (
-
+
);
export type PromptInputSelectItemProps = ComponentProps;
-export const PromptInputSelectItem = ({
- className,
- ...props
-}: PromptInputSelectItemProps) => (
-
+export const PromptInputSelectItem = ({ className, ...props }: PromptInputSelectItemProps) => (
+
);
export type PromptInputSelectValueProps = ComponentProps;
-export const PromptInputSelectValue = ({
- className,
- ...props
-}: PromptInputSelectValueProps) => (
-
+export const PromptInputSelectValue = ({ className, ...props }: PromptInputSelectValueProps) => (
+
);
export type PromptInputHoverCardProps = ComponentProps;
@@ -1322,142 +1252,140 @@ export const PromptInputHoverCard = ({
closeDelay = 0,
...props
}: PromptInputHoverCardProps) => (
-
+
);
-export type PromptInputHoverCardTriggerProps = ComponentProps<
- typeof HoverCardTrigger
->;
+export type PromptInputHoverCardTriggerProps = ComponentProps;
-export const PromptInputHoverCardTrigger = (
- props: PromptInputHoverCardTriggerProps
-) => ;
+export const PromptInputHoverCardTrigger = (props: PromptInputHoverCardTriggerProps) => (
+
+);
-export type PromptInputHoverCardContentProps = ComponentProps<
- typeof HoverCardContent
->;
+export type PromptInputHoverCardContentProps = ComponentProps;
export const PromptInputHoverCardContent = ({
align = "start",
...props
}: PromptInputHoverCardContentProps) => (
-
+
);
export type PromptInputTabsListProps = HTMLAttributes;
-export const PromptInputTabsList = ({
- className,
- ...props
-}: PromptInputTabsListProps) =>
;
+export const PromptInputTabsList = ({ className, ...props }: PromptInputTabsListProps) => (
+
+);
export type PromptInputTabProps = HTMLAttributes;
-export const PromptInputTab = ({
- className,
- ...props
-}: PromptInputTabProps) =>
;
+export const PromptInputTab = ({ className, ...props }: PromptInputTabProps) => (
+
+);
export type PromptInputTabLabelProps = HTMLAttributes;
-export const PromptInputTabLabel = ({
- className,
- ...props
-}: PromptInputTabLabelProps) => (
+export const PromptInputTabLabel = ({ className, ...props }: PromptInputTabLabelProps) => (
// Content provided via children in props
// oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)
);
export type PromptInputTabBodyProps = HTMLAttributes;
-export const PromptInputTabBody = ({
- className,
- ...props
-}: PromptInputTabBodyProps) => (
-
+export const PromptInputTabBody = ({ className, ...props }: PromptInputTabBodyProps) => (
+
);
export type PromptInputTabItemProps = HTMLAttributes;
-export const PromptInputTabItem = ({
- className,
- ...props
-}: PromptInputTabItemProps) => (
+export const PromptInputTabItem = ({ className, ...props }: PromptInputTabItemProps) => (
);
export type PromptInputCommandProps = ComponentProps;
-export const PromptInputCommand = ({
- className,
- ...props
-}: PromptInputCommandProps) => ;
+export const PromptInputCommand = ({ className, ...props }: PromptInputCommandProps) => (
+
+);
export type PromptInputCommandInputProps = ComponentProps;
-export const PromptInputCommandInput = ({
- className,
- ...props
-}: PromptInputCommandInputProps) => (
-
+export const PromptInputCommandInput = ({ className, ...props }: PromptInputCommandInputProps) => (
+
);
export type PromptInputCommandListProps = ComponentProps;
-export const PromptInputCommandList = ({
- className,
- ...props
-}: PromptInputCommandListProps) => (
-
+export const PromptInputCommandList = ({ className, ...props }: PromptInputCommandListProps) => (
+
);
export type PromptInputCommandEmptyProps = ComponentProps;
-export const PromptInputCommandEmpty = ({
- className,
- ...props
-}: PromptInputCommandEmptyProps) => (
-
+export const PromptInputCommandEmpty = ({ className, ...props }: PromptInputCommandEmptyProps) => (
+
);
export type PromptInputCommandGroupProps = ComponentProps;
-export const PromptInputCommandGroup = ({
- className,
- ...props
-}: PromptInputCommandGroupProps) => (
-
+export const PromptInputCommandGroup = ({ className, ...props }: PromptInputCommandGroupProps) => (
+
);
export type PromptInputCommandItemProps = ComponentProps;
-export const PromptInputCommandItem = ({
- className,
- ...props
-}: PromptInputCommandItemProps) => (
-
+export const PromptInputCommandItem = ({ className, ...props }: PromptInputCommandItemProps) => (
+
);
-export type PromptInputCommandSeparatorProps = ComponentProps<
- typeof CommandSeparator
->;
+export type PromptInputCommandSeparatorProps = ComponentProps;
export const PromptInputCommandSeparator = ({
className,
...props
}: PromptInputCommandSeparatorProps) => (
-
+
);
diff --git a/src/components/ai-elements/reasoning.tsx b/src/components/ai-elements/reasoning.tsx
index 7ae3390..aede30e 100644
--- a/src/components/ai-elements/reasoning.tsx
+++ b/src/components/ai-elements/reasoning.tsx
@@ -1,12 +1,9 @@
"use client";
-import { useControllableState } from "@radix-ui/react-use-controllable-state";
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from "@/components/ui/collapsible";
+import { Shimmer } from "./shimmer";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
+import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
@@ -21,8 +18,6 @@ import {
} from "react";
import { Streamdown } from "streamdown";
-import { Shimmer } from "./shimmer";
-
interface ReasoningContextValue {
isStreaming: boolean;
isOpen: boolean;
@@ -103,12 +98,7 @@ export const Reasoning = memo(
// Auto-close when streaming ends (once only, and only if it ever streamed)
useEffect(() => {
- if (
- hasEverStreamedRef.current &&
- !isStreaming &&
- isOpen &&
- !hasAutoClosed
- ) {
+ if (hasEverStreamedRef.current && !isStreaming && isOpen && !hasAutoClosed) {
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosed(true);
@@ -122,12 +112,12 @@ export const Reasoning = memo(
(newOpen: boolean) => {
setIsOpen(newOpen);
},
- [setIsOpen]
+ [setIsOpen],
);
const contextValue = useMemo(
() => ({ duration, isOpen, isStreaming, setIsOpen }),
- [duration, isOpen, isStreaming, setIsOpen]
+ [duration, isOpen, isStreaming, setIsOpen],
);
return (
@@ -142,12 +132,10 @@ export const Reasoning = memo(
);
- }
+ },
);
-export type ReasoningTriggerProps = ComponentProps<
- typeof CollapsibleTrigger
-> & {
+export type ReasoningTriggerProps = ComponentProps & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
};
@@ -173,8 +161,8 @@ export const ReasoningTrigger = memo(
return (
@@ -183,38 +171,31 @@ export const ReasoningTrigger = memo(
{getThinkingMessage(isStreaming, duration)}
>
)}
);
- }
+ },
);
-export type ReasoningContentProps = ComponentProps<
- typeof CollapsibleContent
-> & {
+export type ReasoningContentProps = ComponentProps & {
children: string;
};
-export const ReasoningContent = memo(
- ({ className, children, ...props }: ReasoningContentProps) => (
-
- {children}
-
- )
-);
+export const ReasoningContent = memo(({ className, children, ...props }: ReasoningContentProps) => (
+
+ {children}
+
+));
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";
diff --git a/src/components/docs/demos/generated-command-demo.tsx b/src/components/docs/demos/generated-command-demo.tsx
index 392d510..2653895 100644
--- a/src/components/docs/demos/generated-command-demo.tsx
+++ b/src/components/docs/demos/generated-command-demo.tsx
@@ -8,7 +8,7 @@ const sampleTool: Tool = {
info: {
description: "Transfer data to or from a server",
},
- commands: [{ key: "curl", name: "curl", isDefault: true }],
+ commands: [{ key: "curl", name: "curl" }],
parameters: [
{
key: "url",
diff --git a/src/components/docs/demos/json-output-demo.tsx b/src/components/docs/demos/json-output-demo.tsx
index 93bab03..2a9a003 100644
--- a/src/components/docs/demos/json-output-demo.tsx
+++ b/src/components/docs/demos/json-output-demo.tsx
@@ -7,7 +7,7 @@ const sampleTool: Tool = {
info: {
description: "Transfer data to or from a server",
},
- commands: [{ key: "curl", name: "curl", isDefault: true }],
+ commands: [{ key: "curl", name: "curl" }],
parameters: [
{
key: "url",
diff --git a/src/components/docs/demos/tool-renderer-demo.tsx b/src/components/docs/demos/tool-renderer-demo.tsx
index 52dd232..bb9c74f 100644
--- a/src/components/docs/demos/tool-renderer-demo.tsx
+++ b/src/components/docs/demos/tool-renderer-demo.tsx
@@ -8,7 +8,7 @@ const sampleTool: Tool = {
info: {
description: "Transfer data with URLs",
},
- commands: [{ key: "curl", name: "curl", isDefault: true }],
+ commands: [{ key: "curl", name: "curl" }],
parameters: [
{
key: "url",
diff --git a/src/components/tool-card.tsx b/src/components/tool-card.tsx
index 77f317f..4fc4567 100644
--- a/src/components/tool-card.tsx
+++ b/src/components/tool-card.tsx
@@ -70,7 +70,7 @@ export function ToolCard({
@@ -107,7 +107,9 @@ export function ToolCard({
{tool.info?.description}
- {tool.info?.description}
+
+ {tool.info?.description}
+
) : (
No description available
diff --git a/src/components/tool-editor/ai-chat-store.ts b/src/components/tool-editor/ai-chat-store.ts
new file mode 100644
index 0000000..cc0963a
--- /dev/null
+++ b/src/components/tool-editor/ai-chat-store.ts
@@ -0,0 +1,429 @@
+import type { ApprovalArtifact } from "../ai-chat/ai-chat-message-mapping";
+import {
+ type ChatWithPreview,
+ createChat,
+ getChats,
+ getPersistableMessages,
+ loadChat,
+ truncateMessages,
+ upsertMessage,
+} from "../ai-chat/ai-chat-persistence";
+import {
+ MODEL_GROUPS,
+ type ReasoningEffort,
+} from "../ai-chat/model-picker";
+import type { Tool } from "@/components/commandly/types/flat";
+import type { LanguageModelUsage, UIMessage } from "ai";
+import { isToolUIPart, getToolName, type InferUITools, type Tool as AISDKTool } from "ai";
+
+type AgentUIMessage = UIMessage>>;
+
+export interface ChatStoreSnapshot {
+ chatId: string;
+ input: string;
+ model: string;
+ reasoningEffort: ReasoningEffort | null;
+ schema: object | null;
+ usage: LanguageModelUsage | null;
+ recentSessions: ChatWithPreview[];
+ approvalArtifacts: Record;
+ pendingPreview: Tool | null;
+ lastMessages: AgentUIMessage[];
+}
+
+export class ChatStore {
+ private toolName: string;
+ private currentTool: Tool;
+ private chatId: string;
+ private input: string;
+ private model: string;
+ private reasoningEffort: ReasoningEffort | null;
+ private schema: object | null = null;
+ private usage: LanguageModelUsage | null = null;
+ private recentSessions: ChatWithPreview[] = [];
+ private approvalArtifacts: Record = {};
+ private pendingPreview: Tool | null = null;
+ private lastMessages: AgentUIMessage[] = [];
+
+ private persistedIds: string[] = [];
+ private persistTimer: ReturnType | null = null;
+ private prevGenerating = false;
+ private prevStreamingCleared = true;
+
+ private listeners = new Set<() => void>();
+ private snapshot: ChatStoreSnapshot;
+
+ constructor(toolName: string, currentTool: Tool) {
+ this.toolName = toolName;
+ this.currentTool = currentTool;
+ this.chatId = crypto.randomUUID();
+ this.input = "";
+ this.model = localStorage.getItem("ai-model") ?? MODEL_GROUPS[0].models[0].value;
+ this.reasoningEffort = localStorage.getItem("ai-reasoning-effort") as ReasoningEffort | null;
+ this.snapshot = this.buildSnapshot();
+ this.fetchSchema();
+ this.refreshChats();
+ }
+
+ private buildSnapshot(): ChatStoreSnapshot {
+ return {
+ chatId: this.chatId,
+ input: this.input,
+ model: this.model,
+ reasoningEffort: this.reasoningEffort,
+ schema: this.schema,
+ usage: this.usage,
+ recentSessions: this.recentSessions,
+ approvalArtifacts: this.approvalArtifacts,
+ pendingPreview: this.pendingPreview,
+ lastMessages: this.lastMessages,
+ };
+ }
+
+ private notify(): void {
+ this.snapshot = this.buildSnapshot();
+ for (const listener of this.listeners) {
+ listener();
+ }
+ }
+
+ subscribe = (listener: () => void): (() => void) => {
+ this.listeners.add(listener);
+ return () => {
+ this.listeners.delete(listener);
+ };
+ };
+
+ getSnapshot = (): ChatStoreSnapshot => {
+ return this.snapshot;
+ };
+
+ updateTool(tool: Tool): void {
+ this.currentTool = tool;
+ if (tool.name !== this.toolName) {
+ this.toolName = tool.name;
+ this.refreshChats();
+ }
+ }
+
+ getChatId(): string {
+ return this.chatId;
+ }
+
+ getLastMessages(): AgentUIMessage[] {
+ return this.lastMessages;
+ }
+
+ getPendingPreview(): Tool | null {
+ return this.pendingPreview;
+ }
+
+ getCurrentTool(): Tool {
+ return this.currentTool;
+ }
+
+ setPendingPreview(tool: Tool | null): void {
+ this.pendingPreview = tool;
+ }
+
+ setInput(value: string): void {
+ this.input = value;
+ this.notify();
+ }
+
+ setModel(value: string): void {
+ this.model = value;
+ localStorage.setItem("ai-model", value);
+ const isReasoning = MODEL_GROUPS
+ .flatMap((g) => g.models)
+ .find((m) => m.value === value)?.reasoning === true;
+ if (!isReasoning) {
+ this.reasoningEffort = null;
+ localStorage.removeItem("ai-reasoning-effort");
+ }
+ this.notify();
+ }
+
+ setReasoningEffort(effort: ReasoningEffort | null): void {
+ this.reasoningEffort = effort;
+ if (effort === null) {
+ localStorage.removeItem("ai-reasoning-effort");
+ } else {
+ localStorage.setItem("ai-reasoning-effort", effort);
+ }
+ this.notify();
+ }
+
+ setUsage(usage: LanguageModelUsage | null): void {
+ this.usage = usage;
+ this.notify();
+ }
+
+ private fetchSchema(): void {
+ fetch("/specification/flat.json")
+ .then((r) => r.json())
+ .then((data: object) => {
+ this.schema = data;
+ this.notify();
+ })
+ .catch(() => {});
+ }
+
+ refreshChats(): void {
+ getChats(this.toolName)
+ .then((sessions) => {
+ this.recentSessions = sessions;
+ this.notify();
+ })
+ .catch(console.error);
+ }
+
+ syncApprovals(rawMessages: UIMessage[]): void {
+ let changed = false;
+ const next = { ...this.approvalArtifacts };
+
+ for (const message of rawMessages) {
+ for (const part of message.parts) {
+ if (!isToolUIPart(part) || getToolName(part) !== "applyToolDefinition") {
+ continue;
+ }
+ const approvalId = part.approval?.id;
+ if (!approvalId || next[approvalId]) {
+ continue;
+ }
+ next[approvalId] = {
+ approvalId,
+ previewTool: structuredClone(this.pendingPreview ?? this.currentTool),
+ originalTool: structuredClone(this.currentTool),
+ summary:
+ typeof part.input === "object" &&
+ part.input &&
+ "summary" in part.input &&
+ typeof part.input.summary === "string"
+ ? part.input.summary
+ : "Apply AI changes",
+ };
+ changed = true;
+ }
+ }
+
+ if (changed) {
+ this.approvalArtifacts = next;
+ this.notify();
+ }
+ }
+
+ syncPersistence(rawMessages: UIMessage[], isStreaming: boolean): void {
+ const persistableMessages = getPersistableMessages(rawMessages);
+ if (persistableMessages.length === 0) return;
+
+ const currentChatId = this.chatId;
+ const toolName = this.toolName;
+ const snapshotIds = this.persistedIds.slice();
+
+ if (this.persistTimer) {
+ clearTimeout(this.persistTimer);
+ }
+
+ const delay = isStreaming ? 2000 : 0;
+
+ this.persistTimer = setTimeout(() => {
+ this.persistTimer = null;
+
+ let divergeAt = snapshotIds.length;
+ for (let i = 0; i < Math.min(snapshotIds.length, persistableMessages.length); i++) {
+ if (snapshotIds[i] !== persistableMessages[i].id) {
+ divergeAt = i;
+ break;
+ }
+ }
+ if (persistableMessages.length < divergeAt) {
+ divergeAt = persistableMessages.length;
+ }
+
+ const upsertFrom =
+ snapshotIds.length > 0 && divergeAt === snapshotIds.length && divergeAt > 0
+ ? divergeAt - 1
+ : divergeAt;
+ const needsTruncate = divergeAt < snapshotIds.length;
+ const hasUpdates = upsertFrom < persistableMessages.length;
+
+ if (!hasUpdates && !needsTruncate) return;
+
+ const isNewChat = snapshotIds.length === 0;
+ const ensureChat = isNewChat
+ ? createChat(currentChatId, toolName)
+ : Promise.resolve();
+
+ ensureChat
+ .then(async () => {
+ if (needsTruncate) {
+ await truncateMessages(currentChatId, divergeAt);
+ }
+ for (let i = upsertFrom; i < persistableMessages.length; i++) {
+ await upsertMessage(currentChatId, persistableMessages[i], i);
+ }
+ this.persistedIds = persistableMessages.map((m) => m.id);
+ })
+ .then(() => {
+ if (isNewChat) this.refreshChats();
+ })
+ .catch(console.error);
+ }, delay);
+ }
+
+ syncGeneratingState(
+ rawMessages: UIMessage[],
+ isStreaming: boolean,
+ onGeneratingChange?: (isGenerating: boolean) => void,
+ onStreamingTool?: (tool: Tool | null) => void,
+ ): void {
+ const hasPendingApproval = rawMessages.some((message) =>
+ message.parts.some(
+ (part) =>
+ isToolUIPart(part) &&
+ getToolName(part) === "applyToolDefinition" &&
+ part.state === "approval-requested",
+ ),
+ );
+
+ const isApplying = rawMessages.some((message) =>
+ message.parts.some(
+ (part) =>
+ isToolUIPart(part) &&
+ getToolName(part) === "applyToolDefinition" &&
+ (part.state === "input-available" || part.state === "approval-responded"),
+ ),
+ );
+
+ const generating = isStreaming && isApplying;
+ if (generating !== this.prevGenerating) {
+ this.prevGenerating = generating;
+ onGeneratingChange?.(generating);
+ }
+
+ const shouldClear = !hasPendingApproval && !isApplying && !isStreaming;
+ if (shouldClear && !this.prevStreamingCleared) {
+ this.prevStreamingCleared = true;
+ this.pendingPreview = null;
+ onStreamingTool?.(null);
+ } else if (!shouldClear) {
+ this.prevStreamingCleared = false;
+ }
+ }
+
+ prepareForSend(): void {
+ this.pendingPreview = null;
+ this.approvalArtifacts = {};
+ this.notify();
+ }
+
+ prepareForResend(onStreamingTool?: (tool: Tool | null) => void): void {
+ this.pendingPreview = null;
+ this.approvalArtifacts = {};
+ onStreamingTool?.(null);
+ this.notify();
+ }
+
+ clearInput(): void {
+ this.input = "";
+ this.notify();
+ }
+
+ reset(
+ onStreamingTool?: (tool: Tool | null) => void,
+ onGeneratingChange?: (isGenerating: boolean) => void,
+ ): void {
+ if (this.persistTimer) {
+ clearTimeout(this.persistTimer);
+ this.persistTimer = null;
+ }
+ this.lastMessages = [];
+ this.persistedIds = [];
+ this.prevGenerating = false;
+ this.prevStreamingCleared = true;
+ this.chatId = crypto.randomUUID();
+ this.input = "";
+ this.usage = null;
+ this.approvalArtifacts = {};
+ this.pendingPreview = null;
+ onStreamingTool?.(null);
+ onGeneratingChange?.(false);
+ this.notify();
+ }
+
+ flushAndReset(
+ rawMessages: UIMessage[],
+ onStreamingTool?: (tool: Tool | null) => void,
+ onGeneratingChange?: (isGenerating: boolean) => void,
+ ): void {
+ if (this.persistTimer) {
+ clearTimeout(this.persistTimer);
+ this.persistTimer = null;
+ }
+
+ const persistableMessages = getPersistableMessages(rawMessages);
+ if (persistableMessages.length === 0) {
+ this.reset(onStreamingTool, onGeneratingChange);
+ return;
+ }
+
+ const currentChatId = this.chatId;
+ const toolName = this.toolName;
+ const snapshotIds = this.persistedIds.slice();
+
+ let divergeAt = snapshotIds.length;
+ for (let i = 0; i < Math.min(snapshotIds.length, persistableMessages.length); i++) {
+ if (snapshotIds[i] !== persistableMessages[i].id) {
+ divergeAt = i;
+ break;
+ }
+ }
+ if (persistableMessages.length < divergeAt) {
+ divergeAt = persistableMessages.length;
+ }
+
+ const upsertFrom =
+ snapshotIds.length > 0 && divergeAt === snapshotIds.length && divergeAt > 0
+ ? divergeAt - 1
+ : divergeAt;
+ const needsTruncate = divergeAt < snapshotIds.length;
+
+ const ensureChat =
+ snapshotIds.length === 0 ? createChat(currentChatId, toolName) : Promise.resolve();
+
+ ensureChat
+ .then(async () => {
+ if (needsTruncate) {
+ await truncateMessages(currentChatId, divergeAt);
+ }
+ for (let i = upsertFrom; i < persistableMessages.length; i++) {
+ await upsertMessage(currentChatId, persistableMessages[i], i);
+ }
+ })
+ .then(() => this.refreshChats())
+ .catch(console.error)
+ .finally(() => this.reset(onStreamingTool, onGeneratingChange));
+ }
+
+ loadSession(
+ session: ChatWithPreview,
+ onStreamingTool?: (tool: Tool | null) => void,
+ onGeneratingChange?: (isGenerating: boolean) => void,
+ ): void {
+ loadChat(session.id)
+ .then((messages) => {
+ this.lastMessages = messages;
+ this.persistedIds = messages.map((m) => m.id);
+ this.chatId = session.id;
+ this.usage = null;
+ this.input = "";
+ this.approvalArtifacts = {};
+ this.pendingPreview = null;
+ onStreamingTool?.(null);
+ onGeneratingChange?.(false);
+ this.notify();
+ })
+ .catch(console.error);
+ }
+}
diff --git a/src/components/tool-editor/ai-chat.tsx b/src/components/tool-editor/ai-chat.tsx
index c4263b6..dc891c1 100644
--- a/src/components/tool-editor/ai-chat.tsx
+++ b/src/components/tool-editor/ai-chat.tsx
@@ -1,11 +1,11 @@
-import { ApiKeySettings } from "../ai-chat/api-key-settings";
import {
- ApprovalArtifact,
- ChatMessage,
+ type ChatMessage,
countCompletedToolCalls,
findPendingApproval,
toChatMessage,
} from "../ai-chat/ai-chat-message-mapping";
+import { type ChatWithPreview } from "../ai-chat/ai-chat-persistence";
+import { ApiKeySettings } from "../ai-chat/api-key-settings";
import { DiffView } from "../ai-chat/diff-view";
import { ExtractedContent, type ExtractResult } from "../ai-chat/extracted-content";
import {
@@ -14,12 +14,36 @@ import {
getProviderOptions,
providerForModel,
MODEL_GROUPS,
- type ReasoningEffort,
} from "../ai-chat/model-picker";
-import { generatePrompt } from "./prompt";
-import { ChatSession, getPersistableMessages, getSessionPreview, loadRecentSessions, saveChatSession } from "../ai-chat/ai-chat-persistence";
import { WebSearchResults, type WebSearchResult } from "../ai-chat/web-search-results";
+import { ChatStore } from "./ai-chat-store";
+import { generatePrompt } from "./prompt";
import { useToolBuilder } from "./tool-editor.context";
+import {
+ createApplyToolDefinitionTool,
+ createEditTool,
+ createReadTool,
+ createTavilyExtractTool,
+ createTavilySearchTool,
+} from "./tools";
+import {
+ ChainOfThought,
+ ChainOfThoughtContent,
+ ChainOfThoughtHeader,
+ ChainOfThoughtStep,
+} from "@/components/ai-elements/chain-of-thought";
+import {
+ Context,
+ ContextCacheUsage,
+ ContextContent,
+ ContextContentBody,
+ ContextContentFooter,
+ ContextContentHeader,
+ ContextInputUsage,
+ ContextOutputUsage,
+ ContextReasoningUsage,
+ ContextTrigger,
+} from "@/components/ai-elements/context";
import {
Conversation,
ConversationContent,
@@ -33,19 +57,6 @@ import {
MessageContent,
MessageResponse,
} from "@/components/ai-elements/message";
-import { Shimmer } from "@/components/ai-elements/shimmer";
-import { Suggestion, Suggestions } from "@/components/ai-elements/suggestion";
-import {
- ChainOfThought,
- ChainOfThoughtContent,
- ChainOfThoughtHeader,
- ChainOfThoughtStep,
-} from "@/components/ai-elements/chain-of-thought";
-import {
- Reasoning,
- ReasoningContent,
- ReasoningTrigger,
-} from "@/components/ai-elements/reasoning";
import {
PromptInput,
PromptInputBody,
@@ -55,32 +66,13 @@ import {
PromptInputTextarea,
PromptInputTools,
} from "@/components/ai-elements/prompt-input";
-import {
- Context,
- ContextCacheUsage,
- ContextContent,
- ContextContentBody,
- ContextContentFooter,
- ContextContentHeader,
- ContextInputUsage,
- ContextOutputUsage,
- ContextReasoningUsage,
- ContextTrigger,
-} from "@/components/ai-elements/context";
-import {
- Tool as ToolCard,
- ToolContent,
- ToolHeader,
-} from "@/components/ai-elements/tool";
+import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning";
+import { Shimmer } from "@/components/ai-elements/shimmer";
+import { Suggestion, Suggestions } from "@/components/ai-elements/suggestion";
+import { Tool as ToolCard, ToolContent, ToolHeader } from "@/components/ai-elements/tool";
import { Tool } from "@/components/commandly/types/flat";
-import {
- createApplyToolDefinitionTool,
- createEditTool,
- createReadTool,
- createTavilyExtractTool,
- createTavilySearchTool
-} from "./tools";
import { Button } from "@/components/ui/button";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Textarea } from "@/components/ui/textarea";
import { useAIKeys, type AIProvider } from "@/lib/ai-keys";
import { cn, replaceKey } from "@/lib/utils";
@@ -89,8 +81,8 @@ import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createGroq } from "@ai-sdk/groq";
import { createMistral } from "@ai-sdk/mistral";
import { createOpenAI } from "@ai-sdk/openai";
-import { createXai } from "@ai-sdk/xai";
import { Chat, useChat } from "@ai-sdk/react";
+import { createXai } from "@ai-sdk/xai";
import {
DirectChatTransport,
ToolLoopAgent,
@@ -99,11 +91,10 @@ import {
type LanguageModelUsage,
type Tool as AISDKTool,
type UIMessage,
- isToolUIPart,
- getToolName,
} from "ai";
import {
CheckIcon,
+ ChevronRightIcon,
CopyIcon,
FlagIcon,
HashIcon,
@@ -116,7 +107,7 @@ import {
TerminalIcon,
XIcon,
} from "lucide-react";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useMemo, useState, useSyncExternalStore } from "react";
import { toast } from "sonner";
const PROMPT_PILLS = [
@@ -140,8 +131,8 @@ const MODEL_MAX_TOKENS: Record = {
"claude-haiku-3-5": 200000,
"gpt-4o": 128000,
"gpt-4o-mini": 128000,
- "o1": 200000,
- "o3": 200000,
+ o1: 200000,
+ o3: 200000,
"o4-mini": 200000,
"gemini-2.0-flash": 1048576,
"gemini-2.5-pro": 1048576,
@@ -180,7 +171,10 @@ function createModelInstance(provider: AIProvider, key: string, model: string) {
type ChatProviderOptions = Record<
string,
- Record>
+ Record<
+ string,
+ string | number | boolean | null | Record
+ >
>;
type AgentUIMessage = UIMessage>>;
@@ -206,10 +200,6 @@ function createToolLoopAgent({
});
}
-function cloneTool(tool: Tool): Tool {
- return structuredClone(tool);
-}
-
function useAIChat(
currentTool: Tool,
onApply: (tool: Tool) => void,
@@ -217,58 +207,12 @@ function useAIChat(
onGeneratingChange?: (isGenerating: boolean) => void,
) {
const { contextSelection } = useToolBuilder();
- const [input, setInput] = useState("");
- const [modelInternal, setModelInternal] = useState(
- () => localStorage.getItem("ai-model") ?? MODEL_GROUPS[0].models[0].value,
- );
- const [reasoningEffort, setReasoningEffortState] = useState(
- () => (localStorage.getItem("ai-reasoning-effort") as ReasoningEffort | null),
- );
- const [schema, setSchema] = useState(null);
- const [usage, setUsage] = useState(null);
- const [recentSessions, setRecentSessions] = useState>>([]);
- const [approvalArtifacts, setApprovalArtifacts] = useState>({});
- const [chatId, setChatId] = useState(() => crypto.randomUUID());
- const currentToolRef = useRef(currentTool);
- const chatMessagesRef = useRef([]);
- const pendingPreviewRef = useRef(null);
- const chatIdRef = useRef(chatId);
- const onApplyRef = useRef(onApply);
- const onStreamingToolRef = useRef(onStreamingTool);
- const onGeneratingChangeRef = useRef(onGeneratingChange);
-
- useEffect(() => {
- loadRecentSessions(currentTool.name).then(setRecentSessions).catch(console.error);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [currentTool.name]);
+ const [store] = useState(() => new ChatStore(currentTool.name, currentTool));
+ const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
- currentToolRef.current = currentTool;
- chatIdRef.current = chatId;
- onApplyRef.current = onApply;
- onStreamingToolRef.current = onStreamingTool;
- onGeneratingChangeRef.current = onGeneratingChange;
+ store.updateTool(currentTool);
- const model = modelInternal;
- const setModel = useCallback((m: string) => {
- setModelInternal(m);
- localStorage.setItem("ai-model", m);
- const isReasoning = MODEL_GROUPS.flatMap((g) => g.models).find((mo) => mo.value === m)?.reasoning === true;
- if (!isReasoning) {
- setReasoningEffortState(null);
- localStorage.removeItem("ai-reasoning-effort");
- }
- }, []);
-
- const setReasoningEffort = useCallback((effort: ReasoningEffort | null) => {
- setReasoningEffortState(effort);
- if (effort === null) {
- localStorage.removeItem("ai-reasoning-effort");
- } else {
- localStorage.setItem("ai-reasoning-effort", effort);
- }
- }, []);
-
- const provider = providerForModel(model);
+ const provider = providerForModel(snapshot.model);
const openAIKeys = useAIKeys("openai");
const anthropicKeys = useAIKeys("anthropic");
const googleKeys = useAIKeys("google");
@@ -286,340 +230,227 @@ function useAIChat(
xai: xaiKeys,
};
const currentKeys = allProviderKeys[provider as Exclude];
+
const providerOptions = useMemo(
- () => (reasoningEffort && MODEL_GROUPS.flatMap((g) => g.models).find((m) => m.value === model)?.reasoning === true
- ? getProviderOptions(provider, model, reasoningEffort)
- : undefined) as ChatProviderOptions | undefined,
- [provider, model, reasoningEffort],
+ function computeProviderOptions() {
+ return (
+ snapshot.reasoningEffort &&
+ MODEL_GROUPS.flatMap((g) => g.models).find((m) => m.value === snapshot.model)?.reasoning ===
+ true
+ ? getProviderOptions(provider, snapshot.model, snapshot.reasoningEffort)
+ : undefined
+ ) as ChatProviderOptions | undefined;
+ },
+ [provider, snapshot.model, snapshot.reasoningEffort],
);
- useEffect(() => {
- fetch("/specification/flat.json")
- .then((r) => r.json())
- .then(setSchema)
- .catch(() => { });
- }, []);
-
- const systemPrompt = useMemo(() => {
- const serializedSchema = schema ? JSON.stringify(schema, null, 2) : "{}";
- const contextCommands = currentTool.commands.filter((command) =>
- contextSelection.commandKeys.includes(command.key),
- );
- const contextParameters = currentTool.parameters.filter((parameter) =>
- contextSelection.parameterKeys.includes(parameter.key),
- );
-
- return generatePrompt(serializedSchema, {
- context: {
- selectedCommands: contextCommands.map((command) => ({ key: command.key, name: command.name })),
- selectedParameters: contextParameters.map((parameter) => ({
- key: parameter.key,
- name: parameter.name,
- longFlag: parameter.longFlag,
- shortFlag: parameter.shortFlag,
- })),
- },
- });
- }, [schema, currentTool, contextSelection]);
-
- const agent = useMemo(() => {
- const aiModel = createModelInstance(provider, currentKeys.key ?? "", model);
-
- const editToolDef = createEditTool(
- () => pendingPreviewRef.current ?? currentToolRef.current,
- (tool) => {
- pendingPreviewRef.current = tool;
- onStreamingToolRef.current?.(tool);
- },
- );
-
- const applyToolDefinitionDef = createApplyToolDefinitionTool(
- () => { },
- () => {
- if (pendingPreviewRef.current) {
- onApplyRef.current(replaceKey(pendingPreviewRef.current) as Tool);
- pendingPreviewRef.current = null;
- }
- onStreamingToolRef.current?.(null);
- },
- );
-
- const tools = {
- editTool: editToolDef,
- applyToolDefinition: applyToolDefinitionDef,
- readTool: createReadTool(() => pendingPreviewRef.current ?? currentToolRef.current),
- ...(tavilyKeys.isSaved && tavilyKeys.key ? { tavilySearch: createTavilySearchTool(tavilyKeys.key) } : {}),
- ...(tavilyKeys.isSaved && tavilyKeys.key ? { tavilyExtract: createTavilyExtractTool(tavilyKeys.key) } : {}),
- };
+ const systemPrompt = useMemo(
+ function computeSystemPrompt() {
+ const serializedSchema = snapshot.schema ? JSON.stringify(snapshot.schema, null, 2) : "{}";
+ const contextCommands = currentTool.commands.filter((command) =>
+ contextSelection.commandKeys.includes(command.key),
+ );
+ const contextParameters = currentTool.parameters.filter((parameter) =>
+ contextSelection.parameterKeys.includes(parameter.key),
+ );
+ return generatePrompt(serializedSchema, {
+ context: {
+ selectedCommands: contextCommands.map((command) => ({
+ key: command.key,
+ name: command.name,
+ })),
+ selectedParameters: contextParameters.map((parameter) => ({
+ key: parameter.key,
+ name: parameter.name,
+ longFlag: parameter.longFlag,
+ shortFlag: parameter.shortFlag,
+ })),
+ },
+ });
+ },
+ [snapshot.schema, currentTool, contextSelection],
+ );
- return createToolLoopAgent({
- model: aiModel,
- instructions: systemPrompt,
- tools,
+ const agent = useMemo(
+ function createAgent() {
+ const aiModel = createModelInstance(provider, currentKeys.key ?? "", snapshot.model);
+
+ const editToolDef = createEditTool(
+ () => store.getPendingPreview() ?? store.getCurrentTool(),
+ function onEditPreview(tool) {
+ store.setPendingPreview(tool);
+ onStreamingTool?.(tool);
+ },
+ );
+
+ const applyToolDefinitionDef = createApplyToolDefinitionTool(
+ () => {},
+ function onApplyExecuted() {
+ const preview = store.getPendingPreview();
+ if (preview) {
+ onApply(replaceKey(preview) as Tool);
+ store.setPendingPreview(null);
+ }
+ onStreamingTool?.(null);
+ },
+ );
+
+ const tools = {
+ editTool: editToolDef,
+ applyToolDefinition: applyToolDefinitionDef,
+ readTool: createReadTool(() => store.getPendingPreview() ?? store.getCurrentTool()),
+ ...(tavilyKeys.isSaved && tavilyKeys.key
+ ? { tavilySearch: createTavilySearchTool(tavilyKeys.key) }
+ : {}),
+ ...(tavilyKeys.isSaved && tavilyKeys.key
+ ? { tavilyExtract: createTavilyExtractTool(tavilyKeys.key) }
+ : {}),
+ };
+
+ return createToolLoopAgent({
+ model: aiModel,
+ instructions: systemPrompt,
+ tools,
+ providerOptions,
+ onFinish: function handleFinishUsage({ usage }) {
+ store.setUsage(usage);
+ },
+ });
+ },
+ [
+ provider,
+ currentKeys.key,
+ snapshot.model,
+ systemPrompt,
providerOptions,
- onFinish: ({ usage: currentUsage }) => setUsage(currentUsage),
- });
- }, [provider, currentKeys.key, model, systemPrompt, providerOptions, tavilyKeys.isSaved, tavilyKeys.key]);
+ tavilyKeys.isSaved,
+ tavilyKeys.key,
+ store,
+ onApply,
+ onStreamingTool,
+ ],
+ );
const transport = useMemo(
- () => new DirectChatTransport({
- agent,
- sendReasoning: true,
- }),
+ function createTransport() {
+ return new DirectChatTransport({ agent, sendReasoning: true });
+ },
[agent],
);
- const chatCallbacksRef = useRef({
- onFinish: () => { },
- onError: (_error: Error) => { },
- });
-
- chatCallbacksRef.current = {
- onFinish: () => {
- onGeneratingChangeRef.current?.(false);
- },
- onError: (error) => {
- onGeneratingChangeRef.current?.(false);
- onStreamingToolRef.current?.(null);
- if (error.name !== "AbortError") {
- toast.error(error.message || "AI request failed");
- }
- },
- };
-
const chatInstance = useMemo(
- () => new Chat({
- id: chatId,
- messages: chatMessagesRef.current,
- transport,
- sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
- onFinish: () => chatCallbacksRef.current.onFinish(),
- onError: (error) => chatCallbacksRef.current.onError(error),
- }),
- [chatId, transport],
+ function createChatInstance() {
+ return new Chat({
+ id: snapshot.chatId,
+ messages: store.getLastMessages(),
+ transport,
+ sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
+ onFinish: function handleChatFinish() {
+ onGeneratingChange?.(false);
+ },
+ onError: function handleChatError(error) {
+ onGeneratingChange?.(false);
+ onStreamingTool?.(null);
+ if (error.name !== "AbortError") {
+ toast.error(error.message || "AI request failed");
+ }
+ },
+ });
+ },
+ [snapshot.chatId, transport, store, onGeneratingChange, onStreamingTool],
);
const chat = useChat({ chat: chatInstance });
const rawMessages = chat.messages;
const isStreaming = chat.status === "submitted" || chat.status === "streaming";
- chatMessagesRef.current = rawMessages;
-
- const persistTimerRef = useRef | null>(null);
-
- useEffect(() => {
- const persistableMessages = getPersistableMessages(rawMessages);
- if (persistableMessages.length === 0) return;
-
- if (persistTimerRef.current) {
- clearTimeout(persistTimerRef.current);
- }
-
- const delay = isStreaming ? 2000 : 0;
-
- persistTimerRef.current = setTimeout(() => {
- persistTimerRef.current = null;
- saveChatSession({
- id: chatIdRef.current,
- toolName: currentToolRef.current.name,
- messages: persistableMessages,
- updatedAt: Date.now(),
- preview: getSessionPreview(persistableMessages),
- })
- .then(() => loadRecentSessions(currentToolRef.current.name))
- .then(setRecentSessions)
- .catch(console.error);
- }, delay);
- return () => {
- if (persistTimerRef.current) {
- clearTimeout(persistTimerRef.current);
- }
- };
- }, [rawMessages, isStreaming]);
-
- useEffect(() => {
- setApprovalArtifacts((previous) => {
- const next = { ...previous };
- let changed = false;
-
- for (const message of rawMessages) {
- for (const part of message.parts) {
- if (!isToolUIPart(part) || getToolName(part) !== "applyToolDefinition") {
- continue;
- }
-
- const approvalId = part.approval?.id;
- if (!approvalId || next[approvalId]) {
- continue;
- }
-
- next[approvalId] = {
- approvalId,
- previewTool: cloneTool(pendingPreviewRef.current ?? currentToolRef.current),
- originalTool: cloneTool(currentToolRef.current),
- summary: typeof part.input === "object" && part.input && "summary" in part.input && typeof part.input.summary === "string"
- ? part.input.summary
- : "Apply AI changes",
- };
- changed = true;
- }
- }
-
- return changed ? next : previous;
- });
- }, [rawMessages]);
+ store.syncApprovals(rawMessages);
+ store.syncPersistence(rawMessages, isStreaming);
+ store.syncGeneratingState(rawMessages, isStreaming, onGeneratingChange, onStreamingTool);
const messages = useMemo(
- () => rawMessages
- .filter((message) => message.role === "user" || message.role === "assistant")
- .map((message) => toChatMessage(message, approvalArtifacts)),
- [rawMessages, approvalArtifacts],
+ function computeMessages() {
+ return rawMessages
+ .filter((message) => message.role === "user" || message.role === "assistant")
+ .map((message) => toChatMessage(message, snapshot.approvalArtifacts));
+ },
+ [rawMessages, snapshot.approvalArtifacts],
);
- const pendingApproval = useMemo(() => findPendingApproval(messages), [messages]);
-
- const toolCallCount = useMemo(() => countCompletedToolCalls(rawMessages), [rawMessages]);
-
- useEffect(() => {
- const hasPendingApproval = rawMessages.some((message) =>
- message.parts.some((part) =>
- isToolUIPart(part)
- && getToolName(part) === "applyToolDefinition"
- && part.state === "approval-requested",
- ),
- );
-
- const isApplying = rawMessages.some((message) =>
- message.parts.some((part) =>
- isToolUIPart(part)
- && getToolName(part) === "applyToolDefinition"
- && (part.state === "input-available" || part.state === "approval-responded"),
- ),
- );
-
- onGeneratingChangeRef.current?.(isStreaming && isApplying);
-
- if (!hasPendingApproval && !isApplying && !isStreaming) {
- pendingPreviewRef.current = null;
- onStreamingToolRef.current?.(null);
- }
- }, [rawMessages, isStreaming]);
-
- const sendMessage = useCallback(
- async (textOverride?: string) => {
- const userText = (textOverride ?? input).trim();
- if (!userText || isStreaming || !model) return;
-
- if (!currentKeys.key) {
- toast.error("API key not configured", {
- description: "Open settings to add your API key.",
- });
- return;
- }
-
- pendingPreviewRef.current = null;
- setApprovalArtifacts({});
- if (!textOverride) {
- setInput("");
- }
-
- await chat.sendMessage({ text: userText });
+ const pendingApproval = useMemo(
+ function computePendingApproval() {
+ return findPendingApproval(messages);
},
- [input, isStreaming, model, currentKeys.key, chat],
+ [messages],
);
- const resendFromIndex = useCallback(
- async (index: number, newContent: string) => {
- if (isStreaming || !newContent.trim() || !model || !currentKeys.key) return;
-
- pendingPreviewRef.current = null;
- setApprovalArtifacts({});
- onStreamingToolRef.current?.(null);
- chat.setMessages(rawMessages.slice(0, index));
- await chat.sendMessage({ text: newContent });
+ const toolCallCount = useMemo(
+ function computeToolCallCount() {
+ return countCompletedToolCalls(rawMessages);
},
- [isStreaming, model, currentKeys.key, chat, rawMessages],
+ [rawMessages],
);
- const clearMessages = useCallback(() => {
- if (persistTimerRef.current) {
- clearTimeout(persistTimerRef.current);
- persistTimerRef.current = null;
- }
-
- const persistableMessages = getPersistableMessages(rawMessages);
-
- const resetChat = () => {
- chatMessagesRef.current = [];
- setChatId(crypto.randomUUID());
- setInput("");
- setUsage(null);
- setApprovalArtifacts({});
- pendingPreviewRef.current = null;
- onStreamingToolRef.current?.(null);
- onGeneratingChangeRef.current?.(false);
- };
-
- if (persistableMessages.length === 0) {
- resetChat();
+ function sendMessage(textOverride?: string) {
+ const userText = (textOverride ?? snapshot.input).trim();
+ if (!userText || isStreaming || !snapshot.model) return;
+ if (!currentKeys.key) {
+ toast.error("API key not configured", {
+ description: "Open settings to add your API key.",
+ });
return;
}
+ store.prepareForSend();
+ if (!textOverride) {
+ store.clearInput();
+ }
+ chat.sendMessage({ text: userText });
+ }
+
+ function resendFromIndex(index: number, newContent: string) {
+ if (isStreaming || !newContent.trim() || !snapshot.model || !currentKeys.key) return;
+ store.prepareForResend(onStreamingTool);
+ chat.setMessages(rawMessages.slice(0, index));
+ chat.sendMessage({ text: newContent });
+ }
- saveChatSession({
- id: chatIdRef.current,
- toolName: currentTool.name,
- messages: persistableMessages,
- updatedAt: Date.now(),
- preview: getSessionPreview(persistableMessages),
- })
- .then(() => loadRecentSessions(currentTool.name))
- .then(setRecentSessions)
- .catch(console.error)
- .finally(resetChat);
- }, [rawMessages, currentTool.name]);
+ function clearMessages() {
+ store.flushAndReset(rawMessages, onStreamingTool, onGeneratingChange);
+ }
- const confirmPatch = useCallback(async () => {
+ function confirmPatch() {
if (!pendingApproval) return;
-
- await chat.addToolApprovalResponse({
+ chat.addToolApprovalResponse({
id: pendingApproval.approvalId,
approved: true,
reason: "Applied successfully. Provide a concise summary of all the changes you made to the tool.",
});
- }, [pendingApproval, chat]);
+ }
- const rejectPatch = useCallback(async () => {
+ function rejectPatch() {
if (!pendingApproval) return;
-
- await chat.addToolApprovalResponse({
+ chat.addToolApprovalResponse({
id: pendingApproval.approvalId,
approved: false,
reason: "User rejected the changes",
});
- pendingPreviewRef.current = null;
- onStreamingToolRef.current?.(null);
- }, [pendingApproval, chat]);
+ store.setPendingPreview(null);
+ onStreamingTool?.(null);
+ }
- const loadSession = useCallback((session: ChatSession) => {
- chatMessagesRef.current = session.messages;
- setChatId(session.id);
- setUsage(null);
- setInput("");
- setApprovalArtifacts({});
- pendingPreviewRef.current = null;
- onStreamingToolRef.current?.(null);
- onGeneratingChangeRef.current?.(false);
- }, []);
+ function loadSession(session: ChatWithPreview) {
+ store.loadSession(session, onStreamingTool, onGeneratingChange);
+ }
return {
messages,
- input,
- setInput,
+ input: snapshot.input,
+ setInput: store.setInput.bind(store),
isStreaming,
- model,
- setModel,
- reasoningEffort,
- setReasoningEffort,
+ model: snapshot.model,
+ setModel: store.setModel.bind(store),
+ reasoningEffort: snapshot.reasoningEffort,
+ setReasoningEffort: store.setReasoningEffort.bind(store),
provider,
sendMessage,
stopStreaming: chat.stop,
@@ -630,9 +461,9 @@ function useAIChat(
pendingApproval,
confirmPatch,
rejectPatch,
- usage,
+ usage: snapshot.usage,
toolCallCount,
- recentSessions,
+ recentSessions: snapshot.recentSessions,
loadSession,
};
}
@@ -648,7 +479,13 @@ interface MessagePartProps {
setEditingValue: (v: string) => void;
onResend: (index: number, content: string) => void;
onRetry: () => void;
- pendingApproval: { approvalId: string; previewTool: Tool; originalTool: Tool; summary: string; messageIndex: number } | null;
+ pendingApproval: {
+ approvalId: string;
+ previewTool: Tool;
+ originalTool: Tool;
+ summary: string;
+ messageIndex: number;
+ } | null;
onConfirmPatch: () => void;
onRejectPatch: () => void;
}
@@ -698,42 +535,80 @@ function MessagePart({
: tc.toolName === "tavilyExtract"
? tc.state === "output-available"
? (() => {
- const urls = (tc.input.urls as string[] | undefined) ?? [];
- try { return `Extracted from ${new URL(urls[0]).hostname}`; } catch { return "Extracted content"; }
- })()
+ const urls = (tc.input.urls as string[] | undefined) ?? [];
+ try {
+ return `Extracted from ${new URL(urls[0]).hostname}`;
+ } catch {
+ return "Extracted content";
+ }
+ })()
: "Extracting content…"
- : (tc.title ?? "Editing…")
+ : tc.toolName === "readTool"
+ ? (() => {
+ const jsonPath = tc.input.jsonPath as string | undefined;
+ const summary = tc.input.summary as string | undefined;
+ const pathLabel =
+ jsonPath && jsonPath !== "$" ? jsonPath : "whole tool";
+ if (tc.state !== "output-available")
+ return summary
+ ? `${summary} (${pathLabel})`
+ : `Reading ${pathLabel}…`;
+ return summary ? `${summary} (${pathLabel})` : `Read ${pathLabel}`;
+ })()
+ : (tc.title ?? "Editing…")
}
status={tc.state === "output-available" ? "complete" : "active"}
>
{tc.toolName === "tavilySearch" && tc.state === "output-available" && (
)}
{tc.toolName === "tavilyExtract" && tc.state === "output-available" && (
)}
+ {tc.toolName === "editTool" &&
+ tc.state === "output-available" &&
+ (() => {
+ const patch = tc.input.patch as Record | undefined;
+ if (!patch) return null;
+ const keys = Object.keys(patch);
+ return (
+
+
+
+ {keys.length} field{keys.length !== 1 ? "s" : ""} modified
+
+
+
+ {JSON.stringify(patch, null, 2)}
+
+
+
+ );
+ })()}
))}
)}
{applyToolCall && (
-
+
- {(applyToolCall.originalTool && applyToolCall.previewTool) && (
-
+ {applyToolCall.originalTool && applyToolCall.previewTool && (
+
)}
{isPending && (
@@ -934,7 +809,7 @@ export function AIChatPanel({
{chat.recentSessions.length > 0 && (
-
+
Recent sessions
@@ -944,26 +819,17 @@ export function AIChatPanel({
onClick={() => chat.loadSession(session)}
className="flex w-full items-start justify-between gap-2 rounded border border-border/40 bg-muted/30 px-3 py-2 text-left text-xs transition-colors hover:border-border/60 hover:bg-muted/50"
>
- {session.preview || "Empty session"}
- {formatRelativeTime(session.updatedAt)}
+
+ {session.preview || "Empty session"}
+
+
+ {formatRelativeTime(session.updatedAt)}
+
))}
)}
-
- {PROMPT_PILLS.map((pill) => (
-
- {pill.label}
-
- ))}
-
) : (
chat.messages.map((msg, i) => (
@@ -989,6 +855,24 @@ export function AIChatPanel({
+ {chat.messages.length === 0 && (
+
+
+ {PROMPT_PILLS.map((pill) => (
+
+ {pill.label}
+
+ ))}
+
+
+ )}
+
{
diff --git a/src/components/tool-editor/command-tree.tsx b/src/components/tool-editor/command-tree.tsx
index 4871a9b..88a07ba 100644
--- a/src/components/tool-editor/command-tree.tsx
+++ b/src/components/tool-editor/command-tree.tsx
@@ -1,7 +1,6 @@
import { CommandDialog } from "../tool-editor/dialogs/command-dialog";
import { useToolBuilder } from "./tool-editor.context";
import { Command } from "@/components/commandly/types/flat";
-import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
@@ -79,14 +78,6 @@ function CommandNode({
)}
{command.name}
- {command.isDefault && (
-
- default
-
- )}
-
- {
- setCommand((prev) => ({
- ...prev,
- isDefault: checked,
- }));
- }}
- />
- Default
-
- !parameters.some((p) => p.key === k),
- )
- : [])
- : (pendingChanges
- ? [...pendingChanges.removed].filter(
- (k) =>
- !parameters.some((p) => p.key === k) &&
- !globalParameters.some((p) => p.key === k),
- )
- : []);
+ ? pendingChanges
+ ? [...pendingChanges.removed].filter((k) => !parameters.some((p) => p.key === k))
+ : []
+ : pendingChanges
+ ? [...pendingChanges.removed].filter(
+ (k) => !parameters.some((p) => p.key === k) && !globalParameters.some((p) => p.key === k),
+ )
+ : [];
const getParameterExclusionGroups = (parameterKey: string): ExclusionGroup[] => {
return exclusionGroups.filter((group) => group.parameterKeys.includes(parameterKey));
@@ -135,8 +136,8 @@ export function ParameterList({ title, isGlobal = false, isChatOpen = false, pen
!isAdded && !isUpdated && isChatOpen && isContextSelected
? "border-primary bg-accent/30 ring-1 ring-primary"
: !isAdded && !isUpdated
- ? "border-muted"
- : "",
+ ? "border-muted"
+ : "",
)}
onClick={(e) => handleParameterClick(e, parameter.key, index)}
>
@@ -239,10 +240,10 @@ export function ParameterList({ title, isGlobal = false, isChatOpen = false, pen
{removedParameters.map((key) => (
-
{key}
+
{key}
{
if (cmd.key === action.payload.commandKey) return { ...cmd, ...action.payload.updates };
- if (action.payload.updates.isDefault) return { ...cmd, isDefault: false };
return cmd;
}),
},
diff --git a/src/components/tool-editor/tool-editor.tsx b/src/components/tool-editor/tool-editor.tsx
index a332e90..6b2f0ff 100644
--- a/src/components/tool-editor/tool-editor.tsx
+++ b/src/components/tool-editor/tool-editor.tsx
@@ -67,6 +67,7 @@ function ToolEditorContent({
const {
tool,
originalTool,
+ dialogs,
setDialogOpen,
initializeTool,
selectedParameter,
@@ -79,8 +80,7 @@ function ToolEditorContent({
const [initialToolJson, setInitialToolJson] = useState(() => JSON.stringify(tool));
const isDirty = JSON.stringify(tool) !== initialToolJson;
- const hasAtLeastOneCommand = Array.isArray(tool.commands) && tool.commands.length > 0;
- const isValid = tool.name.trim() !== "" && tool.displayName.trim() !== "" && hasAtLeastOneCommand;
+ const isValid = tool.name.trim() !== "" && tool.displayName.trim() !== "";
const pendingChanges = (() => {
const currentParams = (streamingTool ?? tool).parameters;
@@ -135,9 +135,7 @@ function ToolEditorContent({
};
return (
-
+
Commands
@@ -172,7 +170,7 @@ function ToolEditorContent({
<>
{isDirty && !isValid && (
- Name, display name, and at least one command are required
+ Name and display name are required
)}
setDialogOpen("savedCommands", open)}
savedCommands={savedCommands}
onDeleteCommand={onDeleteSavedCommand ?? (() => {})}
/>
diff --git a/src/components/tool-editor/tools.ts b/src/components/tool-editor/tools.ts
index d25a5bf..023cd12 100644
--- a/src/components/tool-editor/tools.ts
+++ b/src/components/tool-editor/tools.ts
@@ -1,6 +1,7 @@
import { Tool } from "@/components/commandly/types/flat";
import { cleanupTool, exportToStructuredJSON } from "@/components/commandly/utils/flat";
import { tool } from "ai";
+import { JSONPath } from "jsonpath-plus";
import { z } from "zod";
export function applyMergePatch(base: Tool, patch: Partial): Tool {
@@ -55,25 +56,23 @@ export function createApplyToolDefinitionTool(onApplied: () => void, onApply: ()
export function createReadTool(getCurrent: () => Tool) {
return tool({
description:
- "Read the current tool JSON to inspect its structure or verify changes. Call this first before any edits. Use the fields parameter to read only specific top-level sections for large tools.",
+ "Read the current tool JSON to inspect its structure or verify changes. Call this first before any edits. Use jsonPath to read only a specific section (e.g. '$.parameters', '$.commands', '$.info', '$.parameters[0]').",
inputSchema: z.object({
- reason: z.string().optional().describe("Why you are reading the tool JSON"),
- fields: z
- .array(z.enum(["info", "parameters", "commands", "exclusionGroups"]))
+ summary: z.string().optional().describe("Brief description of what you are looking for"),
+ jsonPath: z
+ .string()
.optional()
- .describe("Specific top-level fields to read. Omit to read all."),
+ .describe(
+ "JSONPath expression to read a specific section. Use '$' or omit for the whole tool. Examples: '$.info', '$.parameters', '$.commands', '$.parameters[0]'.",
+ ),
}),
- execute: async ({ fields }) => {
+ execute: async ({ jsonPath }) => {
const current = getCurrent();
- const exported = exportToStructuredJSON(current) as Record;
- if (fields && fields.length > 0) {
- const partial: Record = {};
- for (const f of fields) {
- if (f in exported) partial[f] = exported[f];
- }
- return { tool: partial, note: `Showing fields: ${fields.join(", ")}` };
- }
- return { tool: exported };
+ const exported = exportToStructuredJSON(current);
+ const path = jsonPath && jsonPath !== "$" ? jsonPath : "$";
+ const matches = JSONPath({ path, json: exported as object });
+ const result = matches.length === 1 ? matches[0] : matches;
+ return { result, jsonPath: path };
},
});
}
@@ -94,7 +93,8 @@ export function createTavilySearchTool(apiKey: string) {
};
return {
query,
- results: data.results?.map((r) => ({ title: r.title, url: r.url, content: r.content })) ?? [],
+ results:
+ data.results?.map((r) => ({ title: r.title, url: r.url, content: r.content })) ?? [],
};
},
});
@@ -115,7 +115,9 @@ export function createTavilyExtractTool(apiKey: string) {
maxChars: z
.number()
.optional()
- .describe("Max characters to return per URL (default 6000). Reduce if content is too large to process at once."),
+ .describe(
+ "Max characters to return per URL (default 6000). Reduce if content is too large to process at once.",
+ ),
}),
execute: async ({ urls, startOffset = 0, maxChars = 6000 }) => {
const resp = await fetch("https://api.tavily.com/extract", {
@@ -128,13 +130,14 @@ export function createTavilyExtractTool(apiKey: string) {
results?: { url: string; raw_content: string }[];
};
return {
- results: data.results?.map((r) => ({
- url: r.url,
- raw_content: r.raw_content.slice(startOffset, startOffset + maxChars),
- totalChars: r.raw_content.length,
- hasMore: r.raw_content.length > startOffset + maxChars,
- nextOffset: startOffset + maxChars,
- })) ?? [],
+ results:
+ data.results?.map((r) => ({
+ url: r.url,
+ raw_content: r.raw_content.slice(startOffset, startOffset + maxChars),
+ totalChars: r.raw_content.length,
+ hasMore: r.raw_content.length > startOffset + maxChars,
+ nextOffset: startOffset + maxChars,
+ })) ?? [],
};
},
});
diff --git a/src/lib/ai-keys.ts b/src/lib/ai-keys.ts
index 035b7b3..ad341a6 100644
--- a/src/lib/ai-keys.ts
+++ b/src/lib/ai-keys.ts
@@ -18,19 +18,31 @@ const KEY_ID = "master-key";
function getOrCreateCryptoKey(): Promise {
return new Promise((resolve, reject) => {
- const request = indexedDB.open(DB_NAME, 3);
+ const request = indexedDB.open(DB_NAME, 4);
request.onupgradeneeded = (event) => {
const db = request.result;
const oldVersion = (event as IDBVersionChangeEvent).oldVersion;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
- if (oldVersion < 3 && db.objectStoreNames.contains("sessions")) {
- db.deleteObjectStore("sessions");
+ if (oldVersion < 4) {
+ if (db.objectStoreNames.contains("sessions")) {
+ db.deleteObjectStore("sessions");
+ }
+ if (db.objectStoreNames.contains("chats")) {
+ db.deleteObjectStore("chats");
+ }
+ if (db.objectStoreNames.contains("messages")) {
+ db.deleteObjectStore("messages");
+ }
+ }
+ if (!db.objectStoreNames.contains("chats")) {
+ const chatStore = db.createObjectStore("chats", { keyPath: "id" });
+ chatStore.createIndex("toolName", "toolName", { unique: false });
}
- if (!db.objectStoreNames.contains("sessions")) {
- const store = db.createObjectStore("sessions", { keyPath: "id" });
- store.createIndex("toolName", "toolName", { unique: false });
+ if (!db.objectStoreNames.contains("messages")) {
+ const msgStore = db.createObjectStore("messages", { keyPath: "id" });
+ msgStore.createIndex("chatId", "chatId", { unique: false });
}
};
request.onerror = () => reject(request.error);
diff --git a/src/lib/editor-utils.ts b/src/lib/editor-utils.ts
index f8b8304..87b4e28 100644
--- a/src/lib/editor-utils.ts
+++ b/src/lib/editor-utils.ts
@@ -11,7 +11,7 @@ export const getSavedCommandsFromStorage = (toolId: string): SavedCommand[] => {
export const saveSavedCommandsToStorage = (toolId: string, commands: SavedCommand[]): void => {
try {
- localStorage.setItem(toolId, JSON.stringify(commands));
+ localStorage.setItem(`saved-${toolId}`, JSON.stringify(commands));
} catch (error) {
console.error("Failed to save commands to localStorage:", error);
}
@@ -30,5 +30,5 @@ export const removeSavedCommandFromStorage = (toolId: string, commandKey: string
};
export const clearSavedCommandsFromStorage = (toolId: string): void => {
- localStorage.removeItem(toolId);
+ localStorage.removeItem(`saved-${toolId}`);
};
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 77891cb..fcf9473 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -161,7 +161,6 @@ export const defaultTool = (toolName?: string, displayName?: string): Tool => {
key: slugify(finalToolName),
name: finalToolName,
description: "Main command",
- isDefault: true,
sortOrder: 0,
},
],
diff --git a/src/routes/tools/$toolName/edit.tsx b/src/routes/tools/$toolName/edit.tsx
index c27aa8b..83b903a 100644
--- a/src/routes/tools/$toolName/edit.tsx
+++ b/src/routes/tools/$toolName/edit.tsx
@@ -15,21 +15,15 @@ import { toast } from "sonner";
export const Route = createFileRoute("/tools/$toolName/edit")({
component: RouteComponent,
validateSearch: (search) => ({
- isNew: search.isNew === true,
isLocal: search.isLocal === true,
}),
- loaderDeps: ({ search: { isNew, isLocal } }) => ({
- isNew,
+ loaderDeps: ({ search: { isLocal } }) => ({
isLocal,
}),
- loader: async ({ params: { toolName }, deps: { isNew } }) => {
- if (isNew) {
- return { name: "", displayName: "", commands: [], parameters: [] } as Tool;
- } else {
- const local = localStorage.getItem(`tool-${toolName}`);
- if (local) return JSON.parse(local) as Tool;
- return await fetchToolDetails(toolName);
- }
+ loader: async ({ params: { toolName } }) => {
+ const local = localStorage.getItem(`tool-${toolName}`);
+ if (local) return JSON.parse(local) as Tool;
+ return await fetchToolDetails(toolName);
},
ssr: false,
head: (context) => ({
@@ -43,7 +37,7 @@ export const Route = createFileRoute("/tools/$toolName/edit")({
function RouteComponent() {
const tool = Route.useLoaderData();
- const { isNew, isLocal } = Route.useSearch();
+ const { isLocal } = Route.useSearch();
const [savedCommands, setSavedCommands] = useState(() =>
tool ? getSavedCommandsFromStorage(tool.name) : [],
@@ -62,14 +56,14 @@ function RouteComponent() {
key: slugify(command.substring(0, 20)),
command,
};
- addSavedCommandToStorage(`saved-${toolId}`, newSavedCommand);
+ addSavedCommandToStorage(toolId, newSavedCommand);
setSavedCommands(getSavedCommandsFromStorage(toolId));
toast("Command Saved", { description: "Command has been saved successfully." });
};
const handleDeleteSavedCommand = (commandKey: string) => {
const toolId = tool!.name;
- removeSavedCommandFromStorage(`saved-${toolId}`, commandKey);
+ removeSavedCommandFromStorage(toolId, commandKey);
setSavedCommands(getSavedCommandsFromStorage(toolId));
};
@@ -77,7 +71,7 @@ function RouteComponent() {
{
localStorage.setItem(`tool-${tool.name}`, JSON.stringify(tool));
}}
diff --git a/src/routes/tools/$toolName/index.tsx b/src/routes/tools/$toolName/index.tsx
index aaad307..bebcd5a 100644
--- a/src/routes/tools/$toolName/index.tsx
+++ b/src/routes/tools/$toolName/index.tsx
@@ -85,7 +85,7 @@ function RouteComponent() {
command,
};
- addSavedCommandToStorage(`saved-${toolId}`, newSavedCommand);
+ addSavedCommandToStorage(toolId, newSavedCommand);
setSavedCommands(getSavedCommandsFromStorage(toolId));
toast("Command Saved", {
@@ -96,7 +96,7 @@ function RouteComponent() {
const handleDeleteCommand = (commandKey: string) => {
if (!tool) return;
const toolId = tool.name;
- removeSavedCommandFromStorage(`saved-${toolId}`, commandKey);
+ removeSavedCommandFromStorage(toolId, commandKey);
setSavedCommands(getSavedCommandsFromStorage(toolId));
};
diff --git a/src/routes/tools/index.tsx b/src/routes/tools/index.tsx
index 490959b..d7b1673 100644
--- a/src/routes/tools/index.tsx
+++ b/src/routes/tools/index.tsx
@@ -1,8 +1,19 @@
import { type Tool } from "@/components/commandly/types/flat";
+import { slugify } from "@/components/commandly/utils/flat";
import { SkeletonCard } from "@/components/square-card-skeleton";
import { ToolCard } from "@/components/tool-card";
import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
+import { Label } from "@/components/ui/label";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { fetchToolsList } from "@/lib/api/tools.api";
@@ -60,12 +71,35 @@ function RouteComponent() {
}, [serverToolNames]);
const [searchValue, setSearchValue] = useState("");
+ const [newToolDialogOpen, setNewToolDialogOpen] = useState(false);
+ const [newToolName, setNewToolName] = useState("");
+ const [newToolDisplayName, setNewToolDisplayName] = useState("");
+ const [displayNameEdited, setDisplayNameEdited] = useState(false);
const handleNewTool = () => {
+ setNewToolName("");
+ setNewToolDisplayName("");
+ setDisplayNameEdited(false);
+ setNewToolDialogOpen(true);
+ };
+
+ const handleNewToolNameChange = (value: string) => {
+ setNewToolName(value);
+ if (!displayNameEdited) {
+ setNewToolDisplayName(value.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()));
+ }
+ };
+
+ const handleCreateTool = () => {
+ const name = slugify(newToolName.trim());
+ const displayName = newToolDisplayName.trim() || newToolName.trim();
+ const newTool: Tool = { name, displayName, commands: [], parameters: [] };
+ localStorage.setItem(`tool-${name}`, JSON.stringify(newTool));
+ setNewToolDialogOpen(false);
navigation({
to: "/tools/$toolName/edit",
- params: { toolName: "new" },
- search: { isNew: true, isLocal: true },
+ params: { toolName: name },
+ search: { isLocal: true },
});
};
@@ -112,6 +146,58 @@ function RouteComponent() {
+
+
+
+ New Tool
+ Enter details for your new CLI tool definition.
+
+
+
+ setNewToolDialogOpen(false)}
+ >
+ Cancel
+
+
+ Create
+
+
+
+
diff --git a/tests/tool-editor/ai-chat-message-mapping.test.ts b/tests/tool-editor/ai-chat-message-mapping.test.ts
new file mode 100644
index 0000000..0dd9b43
--- /dev/null
+++ b/tests/tool-editor/ai-chat-message-mapping.test.ts
@@ -0,0 +1,431 @@
+import {
+ toChatMessage,
+ countCompletedToolCalls,
+ findPendingApproval,
+ type ApprovalArtifact,
+ type ChatMessage,
+} from "@/components/ai-chat/ai-chat-message-mapping";
+import type { Tool } from "@/components/commandly/types/flat";
+import type { UIMessage } from "ai";
+
+function makeToolUIPart(
+ toolName: string,
+ overrides: Record = {},
+): UIMessage["parts"][number] {
+ return {
+ type: `tool-${toolName}`,
+ toolCallId: crypto.randomUUID(),
+ state: "output-available",
+ input: {},
+ output: {},
+ ...overrides,
+ } as unknown as UIMessage["parts"][number];
+}
+
+function makeTextMessage(role: "user" | "assistant", text: string, id?: string): UIMessage {
+ return { id: id ?? crypto.randomUUID(), role, parts: [{ type: "text", text }] };
+}
+
+function makeTool(name: string): Tool {
+ return {
+ key: crypto.randomUUID(),
+ name,
+ displayName: name,
+ description: "",
+ version: "1.0.0",
+ info: { website: "", repository: "", author: "" },
+ commands: [],
+ parameters: [],
+ } as unknown as Tool;
+}
+
+describe("toChatMessage", () => {
+ it("maps a user message correctly", () => {
+ const msg = makeTextMessage("user", "Hello world");
+ const result = toChatMessage(msg, {});
+ expect(result.role).toBe("user");
+ expect(result.content).toBe("Hello world");
+ expect(result.toolCalls).toBeUndefined();
+ });
+
+ it("maps an assistant message with text", () => {
+ const msg = makeTextMessage("assistant", "Here is the answer");
+ const result = toChatMessage(msg, {});
+ expect(result.role).toBe("assistant");
+ expect(result.content).toBe("Here is the answer");
+ });
+
+ it("maps extraction tool call with output", () => {
+ const msg: UIMessage = {
+ id: "a1",
+ role: "assistant",
+ parts: [
+ { type: "text", text: "Let me extract." },
+ makeToolUIPart("tavilyExtract", {
+ state: "output-available",
+ input: { urls: ["https://example.com"] },
+ output: {
+ results: [{ url: "https://example.com", raw_content: "content here" }],
+ },
+ }),
+ { type: "text", text: "Here's what I found." },
+ ],
+ };
+ const result = toChatMessage(msg, {});
+ expect(result.content).toBe("Let me extract.Here's what I found.");
+ expect(result.toolCalls).toHaveLength(1);
+ expect(result.toolCalls![0].toolName).toBe("tavilyExtract");
+ expect(result.toolCalls![0].state).toBe("output-available");
+ expect(result.toolCalls![0].output).toEqual({
+ results: [{ url: "https://example.com", raw_content: "content here" }],
+ });
+ });
+
+ it("maps extraction tool call in input-available state", () => {
+ const msg: UIMessage = {
+ id: "a1",
+ role: "assistant",
+ parts: [
+ makeToolUIPart("tavilyExtract", {
+ state: "input-available",
+ input: { urls: ["https://example.com"] },
+ }),
+ ],
+ };
+ const result = toChatMessage(msg, {});
+ expect(result.toolCalls).toHaveLength(1);
+ expect(result.toolCalls![0].state).toBe("input-available");
+ expect(result.toolCalls![0].title).toBe("Extract Content");
+ });
+
+ it("maps search tool call with query", () => {
+ const msg: UIMessage = {
+ id: "a1",
+ role: "assistant",
+ parts: [
+ makeToolUIPart("tavilySearch", {
+ state: "output-available",
+ input: { query: "httpx docs" },
+ output: { results: [{ title: "httpx", url: "https://httpx.io", content: "..." }] },
+ }),
+ ],
+ };
+ const result = toChatMessage(msg, {});
+ expect(result.toolCalls![0].toolName).toBe("tavilySearch");
+ expect(result.toolCalls![0].title).toBe("Web Search");
+ });
+
+ it("maps applyToolDefinition with approval artifacts", () => {
+ const approvalId = "approval-1";
+ const previewTool = makeTool("updated-tool");
+ const originalTool = makeTool("original-tool");
+ const artifacts: Record = {
+ [approvalId]: { approvalId, previewTool, originalTool, summary: "Update tool" },
+ };
+
+ const msg: UIMessage = {
+ id: "a1",
+ role: "assistant",
+ parts: [
+ makeToolUIPart("applyToolDefinition", {
+ state: "approval-requested",
+ input: { summary: "Update tool" },
+ approval: { id: approvalId },
+ }),
+ ],
+ };
+ const result = toChatMessage(msg, artifacts);
+ expect(result.toolCalls).toHaveLength(1);
+ expect(result.toolCalls![0].previewTool).toBe(previewTool);
+ expect(result.toolCalls![0].originalTool).toBe(originalTool);
+ });
+
+ it("marks toolApplied when applyToolDefinition has output-available state", () => {
+ const msg: UIMessage = {
+ id: "a1",
+ role: "assistant",
+ parts: [
+ makeToolUIPart("applyToolDefinition", {
+ state: "output-available",
+ input: { summary: "Applied" },
+ output: { success: true },
+ }),
+ ],
+ };
+ const result = toChatMessage(msg, {});
+ expect(result.toolApplied).toBe(true);
+ });
+
+ it("does not mark toolApplied for non-apply tools", () => {
+ const msg: UIMessage = {
+ id: "a1",
+ role: "assistant",
+ parts: [
+ makeToolUIPart("editTool", {
+ state: "output-available",
+ input: { summary: "edit" },
+ output: { success: true },
+ }),
+ ],
+ };
+ const result = toChatMessage(msg, {});
+ expect(result.toolApplied).toBe(false);
+ });
+
+ it("maps reasoning content", () => {
+ const msg: UIMessage = {
+ id: "a1",
+ role: "assistant",
+ parts: [
+ { type: "reasoning", text: "thinking...", providerMetadata: {} },
+ { type: "text", text: "answer" },
+ ],
+ };
+ const result = toChatMessage(msg, {});
+ expect(result.reasoningContent).toBe("thinking...");
+ expect(result.content).toBe("answer");
+ });
+
+ it("maps message with extraction followed by edit followed by apply", () => {
+ const approvalId = "ap-1";
+ const artifacts: Record = {
+ [approvalId]: {
+ approvalId,
+ previewTool: makeTool("preview"),
+ originalTool: makeTool("original"),
+ summary: "Apply changes",
+ },
+ };
+
+ const msg: UIMessage = {
+ id: "a1",
+ role: "assistant",
+ parts: [
+ makeToolUIPart("tavilyExtract", {
+ state: "output-available",
+ input: { urls: ["https://docs.example.com"] },
+ output: { results: [{ url: "https://docs.example.com", raw_content: "docs" }] },
+ }),
+ makeToolUIPart("editTool", {
+ state: "output-available",
+ input: { summary: "Update from docs", patch: { description: "new desc" } },
+ output: { success: true },
+ }),
+ makeToolUIPart("applyToolDefinition", {
+ state: "approval-requested",
+ input: { summary: "Apply changes" },
+ approval: { id: approvalId },
+ }),
+ { type: "text", text: "I've updated the tool based on the docs." },
+ ],
+ };
+
+ const result = toChatMessage(msg, artifacts);
+ expect(result.toolCalls).toHaveLength(3);
+ expect(result.toolCalls![0].toolName).toBe("tavilyExtract");
+ expect(result.toolCalls![1].toolName).toBe("editTool");
+ expect(result.toolCalls![2].toolName).toBe("applyToolDefinition");
+ expect(result.toolCalls![2].previewTool!.name).toBe("preview");
+ expect(result.content).toBe("I've updated the tool based on the docs.");
+ });
+});
+
+describe("countCompletedToolCalls", () => {
+ it("returns 0 for empty messages", () => {
+ expect(countCompletedToolCalls([])).toBe(0);
+ });
+
+ it("counts output-available tool calls", () => {
+ const messages: UIMessage[] = [
+ {
+ id: "a1",
+ role: "assistant",
+ parts: [
+ makeToolUIPart("editTool", { state: "output-available" }),
+ makeToolUIPart("readTool", { state: "output-available" }),
+ ],
+ },
+ ];
+ expect(countCompletedToolCalls(messages)).toBe(2);
+ });
+
+ it("counts output-error and output-denied", () => {
+ const messages: UIMessage[] = [
+ {
+ id: "a1",
+ role: "assistant",
+ parts: [
+ makeToolUIPart("editTool", { state: "output-error" }),
+ makeToolUIPart("applyToolDefinition", { state: "output-denied" }),
+ ],
+ },
+ ];
+ expect(countCompletedToolCalls(messages)).toBe(2);
+ });
+
+ it("does not count input-available or approval-requested", () => {
+ const messages: UIMessage[] = [
+ {
+ id: "a1",
+ role: "assistant",
+ parts: [
+ makeToolUIPart("editTool", { state: "input-available" }),
+ makeToolUIPart("applyToolDefinition", { state: "approval-requested" }),
+ ],
+ },
+ ];
+ expect(countCompletedToolCalls(messages)).toBe(0);
+ });
+
+ it("counts across multiple messages including extraction", () => {
+ const messages: UIMessage[] = [
+ {
+ id: "a1",
+ role: "assistant",
+ parts: [
+ makeToolUIPart("tavilyExtract", { state: "output-available" }),
+ makeToolUIPart("editTool", { state: "output-available" }),
+ ],
+ },
+ {
+ id: "a2",
+ role: "assistant",
+ parts: [
+ makeToolUIPart("applyToolDefinition", { state: "output-available" }),
+ ],
+ },
+ ];
+ expect(countCompletedToolCalls(messages)).toBe(3);
+ });
+});
+
+describe("findPendingApproval", () => {
+ it("returns null when no approval is pending", () => {
+ const messages: ChatMessage[] = [
+ { id: "1", role: "user", content: "hello" },
+ { id: "2", role: "assistant", content: "hi" },
+ ];
+ expect(findPendingApproval(messages)).toBeNull();
+ });
+
+ it("finds pending approval in the last message", () => {
+ const previewTool = makeTool("preview");
+ const originalTool = makeTool("original");
+ const messages: ChatMessage[] = [
+ { id: "1", role: "user", content: "edit this" },
+ {
+ id: "2",
+ role: "assistant",
+ content: "",
+ toolCalls: [
+ {
+ toolCallId: "tc1",
+ toolName: "applyToolDefinition",
+ approvalId: "ap-1",
+ title: "Preparing to apply",
+ input: { summary: "Apply" },
+ state: "approval-requested",
+ previewTool,
+ originalTool,
+ },
+ ],
+ },
+ ];
+ const result = findPendingApproval(messages);
+ expect(result).not.toBeNull();
+ expect(result!.approvalId).toBe("ap-1");
+ expect(result!.previewTool).toBe(previewTool);
+ expect(result!.originalTool).toBe(originalTool);
+ expect(result!.messageIndex).toBe(1);
+ });
+
+ it("returns null when approval is missing previewTool or originalTool", () => {
+ const messages: ChatMessage[] = [
+ {
+ id: "1",
+ role: "assistant",
+ content: "",
+ toolCalls: [
+ {
+ toolCallId: "tc1",
+ toolName: "applyToolDefinition",
+ approvalId: "ap-1",
+ title: "Preparing to apply",
+ input: { summary: "Apply" },
+ state: "approval-requested",
+ },
+ ],
+ },
+ ];
+ expect(findPendingApproval(messages)).toBeNull();
+ });
+
+ it("ignores non-approval-requested states", () => {
+ const messages: ChatMessage[] = [
+ {
+ id: "1",
+ role: "assistant",
+ content: "",
+ toolCalls: [
+ {
+ toolCallId: "tc1",
+ toolName: "applyToolDefinition",
+ approvalId: "ap-1",
+ title: "Applied",
+ input: { summary: "Apply" },
+ state: "output-available",
+ previewTool: makeTool("p"),
+ originalTool: makeTool("o"),
+ },
+ ],
+ },
+ ];
+ expect(findPendingApproval(messages)).toBeNull();
+ });
+
+ it("finds approval after extraction + edit chain", () => {
+ const previewTool = makeTool("preview");
+ const originalTool = makeTool("original");
+ const messages: ChatMessage[] = [
+ { id: "1", role: "user", content: "update from docs" },
+ {
+ id: "2",
+ role: "assistant",
+ content: "I've updated the tool.",
+ toolCalls: [
+ {
+ toolCallId: "tc1",
+ toolName: "tavilyExtract",
+ title: "Extract Content",
+ input: { urls: ["https://docs.example.com"] },
+ state: "output-available",
+ output: { results: [{ url: "https://docs.example.com", raw_content: "docs" }] },
+ },
+ {
+ toolCallId: "tc2",
+ toolName: "editTool",
+ title: "Update description",
+ input: { summary: "Update description", patch: {} },
+ state: "output-available",
+ output: { success: true },
+ },
+ {
+ toolCallId: "tc3",
+ toolName: "applyToolDefinition",
+ approvalId: "ap-1",
+ title: "Preparing to apply",
+ input: { summary: "Apply doc updates" },
+ state: "approval-requested",
+ previewTool,
+ originalTool,
+ },
+ ],
+ },
+ ];
+ const result = findPendingApproval(messages);
+ expect(result).not.toBeNull();
+ expect(result!.approvalId).toBe("ap-1");
+ expect(result!.summary).toBe("Apply doc updates");
+ expect(result!.messageIndex).toBe(1);
+ });
+});
diff --git a/tests/tool-editor/ai-chat-persistence.test.ts b/tests/tool-editor/ai-chat-persistence.test.ts
index 699b3d1..61fcf81 100644
--- a/tests/tool-editor/ai-chat-persistence.test.ts
+++ b/tests/tool-editor/ai-chat-persistence.test.ts
@@ -1,16 +1,19 @@
-import "fake-indexeddb/auto";
-import type { UIMessage } from "ai";
import {
- saveChatSession,
- loadRecentSessions,
+ createChat,
+ upsertMessage,
+ loadChat,
+ getChats,
+ deleteChat,
+ deleteMessage,
getPersistableMessages,
getSessionPreview,
- type ChatSession,
} from "@/components/ai-chat/ai-chat-persistence";
+import type { UIMessage } from "ai";
-// Persistence is type-agnostic (JSON serialization), so we cast complex
-// SDK tool-invocation parts to avoid fighting deeply generic UIMessage types.
-function makeToolPart(toolName: string, overrides: Record = {}): UIMessage["parts"][number] {
+function makeToolPart(
+ toolName: string,
+ overrides: Record = {},
+): UIMessage["parts"][number] {
return {
type: `tool-${toolName}`,
toolCallId: crypto.randomUUID(),
@@ -37,139 +40,211 @@ function makeEmptyAssistantMessage(): UIMessage {
};
}
-function makeSession(overrides: Partial = {}): ChatSession {
- return {
- id: crypto.randomUUID(),
- toolName: "test-tool",
- messages: [
- makeTextMessage("user", "Hello"),
- makeTextMessage("assistant", "Hi there"),
- ],
- updatedAt: Date.now(),
- preview: "Hello",
- ...overrides,
- };
-}
-
beforeEach(() => {
- // Reset IndexedDB between tests to avoid cross-test contamination
- indexedDB = new IDBFactory();
+ Object.defineProperty(globalThis, "indexedDB", {
+ value: new IDBFactory(),
+ writable: true,
+ configurable: true,
+ });
});
-describe("saveChatSession", () => {
- it("saves a session and retrieves it by toolName", async () => {
- const session = makeSession();
+describe("createChat + loadChat", () => {
+ it("creates a chat and loads its messages", async () => {
+ const chatId = crypto.randomUUID();
+ await createChat(chatId, "test-tool");
- await saveChatSession(session);
- const loaded = await loadRecentSessions(session.toolName);
+ const msg = makeTextMessage("user", "Hello");
+ await upsertMessage(chatId, msg, 0);
+ const loaded = await loadChat(chatId);
expect(loaded).toHaveLength(1);
- expect(loaded[0].id).toBe(session.id);
- expect(loaded[0].toolName).toBe(session.toolName);
- expect(loaded[0].messages).toHaveLength(2);
- expect(loaded[0].messages[0].parts[0]).toEqual({ type: "text", text: "Hello" });
+ expect(loaded[0].id).toBe(msg.id);
+ expect(loaded[0].parts[0]).toEqual({ type: "text", text: "Hello" });
});
- it("overwrites a session with the same id", async () => {
- const session = makeSession();
- await saveChatSession(session);
+ it("returns empty array for chat with no messages", async () => {
+ const chatId = crypto.randomUUID();
+ await createChat(chatId, "test-tool");
- const updated: ChatSession = {
- ...session,
- messages: [
- makeTextMessage("user", "Updated message"),
- makeTextMessage("assistant", "Updated response"),
- ],
- updatedAt: Date.now() + 1000,
- preview: "Updated message",
+ const loaded = await loadChat(chatId);
+ expect(loaded).toEqual([]);
+ });
+
+ it("returns messages in order", async () => {
+ const chatId = crypto.randomUUID();
+ await createChat(chatId, "test-tool");
+
+ const msg1 = makeTextMessage("user", "First");
+ const msg2 = makeTextMessage("assistant", "Second");
+ const msg3 = makeTextMessage("user", "Third");
+
+ await upsertMessage(chatId, msg2, 1);
+ await upsertMessage(chatId, msg3, 2);
+ await upsertMessage(chatId, msg1, 0);
+
+ const loaded = await loadChat(chatId);
+ expect(loaded.map((m) => m.parts[0])).toEqual([
+ { type: "text", text: "First" },
+ { type: "text", text: "Second" },
+ { type: "text", text: "Third" },
+ ]);
+ });
+});
+
+describe("upsertMessage", () => {
+ it("updates an existing message on re-upsert", async () => {
+ const chatId = crypto.randomUUID();
+ await createChat(chatId, "test-tool");
+
+ const msgId = crypto.randomUUID();
+ const original = makeTextMessage("assistant", "Original", msgId);
+ await upsertMessage(chatId, original, 0);
+
+ const updated: UIMessage = {
+ id: msgId,
+ role: "assistant",
+ parts: [{ type: "text", text: "Updated" }],
};
- await saveChatSession(updated);
+ await upsertMessage(chatId, updated, 0);
- const loaded = await loadRecentSessions(session.toolName);
+ const loaded = await loadChat(chatId);
expect(loaded).toHaveLength(1);
- expect(loaded[0].preview).toBe("Updated message");
+ expect(loaded[0].parts[0]).toEqual({ type: "text", text: "Updated" });
+ });
+
+ it("rejects upsert for nonexistent chat", async () => {
+ const msg = makeTextMessage("user", "Hello");
+ await expect(upsertMessage("nonexistent", msg, 0)).rejects.toThrow();
});
- it("stores messages as plain JSON-safe objects", async () => {
+ it("strips non-serializable properties from parts", async () => {
+ const chatId = crypto.randomUUID();
+ await createChat(chatId, "test-tool");
+
const msg = makeTextMessage("user", "test");
- // Attach a non-serializable property to verify JSON roundtrip strips it
(msg as unknown as Record).fn = () => {};
- const session = makeSession({ messages: [msg] });
- await saveChatSession(session);
-
- const loaded = await loadRecentSessions(session.toolName);
- expect(loaded[0].messages[0]).not.toHaveProperty("fn");
+ await upsertMessage(chatId, msg, 0);
+ const loaded = await loadChat(chatId);
+ expect(loaded[0]).not.toHaveProperty("fn");
});
});
-describe("loadRecentSessions", () => {
- it("returns empty array when no sessions exist", async () => {
- const loaded = await loadRecentSessions("nonexistent-tool");
- expect(loaded).toEqual([]);
+describe("getChats", () => {
+ it("returns empty array when no chats exist", async () => {
+ const chats = await getChats("nonexistent-tool");
+ expect(chats).toEqual([]);
});
- it("filters sessions by toolName", async () => {
- await saveChatSession(makeSession({ toolName: "tool-a" }));
- await saveChatSession(makeSession({ toolName: "tool-b" }));
- await saveChatSession(makeSession({ toolName: "tool-a" }));
+ it("filters chats by toolName", async () => {
+ await createChat("a1", "tool-a");
+ await createChat("b1", "tool-b");
+ await createChat("a2", "tool-a");
- const sessionsA = await loadRecentSessions("tool-a");
- const sessionsB = await loadRecentSessions("tool-b");
+ const chatsA = await getChats("tool-a");
+ const chatsB = await getChats("tool-b");
- expect(sessionsA).toHaveLength(2);
- expect(sessionsB).toHaveLength(1);
- expect(sessionsA.every((s) => s.toolName === "tool-a")).toBe(true);
- });
-
- it("returns sessions sorted by updatedAt descending", async () => {
- const now = Date.now();
- await saveChatSession(makeSession({ toolName: "t", updatedAt: now - 2000 }));
- await saveChatSession(makeSession({ toolName: "t", updatedAt: now }));
- await saveChatSession(makeSession({ toolName: "t", updatedAt: now - 1000 }));
-
- const loaded = await loadRecentSessions("t");
- expect(loaded[0].updatedAt).toBe(now);
- expect(loaded[1].updatedAt).toBe(now - 1000);
- expect(loaded[2].updatedAt).toBe(now - 2000);
+ expect(chatsA).toHaveLength(2);
+ expect(chatsB).toHaveLength(1);
+ expect(chatsA.every((c) => c.toolName === "tool-a")).toBe(true);
});
it("respects the limit parameter", async () => {
for (let i = 0; i < 10; i++) {
- await saveChatSession(makeSession({ toolName: "t", updatedAt: i }));
+ await createChat(crypto.randomUUID(), "t");
}
-
- const loaded = await loadRecentSessions("t", 3);
- expect(loaded).toHaveLength(3);
+ const chats = await getChats("t", 3);
+ expect(chats).toHaveLength(3);
});
it("defaults to a limit of 5", async () => {
for (let i = 0; i < 8; i++) {
- await saveChatSession(makeSession({ toolName: "t", updatedAt: i }));
+ await createChat(crypto.randomUUID(), "t");
}
+ const chats = await getChats("t");
+ expect(chats).toHaveLength(5);
+ });
- const loaded = await loadRecentSessions("t");
- expect(loaded).toHaveLength(5);
+ it("computes preview from first user message", async () => {
+ const chatId = crypto.randomUUID();
+ await createChat(chatId, "test-tool");
+ await upsertMessage(chatId, makeTextMessage("assistant", "Welcome"), 0);
+ await upsertMessage(chatId, makeTextMessage("user", "My question"), 1);
+
+ const chats = await getChats("test-tool");
+ expect(chats[0].preview).toBe("My question");
});
- it("regenerates preview from messages on load", async () => {
- const session = makeSession({
- messages: [makeTextMessage("user", "My actual question")],
- preview: "stale-preview",
- });
- await saveChatSession(session);
+ it("includes message count", async () => {
+ const chatId = crypto.randomUUID();
+ await createChat(chatId, "test-tool");
+ await upsertMessage(chatId, makeTextMessage("user", "Hello"), 0);
+ await upsertMessage(chatId, makeTextMessage("assistant", "Hi"), 1);
- const loaded = await loadRecentSessions(session.toolName);
- expect(loaded[0].preview).toBe("My actual question");
+ const chats = await getChats("test-tool");
+ expect(chats[0].messageCount).toBe(2);
+ });
+});
+
+describe("deleteChat", () => {
+ it("removes chat and all its messages", async () => {
+ const chatId = crypto.randomUUID();
+ await createChat(chatId, "test-tool");
+ await upsertMessage(chatId, makeTextMessage("user", "Hello"), 0);
+ await upsertMessage(chatId, makeTextMessage("assistant", "Hi"), 1);
+
+ await deleteChat(chatId);
+
+ const chats = await getChats("test-tool");
+ expect(chats).toHaveLength(0);
+
+ const messages = await loadChat(chatId);
+ expect(messages).toHaveLength(0);
+ });
+
+ it("does not affect other chats", async () => {
+ const chatA = crypto.randomUUID();
+ const chatB = crypto.randomUUID();
+ await createChat(chatA, "test-tool");
+ await createChat(chatB, "test-tool");
+ await upsertMessage(chatA, makeTextMessage("user", "A"), 0);
+ await upsertMessage(chatB, makeTextMessage("user", "B"), 0);
+
+ await deleteChat(chatA);
+
+ const chats = await getChats("test-tool");
+ expect(chats).toHaveLength(1);
+ expect(chats[0].id).toBe(chatB);
+ });
+});
+
+describe("deleteMessage", () => {
+ it("deletes the target message and all subsequent messages", async () => {
+ const chatId = crypto.randomUUID();
+ await createChat(chatId, "test-tool");
+
+ const m1 = makeTextMessage("user", "First");
+ const m2 = makeTextMessage("assistant", "Second");
+ const m3 = makeTextMessage("user", "Third");
+ await upsertMessage(chatId, m1, 0);
+ await upsertMessage(chatId, m2, 1);
+ await upsertMessage(chatId, m3, 2);
+
+ await deleteMessage(m2.id);
+
+ const loaded = await loadChat(chatId);
+ expect(loaded).toHaveLength(1);
+ expect(loaded[0].id).toBe(m1.id);
+ });
+
+ it("is a no-op for nonexistent message", async () => {
+ await expect(deleteMessage("nonexistent")).resolves.toBeUndefined();
});
});
describe("getPersistableMessages", () => {
it("keeps user messages regardless of content", () => {
- const messages: UIMessage[] = [
- makeTextMessage("user", "hello"),
- makeTextMessage("user", ""),
- ];
+ const messages: UIMessage[] = [makeTextMessage("user", "hello"), makeTextMessage("user", "")];
expect(getPersistableMessages(messages)).toHaveLength(2);
});
@@ -186,22 +261,12 @@ describe("getPersistableMessages", () => {
expect(result[1].parts[0]).toEqual({ type: "text", text: "response" });
});
- it("keeps assistant messages with text content", () => {
- const messages: UIMessage[] = [
- makeTextMessage("assistant", "I have content"),
- ];
- expect(getPersistableMessages(messages)).toHaveLength(1);
- });
-
it("keeps assistant messages with tool-invocation parts", () => {
const messages: UIMessage[] = [
{
id: "1",
role: "assistant",
- parts: [
- { type: "step-start" },
- makeToolPart("editTool", { input: {}, output: "done" }),
- ],
+ parts: [{ type: "step-start" }, makeToolPart("editTool", { input: {}, output: "done" })],
},
];
expect(getPersistableMessages(messages)).toHaveLength(1);
@@ -238,204 +303,232 @@ describe("getSessionPreview", () => {
const messages: UIMessage[] = [makeTextMessage("assistant", "Hello")];
expect(getSessionPreview(messages)).toBe("");
});
-
- it("joins multiple text parts from the same message", () => {
- const messages: UIMessage[] = [
- {
- id: "1",
- role: "user",
- parts: [
- { type: "text", text: "Part one " },
- { type: "text", text: "Part two" },
- ],
- },
- ];
- expect(getSessionPreview(messages)).toBe("Part one Part two");
- });
});
-describe("onFinish callback integration", () => {
- it("persists when onFinish fires with valid messages", async () => {
+describe("round-trip message integrity", () => {
+ it("preserves tool-invocation parts through save/load", async () => {
const chatId = crypto.randomUUID();
- const toolName = "test-tool";
- const finishedMessages: UIMessage[] = [
- makeTextMessage("user", "Hello"),
- makeTextMessage("assistant", "Hi there!"),
- ];
-
- // Simulate the exact onFinish flow from ai-chat.tsx
- const persistableMessages = getPersistableMessages(finishedMessages);
- expect(persistableMessages.length).toBeGreaterThan(0);
+ await createChat(chatId, "test-tool");
+
+ const assistantMsg: UIMessage = {
+ id: "a1",
+ role: "assistant",
+ parts: [
+ { type: "text", text: "I'll edit the tool." },
+ makeToolPart("editTool", {
+ input: { field: "name", value: "newName" },
+ output: { success: true },
+ }),
+ ],
+ };
+ await upsertMessage(chatId, makeTextMessage("user", "edit the tool"), 0);
+ await upsertMessage(chatId, assistantMsg, 1);
- await saveChatSession({
- id: chatId,
- toolName,
- messages: persistableMessages,
- updatedAt: Date.now(),
- preview: getSessionPreview(persistableMessages),
- });
+ const loaded = await loadChat(chatId);
+ const loadedAssistant = loaded.find((m) => m.role === "assistant")!;
- const loaded = await loadRecentSessions(toolName);
- expect(loaded).toHaveLength(1);
- expect(loaded[0].id).toBe(chatId);
- expect(loaded[0].messages).toHaveLength(2);
+ expect(loadedAssistant.parts).toHaveLength(2);
+ const toolPart = loadedAssistant.parts[1] as Record