diff --git a/.github/workflows/manual-publish-antigravity.yml b/.github/workflows/manual-publish-antigravity.yml index 79a753c..a305658 100644 --- a/.github/workflows/manual-publish-antigravity.yml +++ b/.github/workflows/manual-publish-antigravity.yml @@ -14,7 +14,7 @@ permissions: id-token: write jobs: - publish: + publish-antigravity: runs-on: ubuntu-latest permissions: id-token: write @@ -35,9 +35,6 @@ jobs: - name: Install ovsx run: npm install ovsx - - name: Build Antigravity - run: npm run compile:antigravity - - name: Pack Antigravity run: npm run package:ovsx diff --git a/.github/workflows/manual-publish-vscode.yml b/.github/workflows/manual-publish-vscode.yml index 69ccf05..406706b 100644 --- a/.github/workflows/manual-publish-vscode.yml +++ b/.github/workflows/manual-publish-vscode.yml @@ -14,7 +14,7 @@ permissions: id-token: write jobs: - publish: + publish-vscode: runs-on: ubuntu-latest permissions: id-token: write @@ -35,12 +35,6 @@ jobs: - name: Install vsce and ovsx run: npm install -g @vscode/vsce ovsx - - name: Build for Vscode - run: npm run compile:vscode - - - name: Pack for Vscode - run: npm run package:vsce - - name: Pack for Vscode run: npm run package:vsce diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index b878d5e..41ebb8a 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -47,9 +47,6 @@ jobs: - name: Install vsce and ovsx run: npm install -g @vscode/vsce ovsx - - name: Build for Vscode - run: npm run compile:vscode - - name: Pack for Vscode run: npm run package:vsce @@ -61,9 +58,6 @@ jobs: - name: Clear build artifacts run: rm -rf dist/* - - name: Build Antigravity - run: npm run compile:antigravity - - name: Pack Antigravity run: npm run package:ovsx @@ -96,3 +90,10 @@ jobs: [Provenance attestations](${{ steps.attestation.outputs.attestation-url }}) env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Build addon typedefs package + run: npm run build:addon-typedefs + + - name: Publish addon typedefs to npm + working-directory: ./dist-addon-api + run: npm publish --provenance --access public diff --git a/.gitignore b/.gitignore index 36f5e1f..acee541 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ node_modules dist/** **/*.vsix .seamless-agent/ -.hintrc \ No newline at end of file +dist-addon-api/** +TODO +.hintrc diff --git a/.hintrc b/.hintrc new file mode 100644 index 0000000..53b8f54 --- /dev/null +++ b/.hintrc @@ -0,0 +1,15 @@ +{ + "extends": [ + "development" + ], + "hints": { + "compat-api/css": [ + "default", + { + "ignore": [ + "min-height: auto" + ] + } + ] + } +} \ No newline at end of file diff --git a/.vscodeignore b/.vscodeignore index c4f2425..a83ef64 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -17,6 +17,14 @@ vsc-extension-quickstart.md **/*.map **/*.ts +# Addon typedefs package output +dist-addon-api/** + +# Typedef build inputs +typings/** +tsconfig.addon-typedefs.json +build-addon-typedefs.js + # All node_modules (bundled with esbuild) node_modules/** diff --git a/media/main.css b/media/main.css index 8b9ab00..3ceff1a 100644 --- a/media/main.css +++ b/media/main.css @@ -917,6 +917,28 @@ button:disabled { white-space: nowrap; } +/* Custom tabs container for addons */ +.custom-tabs-container { + display: contents; +} + +/* Custom tab content pane */ +.custom-tab-pane { + display: none; + padding: 8px 0; +} + +.custom-tab-pane.active { + display: block; +} + +.custom-tab-pane .custom-tab-loading { + color: var(--vscode-descriptionForeground); + font-style: italic; + padding: 16px; + text-align: center; +} + #home-view .section { flex-shrink: 0; } @@ -1567,4 +1589,273 @@ button:disabled { height: 2px; border-radius: 2px; background-color: var(--vscode-panelTitle-activeBorder) !important; +} + +/* ================================ + Settings Tab Styles + ================================ */ +.settings-container { + padding: 8px 0; +} + +.settings-header { + margin-bottom: 16px; +} + +.settings-header h3 { + font-size: 14px; + font-weight: 600; + margin: 0 0 4px 0; + color: var(--vscode-foreground); +} + +.settings-description { + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin: 0; +} + +.settings-section { + margin-bottom: 12px; + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + overflow: hidden; +} + +/* Settings Link Section (for VS Code Settings) - styled like a section header */ +.settings-link-section { + margin-bottom: 12px; + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + overflow: hidden; +} + +.settings-link-btn { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background-color: var(--vscode-sideBarSectionHeader-background); + border: none; + cursor: pointer; + text-align: left; + color: var(--vscode-foreground); + font-family: inherit; + -webkit-user-select: none; + user-select: none; +} + +.settings-link-btn:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.settings-link-btn:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.settings-link-btn .codicon:first-child { + font-size: 12px; +} + +.settings-link-text { + font-size: 13px; + font-weight: 600; + flex: 1; +} + +.settings-link-btn .codicon:last-child { + font-size: 10px; + color: var(--vscode-descriptionForeground); +} + +.settings-section-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background-color: var(--vscode-sideBarSectionHeader-background); + cursor: pointer; + -webkit-user-select: none; + user-select: none; +} + +.settings-section-header:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.settings-section-header h4 { + font-size: 13px; + font-weight: 600; + margin: 0; + flex: 1; +} + +.settings-section-header .codicon { + font-size: 12px; + transition: transform 0.2s ease; +} + +.settings-section.collapsed .settings-section-header .codicon { + transform: rotate(-90deg); +} + +.settings-section-content { + padding: 12px; + border-top: 1px solid var(--vscode-panel-border); +} + +.settings-section.collapsed .settings-section-content { + display: none; +} + +/* Settings Items */ +.setting-item { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; +} + +.setting-item:last-child { + margin-bottom: 0; +} + +.setting-item-row { + display: flex; + align-items: center; + gap: 8px; +} + +.setting-label { + font-size: 13px; + font-weight: 500; + color: var(--vscode-foreground); +} + +.setting-description { + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin: 0; +} + +/* Checkbox setting */ +.setting-checkbox { + display: flex; + align-items: center; + gap: 8px; +} + +.setting-checkbox input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--vscode-checkbox-background); +} + +/* Text/Number input setting */ +.setting-input { + width: 100%; + padding: 6px 8px; + font-size: 13px; + font-family: var(--vscode-font-family); + color: var(--vscode-input-foreground); + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border); + border-radius: 2px; + outline: none; +} + +.setting-input:focus { + border-color: var(--vscode-focusBorder); +} + +/* Select setting */ +.setting-select { + width: 100%; + padding: 6px 8px; + font-size: 13px; + font-family: var(--vscode-font-family); + color: var(--vscode-dropdown-foreground); + background-color: var(--vscode-dropdown-background); + border: 1px solid var(--vscode-dropdown-border); + border-radius: 2px; + outline: none; + cursor: pointer; +} + +.setting-select:focus { + border-color: var(--vscode-focusBorder); +} + +/* Addon Info Card */ +.addon-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 12px; + margin-bottom: 8px; + background-color: var(--vscode-editor-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; +} + +.addon-card:last-child { + margin-bottom: 0; +} + +.addon-card-header { + display: flex; + align-items: center; + gap: 8px; +} + +.addon-card-name { + font-size: 13px; + font-weight: 600; + color: var(--vscode-foreground); +} + +.addon-card-version { + font-size: 11px; + color: var(--vscode-badge-foreground); + background-color: var(--vscode-badge-background); + padding: 1px 6px; + border-radius: 10px; +} + +.addon-card-description { + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin: 0; +} + +.addon-card-meta { + font-size: 11px; + color: var(--vscode-descriptionForeground); + display: flex; + gap: 12px; +} + +.addon-card-meta span { + display: flex; + align-items: center; + gap: 4px; +} + +/* Active/Inactive badge */ +.addon-status { + font-size: 11px; + padding: 1px 6px; + border-radius: 10px; +} + +.addon-status.active { + background-color: var(--vscode-testing-iconPassed); + color: var(--vscode-editor-background); +} + +.addon-status.inactive { + background-color: var(--vscode-testing-iconSkipped); + color: var(--vscode-editor-background); } \ No newline at end of file diff --git a/media/webview.html b/media/webview.html index af80ca7..959d8a3 100644 --- a/media/webview.html +++ b/media/webview.html @@ -65,6 +65,19 @@

data-badge-for="history" > + +
+ + + + +
+ + +
+
+ +

{{registeredAddons}}

+
+
+

{{noAddonsRegistered}}

+
+
+ + + + +
+ @@ -278,6 +331,20 @@

question: "{{question}}", response: "{{response}}", noResponse: "{{noResponse}}", + attachments: "{{attachments}}", + // Home toolbar labels + pendingItems: "{{pendingItems}}", + chatHistory: "{{chatHistory}}", + clearHistory: "{{clearHistory}}", + pastedImage: "{{pastedImage}}", + // Settings labels + settings: "{{settings}}", + settingsDescription: "{{settingsDescription}}", + loadingSettings: "{{loadingSettings}}", + registeredAddons: "{{registeredAddons}}", + noAddonsRegistered: "{{noAddonsRegistered}}", + addonVersion: "{{addonVersion}}", + addonAuthor: "{{addonAuthor}}", // History filtes historyFilterAll: "{{historyFilterAll}}", historyFilterAskUser: "{{historyFilterAskUser}}", diff --git a/package-lock.json b/package-lock.json index 2843f63..eaaec17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@types/markdown-it": "^14.1.2", "@types/node": "^24.10.1", "@types/vscode": "^1.104.0", + "@vscode/codicons": "^0.0.44", "esbuild": "^0.27.1", "npm-run-all": "^4.1.5", "typescript": "^5.9.3" @@ -563,9 +564,10 @@ "license": "MIT" }, "node_modules/@vscode/codicons": { - "version": "0.0.43", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.43.tgz", - "integrity": "sha512-8sf8WOBoZkyUi8ogCm5ycHJJGhwOEG3E9b64+JIx+m6bCExdkc30VwCwr94cXUU1opmRD0CTCWLcN46I8WLJIg==", + "version": "0.0.44", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.44.tgz", + "integrity": "sha512-F7qPRumUK3EHjNdopfICLGRf3iNPoZQt+McTHAn4AlOWPB3W2kL4H0S7uqEqbyZ6rCxaeDjpAn3MCUnwTu/VJQ==", + "dev": true, "license": "CC-BY-4.0" }, "node_modules/accepts": { diff --git a/package.json b/package.json index 1ad8950..6fc7ae8 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ ], "license": "MIT", "main": "./dist/extension.js", + "types": "./dist/extension.d.ts", "engines": { "vscode": "^1.104.0" }, @@ -42,6 +43,14 @@ "activationEvents": [ "onStartupFinished" ], + "extensionKind": [ + "workspace" + ], + "capabilities": { + "untrustedWorkspaces": { + "supported": true + } + }, "repository": { "type": "git", "url": "git+https://github.com/jraylan/seamless-agent.git" @@ -54,7 +63,8 @@ "ia-tool", "vibe-coding", "ai", - "copilot" + "copilot", + "extensible" ], "contributes": { "viewsContainers": { @@ -112,6 +122,12 @@ "default": "workspace", "title": "%config.storageContext.title%", "markdownDescription": "%config.storageContext.description%" + }, + "seamless-agent.askUserAppendText": { + "type": "string", + "default": "", + "title": "%config.askUserAppendText.title%", + "markdownDescription": "%config.askUserAppendText.description%" } } }, @@ -282,15 +298,16 @@ }, "scripts": { "compile": "npm run compile:vscode", - "compile:vscode": "npm run check-types && node esbuild.js", - "compile:antigravity": "npm run check-types && node esbuild.js --target=antigravity", + "compile:vscode": "npm run check-types && node scripts/esbuild.js", + "compile:antigravity": "npm run check-types && node scripts/esbuild.js --target=antigravity", + "build:addon-typedefs": "node scripts/build-addon-typedefs.js", "watch": "npm-run-all -p watch:*", - "watch:esbuild": "node esbuild.js --watch", - "watch:antigravity": "node esbuild.js --watch --target=antigravity", + "watch:esbuild": "node scripts/esbuild.js --watch", + "watch:antigravity": "node scripts/esbuild.js --watch --target=antigravity", "watch:tsc": "tsc --noEmit --watch --project tsconfig.json", "package": "npm run package:vsce", - "package:vsce": "node build-package.js vsce", - "package:ovsx": "node build-package.js ovsx", + "package:vsce": "node scripts/build-package.js vsce", + "package:ovsx": "node scripts/build-package.js ovsx", "check-types": "tsc --noEmit", "test": "echo \"Error: no test specified\" && exit 1" }, diff --git a/package.nls.json b/package.nls.json index 3090325..4160fc3 100644 --- a/package.nls.json +++ b/package.nls.json @@ -100,5 +100,15 @@ "command.clearHistory.title": "Clear History", "status.closed": "Closed", "status.active": "Active", - "errors.noSuchInteraction": "Interaction not found." + "settings.title": "Settings", + "settings.description": "Configure Seamless Agent and installed addons", + "settings.loading": "Loading settings...", + "settings.registeredAddons": "Registered Addons", + "settings.noAddonsRegistered": "No addons registered", + "settings.openInVSCodeSettings": "Open in VS Code Settings", + "settings.addonVersion": "Version", + "settings.addonAuthor": "Author", + "errors.noSuchInteraction": "Interaction not found.", + "config.askUserAppendText.title": "Ask User Append Text", + "config.askUserAppendText.description": "Text to automatically append to every response when using the Ask User feature." } diff --git a/package.nls.pt-br.json b/package.nls.pt-br.json index 52adc52..d5cceb0 100644 --- a/package.nls.pt-br.json +++ b/package.nls.pt-br.json @@ -100,5 +100,15 @@ "command.clearHistory.title": "Limpar Histórico", "status.closed": "Fechado", "status.active": "Ativo", - "errors.noSuchInteraction": "Interação não encontrada." + "settings.title": "Configurações", + "settings.description": "Configure o Seamless Agent e os addons instalados", + "settings.loading": "Carregando configurações...", + "settings.registeredAddons": "Addons Registrados", + "settings.noAddonsRegistered": "Nenhum addon registrado", + "settings.openInVSCodeSettings": "Abrir nas Configurações do VS Code", + "settings.addonVersion": "Versão", + "settings.addonAuthor": "Autor", + "errors.noSuchInteraction": "Interação não encontrada.", + "config.askUserAppendText.title": "Texto Adicional para Perguntar ao Usuário", + "config.askUserAppendText.description": "Texto para anexar automaticamente a cada resposta ao usar o recurso Perguntar ao Usuário." } diff --git a/package.nls.pt.json b/package.nls.pt.json index 4fcbe5a..7341850 100644 --- a/package.nls.pt.json +++ b/package.nls.pt.json @@ -100,5 +100,15 @@ "command.clearHistory.title": "Limpar Histórico", "status.closed": "Fechado", "status.active": "Ativo", - "errors.noSuchInteraction": "Interação não encontrada." + "settings.title": "Definições", + "settings.description": "Configurar Seamless Agent e addons instalados", + "settings.loading": "A carregar definições...", + "settings.registeredAddons": "Addons Registados", + "settings.noAddonsRegistered": "Nenhum addon registado", + "settings.openInVSCodeSettings": "Abrir nas Definições do VS Code", + "settings.addonVersion": "Versão", + "settings.addonAuthor": "Autor", + "errors.noSuchInteraction": "Interação não encontrada.", + "config.askUserAppendText.title": "Texto Adicional para Perguntar ao Utilizador", + "config.askUserAppendText.description": "Texto para anexar automaticamente a cada resposta ao usar o recurso Perguntar ao Utilizador." } diff --git a/scripts/build-addon-typedefs.js b/scripts/build-addon-typedefs.js new file mode 100644 index 0000000..d5736bb --- /dev/null +++ b/scripts/build-addon-typedefs.js @@ -0,0 +1,167 @@ +/* eslint-disable no-console */ + +/** + * Generates a types-only package for addon authors. + * + * Default output: ./dist-addon-api + * + * The generated package contains: + * - .d.ts (emitDeclarationOnly) + * - minimal package.json + * - README.md (composed from src/api/README.md and src/addons/README.md) + * - LICENSE + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +function parseArgs(argv) { + const args = new Map(); + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith('--')) { + const next = argv[i + 1]; + if (!next || next.startsWith('--')) { + args.set(a, true); + } else { + args.set(a, next); + i++; + } + } + } + return args; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function safeReadText(filePath) { + if (!fs.existsSync(filePath)) return ''; + return fs.readFileSync(filePath, 'utf8'); +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +function writeFile(filePath, content) { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, content); +} + +function copyFile(src, dest) { + ensureDir(path.dirname(dest)); + fs.copyFileSync(src, dest); +} + +function rmDir(dir) { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function getLocalTscBin(repoRoot) { + const bin = process.platform === 'win32' + ? path.join(repoRoot, 'node_modules', '.bin', 'tsc.cmd') + : path.join(repoRoot, 'node_modules', '.bin', 'tsc'); + + if (fs.existsSync(bin)) return bin; + return null; +} + +function main() { + const repoRoot = path.resolve(__dirname, '..'); + const args = parseArgs(process.argv.slice(2)); + + const outDir = path.resolve(repoRoot, args.get('--outDir') || 'dist-addon-api'); + const pkgName = String(args.get('--name') || '@seamless-agent/addon'); + + const rootPkg = readJson(path.join(repoRoot, 'package.json')); + const version = String(args.get('--version') || rootPkg.version); + + const vscodeVersion = rootPkg.devDependencies['@types/vscode'] || '^1.81.0'; + const codiconsVersion = rootPkg.devDependencies['@vscode/codicons'] || '^0.0.44'; + + + console.log('[addon-typedefs] output:', outDir); + console.log('[addon-typedefs] package name:', pkgName); + console.log('[addon-typedefs] version:', version); + + rmDir(outDir); + + const tscBin = getLocalTscBin(repoRoot); + const tscCmd = tscBin ? `"${tscBin}"` : 'npx tsc'; + + console.log('[addon-typedefs] generating declarations...'); + execSync(`${tscCmd} -p tsconfig.addon-typedefs.json`, { + cwd: repoRoot, + stdio: 'inherit', + }); + + // Create index.d.ts at the package root to simplify imports + // (re-exports the entrypoint generated in dist-addon-api/addon-typedefs/index.d.ts) + const indexDts = [ + "/**", + "* Seamless Agent Addon API (types-only)", + "*", + "* Use only in type-only contexts: `import type { ... }`.", + "*/", + "export * from './addon-typedefs';", + ].join('\n'); + writeFile(path.join(outDir, 'index.d.ts'), indexDts); + + // README: compose from the modules' README files + const apiReadme = safeReadText(path.join(repoRoot, 'src', 'api', 'README.md')); + const addonsReadme = safeReadText(path.join(repoRoot, 'src', 'addons', 'README.md')); + + const readmeParts = [ + '# Seamless Agent — Addon API (Types Only)\n', + 'This package contains **type definitions only** (TypeScript) for addon authors to integrate with the **Seamless Agent** extension.\n', + '\n> Tip: always use `import type { ... }` to ensure nothing is imported at runtime.\n', + apiReadme ? `\n---\n\n## API\n\n${apiReadme.trim()}\n` : '', + addonsReadme ? `\n---\n\n## Addons\n\n${addonsReadme.trim()}\n` : '', + ].filter(Boolean); + + writeFile(path.join(outDir, 'README.md'), readmeParts.join('\n')); + + // LICENSE + const licenseSrc = path.join(repoRoot, 'LICENSE.md'); + if (fs.existsSync(licenseSrc)) { + copyFile(licenseSrc, path.join(outDir, 'LICENSE.md')); + } + + // package.json for the types-only package + const typesPkg = { + name: pkgName, + version, + description: 'Type definitions for Seamless Agent addon extensions', + license: rootPkg.license || 'MIT', + repository: rootPkg.repository, + keywords: ['vscode', 'seamless-agent', 'addon', 'types', 'typings'], + sideEffects: false, + types: './index.d.ts', + exports: { + '.': { + types: './index.d.ts' + } + }, + // Dependencies used only for consumer typing/compilation + peerDependencies: { + '@types/vscode': vscodeVersion, + '@vscode/codicons': codiconsVersion, + }, + files: [ + '**/*.d.ts', + 'README.md', + 'LICENSE.md', + ] + }; + + writeFile(path.join(outDir, 'package.json'), JSON.stringify(typesPkg, null, 2)); + + console.log('[addon-typedefs] done'); +} + +main(); diff --git a/build-package.js b/scripts/build-package.js similarity index 98% rename from build-package.js rename to scripts/build-package.js index 5f35c3d..4abb1bb 100644 --- a/build-package.js +++ b/scripts/build-package.js @@ -74,7 +74,7 @@ try { // If 'ovsx' CLI is strictly required for packaging, we would use it, but usually vsce produces the standard VSIX. // The user command name 'package:ovsx' implies targeting the OVSX registry/ecosystem. // We'll add a flag to the output filename to distinguish them. - const version = require('./package.json').version; + const version = require('../package.json').version; const outFile = target === 'ovsx' ? `seamless-agent-${version}-antigravity.vsix` : `seamless-agent-${version}.vsix`; execSync(`npx vsce package --out ${outFile}`, { stdio: 'inherit' }); diff --git a/esbuild.js b/scripts/esbuild.js similarity index 100% rename from esbuild.js rename to scripts/esbuild.js diff --git a/src/addon-typedefs/index.ts b/src/addon-typedefs/index.ts new file mode 100644 index 0000000..916d4c7 --- /dev/null +++ b/src/addon-typedefs/index.ts @@ -0,0 +1,71 @@ +/** + * Seamless Agent — Addon API (types only) + * + * This entrypoint allows addon extensions to type their integration + * with Seamless Agent without depending on runtime implementations. + * + * IMPORTANT: + * - This module should only be used in type contexts. + * - Always prefer `import type { ... }`. + */ + +export type { + // Core API + ISeamlessAgentAPI, + + // Addon definition + IAddon, + IAddonRegistration, + IAddonLifecycle, + + // UI + IUIIntegration, + IAddonUICapabilities, + ICustomTab, + IUIContent, + IHistoryType, + IHistoryItemProvider, + IHistoryItem, + + // Settings + ISettingsSection, + IAddonSettingSection, + IAddonSettingDefinition, + ISettingItem, + + // Tools + IToolsIntegration, + IAddonAICapabilities, + IAddonTool, + IToolExecutionContext, + IAskUserParams, + IUserResponse, + IPlanReviewParams, + IPlanReviewResult, + + // Events + IEventEmitter, + + // Storage + IStorageIntegration, + + // Convenience aliases + AskUserInput, + AskUserToolResult, + PlanReviewInput, + PlanReviewToolResult, +} from '../api/types'; + +/** + * Default event names emitted by Seamless Agent. + * + * Note: this is a UNION of strings (type), not a `const`. + * This prevents addons from trying to use this package at runtime. + */ +export type SeamlessAgentEventName = + | 'addon:registered' + | 'addon:unregistered' + | 'settings:changed' + | 'ui:refresh' + | 'tool:executed' + | 'tab:changed'; diff --git a/src/addons/README.md b/src/addons/README.md new file mode 100644 index 0000000..4d3065d --- /dev/null +++ b/src/addons/README.md @@ -0,0 +1,22 @@ +Este diretório contém o **sistema de addons** do Seamless Agent (registro, lifecycle e utilitários). + +## Para autores de addons + +Em geral, você **não** precisa importar nada daqui no seu addon. +Use a **API pública** exposta pela extensão Seamless Agent (ver `src/api`). + +## Para contribuidores do Seamless Agent + +- `registry.ts`: Registro central de addons (`AddonRegistry`) que mantém estado de ativação, tabs, settings e tools. +- `types.ts`: Aliases/re-exports de tipos do contrato público, para manter compatibilidade interna. + +## Responsabilidades + +O `AddonRegistry` agrega e organiza: + +- **Tabs** registradas pelos addons +- **Seções de Settings** registradas pelos addons +- **Providers de histórico** +- **Tools** expostas pelos addons + +O runtime do Seamless Agent usa o registry para alimentar a UI e a execução de ferramentas. diff --git a/src/addons/index.ts b/src/addons/index.ts new file mode 100644 index 0000000..beb7912 --- /dev/null +++ b/src/addons/index.ts @@ -0,0 +1,24 @@ +/** + * Addons Module + * + * This module provides addon management functionality for the Seamless Agent extension. + * The main addon registry is now part of the public API, but this module provides + * legacy compatibility and internal utilities. + */ + +// Re-export registry +export { AddonRegistry } from './registry'; + +// Re-export types for backward compatibility +export * from './types'; + +// Re-export API types that addons need +export type { + ISeamlessAgentAPI, + IAddonRegistration, + IEventEmitter, + IStorageIntegration, + IUIIntegration, + IToolsIntegration, +} from '../api/types'; + diff --git a/src/addons/registry.ts b/src/addons/registry.ts new file mode 100644 index 0000000..4450109 --- /dev/null +++ b/src/addons/registry.ts @@ -0,0 +1,420 @@ +/** + * Addon Registry + * + * Centralized registry for managing addon registrations. + * Implements the Registry Pattern for addon lifecycle management. + */ + +import type * as vscode from 'vscode'; +import type { + IAddon, + IAddonRegistration, + IEventEmitter, + ICustomTab, + ISettingsSection, + IAddonTool, +} from '../api/types'; +import { SeamlessAgentEvents } from '../api/types'; + +/** + * Internal registration data structure + */ +interface RegistrationData { + addon: IAddon; + isActive: boolean; + disposables: vscode.Disposable[]; + registeredTabs: ICustomTab[]; + registeredTools: IAddonTool[]; + registeredSettingsSections: ISettingsSection[]; +} + +/** + * Addon Registration implementation + */ +class AddonRegistrationImpl implements IAddonRegistration { + constructor( + private readonly registry: AddonRegistry, + private readonly data: RegistrationData + ) { } + + get addon(): IAddon { + return this.data.addon; + } + + get id(): string { + return this.data.addon.id; + } + + get isActive(): boolean { + return this.data.isActive; + } + + get tabCount(): number { + return this.data.registeredTabs.length; + } + + get toolCount(): number { + return this.data.registeredTools.length; + } + + /** + * Deactivate the addon without unregistering + */ + deactivate(): void { + if (!this.data.isActive) return; + + this.data.isActive = false; + + // Call lifecycle hook + if (this.data.addon.lifecycle?.onDeactivate) { + try { + const result = this.data.addon.lifecycle.onDeactivate(); + if (result instanceof Promise) { + result.catch(err => { + console.error(`[AddonRegistry] Error in onDeactivate for ${this.id}:`, err); + }); + } + } catch (err) { + console.error(`[AddonRegistry] Error in onDeactivate for ${this.id}:`, err); + } + } + } + + /** + * Reactivate a deactivated addon + */ + activate(): void { + if (this.data.isActive) return; + + this.data.isActive = true; + + // Call lifecycle hook + if (this.data.addon.lifecycle?.onActivate) { + try { + const result = this.data.addon.lifecycle.onActivate(); + if (result instanceof Promise) { + result.catch(err => { + console.error(`[AddonRegistry] Error in onActivate for ${this.id}:`, err); + }); + } + } catch (err) { + console.error(`[AddonRegistry] Error in onActivate for ${this.id}:`, err); + } + } + } + + /** + * Dispose and unregister the addon + */ + dispose(): void { + this.registry.unregister(this.id); + } +} + +/** + * Centralized registry for addon management. + * Implements the Registry Pattern for tracking and querying addons. + */ +export class AddonRegistry implements vscode.Disposable { + private readonly registrations: Map = new Map(); + private readonly eventEmitter: IEventEmitter; + + constructor(eventEmitter: IEventEmitter) { + this.eventEmitter = eventEmitter; + } + + /** + * Register an addon + * @param addon - The addon to register + * @returns Registration handle + */ + register(addon: IAddon): IAddonRegistration { + // Check for duplicate registration + if (this.registrations.has(addon.id)) { + throw new Error(`Addon with ID '${addon.id}' is already registered`); + } + + // Validate addon + this.validateAddon(addon); + + // Create registration data + const data: RegistrationData = { + addon, + isActive: true, + disposables: [], + registeredTabs: [], + registeredTools: [], + registeredSettingsSections: [], + }; + + // Extract and store capabilities + if (addon.ui?.tabs) { + data.registeredTabs = [...addon.ui.tabs]; + } + + if (addon.ai?.tools) { + data.registeredTools = [...addon.ai.tools]; + } + + if (addon.settings) { + data.registeredSettingsSections = addon.settings.map(section => ({ + id: `${addon.id}.${section.key}`, + title: section.label, + description: section.description, + settings: section.settings.map(setting => ({ + key: `${addon.id}.${section.key}.${setting.key}`, + label: setting.label, + description: setting.description, + type: setting.type, + value: setting.defaultValue, + defaultValue: setting.defaultValue, + options: setting.options, + })), + })); + } + + // Store registration + this.registrations.set(addon.id, data); + + // Create registration handle + const registration = new AddonRegistrationImpl(this, data); + + // Call lifecycle hook + if (addon.lifecycle?.onActivate) { + try { + const result = addon.lifecycle.onActivate(); + if (result instanceof Promise) { + result.catch(err => { + console.error(`[AddonRegistry] Error in onActivate for ${addon.id}:`, err); + }); + } + } catch (err) { + console.error(`[AddonRegistry] Error in onActivate for ${addon.id}:`, err); + } + } + + // Emit registration event + this.eventEmitter.emit(SeamlessAgentEvents.ADDON_REGISTERED, { + addonId: addon.id, + addon, + }); + + console.log(`[AddonRegistry] Registered addon: ${addon.id} (${addon.name} v${addon.version})`); + + return registration; + } + + /** + * Unregister an addon by ID + * @param addonId - The addon ID to unregister + */ + unregister(addonId: string): void { + const data = this.registrations.get(addonId); + if (!data) { + console.warn(`[AddonRegistry] Addon '${addonId}' not found for unregistration`); + return; + } + + // Call lifecycle hook + if (data.addon.lifecycle?.onDeactivate) { + try { + const result = data.addon.lifecycle.onDeactivate(); + if (result instanceof Promise) { + result.catch(err => { + console.error(`[AddonRegistry] Error in onDeactivate for ${addonId}:`, err); + }); + } + } catch (err) { + console.error(`[AddonRegistry] Error in onDeactivate for ${addonId}:`, err); + } + } + + // Dispose all registered disposables + for (const disposable of data.disposables) { + try { + disposable.dispose(); + } catch (err) { + console.error(`[AddonRegistry] Error disposing resource for ${addonId}:`, err); + } + } + + // Remove from registry + this.registrations.delete(addonId); + + // Emit unregistration event + this.eventEmitter.emit(SeamlessAgentEvents.ADDON_UNREGISTERED, { + addonId, + }); + + console.log(`[AddonRegistry] Unregistered addon: ${addonId}`); + } + + /** + * Get a registration by addon ID + * @param addonId - The addon ID + * @returns Registration data or undefined + */ + get(addonId: string): IAddonRegistration | undefined { + const data = this.registrations.get(addonId); + if (!data) return undefined; + + return new AddonRegistrationImpl(this, data); + } + + /** + * Get all registered addons + * @returns Array of addon registrations + */ + getAll(): IAddonRegistration[] { + return Array.from(this.registrations.values()).map( + data => new AddonRegistrationImpl(this, data) + ); + } + + /** + * Get all active addons + * @returns Array of active addon registrations + */ + getActive(): IAddonRegistration[] { + return Array.from(this.registrations.values()) + .filter(data => data.isActive) + .map(data => new AddonRegistrationImpl(this, data)); + } + + /** + * Get all registered tabs from all active addons + * @returns Array of custom tabs + */ + getAllTabs(): ICustomTab[] { + const tabs: ICustomTab[] = []; + + for (const data of this.registrations.values()) { + if (data.isActive) { + tabs.push(...data.registeredTabs); + } + } + + // Sort by priority + return tabs.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100)); + } + + /** + * Get all registered tools from all active addons + * @returns Array of addon tools + */ + getAllTools(): IAddonTool[] { + const tools: IAddonTool[] = []; + + for (const data of this.registrations.values()) { + if (data.isActive) { + tools.push(...data.registeredTools); + } + } + + return tools; + } + + /** + * Get all registered settings sections from all active addons + * @returns Array of settings sections + */ + getAllSettingsSections(): ISettingsSection[] { + const sections: ISettingsSection[] = []; + + for (const data of this.registrations.values()) { + if (data.isActive) { + sections.push(...data.registeredSettingsSections); + } + } + + // Sort by priority + return sections.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100)); + } + + /** + * Add a disposable to an addon's registration + * @param addonId - The addon ID + * @param disposable - The disposable to add + */ + addDisposable(addonId: string, disposable: vscode.Disposable): void { + const data = this.registrations.get(addonId); + if (data) { + data.disposables.push(disposable); + } + } + + /** + * Get the count of registered addons + * @returns Number of registered addons + */ + get count(): number { + return this.registrations.size; + } + + /** + * Check if an addon is registered + * @param addonId - The addon ID + * @returns True if registered + */ + has(addonId: string): boolean { + return this.registrations.has(addonId); + } + + /** + * Validate an addon definition + * @param addon - The addon to validate + * @throws Error if validation fails + */ + private validateAddon(addon: IAddon): void { + if (!addon.id || typeof addon.id !== 'string') { + throw new Error('Addon must have a valid ID string'); + } + + if (!addon.name || typeof addon.name !== 'string') { + throw new Error('Addon must have a valid name string'); + } + + if (!addon.version || typeof addon.version !== 'string') { + throw new Error('Addon must have a valid version string'); + } + + // Validate tool names are unique + if (addon.ai?.tools) { + const toolNames = new Set(); + for (const tool of addon.ai.tools) { + if (!tool.name) { + throw new Error(`Tool in addon '${addon.id}' must have a name`); + } + if (toolNames.has(tool.name)) { + throw new Error(`Duplicate tool name '${tool.name}' in addon '${addon.id}'`); + } + toolNames.add(tool.name); + } + } + + // Validate tab IDs are unique + if (addon.ui?.tabs) { + const tabIds = new Set(); + for (const tab of addon.ui.tabs) { + if (!tab.id) { + throw new Error(`Tab in addon '${addon.id}' must have an ID`); + } + if (tabIds.has(tab.id)) { + throw new Error(`Duplicate tab ID '${tab.id}' in addon '${addon.id}'`); + } + tabIds.add(tab.id); + } + } + } + + /** + * Dispose the registry and all addons + */ + dispose(): void { + // Unregister all addons + const addonIds = [...this.registrations.keys()]; + for (const addonId of addonIds) { + this.unregister(addonId); + } + } +} diff --git a/src/addons/types.ts b/src/addons/types.ts new file mode 100644 index 0000000..156f812 --- /dev/null +++ b/src/addons/types.ts @@ -0,0 +1,40 @@ +/** + * Addon Types + * + * This module re-exports the addon-related types from the public API + * for internal use and backward compatibility. + * + * For new code, prefer importing directly from '../api/types'. + */ + +// Re-export all addon-related types from the API +export type { + IAddon, + IAddonRegistration, + IAddonLifecycle, + IAddonUICapabilities, + IAddonAICapabilities, + IAddonSettingSection, + IAddonSettingDefinition, + IAddonTool, + IToolExecutionContext, + ICustomTab, + IUIContent, + IHistoryType, + ISettingsSection, + ISettingItem, +} from '../api/types'; + +// Legacy type aliases for backward compatibility +export type { IAddonSettingSection as AddonSettingSection } from '../api/types'; +export type { IAddonSettingDefinition as AddonSetting } from '../api/types'; +export type { IAddonTool as AITool } from '../api/types'; + +/** + * Tab type identifiers + */ +export type TabType = + | 'notification' + | 'history' + | 'settings' + | string; // Allow custom tab types from addons diff --git a/src/agent/index.ts b/src/agent/index.ts new file mode 100644 index 0000000..b50f840 --- /dev/null +++ b/src/agent/index.ts @@ -0,0 +1,79 @@ +import * as vscode from 'vscode'; + + + + +export class OrchestrationAgent { + + + public dispose() { + // Intentionally left empty: OrchestrationAgent currently has no resources to dispose. + } + + public handler: vscode.ChatRequestHandler = async ( + request: vscode.ChatRequest, + chatContext: vscode.ChatContext, + stream: vscode.ChatResponseStream, + token: vscode.CancellationToken + ) => { + // Get our ask_user tool + const askUserTool = vscode.lm.tools.find(t => t.name === 'ask_user'); + + // Get all available tools including ours + const tools = askUserTool ? [askUserTool] : []; + + // Build the prompt with instruction to use the tool + const systemPrompt = `You are a helpful assistant. + IMPORTANT: You have access to an "ask_user" tool. You MUST use this tool before completing any task to confirm with the user that their request was fulfilled correctly. + Never finish a response without first calling the ask_user tool to verify with the user.`; + + const messages = [ + vscode.LanguageModelChatMessage.User(systemPrompt), + vscode.LanguageModelChatMessage.User(request.prompt) + ]; + + // Get the model + let model = request.model; + + const options: vscode.LanguageModelChatRequestOptions = { + tools: tools.map(t => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema + })), + }; + + try { + const response = await model.sendRequest(messages, options, token); + + for await (const part of response.stream) { + if (part instanceof vscode.LanguageModelTextPart) { + stream.markdown(part.value); + } else if (part instanceof vscode.LanguageModelToolCallPart) { + // Handle tool calls + stream.progress(`Calling ${part.name}...`); + const toolResult = await vscode.lm.invokeTool(part.name, { + input: part.input, + toolInvocationToken: request.toolInvocationToken + }, token); + + // Show tool result + for (const resultPart of toolResult.content) { + if (resultPart instanceof vscode.LanguageModelTextPart) { + stream.markdown(`\n\n**User Response:** ${resultPart.value}\n\n`); + } + } + } + } + } catch (err) { + if (err instanceof vscode.LanguageModelError) { + stream.markdown(`Error: ${err.message}`); + } else { + throw err; + } + } + + return; + } + +} \ No newline at end of file diff --git a/src/api/README.md b/src/api/README.md new file mode 100644 index 0000000..94facec --- /dev/null +++ b/src/api/README.md @@ -0,0 +1,46 @@ +Este diretório contém o **contrato público** (interfaces) consumido por extensões **addon**. + +## Como um addon obtém a API do Seamless Agent + +No `activate()` do seu addon, obtenha a extensão e chame `activate()` para receber o objeto de API: + +- Extensão: `jraylan.seamless-agent` +- API: `ISeamlessAgentAPI` + +### Exemplo (addon) + +- Busque a extensão pelo ID +- Ative-a para obter a API +- Registre o addon com `registerAddon()` + +> Recomendação: use `import type { ISeamlessAgentAPI, IAddon } from '...';` (tipos) e mantenha a integração em runtime via `vscode.extensions.getExtension(...).activate()`. + +## O que a API oferece + +### UI + +- **Tabs**: `api.ui.registerTab(tab)` permite criar novas abas no webview do Seamless Agent. +- **Histórico**: `api.ui.registerHistoryProvider(provider)` permite injetar itens/tipos adicionais no histórico. +- **Settings**: `api.ui.registerSettingsSection(section)` permite adicionar seções de configuração na aba Settings do webview. + +### Tools (LLM) + +- Defina ferramentas em `addon.ai.tools` (ver `IAddonTool`). +- O Seamless Agent expõe e executa essas ferramentas no contexto do agente. + +### Storage + +- `api.storage` fornece persistência (namespaced) para o addon armazenar preferências e dados. + +### Events + +- `api.events` permite observar eventos do Seamless Agent (ex.: refresh de UI, tool executada, etc.). + +## Compatibilidade + +A API expõe `api.version`. Addons devem validar a versão para garantir compatibilidade. + +## Importante (types-only) + +Este repositório gera um pacote separado (para NPM) contendo **somente typedefs** para uso por addons. +Evite depender de implementações deste diretório no runtime do addon. diff --git a/src/api/SeamlessAgentAPI.ts b/src/api/SeamlessAgentAPI.ts new file mode 100644 index 0000000..86c22ae --- /dev/null +++ b/src/api/SeamlessAgentAPI.ts @@ -0,0 +1,535 @@ +/** + * Seamless Agent Public API + * + * Facade implementation providing a unified API for addon extensions. + * Implements the Facade Pattern to simplify addon integration. + */ + +import * as vscode from 'vscode'; +import type { + ISeamlessAgentAPI, + IAddon, + IAddonRegistration, + IUIIntegration, + IToolsIntegration, + IEventEmitter, + IStorageIntegration, + ICustomTab, + ISettingsSection, + IHistoryItemProvider, + IAddonTool, + IToolExecutionContext, + IAskUserParams, + IUserResponse, + IPlanReviewParams, + IPlanReviewResult, +} from './types'; +import { SeamlessAgentEvents } from './types'; +import { SeamlessEventEmitter } from './events'; +import { AddonRegistry } from '../addons/registry'; + +/** + * Current API version + */ +export const API_VERSION = '1.0.0'; + +/** + * UI Integration implementation + */ +class UIIntegrationImpl implements IUIIntegration { + private readonly customTabs: Map = new Map(); + private readonly historyProviders: Map = new Map(); + private readonly settingsSections: Map = new Map(); + private switchTabFn?: (tabId: string) => void; + // Map tabId -> addonId for explicit ownership tracking + private readonly tabOwners: Map = new Map(); + + constructor( + private readonly registry: AddonRegistry, + private readonly eventEmitter: IEventEmitter + ) { } + + /** + * Register a custom tab + * @param tab - Tab configuration + * @param addonId - Addon ID for ownership tracking + */ + registerTab(tab: ICustomTab, addonId: string): vscode.Disposable { + if (this.customTabs.has(tab.id)) { + throw new Error(`Tab with ID '${tab.id}' is already registered`); + } + + this.customTabs.set(tab.id, tab); + this.tabOwners.set(tab.id, addonId); + this.eventEmitter.emit(SeamlessAgentEvents.UI_REFRESH, { type: 'tab_added', tabId: tab.id }); + + return { + dispose: () => { + this.customTabs.delete(tab.id); + this.tabOwners.delete(tab.id); + this.eventEmitter.emit(SeamlessAgentEvents.UI_REFRESH, { type: 'tab_removed', tabId: tab.id }); + } + }; + } + + /** + * Register a history item provider + */ + registerHistoryProvider(provider: IHistoryItemProvider): vscode.Disposable { + if (this.historyProviders.has(provider.id)) { + throw new Error(`History provider with ID '${provider.id}' is already registered`); + } + + this.historyProviders.set(provider.id, provider); + this.eventEmitter.emit(SeamlessAgentEvents.UI_REFRESH, { type: 'history_provider_added' }); + + return { + dispose: () => { + this.historyProviders.delete(provider.id); + this.eventEmitter.emit(SeamlessAgentEvents.UI_REFRESH, { type: 'history_provider_removed' }); + } + }; + } + + /** + * Register a settings section + */ + registerSettingsSection(section: ISettingsSection): vscode.Disposable { + if (this.settingsSections.has(section.id)) { + throw new Error(`Settings section with ID '${section.id}' is already registered`); + } + + this.settingsSections.set(section.id, section); + this.eventEmitter.emit(SeamlessAgentEvents.UI_REFRESH, { type: 'settings_section_added' }); + + return { + dispose: () => { + this.settingsSections.delete(section.id); + this.eventEmitter.emit(SeamlessAgentEvents.UI_REFRESH, { type: 'settings_section_removed' }); + } + }; + } + + /** + * Refresh the webview UI + */ + refresh(): void { + this.eventEmitter.emit(SeamlessAgentEvents.UI_REFRESH, { type: 'manual_refresh' }); + } + + /** + * Get all registered tabs (including from addons via registry) + */ + getTabs(): ICustomTab[] { + // Combine tabs from registry and directly registered tabs + const registryTabs = this.registry.getAllTabs(); + const directTabs = Array.from(this.customTabs.values()); + + const allTabs = [...registryTabs, ...directTabs]; + + // Sort by priority + return allTabs.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100)); + } + + /** + * Get all registered settings sections + */ + getSettingsSections(): ISettingsSection[] { + // Combine sections from registry and directly registered sections + const registrySections = this.registry.getAllSettingsSections(); + const directSections = Array.from(this.settingsSections.values()); + + const allSections = [...registrySections, ...directSections]; + + // Sort by priority + return allSections.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100)); + } + + /** + * Get all registered history providers + */ + getHistoryProviders(): IHistoryItemProvider[] { + return Array.from(this.historyProviders.values()); + } + + /** + * Count tabs registered by a specific addon + * @param addonId - The addon ID to count tabs for + * @returns Number of tabs registered by the addon + */ + getTabCountByAddon(addonId: string): number { + let count = 0; + for (const ownerId of this.tabOwners.values()) { + if (ownerId === addonId) { + count++; + } + } + return count; + } + + /** + * Select/open a specific tab in the webview + */ + selectTab(tabId: string): void { + if (this.switchTabFn) { + this.switchTabFn(tabId); + } else { + console.warn('[UIIntegration] selectTab called but no webview provider is connected'); + } + } + + /** + * @internal Set the function to switch tabs (called during extension initialization) + */ + setSwitchTabFunction(fn: (tabId: string) => void): void { + this.switchTabFn = fn; + } +} + +/** + * Tools Integration implementation + */ +class ToolsIntegrationImpl implements IToolsIntegration { + private readonly tools: Map = new Map(); + private readonly toolDisposables: Map = new Map(); + private askUserFn?: (params: IAskUserParams) => Promise; + private planReviewFn?: (params: IPlanReviewParams) => Promise; + + constructor( + private readonly registry: AddonRegistry, + private readonly eventEmitter: IEventEmitter, + private readonly getAPI: () => ISeamlessAgentAPI + ) { } + + /** + * Set the askUser function for direct API calls + */ + setAskUserFunction(fn: (params: IAskUserParams) => Promise): void { + this.askUserFn = fn; + } + + /** + * Set the planReview function for direct API calls + */ + setPlanReviewFunction(fn: (params: IPlanReviewParams) => Promise): void { + this.planReviewFn = fn; + } + + /** + * Register an AI tool + */ + registerTool(tool: IAddonTool): vscode.Disposable { + if (this.tools.has(tool.name)) { + throw new Error(`Tool with name '${tool.name}' is already registered`); + } + + this.tools.set(tool.name, tool); + + // Register with VS Code Language Model API + const lmTool = vscode.lm.registerTool(tool.name, { + invoke: async (options, token) => { + const context: IToolExecutionContext = { + api: this.getAPI(), + requestId: `${tool.name}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, + }; + + try { + const result = await tool.execute(options.input, context, token); + + this.eventEmitter.emit(SeamlessAgentEvents.TOOL_EXECUTED, { + toolName: tool.name, + success: true, + requestId: context.requestId, + }); + + return new vscode.LanguageModelToolResult([ + new vscode.LanguageModelTextPart(JSON.stringify(result)) + ]); + } catch (error) { + this.eventEmitter.emit(SeamlessAgentEvents.TOOL_EXECUTED, { + toolName: tool.name, + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + requestId: context.requestId, + }); + + throw error; + } + } + }); + + this.toolDisposables.set(tool.name, lmTool); + + return { + dispose: () => { + this.tools.delete(tool.name); + const disposable = this.toolDisposables.get(tool.name); + if (disposable) { + disposable.dispose(); + this.toolDisposables.delete(tool.name); + } + } + }; + } + + /** + * Get all registered tools + */ + getTools(): IAddonTool[] { + // Combine tools from registry and directly registered tools + const registryTools = this.registry.getAllTools(); + const directTools = Array.from(this.tools.values()); + + return [...registryTools, ...directTools]; + } + + /** + * Call the native askUser tool + */ + async askUser(params: IAskUserParams): Promise { + if (!this.askUserFn) { + throw new Error('askUser function not initialized'); + } + return this.askUserFn(params); + } + + /** + * Call the native planReview tool + */ + async planReview(params: IPlanReviewParams): Promise { + if (!this.planReviewFn) { + throw new Error('planReview function not initialized'); + } + return this.planReviewFn(params); + } +} + +/** + * Storage Integration implementation for addon data persistence + */ +class StorageIntegrationImpl implements IStorageIntegration { + private readonly namespace: string; + private readonly globalState: vscode.Memento; + + constructor(context: vscode.ExtensionContext, addonId: string) { + this.namespace = `addon::${addonId}`; + this.globalState = context.globalState; + } + + /** + * Get a stored value + */ + get(key: string, defaultValue?: T): T | undefined { + const fullKey = `${this.namespace}::${key}`; + return this.globalState.get(fullKey, defaultValue as T); + } + + /** + * Set a stored value + */ + async set(key: string, value: T): Promise { + const fullKey = `${this.namespace}::${key}`; + await this.globalState.update(fullKey, value); + } + + /** + * Delete a stored value + */ + async delete(key: string): Promise { + const fullKey = `${this.namespace}::${key}`; + await this.globalState.update(fullKey, undefined); + } + + /** + * Get all keys for this addon + */ + keys(): string[] { + const allKeys = this.globalState.keys(); + const prefix = `${this.namespace}::`; + return allKeys + .filter(key => key.startsWith(prefix)) + .map(key => key.substring(prefix.length)); + } + + /** + * Clear all addon storage + */ + async clear(): Promise { + const keys = this.keys(); + for (const key of keys) { + await this.delete(key); + } + } +} + +/** + * Storage Integration Factory + */ +class StorageIntegrationFactory { + private readonly storages: Map = new Map(); + + constructor(private readonly context: vscode.ExtensionContext) { } + + /** + * Get or create storage for an addon + */ + getStorage(addonId: string): IStorageIntegration { + if (!this.storages.has(addonId)) { + this.storages.set(addonId, new StorageIntegrationImpl(this.context, addonId)); + } + return this.storages.get(addonId)!; + } +} + +/** + * Main Seamless Agent API implementation. + * Facade providing unified access to all addon integration features. + */ +export class SeamlessAgentAPI implements ISeamlessAgentAPI, vscode.Disposable { + public readonly version: string = API_VERSION; + + private readonly _eventEmitter: SeamlessEventEmitter; + private readonly _registry: AddonRegistry; + private readonly _ui: UIIntegrationImpl; + private readonly _tools: ToolsIntegrationImpl; + private readonly _storageFactory: StorageIntegrationFactory; + private readonly _disposables: vscode.Disposable[] = []; + + constructor(private readonly _context: vscode.ExtensionContext) { + // Initialize event emitter + this._eventEmitter = new SeamlessEventEmitter(); + this._disposables.push({ dispose: () => this._eventEmitter.dispose() }); + + // Initialize registry + this._registry = new AddonRegistry(this._eventEmitter); + this._disposables.push(this._registry); + + // Initialize UI integration + this._ui = new UIIntegrationImpl(this._registry, this._eventEmitter); + + // Initialize Tools integration + this._tools = new ToolsIntegrationImpl( + this._registry, + this._eventEmitter, + () => this + ); + + // Initialize storage factory + this._storageFactory = new StorageIntegrationFactory(_context); + + console.log(`[SeamlessAgentAPI] Initialized API v${this.version}`); + } + + /** + * Get the extension context + */ + get context(): vscode.ExtensionContext { + return this._context; + } + + /** + * Get the UI integration + */ + get ui(): IUIIntegration { + return this._ui; + } + + /** + * Get the Tools integration + */ + get tools(): IToolsIntegration { + return this._tools; + } + + /** + * Get the event emitter + */ + get events(): IEventEmitter { + return this._eventEmitter; + } + + /** + * Get storage for a specific addon (internal use) + */ + get storage(): IStorageIntegration { + // Return a no-op storage for the main extension + // Addons get their own namespaced storage via registerAddon + return this._storageFactory.getStorage('seamless-agent'); + } + + /** + * Get the addon registry (internal use) + */ + get registry(): AddonRegistry { + return this._registry; + } + + // ========================================================================= + // Internal Methods (for extension initialization only, not for addons) + // ========================================================================= + + /** + * @internal + * Set the native askUser function implementation. + * This is called during extension initialization, not by addons. + * Addons should use api.tools.askUser() to call this function. + */ + _setAskUserFunction(fn: (params: IAskUserParams) => Promise): void { + this._tools.setAskUserFunction(fn); + } + + /** + * @internal + * Set the native planReview function implementation. + * This is called during extension initialization, not by addons. + * Addons should use api.tools.planReview() to call this function. + */ + _setPlanReviewFunction(fn: (params: IPlanReviewParams) => Promise): void { + this._tools.setPlanReviewFunction(fn); + } + + /** + * @internal + * Set the function to switch tabs in the webview. + * This is called during extension initialization, not by addons. + * Addons should use api.ui.selectTab() to switch tabs. + */ + _setSwitchTabFunction(fn: (tabId: string) => void): void { + this._ui.setSwitchTabFunction(fn); + } + + /** + * Register an addon + */ + registerAddon(addon: IAddon): IAddonRegistration { + return this._registry.register(addon); + } + + /** + * Unregister an addon by ID + */ + unregisterAddon(addonId: string): void { + this._registry.unregister(addonId); + } + + /** + * Dispose all resources + */ + dispose(): void { + for (const disposable of this._disposables) { + try { + disposable.dispose(); + } catch (err) { + console.error('[SeamlessAgentAPI] Error disposing:', err); + } + } + this._disposables.length = 0; + } +} + +/** + * Create a new Seamless Agent API instance + */ +export function createSeamlessAgentAPI(context: vscode.ExtensionContext): SeamlessAgentAPI { + return new SeamlessAgentAPI(context); +} diff --git a/src/api/events.ts b/src/api/events.ts new file mode 100644 index 0000000..cad1686 --- /dev/null +++ b/src/api/events.ts @@ -0,0 +1,199 @@ +/** + * Event Emitter System for Seamless Agent + * + * Provides a type-safe event system for communication between + * the core extension and registered addons. + */ + +import type * as vscode from 'vscode'; +import { IEventEmitter, SeamlessAgentEvents } from './types'; + +/** + * Type-safe event listener + */ +type EventListener = (data: T) => void; + +/** + * Event emitter implementation using the Observer Pattern. + * Provides a pub/sub mechanism for addon communication. + */ +export class SeamlessEventEmitter implements IEventEmitter { + private readonly listeners: Map> = new Map(); + private readonly onceListeners: Map> = new Map(); + private readonly disposables: Map = new Map(); + + /** + * Subscribe to an event + * @param event - Event name + * @param listener - Event listener callback + * @returns Disposable for cleanup + */ + public on(event: string, listener: EventListener): vscode.Disposable { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + + const eventListeners = this.listeners.get(event)!; + eventListeners.add(listener as EventListener); + + const disposable: vscode.Disposable = { + dispose: () => { + eventListeners.delete(listener as EventListener); + this.disposables.delete(listener as EventListener); + + // Clean up empty sets + if (eventListeners.size === 0) { + this.listeners.delete(event); + } + } + }; + + this.disposables.set(listener as EventListener, disposable); + return disposable; + } + + /** + * Subscribe to an event for a single invocation + * @param event - Event name + * @param listener - Event listener callback + * @returns Disposable for cleanup + */ + public once(event: string, listener: EventListener): vscode.Disposable { + if (!this.onceListeners.has(event)) { + this.onceListeners.set(event, new Set()); + } + + const eventListeners = this.onceListeners.get(event)!; + eventListeners.add(listener as EventListener); + + const disposable: vscode.Disposable = { + dispose: () => { + eventListeners.delete(listener as EventListener); + this.disposables.delete(listener as EventListener); + + // Clean up empty sets + if (eventListeners.size === 0) { + this.onceListeners.delete(event); + } + } + }; + + this.disposables.set(listener as EventListener, disposable); + return disposable; + } + + /** + * Emit an event to all listeners + * @param event - Event name + * @param data - Event data + */ + public emit(event: string, data: T): void { + // Call regular listeners + const regularListeners = this.listeners.get(event); + if (regularListeners) { + for (const listener of regularListeners) { + try { + listener(data); + } catch (error) { + console.error(`[SeamlessAgent] Error in event listener for '${event}':`, error); + } + } + } + + // Call once listeners and remove them + const onceListeners = this.onceListeners.get(event); + if (onceListeners) { + const listenersToCall = [...onceListeners]; + this.onceListeners.delete(event); + + for (const listener of listenersToCall) { + try { + listener(data); + } catch (error) { + console.error(`[SeamlessAgent] Error in once listener for '${event}':`, error); + } + + // Clean up disposable + const disposable = this.disposables.get(listener); + if (disposable) { + this.disposables.delete(listener); + } + } + } + } + + /** + * Remove all listeners for a specific event or all events + * @param event - Optional event name. If not provided, removes all listeners. + */ + public removeAllListeners(event?: string): void { + if (event) { + // Remove listeners for specific event + const regularListeners = this.listeners.get(event); + if (regularListeners) { + for (const listener of regularListeners) { + const disposable = this.disposables.get(listener); + if (disposable) { + this.disposables.delete(listener); + } + } + this.listeners.delete(event); + } + + const onceListeners = this.onceListeners.get(event); + if (onceListeners) { + for (const listener of onceListeners) { + const disposable = this.disposables.get(listener); + if (disposable) { + this.disposables.delete(listener); + } + } + this.onceListeners.delete(event); + } + } else { + // Remove all listeners + this.listeners.clear(); + this.onceListeners.clear(); + this.disposables.clear(); + } + } + + /** + * Get the count of listeners for an event + * @param event - Event name + * @returns Number of listeners + */ + public listenerCount(event: string): number { + const regular = this.listeners.get(event)?.size ?? 0; + const once = this.onceListeners.get(event)?.size ?? 0; + return regular + once; + } + + /** + * Get all event names that have listeners + * @returns Array of event names + */ + public eventNames(): string[] { + const names = new Set(); + + for (const event of this.listeners.keys()) { + names.add(event); + } + + for (const event of this.onceListeners.keys()) { + names.add(event); + } + + return [...names]; + } + + /** + * Dispose all listeners and clean up + */ + public dispose(): void { + this.removeAllListeners(); + } +} + +// Re-export event names for convenience +export { SeamlessAgentEvents }; diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..b104d17 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,86 @@ +/** + * Seamless Agent Public API + * + * This module exports the public API for addon extensions to integrate + * with the Seamless Agent extension. + * + * @example + * ```typescript + * import * as vscode from 'vscode'; + * + * export async function activate(context: vscode.ExtensionContext) { + * const seamlessExt = vscode.extensions.getExtension('jraylan.seamless-agent'); + * if (!seamlessExt) return; + * + * const api = await seamlessExt.activate(); + * + * const registration = api.registerAddon({ + * id: 'my-addon', + * name: 'My Custom Addon', + * version: '1.0.0', + * ai: { + * tools: [{ + * name: 'my_tool', + * description: 'My custom tool', + * execute: async (params, context, token) => { + * return { success: true }; + * } + * }] + * } + * }); + * + * context.subscriptions.push(registration); + * } + * ``` + */ + +// Export main API class and factory +export { SeamlessAgentAPI, createSeamlessAgentAPI, API_VERSION } from './SeamlessAgentAPI'; + +// Export event system +export { SeamlessEventEmitter } from './events'; + +// Export all public types +export type { + // Core API + ISeamlessAgentAPI, + + // Addon definition + IAddon, + IAddonRegistration, + IAddonLifecycle, + + // UI Integration + IUIIntegration, + IAddonUICapabilities, + ICustomTab, + IUIContent, + IHistoryType, + IHistoryItemProvider, + IHistoryItem, + + // Settings + ISettingsSection, + IAddonSettingSection, + IAddonSettingDefinition, + ISettingItem, + + // Tools Integration + IToolsIntegration, + IAddonAICapabilities, + IAddonTool, + IToolExecutionContext, + IAskUserParams, + IUserResponse, + IPlanReviewParams, + IPlanReviewResult, + + // Events + IEventEmitter, + + // Storage + IStorageIntegration, +} from './types'; + +// Export event constants +export { SeamlessAgentEvents } from './types'; diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..f0ed621 --- /dev/null +++ b/src/api/types.ts @@ -0,0 +1,692 @@ +/** + * Public API Types for Seamless Agent Addons + * + * This module defines the public interfaces that addon extensions + * use to integrate with the Seamless Agent extension. + */ + +import type * as vscode from 'vscode'; +import type { codiconsLibrary } from '@vscode/codicons/dist/codiconsLibrary'; + +// ============================================================================ +// Core API Interfaces +// ============================================================================ + +/** + * Main public API interface for the Seamless Agent extension. + * This is the entry point for addon extensions to integrate with Seamless Agent. + */ +export interface ISeamlessAgentAPI { + /** Current API version for compatibility checking */ + readonly version: string; + + /** Extension context for accessing VS Code services */ + readonly context: vscode.ExtensionContext; + + /** + * Register an addon with the Seamless Agent + * @param addon - The addon configuration + * @returns Registration handle for cleanup + */ + registerAddon(addon: IAddon): IAddonRegistration; + + /** + * Unregister an addon by ID + * @param addonId - The unique addon identifier + */ + unregisterAddon(addonId: string): void; + + /** + * UI integration capabilities + */ + readonly ui: IUIIntegration; + + /** + * AI Tools integration capabilities + */ + readonly tools: IToolsIntegration; + + /** + * Event system for addon communication + */ + readonly events: IEventEmitter; + + /** + * Storage integration for addon data + */ + readonly storage: IStorageIntegration; +} + +// ============================================================================ +// Addon Definition Interfaces +// ============================================================================ + +/** + * Addon definition interface. + * Addons implement this interface to describe their capabilities. + */ +export interface IAddon { + /** Unique identifier for the addon (e.g., 'my-extension.my-addon') */ + readonly id: string; + + /** Human-readable name */ + readonly name: string; + + /** Semantic version string */ + readonly version: string; + + /** Optional description */ + readonly description?: string; + + /** Optional author name or handle */ + readonly author?: string; + + /** Optional repository URL */ + readonly repositoryUrl?: string; + + /** UI capabilities */ + readonly ui?: IAddonUICapabilities; + + /** AI/LLM tool capabilities */ + readonly ai?: IAddonAICapabilities; + + /** Settings sections */ + readonly settings?: IAddonSettingSection[]; + + /** Lifecycle hooks */ + readonly lifecycle?: IAddonLifecycle; +} + +/** + * Registration handle returned when an addon is registered. + * Implements Disposable for cleanup. + */ +export interface IAddonRegistration extends vscode.Disposable { + /** The registered addon */ + readonly addon: IAddon; + + /** Registration ID */ + readonly id: string; + + /** Whether the addon is currently active */ + readonly isActive: boolean; + + /** Number of tabs registered by this addon */ + readonly tabCount: number; + + /** Number of tools registered by this addon */ + readonly toolCount: number; + + /** Deactivate the addon without unregistering */ + deactivate(): void; + + /** Reactivate a deactivated addon */ + activate(): void; +} + +/** + * Addon lifecycle hooks + */ +export interface IAddonLifecycle { + /** Called when the addon is activated */ + onActivate?(): Promise | void; + + /** Called when the addon is deactivated */ + onDeactivate?(): Promise | void; + + /** Called when settings change */ + onSettingsChange?(settings: Record): Promise | void; +} + +// ============================================================================ +// UI Integration Interfaces +// ============================================================================ + +/** + * UI integration capabilities for addons + */ +export interface IUIIntegration { + /** + * Register a custom tab in the webview + * @param tab - Tab configuration + * @param addonId - Addon ID for ownership tracking + * @returns Disposable for cleanup + */ + registerTab(tab: ICustomTab, addonId: string): vscode.Disposable; + + /** + * Register a history item provider + * @param provider - History provider configuration + * @returns Disposable for cleanup + */ + registerHistoryProvider(provider: IHistoryItemProvider): vscode.Disposable; + + /** + * Register a settings section in the Settings tab + * @param section - Settings section configuration + * @returns Disposable for cleanup + */ + registerSettingsSection(section: ISettingsSection): vscode.Disposable; + + /** + * Refresh the webview UI + */ + refresh(): void; + + /** + * Get all registered tabs + */ + getTabs(): ICustomTab[]; + + /** + * Get all registered settings sections + */ + getSettingsSections(): ISettingsSection[]; + + /** + * Select/open a specific tab in the webview + * @param tabId - The tab identifier ('pending', 'history', 'settings', or a custom tab ID) + */ + selectTab(tabId: string): void; +} + +/** + * Addon UI capabilities definition + */ +export interface IAddonUICapabilities { + /** + * Get content for a specific tab + * @param tabId - The tab identifier + */ + getTabContent?(tabId: string): Promise; + + /** + * Get history content for specific types + * @param types - History types to retrieve + */ + getHistoryContent?(...types: string[]): Promise; + + /** + * Handle clearing history for specific types + * @param types - History types to clear + */ + handleClearHistory?(...types: string[]): Promise; + + /** + * Get available history types + */ + getHistoryTypes?(): Promise; + + /** + * Custom tabs provided by this addon + */ + tabs?: ICustomTab[]; +} + +/** + * Custom tab definition for webview + */ +export interface ICustomTab { + /** Unique tab identifier */ + id: string; + + /** Display label */ + label: string; + + /** Codicon name for the tab icon */ + icon: keyof typeof codiconsLibrary; + + /** Sort priority (lower = first) */ + priority?: number; + + /** + * Render the tab content + * @returns HTML string to display + */ + render(): Promise | string; + + /** + * Handle messages from the webview + * @param message - Message from webview + * @returns Response to send back + */ + onMessage?(message: unknown): Promise; + + /** + * Called when the tab becomes active + */ + onActivate?(): void; + + /** + * Called when the tab becomes inactive + */ + onDeactivate?(): void; +} + +/** + * UI content descriptor for dynamic rendering + */ +export interface IUIContent { + /** Content type */ + type: 'web-component' | 'html'; + + /** For web-component type: custom element tag name */ + tagname?: string; + + /** Sorting key for ordering */ + sortingKey: string; + + /** Script URI for web components */ + scriptUri?: string; + + /** Raw HTML content (for html type) */ + html?: string; +} + +/** + * History type definition for filtering + */ +export interface IHistoryType { + /** Codicon name */ + icon: keyof typeof codiconsLibrary; + + /** Type identifier */ + type: string; + + /** Display label */ + label: string; +} + +/** + * History item provider interface + */ +export interface IHistoryItemProvider { + /** Provider identifier */ + id: string; + + /** History types this provider handles */ + types: string[]; + + /** + * Get history items + * @param type - Optional type filter + * @returns History items + */ + getItems(type?: string): Promise; + + /** + * Clear history items + * @param type - Optional type filter + */ + clearItems(type?: string): Promise; +} + +/** + * History item definition + */ +export interface IHistoryItem { + /** Unique item ID */ + id: string; + + /** Item type */ + type: string; + + /** Timestamp */ + timestamp: number; + + /** Display title */ + title: string; + + /** Optional description */ + description?: string; + + /** Item status */ + status?: 'completed' | 'cancelled' | 'pending'; + + /** Additional metadata */ + metadata?: Record; +} + +// ============================================================================ +// Settings Interfaces +// ============================================================================ + +/** + * Settings section definition + */ +export interface ISettingsSection { + /** Unique section identifier */ + id: string; + + /** Section title */ + title: string; + + /** Optional description */ + description?: string; + + /** Settings items in this section */ + settings: ISettingItem[]; + + /** Sort priority (lower = first) */ + priority?: number; +} + +/** + * Addon setting section (from addon definition) + */ +export interface IAddonSettingSection { + /** Setting key (will be prefixed with addon ID) */ + key: string; + + /** Display label */ + label: string; + + /** Optional description */ + description?: string; + + /** Settings in this section */ + settings: IAddonSettingDefinition[]; +} + +/** + * Individual setting definition from addon + */ +export interface IAddonSettingDefinition { + /** Setting key */ + key: string; + + /** Display label */ + label: string; + + /** Optional description */ + description?: string; + + /** Setting type */ + type: 'boolean' | 'string' | 'number' | 'select' | 'multiselect' | 'text'; + + /** Default value */ + defaultValue?: unknown; + + /** Options for select/multiselect types */ + options?: Array<{ value: string; label: string }>; + + /** Validation constraints */ + validation?: { + min?: number; + max?: number; + pattern?: string; + required?: boolean; + }; +} + +/** + * Setting item for UI rendering + */ +export interface ISettingItem { + /** Full setting key */ + key: string; + + /** Display label */ + label: string; + + /** Description */ + description?: string; + + /** Setting type */ + type: 'boolean' | 'string' | 'number' | 'select' | 'multiselect' | 'text'; + + /** Current value */ + value: unknown; + + /** Default value */ + defaultValue?: unknown; + + /** Options for select types */ + options?: Array<{ value: string; label: string }>; + + /** Whether setting is from VS Code configuration */ + isVSCodeSetting?: boolean; +} + +// ============================================================================ +// Tools Integration Interfaces +// ============================================================================ + +/** + * AI Tools integration interface + */ +export interface IToolsIntegration { + /** + * Register an AI tool + * @param tool - Tool configuration + * @returns Disposable for cleanup + */ + registerTool(tool: IAddonTool): vscode.Disposable; + + /** + * Get all registered tools + */ + getTools(): IAddonTool[]; + + /** + * Native askUser tool access + */ + askUser(params: IAskUserParams): Promise; + + /** + * Native planReview tool access + */ + planReview(params: IPlanReviewParams): Promise; +} + +/** + * Addon AI capabilities definition + */ +export interface IAddonAICapabilities { + /** Tools provided by this addon */ + tools: IAddonTool[]; +} + +/** + * Addon tool definition + */ +export interface IAddonTool { + /** Tool name (should be unique) */ + name: string; + + /** Tool description for LLM */ + description: string; + + /** JSON schema for input validation */ + inputSchema?: Record; + + /** Tags for categorization */ + tags?: string[]; + + /** + * Execute the tool + * @param params - Input parameters + * @param context - Execution context + * @param token - Cancellation token + * @returns Tool result + */ + execute( + params: unknown, + context: IToolExecutionContext, + token: vscode.CancellationToken + ): Promise; +} + +/** + * Tool execution context + */ +export interface IToolExecutionContext { + /** The Seamless Agent API */ + api: ISeamlessAgentAPI; + + /** Request ID for tracking */ + requestId: string; +} + +/** + * Parameters for askUser tool + */ +export interface IAskUserParams { + question: string; + title?: string; + agentName?: string; +} + +/** + * User response from askUser + */ +export interface IUserResponse { + responded: boolean; + response: string; + attachments: string[]; +} + +/** + * Parameters for planReview tool + */ +export interface IPlanReviewParams { + plan: string; + title?: string; + chatId?: string; + mode?: 'review' | 'walkthrough'; +} + +/** + * Result from planReview + */ +export interface IPlanReviewResult { + status: 'approved' | 'recreateWithChanges' | 'acknowledged' | 'cancelled'; + requiredRevisions?: Array<{ + revisedPart: string; + revisorInstructions: string; + }>; + reviewId: string; +} + +// ============================================================================ +// Event System Interfaces +// ============================================================================ + +/** + * Event emitter interface + */ +export interface IEventEmitter { + /** + * Subscribe to an event + * @param event - Event name + * @param listener - Event listener + * @returns Disposable for cleanup + */ + on(event: string, listener: (data: T) => void): vscode.Disposable; + + /** + * Subscribe to an event (one-time) + * @param event - Event name + * @param listener - Event listener + * @returns Disposable for cleanup + */ + once(event: string, listener: (data: T) => void): vscode.Disposable; + + /** + * Emit an event + * @param event - Event name + * @param data - Event data + */ + emit(event: string, data: T): void; + + /** + * Remove all listeners for an event + * @param event - Event name + */ + removeAllListeners(event?: string): void; +} + +/** + * Standard event names + */ +export const SeamlessAgentEvents = { + /** Addon registered */ + ADDON_REGISTERED: 'addon:registered', + /** Addon unregistered */ + ADDON_UNREGISTERED: 'addon:unregistered', + /** Settings changed */ + SETTINGS_CHANGED: 'settings:changed', + /** UI refresh requested */ + UI_REFRESH: 'ui:refresh', + /** Tool executed */ + TOOL_EXECUTED: 'tool:executed', + /** Tab changed */ + TAB_CHANGED: 'tab:changed', +} as const; + +// ============================================================================ +// Storage Integration Interfaces +// ============================================================================ + +/** + * Storage integration interface for addon data persistence + */ +export interface IStorageIntegration { + /** + * Get a stored value + * @param key - Storage key (will be namespaced by addon ID) + * @param defaultValue - Default value if not found + */ + get(key: string, defaultValue?: T): T | undefined; + + /** + * Set a stored value + * @param key - Storage key + * @param value - Value to store + */ + set(key: string, value: T): Promise; + + /** + * Delete a stored value + * @param key - Storage key + */ + delete(key: string): Promise; + + /** + * Get all keys for the addon + */ + keys(): string[]; + + /** + * Clear all addon storage + */ + clear(): Promise; +} + +// ============================================================================ +// Type Aliases for Tool Compatibility +// ============================================================================ + +/** + * Input parameters for the askUser tool + * (Alias for IAskUserParams for tool implementations) + */ +export type AskUserInput = IAskUserParams; + +/** + * Result from the askUser tool + * (Alias for IUserResponse for tool implementations) + */ +export type AskUserToolResult = IUserResponse; + +/** + * Input parameters for the planReview tool + * (Alias for IPlanReviewParams for tool implementations) + */ +export type PlanReviewInput = IPlanReviewParams; + +/** + * Result from the planReview tool + * (Alias for IPlanReviewResult for tool implementations) + */ +export type PlanReviewToolResult = IPlanReviewResult; diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000..860521b --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,188 @@ +import * as vscode from 'vscode'; +import { registerNativeTools } from '../tools'; +import { AgentInteractionProvider } from '../webview/webviewProvider'; +import { initializeChatHistoryStorage, getChatHistoryStorage } from '../storage/chatHistoryStorage'; +import { strings } from '../localization'; +import { OrchestrationAgent } from '../agent'; +import { ExtensionCoreOptions, IExtensionCore } from './types'; +import { SeamlessAgentAPI, createSeamlessAgentAPI } from '../api'; +import { AddonRegistry } from '../addons/registry'; +import { SeamlessAgentEvents } from '../api/types'; + + +const PARTICIPANT_ID = 'seamless-agent.agent'; + + +/** + * Core class for the Seamless Agent extension. + * Manages initialization, lifecycle, and provides access to core services. + */ +export class ExtensionCore implements IExtensionCore { + private provider: AgentInteractionProvider; + private api: SeamlessAgentAPI; + public readonly subscriptions: vscode.Disposable[] = []; + + constructor(private context: vscode.ExtensionContext, options?: ExtensionCoreOptions) { + // Initialize the public API first (creates event emitter and registry) + this.api = createSeamlessAgentAPI(context); + this.subscriptions.push(this.api); + + // Initialize the chat history storage (must be done before tools are registered) + initializeChatHistoryStorage(this); + + if (options?.createMcpServer) { + this.setupMCPServer().then(() => { + console.log('MCP Server initialized'); + }).catch((err) => { + console.error('Error initializing MCP Server:', err); + }); + } + + // Register the webview provider for the Agent Console panel + this.provider = new AgentInteractionProvider(this); + + this.subscriptions.push( + vscode.window.registerWebviewViewProvider(AgentInteractionProvider.viewType, this.provider, { + webviewOptions: { retainContextWhenHidden: true } + }) + ); + + // Listen for UI refresh events from addons (e.g., when tabs are added/removed) + this.api.events.on(SeamlessAgentEvents.UI_REFRESH, (data: { type: string }) => { + if (data.type === 'tab_added' || data.type === 'tab_removed' || data.type === 'manual_refresh') { + this.provider.updateCustomTabs(); + } + }); + + // Register the ask_user tool with the webview provider + // This also sets up the native tool functions in the API + registerNativeTools(this, this.provider); + + // Register command to cancel pending plans + const cancelPendingPlansCommand = vscode.commands.registerCommand('seamless-agent.cancelPendingPlans', async () => { + const storage = getChatHistoryStorage(); + const pendingReviews = storage.getPendingPlanReviews(); + + if (pendingReviews.length === 0) { + vscode.window.showInformationMessage('No pending plan reviews to cancel.'); + return; + } + + // Create QuickPick items + const items = pendingReviews.map(review => ({ + label: review.title || 'Plan Review', + description: `Created: ${new Date(review.timestamp).toLocaleString()}`, + detail: review.plan?.substring(0, 100) + (review.plan && review.plan.length > 100 ? '...' : ''), + id: review.id + })); + + // Show QuickPick with multi-select + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Select pending plans to cancel', + canPickMany: true, + title: 'Cancel Pending Plans' + }); + + if (selected && selected.length > 0) { + // Import PlanReviewPanel to close any open panels + const { PlanReviewPanel } = await import('../webview/planReviewPanel'); + + // Mark selected plans as cancelled and close their panels + for (const item of selected) { + storage.updateInteraction(item.id, { status: 'cancelled' }); + // Close the panel if it's open + PlanReviewPanel.closeIfOpen(item.id); + } + + vscode.window.showInformationMessage(`Cancelled ${selected.length} pending plan(s).`); + + // Refresh the panel if it's visible + this.provider.refreshHome(); + } + }); + + this.subscriptions.push(cancelPendingPlansCommand); + + // Register command to show pending requests + const showPendingCommand = vscode.commands.registerCommand('seamless-agent.showPending', () => { + this.provider.switchTab('pending'); + }); + this.subscriptions.push(showPendingCommand); + + // Register command to show history + const showHistoryCommand = vscode.commands.registerCommand('seamless-agent.showHistory', () => { + this.provider.switchTab('history'); + }); + this.subscriptions.push(showHistoryCommand); + + // Register command to clear history + const clearHistoryCommand = vscode.commands.registerCommand('seamless-agent.clearHistory', async () => { + const result = await vscode.window.showWarningMessage( + strings.confirmClearHistory, + { modal: true }, + strings.confirm + ); + if (result === strings.confirm) { + this.provider.clearHistory(); + } + }); + + this.subscriptions.push(clearHistoryCommand); + + const orchestrationAgent = new OrchestrationAgent(); + + const participant = vscode.chat.createChatParticipant(PARTICIPANT_ID, orchestrationAgent.handler); + participant.iconPath = new vscode.ThemeIcon('question'); + + this.subscriptions.push(participant, orchestrationAgent); + } + + /** + * Get the VS Code extension context + */ + getContext(): vscode.ExtensionContext { + return this.context; + } + + /** + * Get the public API instance for addon integration + */ + getAPI(): SeamlessAgentAPI { + return this.api; + } + + /** + * Get the addon registry + */ + getAddonRegistry(): AddonRegistry { + return this.api.registry; + } + + /** + * Get the webview provider + */ + getProvider(): AgentInteractionProvider { + return this.provider; + } + + /** + * Dispose all resources + */ + dispose() { + this.subscriptions.forEach(sub => sub.dispose()); + } + + private async setupMCPServer(): Promise { + const { ApiServiceManager } = await import('../mcp/apiService'); + const apiServiceManager = new ApiServiceManager(this, this.provider); + apiServiceManager.start().then(() => { + // Register restart command + this.subscriptions.push( + vscode.commands.registerCommand('seamless-agent.restartMcpServer', async () => { + await apiServiceManager?.restart(); + }) + ); + }); + } + +} \ No newline at end of file diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..2b44a5b --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,44 @@ +import type * as vscode from 'vscode'; +import type { SeamlessAgentAPI } from '../api/SeamlessAgentAPI'; +import type { AddonRegistry } from '../addons/registry'; +import type { AgentInteractionProvider } from '../webview/webviewProvider'; + +/** + * Core extension interface + */ +export interface IExtensionCore { + /** + * Get the VS Code extension context + */ + getContext(): vscode.ExtensionContext; + + /** + * Extension subscriptions for cleanup + */ + readonly subscriptions: vscode.Disposable[]; + + /** + * Get the public API instance + */ + getAPI(): SeamlessAgentAPI; + + /** + * Get the addon registry + */ + getAddonRegistry(): AddonRegistry; + + /** + * Get the webview provider + */ + getProvider(): AgentInteractionProvider; +} + +/** + * Extension core initialization options + */ +export type ExtensionCoreOptions = { + /** + * Whether to create the MCP server + */ + createMcpServer?: boolean; +} \ No newline at end of file diff --git a/src/extension.antigravity.ts b/src/extension.antigravity.ts index 43c61b9..6faa672 100644 --- a/src/extension.antigravity.ts +++ b/src/extension.antigravity.ts @@ -1,145 +1,14 @@ import * as vscode from 'vscode'; -import { registerNativeTools, askUser } from './tools'; -import { AgentInteractionProvider } from './webview/webviewProvider'; -import { ApiServiceManager } from './mcp/apiService'; -import { initializeChatHistoryStorage, getChatHistoryStorage } from './storage/chatHistoryStorage'; -import { strings } from './localization'; +import { ExtensionCore } from './core'; -const PARTICIPANT_ID = 'seamless-agent.agent'; -let apiServiceManager: ApiServiceManager | undefined; -export async function activate(context: vscode.ExtensionContext) { - console.log('Seamless Agent extension active'); - - // Initialize the chat history storage (must be done before provider is created) - initializeChatHistoryStorage(context); - - // Create provider - const provider = new AgentInteractionProvider(context); - - // Register webview provider - context.subscriptions.push( - vscode.window.registerWebviewViewProvider(AgentInteractionProvider.viewType, provider, { - webviewOptions: { - retainContextWhenHidden: true - } - }) - ); - - // Initialize API Service (replaces MCP Server) - apiServiceManager = new ApiServiceManager(context, provider); - await apiServiceManager.start(); - - // Register restart command - context.subscriptions.push( - vscode.commands.registerCommand('seamless-agent.restartMcpServer', async () => { - await apiServiceManager?.restart(); - }) - ); - - // Create Status Bar Item - const restartStatusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); - restartStatusBarItem.command = 'seamless-agent.restartMcpServer'; - restartStatusBarItem.text = '$(sync) Restart API'; - restartStatusBarItem.tooltip = 'Restart the Seamless Agent API Service'; - restartStatusBarItem.show(); - context.subscriptions.push(restartStatusBarItem); - - // Register chat participant - const handler: vscode.ChatRequestHandler = async (request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) => { - // Chat handler implementation... - - try { - await askUser({ - question: "This is a test question from the chat participant. Do you accept?", - title: "Chat Confirmation" - }, provider, token); - - stream.markdown('User accepted the prompt!'); - } catch (err) { - stream.markdown('User declined or request failed.'); - } - - return { metadata: { command: '' } }; - }; - const transcriptParticipant = vscode.chat.createChatParticipant(PARTICIPANT_ID, handler); - transcriptParticipant.iconPath = vscode.Uri.joinPath(context.extensionUri, 'resources', 'icon.png'); - context.subscriptions.push(transcriptParticipant); - - // Keep the registerNativeTools for backward compatibility or direct usage - try { - registerNativeTools(context, provider); - } catch (e) { - console.warn('Failed to register native tools:', e); - } - - // Register command to cancel pending plans - const cancelPendingPlansCommand = vscode.commands.registerCommand('seamless-agent.cancelPendingPlans', async () => { - const storage = getChatHistoryStorage(); - const pendingReviews = storage.getPendingPlanReviews(); - - if (pendingReviews.length === 0) { - vscode.window.showInformationMessage('No pending plan reviews to cancel.'); - return; - } - - const items = pendingReviews.map(review => ({ - label: review.title || 'Plan Review', - description: `Created: ${new Date(review.timestamp).toLocaleString()}`, - detail: review.plan?.substring(0, 100) + (review.plan && review.plan.length > 100 ? '...' : ''), - id: review.id - })); - - const selected = await vscode.window.showQuickPick(items, { - placeHolder: 'Select pending plans to cancel', - canPickMany: true, - title: 'Cancel Pending Plans' - }); - - if (selected && selected.length > 0) { - const { PlanReviewPanel } = await import('./webview/planReviewPanel'); - for (const item of selected) { - storage.updateInteraction(item.id, { status: 'cancelled' }); - PlanReviewPanel.closeIfOpen(item.id); - } - vscode.window.showInformationMessage(`Cancelled ${selected.length} pending plan(s).`); - provider.refreshHome(); - } - }); - context.subscriptions.push(cancelPendingPlansCommand); - - // Register command to show pending requests - const showPendingCommand = vscode.commands.registerCommand('seamless-agent.showPending', () => { - provider.switchTab('pending'); - }); - context.subscriptions.push(showPendingCommand); - - // Register command to show history - const showHistoryCommand = vscode.commands.registerCommand('seamless-agent.showHistory', () => { - provider.switchTab('history'); - }); - context.subscriptions.push(showHistoryCommand); - - // Register command to clear history - const clearHistoryCommand = vscode.commands.registerCommand('seamless-agent.clearHistory', async () => { - const result = await vscode.window.showWarningMessage( - strings.confirmClearHistory, - { modal: true }, - strings.confirm - ); - if (result === strings.confirm) { - const storage = getChatHistoryStorage(); - storage.clearAll(); - provider.refreshHome(); - } - }); - context.subscriptions.push(clearHistoryCommand); +export function activate(context: vscode.ExtensionContext) { + console.log('Seamless Agent extension active'); + const core = new ExtensionCore(context); + context.subscriptions.push(core); } -// This method is called when your extension is deactivated export function deactivate() { - if (apiServiceManager) { - apiServiceManager.dispose(); - } -} + console.log('Seamless Agent extension deactivated'); +} \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 07c0cea..43b360f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,182 +1,38 @@ import * as vscode from 'vscode'; -import { registerNativeTools } from './tools'; -import { AgentInteractionProvider } from './webview/webviewProvider'; -import { initializeChatHistoryStorage, getChatHistoryStorage } from './storage/chatHistoryStorage'; -import { strings } from './localization'; - -const PARTICIPANT_ID = 'seamless-agent.agent'; - -// Store provider reference for cleanup on deactivation -let agentProvider: AgentInteractionProvider | null = null; - -export function activate(context: vscode.ExtensionContext) { +import { ExtensionCore } from './core'; +import type { ISeamlessAgentAPI } from './api'; + +// Store reference to core for API access +let core: ExtensionCore | undefined; + +/** + * Activate the Seamless Agent extension. + * Returns the public API for addon extensions to integrate. + * + * @example + * ```typescript + * // In an addon extension: + * const seamlessExt = vscode.extensions.getExtension('jraylan.seamless-agent'); + * if (seamlessExt) { + * const api = await seamlessExt.activate(); + * const registration = api.registerAddon({ ... }); + * } + * ``` + */ +export function activate(context: vscode.ExtensionContext): ISeamlessAgentAPI { console.log('Seamless Agent extension active'); - - // Initialize the chat history storage (must be done before tools are registered) - initializeChatHistoryStorage(context); - - // Register the webview provider for the Agent Console panel - const provider = new AgentInteractionProvider(context); - agentProvider = provider; // Store reference for deactivation cleanup - - (context.subscriptions as unknown as Array).push( - vscode.window.registerWebviewViewProvider(AgentInteractionProvider.viewType, provider, { - webviewOptions: { retainContextWhenHidden: true } - }) - ); - - // Register the ask_user tool with the webview provider - registerNativeTools(context, provider); - - // Register command to cancel pending plans - const cancelPendingPlansCommand = vscode.commands.registerCommand('seamless-agent.cancelPendingPlans', async () => { - const storage = getChatHistoryStorage(); - const pendingReviews = storage.getPendingPlanReviews(); - - if (pendingReviews.length === 0) { - vscode.window.showInformationMessage('No pending plan reviews to cancel.'); - return; - } - - // Create QuickPick items - const items = pendingReviews.map(review => ({ - label: review.title || 'Plan Review', - description: `Created: ${new Date(review.timestamp).toLocaleString()}`, - detail: review.plan?.substring(0, 100) + (review.plan && review.plan.length > 100 ? '...' : ''), - id: review.id - })); - - // Show QuickPick with multi-select - const selected = await vscode.window.showQuickPick(items, { - placeHolder: 'Select pending plans to cancel', - canPickMany: true, - title: 'Cancel Pending Plans' - }); - - if (selected && selected.length > 0) { - // Import PlanReviewPanel to close any open panels - const { PlanReviewPanel } = await import('./webview/planReviewPanel'); - - // Mark selected plans as cancelled and close their panels - for (const item of selected) { - storage.updateInteraction(item.id, { status: 'cancelled' }); - // Close the panel if it's open - PlanReviewPanel.closeIfOpen(item.id); - } - - vscode.window.showInformationMessage(`Cancelled ${selected.length} pending plan(s).`); - - // Refresh the panel if it's visible - provider.refreshHome(); - } - }); - - (context.subscriptions as unknown as Array).push(cancelPendingPlansCommand); - - // Register command to show pending requests - const showPendingCommand = vscode.commands.registerCommand('seamless-agent.showPending', () => { - provider.switchTab('pending'); - }); - (context.subscriptions as unknown as Array).push(showPendingCommand); - - // Register command to show history - const showHistoryCommand = vscode.commands.registerCommand('seamless-agent.showHistory', () => { - provider.switchTab('history'); - }); - (context.subscriptions as unknown as Array).push(showHistoryCommand); - - // Register command to clear history - const clearHistoryCommand = vscode.commands.registerCommand('seamless-agent.clearHistory', async () => { - const result = await vscode.window.showWarningMessage( - strings.confirmClearHistory, - { modal: true }, - strings.confirm - ); - if (result === strings.confirm) { - provider.clearHistory(); - } - }); - (context.subscriptions as unknown as Array).push(clearHistoryCommand); - - // Create a Chat Participant that uses our tool - const handler: vscode.ChatRequestHandler = async ( - request: vscode.ChatRequest, - chatContext: vscode.ChatContext, - stream: vscode.ChatResponseStream, - token: vscode.CancellationToken - ) => { - // Get our ask_user tool - const askUserTool = vscode.lm.tools.find(t => t.name === 'ask_user'); - - // Get all available tools including ours - const tools = askUserTool ? [askUserTool] : []; - - // Build the prompt with instruction to use the tool - const systemPrompt = `You are a helpful assistant. -IMPORTANT: You have access to an "ask_user" tool. You MUST use this tool before completing any task to confirm with the user that their request was fulfilled correctly. -Never finish a response without first calling the ask_user tool to verify with the user.`; - - const messages = [ - vscode.LanguageModelChatMessage.User(systemPrompt), - vscode.LanguageModelChatMessage.User(request.prompt) - ]; - - // Get the model - let model = request.model; - - const options: vscode.LanguageModelChatRequestOptions = { - tools: tools.map(t => ({ - name: t.name, - description: t.description, - inputSchema: t.inputSchema - })), - }; - - try { - const response = await model.sendRequest(messages, options, token); - - for await (const part of response.stream) { - if (part instanceof vscode.LanguageModelTextPart) { - stream.markdown(part.value); - } else if (part instanceof vscode.LanguageModelToolCallPart) { - // Handle tool calls - stream.progress(`Calling ${part.name}...`); - const toolResult = await vscode.lm.invokeTool(part.name, { - input: part.input, - toolInvocationToken: request.toolInvocationToken - }, token); - - // Show tool result - for (const resultPart of toolResult.content) { - if (resultPart instanceof vscode.LanguageModelTextPart) { - stream.markdown(`\n\n**User Response:** ${resultPart.value}\n\n`); - } - } - } - } - } catch (err) { - if (err instanceof vscode.LanguageModelError) { - stream.markdown(`Error: ${err.message}`); - } else { - throw err; - } - } - - return; - }; - - // Register the chat participant - const participant = vscode.chat.createChatParticipant(PARTICIPANT_ID, handler); - participant.iconPath = new vscode.ThemeIcon('question'); - - (context.subscriptions as unknown as Array).push(participant); + core = new ExtensionCore(context); + context.subscriptions.push(core); + + // Return the public API for addon extensions + return core.getAPI(); } export function deactivate() { - // Clean up any orphaned temp files on extension deactivation - if (agentProvider) { - agentProvider.cleanupAllTempFiles(); - agentProvider = null; - } console.log('Seamless Agent extension deactivated'); -} \ No newline at end of file + core = undefined; +} + +// Re-export public API types for addon developers +export * from './api'; +export type { IAddon, IAddonRegistration } from './api/types'; \ No newline at end of file diff --git a/src/localization.ts b/src/localization.ts index 8c4101c..47376dd 100644 --- a/src/localization.ts +++ b/src/localization.ts @@ -147,6 +147,16 @@ export const strings = { get attachmentFolderDepth2() { return localize('attachment.folderDepth.depth2'); }, get attachmentFolderDepthRecursive() { return localize('attachment.folderDepth.recursive'); }, get pastedImage() { return localize('attachment.pastedImage'); }, + + // Settings + get settings() { return localize('settings.title'); }, + get settingsDescription() { return localize('settings.description'); }, + get loadingSettings() { return localize('settings.loading'); }, + get registeredAddons() { return localize('settings.registeredAddons'); }, + get noAddonsRegistered() { return localize('settings.noAddonsRegistered'); }, + get openInVSCodeSettings() { return localize('settings.openInVSCodeSettings'); }, + get addonVersion() { return localize('settings.addonVersion'); }, + get addonAuthor() { return localize('settings.addonAuthor'); }, // Errors get noSuchInteraction() { return localize('error.noSuchInteraction'); }, diff --git a/src/mcp/apiService.ts b/src/mcp/apiService.ts index 65c6064..dfb1985 100644 --- a/src/mcp/apiService.ts +++ b/src/mcp/apiService.ts @@ -4,6 +4,7 @@ import * as crypto from 'crypto'; import { AgentInteractionProvider } from '../webview/webviewProvider'; import { askUser, planReview } from '../tools'; import { PlanReviewInput, parsePlanReviewInput } from '../tools/schemas'; +import { IExtensionCore } from '../core/types'; export { planReviewApproval, walkthroughReview } from '../tools/planReview'; @@ -22,19 +23,19 @@ export class ApiServiceManager { private authToken: string | undefined; constructor( - private context: vscode.ExtensionContext, + private core: IExtensionCore, private provider: AgentInteractionProvider ) { } async start() { try { this.port = await this.findAvailablePort(); - const storedToken = this.context.globalState.get('seamlessAgent.apiAuthToken'); + const storedToken = this.core.getContext().globalState.get('seamlessAgent.apiAuthToken'); if (storedToken && typeof storedToken === 'string' && storedToken.length >= 20) { this.authToken = storedToken; } else { this.authToken = crypto.randomBytes(32).toString('base64url'); - await this.context.globalState.update('seamlessAgent.apiAuthToken', this.authToken); + await this.core.getContext().globalState.update('seamlessAgent.apiAuthToken', this.authToken); } console.log(`Starting API service on port ${this.port}`); @@ -224,7 +225,7 @@ export class ApiServiceManager { try { const result = await planReview( params, - this.context, + this.core, this.provider, tokenSource.token ); @@ -355,7 +356,7 @@ export class ApiServiceManager { const mcpConfigPath = path.join(os.homedir(), '.gemini', 'antigravity', 'mcp_config.json'); // Get the path to the bundled CLI script in dist/ - const cliScriptPath = path.join(this.context.extensionPath, 'dist', 'seamless-agent-mcp.js'); + const cliScriptPath = path.join(this.core.getContext().extensionPath, 'dist', 'seamless-agent-mcp.js'); try { // Ensure directory exists diff --git a/src/mcp/mcpServer.ts b/src/mcp/mcpServer.ts index 4dba60d..b32d792 100644 --- a/src/mcp/mcpServer.ts +++ b/src/mcp/mcpServer.ts @@ -9,6 +9,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/ import { z } from 'zod'; import { AgentInteractionProvider } from '../webview/webviewProvider'; import { askUser, planReviewApproval, walkthroughReview } from '../tools'; +import { IExtensionCore } from '../core/types'; export class McpServerManager { private server: http.Server | undefined; @@ -17,7 +18,7 @@ export class McpServerManager { private transport: StreamableHTTPServerTransport | undefined; constructor( - private context: vscode.ExtensionContext, + private core: IExtensionCore, private provider: AgentInteractionProvider ) { } @@ -104,7 +105,7 @@ export class McpServerManager { title: args.title ? String(args.title) : undefined, chatId: args.chatId ? String(args.chatId) : undefined }, - this.context, + this.core, this.provider, tokenSource.token ); @@ -146,7 +147,7 @@ export class McpServerManager { title: args.title ? String(args.title) : undefined, chatId: args.chatId ? String(args.chatId) : undefined }, - this.context, + this.core, this.provider, tokenSource.token ); diff --git a/src/storage/chatHistoryStorage.ts b/src/storage/chatHistoryStorage.ts index d0014f2..ac269ab 100644 --- a/src/storage/chatHistoryStorage.ts +++ b/src/storage/chatHistoryStorage.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import type { RequiredPlanRevisions, StoredInteraction } from '../webview/types'; import { getStorageContext } from '../config/storage'; +import { IExtensionCore } from '../core/types'; /** * Storage keys for global state @@ -15,11 +16,9 @@ const STORAGE_KEYS = { * Simplified: each interaction is individual, no chat grouping */ export class ChatHistoryStorage { - private context: vscode.ExtensionContext; private config: vscode.WorkspaceConfiguration; - constructor(context: vscode.ExtensionContext) { - this.context = context; + constructor(private core: IExtensionCore) { this.config = vscode.workspace.getConfiguration('seamless-agent'); } @@ -30,9 +29,9 @@ export class ChatHistoryStorage { get storage(): vscode.Memento { if (getStorageContext() === 'workspace') { - return this.context.workspaceState; + return this.core.getContext().workspaceState; } - return this.context.globalState; + return this.core.getContext().globalState; } /** @@ -274,8 +273,8 @@ let storageInstance: ChatHistoryStorage | undefined; /** * Initialize the storage with extension context */ -export function initializeChatHistoryStorage(context: vscode.ExtensionContext): ChatHistoryStorage { - storageInstance = new ChatHistoryStorage(context); +export function initializeChatHistoryStorage(core: IExtensionCore): ChatHistoryStorage { + storageInstance = new ChatHistoryStorage(core); return storageInstance; } diff --git a/src/tools/askUser.ts b/src/tools/askUser.ts index ca5804a..d74b7be 100644 --- a/src/tools/askUser.ts +++ b/src/tools/askUser.ts @@ -2,7 +2,6 @@ import * as vscode from 'vscode'; import { strings } from '../localization'; import { AgentInteractionProvider } from '../webview/webviewProvider'; import { UserResponseResult } from '../webview/types'; -import { getChatHistoryStorage } from '../storage/chatHistoryStorage'; import { AskUserInput, AskUserToolResult } from './schemas'; /** @@ -64,6 +63,14 @@ async function askViaWebview( // Create a promise that rejects on cancellation return new Promise((resolve) => { + let append = vscode.workspace.getConfiguration('seamless-agent').get('askUserAppendText', undefined); + + if (append) { + append = `\n\n${append}`; + } else { + append = ''; + } + // Listen for cancellation const cancellationListener = token.onCancellationRequested(() => { // Try to find and cancel this request in the provider @@ -76,7 +83,11 @@ async function askViaWebview( cancellationListener.dispose(); - resolve({ responded: false, response: strings.cancelled, attachments: [] }); + resolve({ + responded: false, + response: strings.cancelled, + attachments: [] + }); }); // Start the actual request @@ -91,7 +102,10 @@ async function askViaWebview( return; } - resolve(result); + resolve({ + ...result, + response: result.response + append + }); }); }); } diff --git a/src/tools/index.ts b/src/tools/index.ts index 9b9f034..0379254 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -27,11 +27,12 @@ import { parsePlanReviewInput, parseWalkthroughReviewInput, } from './schemas'; +import { IExtensionCore } from '../core/types'; /** * Registers the native VS Code LM Tools */ -export function registerNativeTools(context: vscode.ExtensionContext, provider: AgentInteractionProvider) { +export function registerNativeTools(core: IExtensionCore, provider: AgentInteractionProvider) { // Register the tool defined in package.json const confirmationTool = vscode.lm.registerTool('ask_user', { @@ -133,7 +134,7 @@ export function registerNativeTools(context: vscode.ExtensionContext, provider: title: params.title, chatId: undefined }, - context, + core, provider, token ); @@ -173,7 +174,7 @@ export function registerNativeTools(context: vscode.ExtensionContext, provider: title: params.title, chatId: params.chatId }, - context, + core, provider, token ); @@ -209,7 +210,7 @@ export function registerNativeTools(context: vscode.ExtensionContext, provider: title: params.title, chatId: params.chatId }, - context, + core, provider, token ); @@ -220,7 +221,7 @@ export function registerNativeTools(context: vscode.ExtensionContext, provider: } }); - (context.subscriptions as unknown as Array).push( + core.subscriptions.push( confirmationTool, approvePlanTool, planReviewTool, @@ -228,5 +229,40 @@ export function registerNativeTools(context: vscode.ExtensionContext, provider: ); // Initialize chat history storage - initializeChatHistoryStorage(context); + initializeChatHistoryStorage(core); + + // Set up native tool functions in the API for addon access + const api = core.getAPI(); + + // Configure askUser implementation (internal - addons call api.tools.askUser()) + api._setAskUserFunction(async (params) => { + const result = await askUser( + { question: params.question, title: params.title, agentName: params.agentName }, + provider, + new vscode.CancellationTokenSource().token + ); + return result; + }); + + // Configure planReview implementation (internal - addons call api.tools.planReview()) + api._setPlanReviewFunction(async (params) => { + const { planReviewApproval: planReviewFn } = await import('./planReview'); + const result = await planReviewFn( + { plan: params.plan, title: params.title, chatId: params.chatId }, + core, + provider, + new vscode.CancellationTokenSource().token + ); + return { + status: result.status, + requiredRevisions: result.requiredRevisions, + reviewId: result.reviewId + }; + }); + // Configure switchTab implementation (internal - addons call api.ui.selectTab()) + api._setSwitchTabFunction((tabId) => { + provider.switchTab(tabId); + }); + + console.log('[Seamless Agent] Native tools registered and API functions configured'); } diff --git a/src/tools/planReview.ts b/src/tools/planReview.ts index e57e9b9..e6f18d6 100644 --- a/src/tools/planReview.ts +++ b/src/tools/planReview.ts @@ -4,6 +4,7 @@ import type { PlanReviewOptions } from '../webview/types'; import { AgentInteractionProvider } from '../webview/webviewProvider'; import { getChatHistoryStorage } from '../storage/chatHistoryStorage'; import { PlanReviewInput, PlanReviewToolResult, WalkthroughReviewInput } from './schemas'; +import { IExtensionCore } from '../core/types'; export type PlanReviewApprovalInput = Pick; @@ -14,7 +15,7 @@ export type PlanReviewApprovalInput = Pick { @@ -67,7 +68,7 @@ export async function planReview( }; // Show the plan review panel - const result = await PlanReviewPanel.showWithOptions(context.extensionUri, options); + const result = await PlanReviewPanel.showWithOptions(core.getContext().extensionUri, options); const interactionState = ['approved', 'recreateWithChanges', 'acknowledged'].includes(result.action) @@ -117,7 +118,7 @@ export async function planReview( */ export async function planReviewApproval( params: PlanReviewApprovalInput, - context: vscode.ExtensionContext, + core: IExtensionCore, provider: AgentInteractionProvider, token: vscode.CancellationToken ): Promise { @@ -128,7 +129,7 @@ export async function planReviewApproval( mode: 'review', chatId: params.chatId }, - context, + core, provider, token ); @@ -139,7 +140,7 @@ export async function planReviewApproval( */ export async function walkthroughReview( params: WalkthroughReviewInput, - context: vscode.ExtensionContext, + core: IExtensionCore, provider: AgentInteractionProvider, token: vscode.CancellationToken ): Promise { @@ -150,7 +151,7 @@ export async function walkthroughReview( mode: 'walkthrough', chatId: params.chatId }, - context, + core, provider, token ); diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..056eeea --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,6 @@ + + +// Type definitions for @vscode/codicons/dist/codiconsUtil.js +declare module "*codiconsUtil.js" { + export function register(name: string, code: number): any; +} \ No newline at end of file diff --git a/src/webview/main.ts b/src/webview/main.ts index 351a5eb..baf78d6 100644 --- a/src/webview/main.ts +++ b/src/webview/main.ts @@ -90,14 +90,11 @@ declare global { back: string; noPendingRequests: string; noPendingItems: string; - pendingItems: string; pendingRequests: string; yourResponse: string; inputPlaceholder: string; - attachments: string; noAttachments: string; addAttachment: string; - pastedImage: string; submit: string; close: string; cancel: string; @@ -109,19 +106,19 @@ declare global { selectFile: string; noFilesFound: string; dropImageHere: string; - // Session histors + pastedImage: string; + attachments: string; + // Session history recentSessions: string; noRecentSessions: string; - clearHistory: string; sessionInput: string; sessionOutput: string; input: string; output: string; addFolder: string; - // Chat histors + // Chat histories pendingReviews: string; noPendingReviews: string; - chatHistory: string; noChats: string; openInPanel: string; deleteChat: string; @@ -133,6 +130,12 @@ declare global { question: string; response: string; noResponse: string; + // Home toolbar labels + // Settings tab + settings: string; + noAddonsRegistered: string; + pendingItems: string; + chatHistory: string; // History filtes historyFilterAll: string; historyFilterAskUser: string; @@ -149,8 +152,8 @@ import type { RequestItem, FileSearchResult, ToolCallInteraction, - RequiredPlanRevisions, - StoredInteraction + StoredInteraction, + CustomTabData } from './types'; import { truncate } from './utils'; @@ -199,7 +202,13 @@ import { truncate } from './utils'; // History filter state let currentHistoryFilter: string = 'all'; - type HomeTab = 'pending' | 'history'; + // Custom tabs state + let customTabs: CustomTabData[] = []; + let activeCustomTab: string | null = null; + const customTabsContainer = document.getElementById('custom-tabs-container'); + const customTabContentContainer = document.getElementById('custom-tab-content-container'); + + type HomeTab = 'pending' | 'history' | 'settings' | string; function setHomeToolbarActiveTab(tab: HomeTab): void { document.querySelectorAll('.home-toolbar-btn[data-tab]').forEach(btn => { @@ -272,11 +281,17 @@ import { truncate } from './utils'; type: 'clearHistory' }); }); + + // Settings link to open VS Code settings + const settingsLinkBtn = document.querySelector('.settings-link-btn[data-action="openVSCodeSettings"]') as HTMLElement | null; + settingsLinkBtn?.addEventListener('click', () => { + vscode.postMessage({ type: 'openVSCodeSettings' }); + }); } /** - * Apply filter to history items - */ + * Apply filter to history items + */ function applyHistoryFilter(filter: string): void { currentHistoryFilter = filter; @@ -309,8 +324,94 @@ import { truncate } from './utils'; } /** - * Initialize history filter buttons - */ + * Render custom tab buttons in the toolbar + */ + function renderCustomTabs(tabs: CustomTabData[]): void { + customTabs = tabs; + if (!customTabsContainer) return; + + // Clear existing custom tab buttons + customTabsContainer.innerHTML = ''; + + // Create buttons for each custom tab + tabs.forEach(tab => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'home-toolbar-btn'; + btn.setAttribute('data-tab', tab.id); + btn.setAttribute('data-label', tab.label); + btn.setAttribute('data-custom-tab', 'true'); + btn.title = tab.label; + btn.setAttribute('aria-label', tab.label); + btn.setAttribute('aria-pressed', 'false'); + + const icon = document.createElement('span'); + icon.className = `codicon codicon-${tab.icon}`; + btn.appendChild(icon); + + btn.addEventListener('click', () => { + switchTab(tab.id); + }); + + customTabsContainer.appendChild(btn); + }); + + // Create content panes for each custom tab + if (customTabContentContainer) { + // Keep existing panes that might have content, remove ones no longer in tabs + const existingPanes = customTabContentContainer.querySelectorAll('.custom-tab-pane'); + const tabIds = new Set(tabs.map(t => t.id)); + + existingPanes.forEach(pane => { + const paneId = pane.getAttribute('data-tab-id'); + if (paneId && !tabIds.has(paneId)) { + pane.remove(); + } + }); + + // Create panes for new tabs + tabs.forEach(tab => { + const existingPane = customTabContentContainer.querySelector(`[data-tab-id="${tab.id}"]`); + if (!existingPane) { + const pane = document.createElement('div'); + pane.className = 'custom-tab-pane tab-pane hidden'; + pane.id = `content-custom-${tab.id}`; + pane.setAttribute('data-tab-id', tab.id); + + const loading = document.createElement('div'); + loading.className = 'custom-tab-loading'; + loading.textContent = 'Loading...'; + pane.appendChild(loading); + + customTabContentContainer.appendChild(pane); + } + }); + } + } + + /** + * Show content for a custom tab + */ + function showCustomTabContent(tabId: string, content: string): void { + if (!customTabContentContainer) return; + + const pane = customTabContentContainer.querySelector(`[data-tab-id="${tabId}"]`) as HTMLElement; + if (!pane) return; + + // Render the content + pane.innerHTML = content; + } + + /** + * Check if a tab is a custom tab from addons + */ + function isCustomTab(tabId: string): boolean { + return customTabs.some(t => t.id === tabId); + } + + /** + * Initialize history filter buttons + */ function initHistoryFilters(): void { document.querySelectorAll('.filter-btn').forEach(btn => { btn.addEventListener('click', () => { @@ -381,9 +482,9 @@ import { truncate } from './utils'; } /** - * Bind a single delegated handler for history list interactions. - * This avoids losing per-item handlers when the list is re-rendered via innerHTML. - */ + * Bind a single delegated handler for history list interactions. + * This avoids losing per-item handlers when the list is re-rendered via innerHTML. + */ function initHistoryListDelegation(): void { if (!historyList) return; @@ -439,9 +540,9 @@ import { truncate } from './utils'; } /** - * Announce a message to screen readers via the live region - * @param message The message to announce - */ + * Announce a message to screen readers via the live region + * @param message The message to announce + */ function announceToScreenReader(message: string): void { if (srAnnounce) { // Clear and set text to trigger announcement @@ -450,9 +551,7 @@ import { truncate } from './utils'; // Use setTimeout to ensure the DOM change is detected setTimeout(() => { srAnnounce.textContent = message; - } - - , 50); + }, 50); } } @@ -488,10 +587,34 @@ import { truncate } from './utils'; } } + /** + * Helper function to create TextNode + * + * @param {string} text + * @return {*} {Text} + */ function tn(text: string): Text { return document.createTextNode(text); } + /** + * Helper function to create HTMLElement + * + * @template K + * @param {K} tag + * @param {{ + * className?: string; + * text?: string; + * html?: string; + * title?: string; + * attrs?: Record; + * on?: Partial<{ + * [K in keyof HTMLElementEventMap]?: (ev: HTMLElementEventMap[K]) => any; + * }> + * }} [options] + * @param {...ElementChild[]} children + * @return {*} {HTMLElementTagNameMap[K]} + */ function el( tag: K, options?: { @@ -528,13 +651,19 @@ import { truncate } from './utils'; return node; } + /** + * Helper function to create icon + * + * @param {string} name + * @return {*} {HTMLSpanElement} + */ function codicon(name: string): HTMLSpanElement { return el('span', { className: `codicon codicon-${name}` }); } /** - * Show the list of pending requests - */ + * Show the list of pending requests + */ function showList(requests: RequestItem[]): void { if (requests.length === 0) { @@ -598,8 +727,8 @@ import { truncate } from './utils'; } /** -* Show the question form and hide other views -*/ + * Show the question form and hide other views + */ function showQuestion(question: string, title: string, requestId: string): void { currentRequestId = requestId; @@ -635,28 +764,64 @@ import { truncate } from './utils'; } /** - * Switch between tabs in the home view - */ - function switchTab(tab: 'pending' | 'history'): void { - // Update content panes visibility + * Switch between tabs in the home view + */ + function switchTab(tab: HomeTab): void { + if (typeof tab !== 'string') return; + + // Get settings content element + const contentSettings = document.getElementById('content-settings'); + + // Check if it's a custom tab + const isCustom = isCustomTab(tab); + + // Update built-in content panes visibility contentPending?.classList.toggle('hidden', tab !== 'pending'); contentHistory?.classList.toggle('hidden', tab !== 'history'); + contentSettings?.classList.toggle('hidden', tab !== 'settings'); + + // Handle custom tab content panes + if (customTabContentContainer) { + customTabContentContainer.querySelectorAll('.custom-tab-pane').forEach(pane => { + const paneId = pane.getAttribute('data-tab-id'); + pane.classList.toggle('hidden', paneId !== tab); + pane.classList.toggle('active', paneId === tab); + }); + } setHomeToolbarActiveTab(tab); + // If switching to settings, request settings data + if (tab === 'settings') { + vscode.postMessage({ type: 'getSettings' }); + } + + // If switching to a custom tab, request its content + if (isCustom) { + activeCustomTab = tab; + vscode.postMessage({ type: 'getCustomTabContent', tabId: tab }); + } else { + activeCustomTab = null; + } + // Announce tab change to screen readers const tabNames: Record = { pending: window.__STRINGS__?.pendingItems || 'Pending Items', history: window.__STRINGS__?.chatHistory || 'Chat History', + settings: window.__STRINGS__?.settings || 'Settings', }; - announceToScreenReader(`${tabNames[tab]}tab selected`); + // For custom tabs, use the tab label + const customTab = customTabs.find(t => t.id === tab); + const tabName = customTab ? customTab.label : tabNames[tab] || tab; + + announceToScreenReader(`${tabName} tab selected`); } /** - * Update the unified pending placeholder visibility - * Shows placeholder only when both requests and reviews are empty - */ + * Update the unified pending placeholder visibility + * Shows placeholder only when both requests and reviews are empty + */ function updatePendingPlaceholder(): void { const hasRequests = ! !(pendingRequestsList && pendingRequestsList.children.length > 0); const hasReviews = ! !(pendingReviewsList && pendingReviewsList.children.length > 0); @@ -667,8 +832,8 @@ import { truncate } from './utils'; } /** - * Show home view (pending requests + recent interactions) - */ + * Show home view (pending requests + recent interactions) + */ function showHome(): void { currentRequestId = null; currentInteractionId = null; @@ -701,9 +866,9 @@ import { truncate } from './utils'; } /** - * Extract a meaningful title from the LLM's input question - * Uses the first sentence (up to ~80 chars) as the title - */ + * Extract a meaningful title from the LLM's input question + * Uses the first sentence (up to ~80 chars) as the title + */ function extractTitleFromQuestion(question: string): string { if (!question) return 'Tool Call'; @@ -981,6 +1146,287 @@ import { truncate } from './utils'; updateHomeToolbarBadgesFromDom(); } + /** + * Settings data types (matching types.ts) + */ + interface SettingItemData { + key: string; + label: string; + description?: string; + type: 'boolean' | 'string' | 'number' | 'select' | 'multiselect' | 'text'; + value: unknown; + defaultValue?: unknown; + options?: Array<{ value: string; label: string }>; + } + + interface SettingsSectionData { + id: string; + title: string; + description?: string; + settings: SettingItemData[]; + priority?: number; + } + + interface AddonInfoData { + id: string; + name: string; + version: string; + description?: string; + author?: string; + repositoryUrl?: string; + isActive: boolean; + toolCount: number; + tabCount: number; + } + + /** + * Render settings in the Settings tab + * Note: Native Seamless Agent settings are now accessed via VS Code Settings + * through the link button in the webview + */ + function renderSettings(sections: SettingsSectionData[], addons: AddonInfoData[]): void { + const addonSettingsSections = document.getElementById('addon-settings-sections'); + const addonsListContent = document.getElementById('addons-list-content'); + + // Render addon settings sections (native settings are accessed via VS Code link) + if (addonSettingsSections) { + clearChildren(addonSettingsSections); + // All sections are addon sections now (native settings removed from API response) + for (const section of sections) { + addonSettingsSections.appendChild(renderSettingsSection(section)); + } + } + + // Render registered addons list + if (addonsListContent) { + clearChildren(addonsListContent); + if (addons.length === 0) { + addonsListContent.appendChild(el('p', { + className: 'placeholder', + text: window.__STRINGS__?.noAddonsRegistered || 'No addons registered' + })); + } else { + for (const addon of addons) { + addonsListContent.appendChild(renderAddonCard(addon)); + } + } + } + + // Note: collapse/expand is handled by event delegation initialized at startup + } + + /** + * Render a settings section + */ + function renderSettingsSection(section: SettingsSectionData): HTMLElement { + const sectionEl = el('div', { className: 'settings-section' }); + + const header = el('div', { + className: 'settings-section-header', + attrs: { 'data-section': section.id } + }); + appendChildren(header, codicon('chevron-down'), ' '); + header.appendChild(el('h4', { text: section.title })); + + const content = el('div', { className: 'settings-section-content' }); + + if (section.description) { + content.appendChild(el('p', { + className: 'settings-description', + text: section.description + })); + } + + for (const setting of section.settings) { + content.appendChild(renderSettingItem(setting)); + } + + appendChildren(sectionEl, header, content); + return sectionEl; + } + + /** + * Render a single setting item + */ + function renderSettingItem(setting: SettingItemData): HTMLElement { + const item = el('div', { className: 'setting-item' }); + + switch (setting.type) { + case 'boolean': + const checkboxRow = el('div', { className: 'setting-item-row setting-checkbox' }); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.id = `setting-${setting.key}`; + checkbox.checked = Boolean(setting.value); + checkbox.addEventListener('change', () => { + vscode.postMessage({ + type: 'updateSetting', + key: setting.key, + value: checkbox.checked + }); + }); + const checkboxLabel = el('label', { + className: 'setting-label', + text: setting.label, + attrs: { for: `setting-${setting.key}` } + }); + appendChildren(checkboxRow, checkbox, checkboxLabel); + item.appendChild(checkboxRow); + break; + + case 'select': + item.appendChild(el('label', { + className: 'setting-label', + text: setting.label + })); + const select = document.createElement('select'); + select.className = 'setting-select'; + select.id = `setting-${setting.key}`; + for (const opt of setting.options || []) { + const option = document.createElement('option'); + option.value = opt.value; + option.textContent = opt.label; + if (opt.value === setting.value) { + option.selected = true; + } + select.appendChild(option); + } + select.addEventListener('change', () => { + vscode.postMessage({ + type: 'updateSetting', + key: setting.key, + value: select.value + }); + }); + item.appendChild(select); + break; + + case 'string': + case 'number': + item.appendChild(el('label', { + className: 'setting-label', + text: setting.label + })); + const input = document.createElement('input'); + input.type = setting.type === 'number' ? 'number' : 'text'; + input.className = 'setting-input'; + input.id = `setting-${setting.key}`; + input.value = String(setting.value ?? ''); + input.addEventListener('change', () => { + const value = setting.type === 'number' + ? parseFloat(input.value) + : input.value; + vscode.postMessage({ + type: 'updateSetting', + key: setting.key, + value + }); + }); + item.appendChild(input); + break; + + case 'text': + item.appendChild(el('label', { + className: 'setting-label', + text: setting.label + })); + const textarea = document.createElement('textarea'); + textarea.className = 'setting-input'; + textarea.id = `setting-${setting.key}`; + textarea.rows = 3; + textarea.value = String(setting.value ?? ''); + textarea.addEventListener('change', () => { + vscode.postMessage({ + type: 'updateSetting', + key: setting.key, + value: textarea.value + }); + }); + item.appendChild(textarea); + break; + } + + if (setting.description) { + item.appendChild(el('p', { + className: 'setting-description', + text: setting.description + })); + } + + return item; + } + + /** + * Render an addon info card + */ + function renderAddonCard(addon: AddonInfoData): HTMLElement { + const card = el('div', { className: 'addon-card' }); + const header = el('div', { className: 'addon-card-header' }); + + header.appendChild(el('span', { className: 'addon-card-name', text: addon.name })); + header.appendChild(el('span', { + className: 'addon-card-version', + text: `v${addon.version}` + })); + header.appendChild(el('span', { + className: `addon-status ${addon.isActive ? 'active' : 'inactive'}`, + text: addon.isActive ? 'Active' : 'Inactive' + })); + + card.appendChild(header); + + if (addon.description) { + card.appendChild(el('p', { + className: 'addon-card-description', + text: addon.description + })); + } + + const meta = el('div', { className: 'addon-card-meta' }); + if (addon.author) { + const authorSpan = el('span'); + appendChildren(authorSpan, codicon('account'), ' ', addon.author); + meta.appendChild(authorSpan); + } + if (addon.toolCount > 0) { + const toolsSpan = el('span'); + appendChildren(toolsSpan, codicon('tools'), ` ${addon.toolCount} tools`); + meta.appendChild(toolsSpan); + } + if (addon.tabCount > 0) { + const tabsSpan = el('span'); + appendChildren(tabsSpan, codicon('layout'), ` ${addon.tabCount} tabs`); + meta.appendChild(tabsSpan); + } + if (meta.children.length > 0) { + card.appendChild(meta); + } + + return card; + } + + /** + * Initialize settings sections collapse/expand behavior using event delegation. + * This should only be called once during initialization. + */ + function initSettingsSections(): void { + const settingsContainer = document.getElementById('content-settings'); + if (!settingsContainer) { + return; + } + + // Use event delegation to handle clicks on section headers + // This way we don't need to re-attach listeners when content is re-rendered + settingsContainer.addEventListener('click', (event) => { + const target = event.target as HTMLElement; + const header = target.closest('.settings-section-header'); + if (header) { + const section = header.closest('.settings-section'); + section?.classList.toggle('collapsed'); + } + }); + } + /** * Show interaction detail view (for ask_user) @@ -1092,15 +1538,15 @@ import { truncate } from './utils'; } /** - * Update attachments display - renders chips above textarea - */ + * Update attachments display - renders chips above textarea + */ function updateAttachmentsDisplay(): void { updateChipsDisplay(); } /** - * Update chips display above textarea - */ + * Update chips display above textarea + */ function updateChipsDisplay(): void { if (!chipsContainer) return; @@ -1112,7 +1558,6 @@ import { truncate } from './utils'; else { chipsContainer.classList.remove('hidden'); - const preview = document.querySelector('.image-hover-preview') as HTMLElement; const previewImg = preview?.querySelector('img') as HTMLImageElement; @@ -1195,8 +1640,8 @@ import { truncate } from './utils'; } /** - * Remove an attachment by ID - */ + * Remove an attachment by ID + */ function removeAttachment(attachmentId: string): void { if (currentRequestId) { vscode.postMessage({ @@ -1212,8 +1657,8 @@ import { truncate } from './utils'; } /** - * Handle submit button click - */ + * Handle submit button click + */ function handleSubmit(): void { const response = responseInput?.value.trim() || ''; @@ -1231,8 +1676,8 @@ import { truncate } from './utils'; } /** - * Handle cancel button click - */ + * Handle cancel button click + */ function handleCancel(): void { if (currentRequestId) { vscode.postMessage({ @@ -1286,8 +1731,8 @@ import { truncate } from './utils'; } /** - * Get Codicon icon name for a file based on its extension - */ + * Get Codicon icon name for a file based on its extension + */ function getFileIcon(filename: string): string { const ext = filename.split('.').pop()?.toLowerCase() || ''; @@ -1437,6 +1882,7 @@ import { truncate } from './utils'; const index = parseInt((item as HTMLElement).getAttribute('data-index') || '0', 10); selectAutocompleteItem(index); }); + item.addEventListener('mouseenter', () => { const index = parseInt((item as HTMLElement).getAttribute('data-index') || '0', 10); selectedAutocompleteIndex = index; @@ -1817,6 +2263,9 @@ import { truncate } from './utils'; cancelBtn?.addEventListener('click', handleCancel); backBtn?.addEventListener('click', handleBack); + // Initialize settings sections collapse/expand (event delegation) + initSettingsSections(); + // Attach button click handler - opens file picker attachBtn?.addEventListener('click', () => { if (currentRequestId) { @@ -1881,19 +2330,22 @@ import { truncate } from './utils'; // Autocomplete navigation if (autocompleteVisible) { switch (event.key) { - case 'ArrowDown': event.preventDefault(); + case 'ArrowDown': + event.preventDefault(); if (selectedAutocompleteIndex < autocompleteResults.length - 1) { selectedAutocompleteIndex++; updateAutocompleteSelection(); } return; - case 'ArrowUp': event.preventDefault(); + case 'ArrowUp': + event.preventDefault(); if (selectedAutocompleteIndex > 0) { selectedAutocompleteIndex--; updateAutocompleteSelection(); } return; - case 'Enter': case 'Tab': + case 'Enter': + case 'Tab': if (selectedAutocompleteIndex >= 0) { event.preventDefault(); selectAutocompleteItem(selectedAutocompleteIndex); @@ -1927,11 +2379,14 @@ import { truncate } from './utils'; const message = event.data; switch (message.type) { - case 'showQuestion': showQuestion(message.question, message.title, message.requestId); + case 'showQuestion': + showQuestion(message.question, message.title, message.requestId); break; - case 'showList': showList(message.requests); + case 'showList': + showList(message.requests); break; - case 'showHome': recentInteractions = message.recentInteractions || []; + case 'showHome': + recentInteractions = message.recentInteractions || []; showHome(); // Update pending requests if provided @@ -1954,53 +2409,68 @@ import { truncate } from './utils'; } break; - case 'showInteractionDetail': showInteractionDetail(message.interaction); + case 'showInteractionDetail': + showInteractionDetail(message.interaction); break; - case 'updateAttachments': if (message.requestId === currentRequestId) { - - // Preserve flags from existing attachments when updating - const existingFlags = new Map(currentAttachments.map(a => [a.id, { - isImage: a.isImage, isTextReference: a.isTextReference - }])); - - currentAttachments = (message.attachments || []).map((att: AttachmentInfo) => { - const existing = existingFlags.get(att.id); - return { - ...att, - isImage: att.isImage || existing?.isImage || /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(att.name), - isTextReference: att.isTextReference ?? existing?.isTextReference ?? false, - }; - }); - updateAttachmentsDisplay(); - } + case 'updateAttachments': + if (message.requestId === currentRequestId) { + + // Preserve flags from existing attachments when updating + const existingFlags = new Map(currentAttachments.map(a => [a.id, { + isImage: a.isImage, isTextReference: a.isTextReference + }])); + + currentAttachments = (message.attachments || []).map((att: AttachmentInfo) => { + const existing = existingFlags.get(att.id); + return { + ...att, + isImage: att.isImage || existing?.isImage || /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(att.name), + isTextReference: att.isTextReference ?? existing?.isTextReference ?? false, + }; + }); + updateAttachmentsDisplay(); + } break; - case 'fileSearchResults': if (autocompleteQuery !== undefined) { - showAutocomplete(message.files || []); - } + case 'fileSearchResults': + if (autocompleteQuery !== undefined) { + showAutocomplete(message.files || []); + } break; - case 'imageSaved': if (message.requestId === currentRequestId && message.attachment) { - // Add to local attachments if not already there - const exists = currentAttachments.some(a => a.id === message.attachment.id); - - if (!exists) { - currentAttachments.push({ - ...message.attachment, - isImage: true, - }); - updateChipsDisplay(); + case 'imageSaved': + if (message.requestId === currentRequestId && message.attachment) { + // Add to local attachments if not already there + const exists = currentAttachments.some(a => a.id === message.attachment.id); + + if (!exists) { + currentAttachments.push({ + ...message.attachment, + isImage: true, + }); + updateChipsDisplay(); + } } - } break; - case 'switchTab': if (message.tab) { - switchTab(message.tab); - } - + case 'switchTab': + if (message.tab) { + switchTab(message.tab); + } + break; + case 'showSettings': + renderSettings(message.settings || [], message.addons || []); + break; + case 'updateCustomTabs': + renderCustomTabs(message.tabs || []); + break; + case 'showCustomTabContent': + if (message.tabId && message.content !== undefined) { + showCustomTabContent(message.tabId, message.content); + } break; case 'clear': showHome(); hideAutocomplete(); @@ -2027,4 +2497,4 @@ declare function acquireVsCodeApi(): { postMessage(message: unknown): void; getState(): unknown; setState(state: unknown): void; -}; \ No newline at end of file +}; diff --git a/src/webview/types.ts b/src/webview/types.ts index c68d6b5..a94d342 100644 --- a/src/webview/types.ts +++ b/src/webview/types.ts @@ -119,7 +119,24 @@ export type ToWebviewMessage = | { } | { type: 'switchTab'; - tab: 'pending' | 'history' + tab: 'pending' | 'history' | 'settings' | string; + } + + | { + type: 'showSettings'; + settings: SettingsSectionData[]; + addons: AddonInfoData[]; + } + + | { + type: 'updateCustomTabs'; + tabs: CustomTabData[]; + } + + | { + type: 'showCustomTabContent'; + tabId: string; + content: string; } | { type: 'clear' @@ -195,6 +212,26 @@ export type FromWebviewMessage = | { type: 'deleteInteraction'; interactionId: string } + | { + type: 'getSettings' + } + | { + type: 'updateSetting'; + key: string; + value: unknown + } + | { + type: 'openVSCodeSettings' + } + | { + type: 'getCustomTabContent'; + tabId: string; + } + | { + type: 'customTabMessage'; + tabId: string; + message: unknown; + } | { type: 'cancelPendingRequest'; requestId: string @@ -248,3 +285,44 @@ export interface UserResponseResult { response: string; attachments: AttachmentInfo[]; } + +// Settings data types for webview +export interface SettingItemData { + key: string; + label: string; + description?: string; + type: 'boolean' | 'string' | 'number' | 'select' | 'multiselect' | 'text'; + value: unknown; + defaultValue?: unknown; + options?: Array<{ value: string; label: string }>; +} + +export interface SettingsSectionData { + id: string; + title: string; + description?: string; + settings: SettingItemData[]; + priority?: number; +} + +export interface AddonInfoData { + id: string; + name: string; + version: string; + description?: string; + author?: string; + repositoryUrl?: string; + isActive: boolean; + toolCount: number; + tabCount: number; +} + +/** + * Custom tab data for webview (serializable version of ICustomTab) + */ +export interface CustomTabData { + id: string; + label: string; + icon: string; + priority?: number; +} diff --git a/src/webview/webviewProvider.ts b/src/webview/webviewProvider.ts index f44dfd8..16c3ff0 100644 --- a/src/webview/webviewProvider.ts +++ b/src/webview/webviewProvider.ts @@ -19,7 +19,9 @@ import { FromWebviewMessage, FileSearchResult, UserResponseResult, + CustomTabData, } from "./types"; +import { IExtensionCore } from '../core/types'; import { truncate } from './utils'; @@ -46,7 +48,8 @@ export class AgentInteractionProvider implements vscode.WebviewViewProvider { // Chat history storage for plan reviews private _chatHistoryStorage: ChatHistoryStorage; - constructor(private readonly _context: vscode.ExtensionContext) { + + constructor(private core: IExtensionCore) { // Use the singleton instance that was initialized in extension.ts this._chatHistoryStorage = getChatHistoryStorage(); //this.loadSessionsFromDisk() @@ -60,7 +63,7 @@ export class AgentInteractionProvider implements vscode.WebviewViewProvider { } private get _extensionUri(): vscode.Uri { - return this._context.extensionUri; + return this.core.getContext().extensionUri; } /** @@ -103,6 +106,11 @@ export class AgentInteractionProvider implements vscode.WebviewViewProvider { // Always show home view first (which includes pending requests and recent sessions) this._showHome(); + // Send custom tabs info after a short delay to ensure webview is ready + setTimeout(() => { + this.updateCustomTabs(); + }, 100); + // Update badge count if (this._pendingRequests.size > 0) { this._setBadge(this._pendingRequests.size); @@ -299,9 +307,13 @@ export class AgentInteractionProvider implements vscode.WebviewViewProvider { } /** - * Public method to switch tabs in the webview (called from commands) + * Public method to switch tabs in the webview (called from commands or API) + * @param tab - Tab ID: 'pending', 'history', 'settings', or a custom tab ID */ - public switchTab(tab: 'pending' | 'history'): void { + public switchTab(tab: string): void { + // Focus the webview panel first + this._view?.show(true); + const message: ToWebviewMessage = { type: 'switchTab', tab @@ -384,6 +396,16 @@ export class AgentInteractionProvider implements vscode.WebviewViewProvider { break; case 'deleteInteraction': this._handleDeleteInteraction(message.interactionId); break; + case 'getSettings': this._handleGetSettings(); + break; + case 'updateSetting': this._handleUpdateSetting(message.key, message.value); + break; + case 'openVSCodeSettings': this._handleOpenVSCodeSettings(); + break; + case 'getCustomTabContent': this._handleGetCustomTabContent(message.tabId); + break; + case 'customTabMessage': this._handleCustomTabMessage(message.tabId, message.message); + break; case 'cancelPendingRequest': { this.cancelPendingRequest(message.requestId); break; @@ -857,7 +879,7 @@ export class AgentInteractionProvider implements vscode.WebviewViewProvider { const ext = extMap[effectiveMimeType] || '.png'; // Use VS Code storage for temp images - const storageUri = this._context.storageUri; + const storageUri = this.core.getContext().storageUri; if (!storageUri) { throw new Error('Storage URI not available'); @@ -1035,7 +1057,7 @@ export class AgentInteractionProvider implements vscode.WebviewViewProvider { */ public cleanupAllTempFiles(): void { try { - const storageUri = this._context.storageUri; + const storageUri = this.core.getContext().storageUri; if (!storageUri) return; const tempDir = path.join(storageUri.fsPath, 'temp-images'); @@ -1198,6 +1220,181 @@ export class AgentInteractionProvider implements vscode.WebviewViewProvider { } } + /** + * Handle getting settings data for the Settings tab + */ + private _handleGetSettings(): void { + // Import types + type SettingsSectionDataType = import('./types').SettingsSectionData; + type AddonInfoDataType = import('./types').AddonInfoData; + + const settings: SettingsSectionDataType[] = []; + const addons: AddonInfoDataType[] = []; + + try { + const api = this.core.getAPI(); + const registry = api.registry; + + // Get settings sections from addons + const addonSections = api.ui.getSettingsSections(); + for (const section of addonSections) { + settings.push({ + id: section.id, + title: section.title, + description: section.description, + settings: section.settings.map(s => ({ + key: s.key, + label: s.label, + description: s.description, + type: s.type, + value: s.value, + defaultValue: s.defaultValue, + options: s.options, + })), + priority: section.priority, + }); + } + + // Get addon info + const registrations = registry.getAll(); + for (const reg of registrations) { + const addon = reg.addon; + // Count tabs from registry + tabs registered via API (by prefix/pattern matching) + const apiTabCount = (api.ui as { getTabCountByAddon?: (id: string) => number }).getTabCountByAddon?.(addon.id) ?? 0; + const totalTabCount = reg.tabCount + apiTabCount; + addons.push({ + id: addon.id, + name: addon.name, + version: addon.version, + description: addon.description, + author: addon.author, + repositoryUrl: addon.repositoryUrl, + isActive: reg.isActive, + toolCount: reg.toolCount, + tabCount: totalTabCount, + }); + } + } catch (err) { + console.error('[Seamless Agent] Error getting addon settings:', err); + } + + // Send settings to webview + const message: ToWebviewMessage = { + type: 'showSettings', + settings, + addons, + }; + this._view?.webview.postMessage(message); + } + + /** + * Handle updating a setting value + */ + private async _handleUpdateSetting(key: string, value: unknown): Promise { + try { + // Check if it's a VS Code setting (seamless-agent.*) + if (key.startsWith('seamless-agent.')) { + const settingKey = key.replace('seamless-agent.', ''); + const config = vscode.workspace.getConfiguration('seamless-agent'); + await config.update(settingKey, value, vscode.ConfigurationTarget.Global); + } else { + // It's an addon setting - store in extension storage + const api = this.core.getAPI(); + await api.storage.set(key, value); + } + + // Refresh settings view + this._handleGetSettings(); + } catch (err) { + console.error('[Seamless Agent] Error updating setting:', err); + vscode.window.showErrorMessage(`Failed to update setting: ${err instanceof Error ? err.message : 'Unknown error'}`); + } + } + + /** + * Handle opening VS Code settings filtered to Seamless Agent + */ + private _handleOpenVSCodeSettings(): void { + vscode.commands.executeCommand('workbench.action.openSettings', `@ext:${this.core.getContext()?.extension?.id}`); + } + + /** + * Get custom tab content from addon and send to webview + */ + private async _handleGetCustomTabContent(tabId: string): Promise { + try { + const api = this.core.getAPI(); + const tabs = api.ui.getTabs(); + const tab = tabs.find(t => t.id === tabId); + + if (!tab) { + console.error(`[Seamless Agent] Custom tab not found: ${tabId}`); + return; + } + + // Call the tab's render method + const content = await tab.render(); + + // Call onActivate if defined + if (tab.onActivate) { + tab.onActivate(); + } + + // Send content to webview + this._view?.webview.postMessage({ + type: 'showCustomTabContent', + tabId, + content, + }); + } catch (err) { + console.error(`[Seamless Agent] Error getting custom tab content:`, err); + } + } + + /** + * Handle messages from webview to custom tabs + */ + private async _handleCustomTabMessage(tabId: string, message: unknown): Promise { + try { + const api = this.core.getAPI(); + const tabs = api.ui.getTabs(); + const tab = tabs.find(t => t.id === tabId); + + if (!tab || !tab.onMessage) { + return; + } + + // Call the tab's message handler + await tab.onMessage(message); + } catch (err) { + console.error(`[Seamless Agent] Error handling custom tab message:`, err); + } + } + + /** + * Send custom tabs info to webview + */ + public updateCustomTabs(): void { + try { + const api = this.core.getAPI(); + const tabs = api.ui.getTabs(); + + const tabsData: CustomTabData[] = tabs.map(tab => ({ + id: tab.id, + label: tab.label, + icon: tab.icon as string, + priority: tab.priority, + })); + + this._view?.webview.postMessage({ + type: 'updateCustomTabs', + tabs: tabsData, + }); + } catch (err) { + console.error(`[Seamless Agent] Error updating custom tabs:`, err); + } + } + private _getHtmlContent(webview: vscode.Webview): string { // Get URIs for resources const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'main.css')); @@ -1270,6 +1467,15 @@ export class AgentInteractionProvider implements vscode.WebviewViewProvider { '{{historyFilterAll}}': strings.historyFilterAll, '{{historyFilterAskUser}}': strings.historyFilterAskUser, '{{historyFilterPlanReview}}': strings.historyFilterPlanReview, + // Settings strings + '{{settings}}': strings.settings, + '{{settingsDescription}}': strings.settingsDescription, + '{{loadingSettings}}': strings.loadingSettings, + '{{registeredAddons}}': strings.registeredAddons, + '{{noAddonsRegistered}}': strings.noAddonsRegistered, + '{{openInVSCodeSettings}}': strings.openInVSCodeSettings, + '{{addonVersion}}': strings.addonVersion, + '{{addonAuthor}}': strings.addonAuthor, }; for (const [placeholder, value] of Object.entries(replacements)) { diff --git a/tsconfig.addon-typedefs.json b/tsconfig.addon-typedefs.json new file mode 100644 index 0000000..cc8cc50 --- /dev/null +++ b/tsconfig.addon-typedefs.json @@ -0,0 +1,22 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "declarationMap": false, + "stripInternal": true, + "noEmitOnError": true, + "baseUrl": ".", + "paths": { + "@vscode/codicons/dist/codiconsLibrary": [ + "typings/addon-typedefs/codiconsLibrary.d.ts" + ] + }, + "outDir": "dist-addon-api" + }, + "include": [ + "src/addon-typedefs/index.ts", + "typings/addon-typedefs/codiconsLibrary.d.ts" + ], + "exclude": ["node_modules", "dist", "dist-addon-api"] +} diff --git a/tsconfig.json b/tsconfig.json index ea7548a..e0dd618 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,8 +10,10 @@ "rootDir": "src", "lib": ["ES2022"], "skipLibCheck": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] -} \ No newline at end of file +} diff --git a/typings/addon-typedefs/codiconsLibrary.d.ts b/typings/addon-typedefs/codiconsLibrary.d.ts new file mode 100644 index 0000000..4ecc224 --- /dev/null +++ b/typings/addon-typedefs/codiconsLibrary.d.ts @@ -0,0 +1,13 @@ +/** + * Shim para `@vscode/codicons/dist/codiconsLibrary`. + * + * Motivo: + * - O pacote `@vscode/codicons` referencia arquivos `.js` internos (ex.: `codiconsUtil.js`) + * que nem sempre vêm acompanhados de declarações de tipo resolvíveis pelo `tsc`. + * - Para gerar um pacote *types-only* dos contratos públicos do Seamless Agent, + * basta sabermos que `codiconsLibrary` é um objeto indexável. + */ + +declare module '@vscode/codicons/dist/codiconsLibrary' { + export const codiconsLibrary: Record; +}